Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.50% covered (success)
95.50%
276 / 289
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardService
95.50% covered (success)
95.50%
276 / 289
53.33% covered (warning)
53.33%
8 / 15
56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDashboardData
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 buildFilters
98.00% covered (success)
98.00%
49 / 50
0.00% covered (danger)
0.00%
0 / 1
4
 resolveDates
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
3.65
 getScopedChildren
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 getEvaluations
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 getAttendanceRows
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 latestEvaluationsByChild
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 buildKpis
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
7
 buildLevelDistribution
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 buildChildrenPerCoach
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 buildRecentEvaluations
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 buildAlerts
98.33% covered (success)
98.33%
59 / 60
0.00% covered (danger)
0.00%
0 / 1
13
 settingsMap
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 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 CodeIgniter\Shield\Entities\User;
6use DateTimeImmutable;
7
8class DashboardService
9{
10    private AttendanceService $attendanceService;
11    private AuthorizationService $authorizationService;
12
13    public function __construct()
14    {
15        helper('academy');
16
17        $this->attendanceService    = service('attendance');
18        $this->authorizationService = service('authorization');
19    }
20
21    /**
22     * @param array<string, mixed> $input
23     * @return array<string, mixed>
24     */
25    public function getDashboardData(array $input, User $user): array
26    {
27        $filters          = $this->buildFilters($input, (int) $user->id);
28        $children         = $this->getScopedChildren($filters);
29        $childIds         = array_map(static fn (array $child): int => (int) $child['id'], $children);
30        $activeChildren   = array_values(array_filter($children, static fn (array $child): bool => $child['status'] === 'active'));
31        $activeChildIds   = array_map(static fn (array $child): int => (int) $child['id'], $activeChildren);
32        $allEvaluations   = $this->getEvaluations($childIds, null, null);
33        $periodEvaluations = $this->getEvaluations($childIds, $filters['date_start'], $filters['date_end']);
34        $latestFull       = $this->latestEvaluationsByChild($allEvaluations, 'full-evaluation');
35        $latestAny        = $this->latestEvaluationsByChild($allEvaluations, null);
36        $attendanceRows   = $this->getAttendanceRows($childIds, $filters['date_start'], $filters['date_end']);
37
38        return [
39            'pageTitle' => 'Dashboard',
40            'filters' => $filters,
41            'kpis' => $this->buildKpis($activeChildren, $periodEvaluations, $latestFull, $attendanceRows),
42            'levelDistribution' => $this->buildLevelDistribution($activeChildren),
43            'childrenPerCoach' => $this->buildChildrenPerCoach($activeChildren),
44            'recentEvaluations' => $this->buildRecentEvaluations($periodEvaluations),
45            'alerts' => $this->buildAlerts($children, $latestFull, $latestAny, $attendanceRows, $filters),
46        ];
47    }
48
49    /**
50     * @param array<string, mixed> $input
51     * @return array<string, mixed>
52     */
53    private function buildFilters(array $input, int $userId): array
54    {
55        $settings       = $this->settingsMap();
56        $primaryRole    = $this->authorizationService->getPrimaryRole($userId);
57        $coachId        = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
58        $selectedPeriod = (string) ($input['period'] ?? ($settings['default_dashboard_period'] ?? '30'));
59        $today          = new DateTimeImmutable('today');
60        [$dateStart, $dateEnd] = $this->resolveDates($selectedPeriod, $today);
61
62        $levelOptions = $this->db()
63            ->table('academy_levels')
64            ->select('id, name, slug, color_hex')
65            ->orderBy('sort_order', 'ASC')
66            ->get()
67            ->getResultArray();
68
69        $coachOptions = $this->db()
70            ->table('coaches')
71            ->select('id, full_name')
72            ->where('is_active', 1)
73            ->orderBy('full_name', 'ASC')
74            ->get()
75            ->getResultArray();
76
77        $selectedTrainer = $coachId ?: (int) ($input['coach_id'] ?? 0);
78        $selectedLevel = (int) ($input['level_id'] ?? 0);
79        $selectedStatus = (string) ($input['child_status'] ?? 'all');
80        if (! in_array($selectedStatus, ['all', 'active', 'pause', 'left'], true)) {
81            $selectedStatus = 'all';
82        }
83
84        return [
85            'current_role' => $primaryRole,
86            'date_start' => $dateStart,
87            'date_end' => $dateEnd,
88            'selected_period' => $selectedPeriod,
89            'selected_coach_id' => $selectedTrainer,
90            'selected_level_id' => $selectedLevel,
91            'selected_child_status' => $selectedStatus,
92            'coach_locked' => $primaryRole === 'coach',
93            'period_options' => [
94                '7' => 'Ultimele 7 zile',
95                '30' => 'Ultimele 30 zile',
96                '90' => 'Ultimele 90 zile',
97                'month' => 'Luna curenta',
98            ],
99            'status_options' => [
100                'all' => 'Toate statusurile',
101                'active' => 'Activ',
102                'pause' => 'Pauza',
103                'left' => 'Plecat',
104            ],
105            'coach_options' => $coachOptions,
106            'level_options' => $levelOptions,
107            'no_evaluation_days' => (int) ($settings['no_evaluation_days'] ?? 45),
108            'absence_alert_threshold' => (int) ($settings['absence_alert_threshold'] ?? 25),
109        ];
110    }
111
112    /**
113     * @return array{0: string, 1: string}
114     */
115    private function resolveDates(string $selectedPeriod, DateTimeImmutable $today): array
116    {
117        if ($selectedPeriod === 'month') {
118            return [
119                $today->modify('first day of this month')->format('Y-m-d'),
120                $today->format('Y-m-d'),
121            ];
122        }
123
124        $days = (int) $selectedPeriod;
125        if (! in_array($days, [7, 30, 90], true)) {
126            $days = 30;
127        }
128
129        return [
130            $today->modify('-' . ($days - 1) . ' days')->format('Y-m-d'),
131            $today->format('Y-m-d'),
132        ];
133    }
134
135    /**
136     * @param array<string, mixed> $filters
137     * @return list<array<string, mixed>>
138     */
139    private function getScopedChildren(array $filters): array
140    {
141        $builder = $this->db()
142            ->table('children c')
143            ->select('c.id, c.full_name, c.status, c.primary_coach_id, c.academy_level_id, levels.name as level_name, levels.slug as level_slug, levels.color_hex, groups.name as group_name, coaches.full_name as coach_name')
144            ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
145            ->join('academy_groups groups', 'groups.id = c.academy_group_id', 'left')
146            ->join('coaches', 'coaches.id = c.primary_coach_id', 'left')
147            ->orderBy('c.full_name', 'ASC');
148
149        if ($filters['selected_coach_id'] > 0) {
150            $builder->where('c.primary_coach_id', $filters['selected_coach_id']);
151        }
152
153        if ($filters['selected_level_id'] > 0) {
154            $builder->where('c.academy_level_id', $filters['selected_level_id']);
155        }
156
157        if ($filters['selected_child_status'] !== 'all') {
158            $builder->where('c.status', $filters['selected_child_status']);
159        }
160
161        return $builder->get()->getResultArray();
162    }
163
164    /**
165     * @param list<int> $childIds
166     * @return list<array<string, mixed>>
167     */
168    private function getEvaluations(array $childIds, ?string $dateStart, ?string $dateEnd): array
169    {
170        if ($childIds === []) {
171            return [];
172        }
173
174        $builder = $this->db()
175            ->table('evaluations e')
176            ->select('e.id, e.child_id, e.evaluation_date, e.final_score, e.final_status, e.promotion_signal, children.full_name as child_name, types.name as type_name, types.slug as type_slug')
177            ->join('children', 'children.id = e.child_id')
178            ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
179            ->whereIn('e.child_id', $childIds)
180            ->orderBy('e.evaluation_date', 'DESC')
181            ->orderBy('e.id', 'DESC');
182
183        if ($dateStart !== null && $dateEnd !== null) {
184            $builder->where('e.evaluation_date >=', $dateStart)->where('e.evaluation_date <=', $dateEnd);
185        }
186
187        return $builder->get()->getResultArray();
188    }
189
190    /**
191     * @param list<int> $childIds
192     * @return list<array<string, mixed>>
193     */
194    private function getAttendanceRows(array $childIds, string $dateStart, string $dateEnd): array
195    {
196        if ($childIds === []) {
197            return [];
198        }
199
200        return $this->db()
201            ->table('attendance_records ar')
202            ->select('ar.child_id, ar.status, children.full_name as child_name, sessions.session_date')
203            ->join('children', 'children.id = ar.child_id')
204            ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
205            ->whereIn('ar.child_id', $childIds)
206            ->where('sessions.is_cancelled', 0)
207            ->where('sessions.session_date >=', $dateStart)
208            ->where('sessions.session_date <=', $dateEnd)
209            ->get()
210            ->getResultArray();
211    }
212
213    /**
214     * @param list<array<string, mixed>> $evaluations
215     * @return array<int, array<string, mixed>>
216     */
217    private function latestEvaluationsByChild(array $evaluations, ?string $typeSlug): array
218    {
219        $latest = [];
220
221        foreach ($evaluations as $evaluation) {
222            if ($typeSlug !== null && $evaluation['type_slug'] !== $typeSlug) {
223                continue;
224            }
225
226            $childId = (int) $evaluation['child_id'];
227            if (! isset($latest[$childId])) {
228                $latest[$childId] = $evaluation;
229            }
230        }
231
232        return $latest;
233    }
234
235    /**
236     * @param list<array<string, mixed>> $activeChildren
237     * @param list<array<string, mixed>> $periodEvaluations
238     * @param array<int, array<string, mixed>> $latestFull
239     * @param list<array<string, mixed>> $attendanceRows
240     * @return list<array<string, mixed>>
241     */
242    private function buildKpis(array $activeChildren, array $periodEvaluations, array $latestFull, array $attendanceRows): array
243    {
244        $readyCount = 0;
245        $almostCount = 0;
246
247        foreach ($activeChildren as $child) {
248            $latest = $latestFull[(int) $child['id']] ?? null;
249
250            if (! $latest) {
251                continue;
252            }
253
254            if ($latest['final_status'] === 'READY') {
255                $readyCount++;
256            }
257
258            if ($latest['final_status'] === 'ALMOST READY') {
259                $almostCount++;
260            }
261        }
262
263        $attendanceBreakdown = [
264            'present' => 0,
265            'recovery' => 0,
266            'absent_excused' => 0,
267            'absent_unexcused' => 0,
268        ];
269
270        foreach ($attendanceRows as $row) {
271            $status = (string) $row['status'];
272            if (! isset($attendanceBreakdown[$status])) {
273                $attendanceBreakdown[$status] = 0;
274            }
275
276            $attendanceBreakdown[$status]++;
277        }
278
279        return [
280            [
281                'label' => 'Copii activi',
282                'value' => count($activeChildren),
283                'meta' => 'Inscrieri active in selectie',
284                'icon' => 'children',
285            ],
286            [
287                'label' => 'Evaluari in perioada',
288                'value' => count($periodEvaluations),
289                'meta' => 'Quick Check + Full Evaluation',
290                'icon' => 'evaluations',
291            ],
292            [
293                'label' => 'Ready',
294                'value' => $readyCount,
295                'meta' => 'Ultimul full evaluation este READY',
296                'icon' => 'ready',
297            ],
298            [
299                'label' => 'Aproape gata',
300                'value' => $almostCount,
301                'meta' => 'Copii aproape de promovare',
302                'icon' => 'almost',
303            ],
304            [
305                'label' => 'Rata prezenta',
306                'value' => format_percentage($this->attendanceService->calculateRateFromBreakdown($attendanceBreakdown), 1),
307                'meta' => 'Raport prezente + recovery / total sesiuni',
308                'icon' => 'attendance',
309            ],
310        ];
311    }
312
313    /**
314     * @param list<array<string, mixed>> $activeChildren
315     * @return array<string, mixed>
316     */
317    private function buildLevelDistribution(array $activeChildren): array
318    {
319        $grouped = [];
320
321        foreach ($activeChildren as $child) {
322            $key = (string) $child['level_slug'];
323            if (! isset($grouped[$key])) {
324                $grouped[$key] = [
325                    'label' => $child['level_name'],
326                    'count' => 0,
327                    'color' => $child['color_hex'] ?: '#94a3b8',
328                ];
329            }
330
331            $grouped[$key]['count']++;
332        }
333
334        return [
335            'labels' => array_values(array_map(static fn (array $row): string => $row['label'], $grouped)),
336            'series' => array_values(array_map(static fn (array $row): int => $row['count'], $grouped)),
337            'colors' => array_values(array_map(static fn (array $row): string => $row['color'], $grouped)),
338        ];
339    }
340
341    /**
342     * @param list<array<string, mixed>> $activeChildren
343     * @return array<string, mixed>
344     */
345    private function buildChildrenPerCoach(array $activeChildren): array
346    {
347        $grouped = [];
348
349        foreach ($activeChildren as $child) {
350            $key = $child['coach_name'] ?: 'Neasignat';
351            if (! isset($grouped[$key])) {
352                $grouped[$key] = 0;
353            }
354
355            $grouped[$key]++;
356        }
357
358        return [
359            'categories' => array_keys($grouped),
360            'series' => array_values($grouped),
361        ];
362    }
363
364    /**
365     * @param list<array<string, mixed>> $periodEvaluations
366     * @return list<array<string, mixed>>
367     */
368    private function buildRecentEvaluations(array $periodEvaluations): array
369    {
370        return array_slice(array_map(static function (array $evaluation): array {
371            return [
372                'child_name' => $evaluation['child_name'],
373                'date' => $evaluation['evaluation_date'],
374                'type_name' => $evaluation['type_name'],
375                'final_score' => number_format((float) $evaluation['final_score'], 2),
376                'final_status' => $evaluation['final_status'],
377            ];
378        }, $periodEvaluations), 0, 6);
379    }
380
381    /**
382     * @param list<array<string, mixed>> $children
383     * @param array<int, array<string, mixed>> $latestFull
384     * @param array<int, array<string, mixed>> $latestAny
385     * @param list<array<string, mixed>> $attendanceRows
386     * @param array<string, mixed> $filters
387     * @return list<array<string, mixed>>
388     */
389    private function buildAlerts(array $children, array $latestFull, array $latestAny, array $attendanceRows, array $filters): array
390    {
391        $buckets = [
392            'ready' => [],
393            'almost' => [],
394            'attendance' => [],
395            'overdue' => [],
396        ];
397        $attendanceByChild = [];
398
399        foreach ($attendanceRows as $row) {
400            $childId = (int) $row['child_id'];
401            if (! isset($attendanceByChild[$childId])) {
402                $attendanceByChild[$childId] = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
403            }
404
405            $attendanceByChild[$childId][$row['status']]++;
406        }
407
408        foreach ($children as $child) {
409            if ($child['status'] === 'left') {
410                continue;
411            }
412
413            $childId   = (int) $child['id'];
414            $childName = $child['full_name'];
415            $latest    = $latestFull[$childId] ?? null;
416
417            if ($latest && $latest['final_status'] === 'READY') {
418                $buckets['ready'][] = [
419                    'type' => 'ready',
420                    'child_name' => $childName,
421                    'title' => 'Ready pentru avansare',
422                    'description' => 'Ultimul full evaluation este READY (' . number_format((float) $latest['final_score'], 2) . ').',
423                ];
424            } elseif ($latest && $latest['final_status'] === 'ALMOST READY') {
425                $buckets['almost'][] = [
426                    'type' => 'almost',
427                    'child_name' => $childName,
428                    'title' => 'Aproape gata',
429                    'description' => 'Copilul este aproape de pragul READY in ultimul full evaluation.',
430                ];
431            }
432
433            $attendance = $attendanceByChild[$childId] ?? ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
434            $attendanceRate = $this->attendanceService->calculateRateFromBreakdown($attendance);
435            $absenceRate    = 100 - $attendanceRate;
436
437            if (array_sum($attendance) > 0 && $absenceRate >= (float) $filters['absence_alert_threshold']) {
438                $buckets['attendance'][] = [
439                    'type' => 'attendance',
440                    'child_name' => $childName,
441                    'title' => 'Absente ridicate',
442                    'description' => 'Absenta estimata la ' . format_percentage($absenceRate, 1) . ' in perioada filtrata.',
443                ];
444            }
445
446            $latestEvaluation = $latestAny[$childId] ?? null;
447            if (! $latestEvaluation) {
448                continue;
449            }
450
451            $daysSinceEvaluation = (new DateTimeImmutable($filters['date_end']))->diff(new DateTimeImmutable($latestEvaluation['evaluation_date']))->days;
452
453            if ($daysSinceEvaluation >= (int) $filters['no_evaluation_days']) {
454                $buckets['overdue'][] = [
455                    'type' => 'overdue',
456                    'child_name' => $childName,
457                    'title' => 'Fara evaluare recenta',
458                    'description' => 'Ultima evaluare a fost inregistrata acum ' . $daysSinceEvaluation . ' zile.',
459                ];
460            }
461        }
462
463        $alerts = array_merge(
464            array_slice($buckets['ready'], 0, 2),
465            array_slice($buckets['almost'], 0, 2),
466            array_slice($buckets['attendance'], 0, 2),
467            array_slice($buckets['overdue'], 0, 2),
468        );
469
470        return array_slice($alerts, 0, 8);
471    }
472
473    /**
474     * @return array<string, string>
475     */
476    private function settingsMap(): array
477    {
478        $rows = $this->db()
479            ->table('settings')
480            ->select('`key`, value')
481            ->get()
482            ->getResultArray();
483
484        $settings = [];
485        foreach ($rows as $row) {
486            $settings[$row['key']] = $row['value'];
487        }
488
489        return $settings;
490    }
491
492    private function db()
493    {
494        return db_connect();
495    }
496}