| 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 |
3 | namespace App\Services;
4 |
5 | use CodeIgniter\Shield\Entities\User;
6 | use DateTimeImmutable;
7 |
8 | class 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 | }