Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.99% covered (warning)
71.99%
347 / 482
62.96% covered (warning)
62.96%
17 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChildrenService
71.99% covered (warning)
71.99%
347 / 482
62.96% covered (warning)
62.96%
17 / 27
239.03
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getIndexData
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 getFormData
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
110
 save
82.26% covered (warning)
82.26%
51 / 62
0.00% covered (danger)
0.00%
0 / 1
17.43
 getProfileData
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
3
 buildIndexFilters
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 getChildrenRows
62.50% covered (warning)
62.50%
15 / 24
0.00% covered (danger)
0.00%
0 / 1
7.90
 buildChildMetricMaps
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
5
 formOptions
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeChildPayload
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 syncChildGuardian
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
2.00
 syncPrimaryCoach
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
5.39
 recordJourneyEvents
27.78% covered (danger)
27.78%
15 / 54
0.00% covered (danger)
0.00%
0 / 1
14.42
 getAccessibleChild
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 getChildWithRelations
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getEvaluations
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getAttendanceRows
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getJourneyRows
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getCriteriaProfile
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 buildScoreTrend
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 averageFinalScore
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 calculateAttendanceRate
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 profileTabs
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 groupOptions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 levelOptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 coachOptions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 db
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services;
4
5use App\Models\ChildCoachModel;
6use App\Models\ChildGuardianModel;
7use App\Models\ChildModel;
8use App\Models\GuardianModel;
9use CodeIgniter\Shield\Entities\User;
10
11class ChildrenService
12{
13    private AttendanceService $attendanceService;
14    private AuthorizationService $authorizationService;
15    private JourneyService $journeyService;
16
17    public function __construct()
18    {
19        helper('academy');
20
21        $this->attendanceService    = service('attendance');
22        $this->authorizationService = service('authorization');
23        $this->journeyService       = service('journey');
24    }
25
26    /**
27     * @param array<string, mixed> $input
28     * @return array<string, mixed>
29     */
30    public function getIndexData(array $input, User $user): array
31    {
32        $filters    = $this->buildIndexFilters($input, (int) $user->id);
33        $children   = $this->getChildrenRows($filters);
34        $metricsMap = $this->buildChildMetricMaps(array_map(static fn (array $row): int => (int) $row['id'], $children));
35
36        foreach ($children as &$child) {
37            $childId                   = (int) $child['id'];
38            $child['avg_score']        = $metricsMap['scores'][$childId]['avg_score'] ?? null;
39            $child['avg_score_label']  = format_score($child['avg_score']);
40            $child['attendance_rate']  = $metricsMap['attendance'][$childId] ?? 0.0;
41            $child['attendance_label'] = format_percentage((float) $child['attendance_rate'], 1);
42            $child['evaluations_count'] = $metricsMap['scores'][$childId]['evaluations_count'] ?? 0;
43        }
44        unset($child);
45
46        return [
47            'pageTitle' => 'Copii',
48            'pageDescription' => 'Administrare copii, cautare rapida si acces direct spre profilul individual.',
49            'filters' => $filters,
50            'children' => $children,
51            'canManage' => $filters['current_role'] === 'admin',
52        ];
53    }
54
55    /**
56     * @param array<string, mixed> $input
57     * @param array<string, string> $errors
58     * @return array<string, mixed>
59     */
60    public function getFormData(User $user, ?int $childId = null, array $input = [], array $errors = []): array
61    {
62        $child = $childId !== null ? $this->getChildWithRelations($childId) : null;
63
64        $values = [
65            'first_name' => '',
66            'last_name' => '',
67            'birth_date' => '',
68            'gender' => '',
69            'academy_level_id' => '',
70            'academy_group_id' => '',
71            'primary_coach_id' => '',
72            'first_class_at' => '',
73            'status' => 'active',
74            'has_siblings' => '0',
75            'notes' => '',
76            'guardian_name' => '',
77            'guardian_phone' => '',
78            'guardian_email' => '',
79        ];
80
81        if ($child !== null) {
82            $values = [
83                'first_name' => $child['first_name'],
84                'last_name' => $child['last_name'],
85                'birth_date' => $child['birth_date'],
86                'gender' => $child['gender'],
87                'academy_level_id' => (string) ($child['academy_level_id'] ?? ''),
88                'academy_group_id' => (string) ($child['academy_group_id'] ?? ''),
89                'primary_coach_id' => (string) ($child['primary_coach_id'] ?? ''),
90                'first_class_at' => $child['first_class_at'],
91                'status' => $child['status'],
92                'has_siblings' => (string) ((int) ($child['has_siblings'] ?? 0)),
93                'notes' => $child['notes'],
94                'guardian_name' => $child['guardian_name'],
95                'guardian_phone' => $child['guardian_phone'],
96                'guardian_email' => $child['guardian_email'],
97            ];
98        }
99
100        foreach ($input as $key => $value) {
101            if (array_key_exists($key, $values)) {
102                $values[$key] = is_string($value) ? trim($value) : $value;
103            }
104        }
105
106        return [
107            'pageTitle' => $childId === null ? 'Copil nou' : 'Editare copil',
108            'pageDescription' => $childId === null
109                ? 'Introdu datele de baza ale copilului, tutorului si asignarile initiale.'
110                : 'Actualizeaza profilul copilului fara sa pierzi istoricul de evaluari si journey.',
111            'child' => $child,
112            'values' => $values,
113            'errors' => $errors,
114            'options' => $this->formOptions(),
115            'submitUrl' => $childId === null ? url_to('children.create') : url_to('children.update', $childId),
116            'cancelUrl' => $childId === null ? url_to('children.index') : url_to('children.show', $childId),
117            'canManage' => $this->authorizationService->userHasRole((int) $user->id, 'admin'),
118        ];
119    }
120
121    /**
122     * @param array<string, mixed> $input
123     * @return array{success: bool, child_id?: int, errors?: array<string, string>}
124     */
125    public function save(array $input, User $user, ?int $childId = null): array
126    {
127        $validation = service('validation');
128        $validation->setRules(config('Validation')->child);
129
130        $payload = $this->normalizeChildPayload($input);
131
132        if (! $validation->run($payload)) {
133            return [
134                'success' => false,
135                'errors' => $validation->getErrors(),
136            ];
137        }
138
139        $existing = $childId !== null ? $this->getChildWithRelations($childId) : null;
140        $now      = date('Y-m-d H:i:s');
141
142        $guardianPayload = [
143            'full_name' => $payload['guardian_name'],
144            'phone' => $payload['guardian_phone'] !== '' ? $payload['guardian_phone'] : null,
145            'email' => $payload['guardian_email'] !== '' ? $payload['guardian_email'] : null,
146            'relationship_type' => 'parent',
147            'status' => 'active',
148            'notes' => null,
149        ];
150
151        $childPayload = [
152            'first_name' => $payload['first_name'],
153            'last_name' => $payload['last_name'],
154            'full_name' => trim($payload['first_name'] . ' ' . $payload['last_name']),
155            'birth_date' => $payload['birth_date'] !== '' ? $payload['birth_date'] : null,
156            'gender' => $payload['gender'] !== '' ? $payload['gender'] : null,
157            'academy_level_id' => $payload['academy_level_id'] !== '' ? (int) $payload['academy_level_id'] : null,
158            'academy_group_id' => $payload['academy_group_id'] !== '' ? (int) $payload['academy_group_id'] : null,
159            'primary_coach_id' => $payload['primary_coach_id'] !== '' ? (int) $payload['primary_coach_id'] : null,
160            'first_class_at' => $payload['first_class_at'] !== '' ? $payload['first_class_at'] : null,
161            'status' => $payload['status'],
162            'has_siblings' => (int) $payload['has_siblings'],
163            'notes' => $payload['notes'] !== '' ? $payload['notes'] : null,
164            'updated_at' => $now,
165        ];
166
167        $db = $this->db();
168        $db->transStart();
169
170        $guardianModel = model(GuardianModel::class);
171        $childModel    = model(ChildModel::class);
172
173        if ($existing !== null && ! empty($existing['primary_guardian_id'])) {
174            $guardianId = (int) $existing['primary_guardian_id'];
175            $guardianModel->update($guardianId, $guardianPayload);
176        } else {
177            $guardianPayload['created_at'] = $now;
178            $guardianModel->insert($guardianPayload);
179            $guardianId = (int) $guardianModel->getInsertID();
180        }
181
182        $childPayload['primary_guardian_id'] = $guardianId;
183
184        if ($existing === null) {
185            $childPayload['created_at'] = $now;
186            $childModel->insert($childPayload);
187            $childId = (int) $childModel->getInsertID();
188        } else {
189            $childModel->update($childId, $childPayload);
190        }
191
192        $this->syncChildGuardian((int) $childId, $guardianId, $now);
193        $this->syncPrimaryCoach((int) $childId, $childPayload['primary_coach_id'], $now);
194        $this->recordJourneyEvents((int) $childId, $existing, $childPayload, (int) $user->id, $now);
195
196        $db->transComplete();
197
198        if (! $db->transStatus()) {
199            return [
200                'success' => false,
201                'errors' => ['general' => 'Nu am putut salva copilul. Verifica datele si incearca din nou.'],
202            ];
203        }
204
205        return [
206            'success' => true,
207            'child_id' => (int) $childId,
208        ];
209    }
210
211    /**
212     * @return array<string, mixed>|null
213     */
214    public function getProfileData(int $childId, User $user, string $tab = 'overview'): ?array
215    {
216        $child = $this->getAccessibleChild($childId, $user);
217        if ($child === null) {
218            return null;
219        }
220
221        $evaluations     = $this->getEvaluations($childId);
222        $quickChecks     = array_values(array_filter($evaluations, static fn (array $row): bool => $row['type_slug'] === 'quick-check'));
223        $fullEvaluations = array_values(array_filter($evaluations, static fn (array $row): bool => $row['type_slug'] === 'full-evaluation'));
224        $attendanceRows  = $this->getAttendanceRows($childId);
225        $journeyRows     = $this->getJourneyRows($childId);
226        $latestAny       = $evaluations[0] ?? null;
227        $latestFull      = $fullEvaluations[0] ?? $latestAny;
228        $scoreAverage    = $this->averageFinalScore($evaluations);
229        $attendanceRate  = $this->calculateAttendanceRate($attendanceRows);
230        $criteriaProfile = $latestFull !== null ? $this->getCriteriaProfile((int) $latestFull['id']) : ['labels' => [], 'series' => []];
231        $scoreTrend      = $this->buildScoreTrend($evaluations);
232
233        return [
234            'pageTitle' => $child['full_name'],
235            'pageDescription' => 'Profil individual cu progres, prezenta, quick checks si journey complet.',
236            'child' => $child,
237            'tabs' => $this->profileTabs($childId, $tab, count($fullEvaluations), count($quickChecks), count($attendanceRows), count($journeyRows)),
238            'activeTab' => $tab,
239            'canManage' => $this->authorizationService->userHasRole((int) $user->id, 'admin'),
240            'canEvaluate' => $this->authorizationService->userHasRole((int) $user->id, ['admin', 'coach']),
241            'evaluationActions' => [
242                'full' => url_to('evaluations.new', $childId, 'full-evaluation'),
243                'quick' => url_to('evaluations.new', $childId, 'quick-check'),
244            ],
245            'overview' => [
246                'average_score' => $scoreAverage,
247                'attendance_rate' => $attendanceRate,
248                'evaluations_count' => count($evaluations),
249                'latest_status' => $latestFull['final_status'] ?? $child['status'],
250                'latest_evaluation' => $latestAny,
251                'score_trend' => $scoreTrend,
252                'criteria_profile' => $criteriaProfile,
253            ],
254            'fullEvaluations' => $fullEvaluations,
255            'quickChecks' => $quickChecks,
256            'attendanceRows' => $attendanceRows,
257            'journeyRows' => $journeyRows,
258        ];
259    }
260
261    /**
262     * @param array<string, mixed> $input
263     * @return array<string, mixed>
264     */
265    private function buildIndexFilters(array $input, int $userId): array
266    {
267        $primaryRole = $this->authorizationService->getPrimaryRole($userId);
268        $coachId     = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
269
270        $selectedTrainer = $coachId ?: (int) ($input['coach_id'] ?? 0);
271        $selectedLevel = (int) ($input['level_id'] ?? 0);
272        $selectedGroup = (int) ($input['group_id'] ?? 0);
273        $selectedStatus = (string) ($input['status'] ?? 'all');
274        $query = trim((string) ($input['q'] ?? ''));
275
276        return [
277            'current_role' => $primaryRole,
278            'coach_locked' => $primaryRole === 'coach',
279            'selected_coach_id' => $selectedTrainer,
280            'selected_level_id' => $selectedLevel,
281            'selected_group_id' => $selectedGroup,
282            'selected_status' => in_array($selectedStatus, ['all', 'active', 'pause', 'left'], true) ? $selectedStatus : 'all',
283            'query' => $query,
284            'coach_options' => $this->coachOptions(),
285            'level_options' => $this->levelOptions(),
286            'group_options' => $this->groupOptions($coachId),
287            'status_options' => [
288                'all' => 'Toate statusurile',
289                'active' => 'Activ',
290                'pause' => 'Pauza',
291                'left' => 'Plecat',
292            ],
293        ];
294    }
295
296    /**
297     * @param array<string, mixed> $filters
298     * @return list<array<string, mixed>>
299     */
300    private function getChildrenRows(array $filters): array
301    {
302        $builder = $this->db()
303            ->table('children c')
304            ->select('c.*, levels.name as level_name, levels.color_hex, groups.name as group_name, groups.code as group_code, coaches.full_name as coach_name, guardians.full_name as guardian_name')
305            ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
306            ->join('academy_groups groups', 'groups.id = c.academy_group_id', 'left')
307            ->join('coaches', 'coaches.id = c.primary_coach_id', 'left')
308            ->join('parents guardians', 'guardians.id = c.primary_guardian_id', 'left')
309            ->orderBy('c.full_name', 'ASC');
310
311        if ($filters['selected_coach_id'] > 0) {
312            $builder->where('c.primary_coach_id', $filters['selected_coach_id']);
313        }
314
315        if ($filters['selected_level_id'] > 0) {
316            $builder->where('c.academy_level_id', $filters['selected_level_id']);
317        }
318
319        if ($filters['selected_group_id'] > 0) {
320            $builder->where('c.academy_group_id', $filters['selected_group_id']);
321        }
322
323        if ($filters['selected_status'] !== 'all') {
324            $builder->where('c.status', $filters['selected_status']);
325        }
326
327        if ($filters['query'] !== '') {
328            $builder
329                ->groupStart()
330                ->like('c.full_name', $filters['query'])
331                ->orLike('guardians.full_name', $filters['query'])
332                ->orLike('groups.name', $filters['query'])
333                ->groupEnd();
334        }
335
336        return $builder->get()->getResultArray();
337    }
338
339    /**
340     * @param list<int> $childIds
341     * @return array<string, array<int, mixed>>
342     */
343    private function buildChildMetricMaps(array $childIds): array
344    {
345        if ($childIds === []) {
346            return ['scores' => [], 'attendance' => []];
347        }
348
349        $scoreRows = $this->db()
350            ->table('evaluations')
351            ->select('child_id, ROUND(AVG(final_score), 2) as avg_score, COUNT(*) as evaluations_count')
352            ->whereIn('child_id', $childIds)
353            ->groupBy('child_id')
354            ->get()
355            ->getResultArray();
356
357        $attendanceRows = $this->db()
358            ->table('attendance_records ar')
359            ->select("ar.child_id, SUM(CASE WHEN ar.status IN ('present', 'recovery') THEN 1 ELSE 0 END) as attended, COUNT(*) as total", false)
360            ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
361            ->where('sessions.is_cancelled', 0)
362            ->whereIn('ar.child_id', $childIds)
363            ->groupBy('ar.child_id')
364            ->get()
365            ->getResultArray();
366
367        $scores = [];
368        foreach ($scoreRows as $row) {
369            $scores[(int) $row['child_id']] = [
370                'avg_score' => $row['avg_score'] !== null ? (float) $row['avg_score'] : null,
371                'evaluations_count' => (int) $row['evaluations_count'],
372            ];
373        }
374
375        $attendance = [];
376        foreach ($attendanceRows as $row) {
377            $attendance[(int) $row['child_id']] = $this->attendanceService->calculateRate((int) $row['attended'], (int) $row['total']);
378        }
379
380        return ['scores' => $scores, 'attendance' => $attendance];
381    }
382
383    /**
384     * @return array<string, list<array<string, mixed>>>
385     */
386    private function formOptions(): array
387    {
388        return [
389            'genders' => [
390                ['value' => 'male', 'label' => 'Baiat'],
391                ['value' => 'female', 'label' => 'Fata'],
392            ],
393            'statuses' => [
394                ['value' => 'active', 'label' => 'Activ'],
395                ['value' => 'pause', 'label' => 'Pauza'],
396                ['value' => 'left', 'label' => 'Plecat'],
397            ],
398            'levels' => $this->levelOptions(),
399            'groups' => $this->groupOptions(),
400            'coaches' => $this->coachOptions(),
401        ];
402    }
403
404    /**
405     * @param array<string, mixed> $input
406     * @return array<string, string>
407     */
408    private function normalizeChildPayload(array $input): array
409    {
410        return [
411            'first_name' => trim((string) ($input['first_name'] ?? '')),
412            'last_name' => trim((string) ($input['last_name'] ?? '')),
413            'birth_date' => trim((string) ($input['birth_date'] ?? '')),
414            'gender' => trim((string) ($input['gender'] ?? '')),
415            'academy_level_id' => trim((string) ($input['academy_level_id'] ?? '')),
416            'academy_group_id' => trim((string) ($input['academy_group_id'] ?? '')),
417            'primary_coach_id' => trim((string) ($input['primary_coach_id'] ?? '')),
418            'first_class_at' => trim((string) ($input['first_class_at'] ?? '')),
419            'status' => trim((string) ($input['status'] ?? 'active')),
420            'has_siblings' => (string) ((int) ($input['has_siblings'] ?? 0)),
421            'notes' => trim((string) ($input['notes'] ?? '')),
422            'guardian_name' => trim((string) ($input['guardian_name'] ?? '')),
423            'guardian_phone' => trim((string) ($input['guardian_phone'] ?? '')),
424            'guardian_email' => trim((string) ($input['guardian_email'] ?? '')),
425        ];
426    }
427
428    private function syncChildGuardian(int $childId, int $guardianId, string $now): void
429    {
430        $pivot = model(ChildGuardianModel::class)
431            ->where('child_id', $childId)
432            ->where('parent_id', $guardianId)
433            ->first();
434
435        $this->db()->table('child_guardians')
436            ->where('child_id', $childId)
437            ->update(['is_primary' => 0]);
438
439        if ($pivot === null) {
440            model(ChildGuardianModel::class)->insert([
441                'child_id' => $childId,
442                'parent_id' => $guardianId,
443                'relationship_type' => 'parent',
444                'is_primary' => 1,
445                'created_at' => $now,
446            ]);
447
448            return;
449        }
450
451        model(ChildGuardianModel::class)->update($pivot['id'], ['is_primary' => 1]);
452    }
453
454    private function syncPrimaryCoach(int $childId, mixed $coachId, string $now): void
455    {
456        $this->db()->table('child_coaches')
457            ->where('child_id', $childId)
458            ->update(['is_primary' => 0, 'unassigned_at' => $now]);
459
460        if ($coachId === null || $coachId === '') {
461            return;
462        }
463
464        $coachId = (int) $coachId;
465
466        $existing = model(ChildCoachModel::class)
467            ->where('child_id', $childId)
468            ->where('coach_id', $coachId)
469            ->first();
470
471        if ($existing === null) {
472            model(ChildCoachModel::class)->insert([
473                'child_id' => $childId,
474                'coach_id' => $coachId,
475                'is_primary' => 1,
476                'assigned_at' => $now,
477                'unassigned_at' => null,
478            ]);
479
480            return;
481        }
482
483        model(ChildCoachModel::class)->update($existing['id'], [
484            'is_primary' => 1,
485            'assigned_at' => $existing['assigned_at'] ?: $now,
486            'unassigned_at' => null,
487        ]);
488    }
489
490    /**
491     * @param array<string, mixed>|null $existing
492     * @param array<string, mixed> $payload
493     */
494    private function recordJourneyEvents(int $childId, ?array $existing, array $payload, int $userId, string $now): void
495    {
496        if ($existing === null) {
497            $this->journeyService->recordEvent([
498                'child_id' => $childId,
499                'event_type' => 'child_created',
500                'title' => 'Copil adaugat in academie',
501                'description' => 'Profilul copilului a fost creat si asignat in structura academiei.',
502                'metadata' => json_encode([
503                    'level_id' => $payload['academy_level_id'],
504                    'group_id' => $payload['academy_group_id'],
505                    'coach_id' => $payload['primary_coach_id'],
506                ]),
507                'created_by' => $userId,
508                'created_at' => $now,
509            ]);
510
511            return;
512        }
513
514        if ((int) ($existing['academy_level_id'] ?? 0) !== (int) ($payload['academy_level_id'] ?? 0)) {
515            $this->journeyService->recordEvent([
516                'child_id' => $childId,
517                'event_type' => 'level_changed',
518                'title' => 'Nivel actualizat',
519                'description' => 'Nivelul copilului a fost modificat in profil.',
520                'metadata' => json_encode([
521                    'from' => $existing['academy_level_id'],
522                    'to' => $payload['academy_level_id'],
523                ]),
524                'created_by' => $userId,
525                'created_at' => $now,
526            ]);
527        }
528
529        if ((int) ($existing['primary_coach_id'] ?? 0) !== (int) ($payload['primary_coach_id'] ?? 0)) {
530            $this->journeyService->recordEvent([
531                'child_id' => $childId,
532                'event_type' => 'coach_changed',
533                'title' => 'Trainer principal schimbat',
534                'description' => 'Copilul a fost realocat catre un alt trainer principal.',
535                'metadata' => json_encode([
536                    'from' => $existing['primary_coach_id'],
537                    'to' => $payload['primary_coach_id'],
538                ]),
539                'created_by' => $userId,
540                'created_at' => $now,
541            ]);
542        }
543
544        if ((string) ($existing['status'] ?? '') !== (string) $payload['status']) {
545            $this->journeyService->recordEvent([
546                'child_id' => $childId,
547                'event_type' => 'status_changed',
548                'title' => 'Status copil actualizat',
549                'description' => 'Statusul operational al copilului a fost schimbat.',
550                'metadata' => json_encode([
551                    'from' => $existing['status'],
552                    'to' => $payload['status'],
553                ]),
554                'created_by' => $userId,
555                'created_at' => $now,
556            ]);
557        }
558    }
559
560    /**
561     * @return array<string, mixed>|null
562     */
563    private function getAccessibleChild(int $childId, User $user): ?array
564    {
565        $child = $this->getChildWithRelations($childId);
566        if ($child === null) {
567            return null;
568        }
569
570        if (! $this->authorizationService->userHasRole((int) $user->id, 'coach')) {
571            return $child;
572        }
573
574        $coachId = $this->authorizationService->getCoachIdForUser((int) $user->id);
575
576        if ($coachId !== null && (int) $child['primary_coach_id'] === $coachId) {
577            return $child;
578        }
579
580        if ($this->authorizationService->userHasRole((int) $user->id, 'admin')) {
581            return $child;
582        }
583
584        return null;
585    }
586
587    /**
588     * @return array<string, mixed>|null
589     */
590    private function getChildWithRelations(int $childId): ?array
591    {
592        return $this->db()
593            ->table('children c')
594            ->select('c.*, levels.name as level_name, levels.color_hex, groups.name as group_name, groups.code as group_code, coaches.full_name as coach_name, guardians.full_name as guardian_name, guardians.phone as guardian_phone, guardians.email as guardian_email')
595            ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
596            ->join('academy_groups groups', 'groups.id = c.academy_group_id', 'left')
597            ->join('coaches', 'coaches.id = c.primary_coach_id', 'left')
598            ->join('parents guardians', 'guardians.id = c.primary_guardian_id', 'left')
599            ->where('c.id', $childId)
600            ->get()
601            ->getRowArray();
602    }
603
604    /**
605     * @return list<array<string, mixed>>
606     */
607    private function getEvaluations(int $childId): array
608    {
609        return $this->db()
610            ->table('evaluations e')
611            ->select('e.*, types.name as type_name, types.slug as type_slug, coaches.full_name as evaluator_name')
612            ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
613            ->join('coaches', 'coaches.id = e.evaluator_coach_id', 'left')
614            ->where('e.child_id', $childId)
615            ->orderBy('e.evaluation_date', 'DESC')
616            ->orderBy('e.id', 'DESC')
617            ->get()
618            ->getResultArray();
619    }
620
621    /**
622     * @return list<array<string, mixed>>
623     */
624    private function getAttendanceRows(int $childId): array
625    {
626        return $this->db()
627            ->table('attendance_records ar')
628            ->select('ar.status, ar.notes, sessions.session_date, sessions.start_time, sessions.end_time, groups.name as group_name, coaches.full_name as coach_name')
629            ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
630            ->join('academy_groups groups', 'groups.id = sessions.academy_group_id', 'left')
631            ->join('coaches', 'coaches.id = sessions.coach_id', 'left')
632            ->where('ar.child_id', $childId)
633            ->where('sessions.is_cancelled', 0)
634            ->orderBy('sessions.session_date', 'DESC')
635            ->limit(20)
636            ->get()
637            ->getResultArray();
638    }
639
640    /**
641     * @return list<array<string, mixed>>
642     */
643    private function getJourneyRows(int $childId): array
644    {
645        return $this->db()
646            ->table('journey_events')
647            ->where('child_id', $childId)
648            ->orderBy('created_at', 'DESC')
649            ->limit(30)
650            ->get()
651            ->getResultArray();
652    }
653
654    /**
655     * @return array<string, mixed>
656     */
657    private function getCriteriaProfile(int $evaluationId): array
658    {
659        $rows = $this->db()
660            ->table('evaluation_scores scores')
661            ->select('criteria.name, scores.score')
662            ->join('evaluation_criteria criteria', 'criteria.id = scores.evaluation_criteria_id')
663            ->where('scores.evaluation_id', $evaluationId)
664            ->orderBy('criteria.sort_order', 'ASC')
665            ->get()
666            ->getResultArray();
667
668        return [
669            'labels' => array_map(static fn (array $row): string => $row['name'], $rows),
670            'series' => array_map(static fn (array $row): float => (float) $row['score'], $rows),
671        ];
672    }
673
674    /**
675     * @param list<array<string, mixed>> $evaluations
676     * @return array<string, mixed>
677     */
678    private function buildScoreTrend(array $evaluations): array
679    {
680        $ordered = array_reverse($evaluations);
681
682        return [
683            'categories' => array_map(static fn (array $row): string => date('d M', strtotime((string) $row['evaluation_date'])), $ordered),
684            'series' => array_map(static fn (array $row): float => (float) $row['final_score'], $ordered),
685        ];
686    }
687
688    /**
689     * @param list<array<string, mixed>> $evaluations
690     */
691    private function averageFinalScore(array $evaluations): ?float
692    {
693        if ($evaluations === []) {
694            return null;
695        }
696
697        $total = array_sum(array_map(static fn (array $row): float => (float) $row['final_score'], $evaluations));
698
699        return round($total / count($evaluations), 2);
700    }
701
702    /**
703     * @param list<array<string, mixed>> $rows
704     */
705    private function calculateAttendanceRate(array $rows): float
706    {
707        $breakdown = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
708
709        foreach ($rows as $row) {
710            $status = (string) $row['status'];
711            if (! isset($breakdown[$status])) {
712                $breakdown[$status] = 0;
713            }
714
715            $breakdown[$status]++;
716        }
717
718        return $this->attendanceService->calculateRateFromBreakdown($breakdown);
719    }
720
721    /**
722     * @return list<array<string, mixed>>
723     */
724    private function profileTabs(int $childId, string $activeTab, int $fullCount, int $quickCount, int $attendanceCount, int $journeyCount): array
725    {
726        return [
727            ['key' => 'overview', 'label' => 'Privire generala', 'href' => url_to('children.show', $childId), 'count' => null, 'active' => $activeTab === 'overview'],
728            ['key' => 'evaluations', 'label' => 'Evaluari', 'href' => url_to('children.show', $childId) . '?tab=evaluations', 'count' => $fullCount, 'active' => $activeTab === 'evaluations'],
729            ['key' => 'quick-checks', 'label' => 'Quick Check', 'href' => url_to('children.show', $childId) . '?tab=quick-checks', 'count' => $quickCount, 'active' => $activeTab === 'quick-checks'],
730            ['key' => 'attendance', 'label' => 'Prezenta', 'href' => url_to('children.show', $childId) . '?tab=attendance', 'count' => $attendanceCount, 'active' => $activeTab === 'attendance'],
731            ['key' => 'journey', 'label' => 'Journey', 'href' => url_to('children.show', $childId) . '?tab=journey', 'count' => $journeyCount, 'active' => $activeTab === 'journey'],
732        ];
733    }
734
735    /**
736     * @param int|null $coachId
737     * @return list<array<string, mixed>>
738     */
739    private function groupOptions(?int $coachId = null): array
740    {
741        $builder = $this->db()
742            ->table('academy_groups')
743            ->select('id, name, code')
744            ->orderBy('name', 'ASC');
745
746        if ($coachId !== null) {
747            $builder->where('primary_coach_id', $coachId);
748        }
749
750        return $builder->get()->getResultArray();
751    }
752
753    /**
754     * @return list<array<string, mixed>>
755     */
756    private function levelOptions(): array
757    {
758        return $this->db()
759            ->table('academy_levels')
760            ->select('id, name, slug, color_hex')
761            ->orderBy('sort_order', 'ASC')
762            ->get()
763            ->getResultArray();
764    }
765
766    /**
767     * @return list<array<string, mixed>>
768     */
769    private function coachOptions(): array
770    {
771        return $this->db()
772            ->table('coaches')
773            ->select('id, full_name')
774            ->where('is_active', 1)
775            ->orderBy('full_name', 'ASC')
776            ->get()
777            ->getResultArray();
778    }
779
780    private function db()
781    {
782        return db_connect();
783    }
784}