Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.63% covered (danger)
48.63%
249 / 512
33.33% covered (danger)
33.33%
9 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
AttendanceService
48.63% covered (danger)
48.63%
249 / 512
33.33% covered (danger)
33.33%
9 / 27
2038.34
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
 calculateRate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 calculateRateFromBreakdown
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getIndexData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getFormData
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
42
 saveSession
83.72% covered (warning)
83.72%
36 / 43
0.00% covered (danger)
0.00%
0 / 1
15.97
 getSessionData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 saveCheckIn
83.33% covered (warning)
83.33%
40 / 48
0.00% covered (danger)
0.00%
0 / 1
16.04
 buildFilters
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
20
 resolveDates
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getSessionRows
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
110
 buildIndexSummary
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 validateSessionPayload
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
16.40
 normalizeSessionPayload
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 groupOptions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 coachOptions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 recoverySessionOptions
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 sessionMetricMap
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 groupRosterCountMap
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getSessionRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getSessionRoster
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 buildRosterBreakdown
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 recordRecoveryJourneyEvents
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 createAttendanceIssueIfNeeded
96.00% covered (success)
96.00%
48 / 50
0.00% covered (danger)
0.00%
0 / 1
6
 createAttendanceNotifications
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
7
 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 App\Models\AttendanceRecordModel;
6use App\Models\AttendanceSessionModel;
7use App\Models\NotificationModel;
8use CodeIgniter\Shield\Entities\User;
9use DateTimeImmutable;
10
11class AttendanceService
12{
13    private AuthorizationService $authorizationService;
14    private JourneyService $journeyService;
15
16    public function __construct()
17    {
18        helper(['academy', 'url']);
19
20        $this->authorizationService = service('authorization');
21        $this->journeyService       = service('journey');
22    }
23
24    public function calculateRate(int $attendedSessions, int $totalSessions): float
25    {
26        if ($totalSessions === 0) {
27            return 0.0;
28        }
29
30        return round(($attendedSessions / $totalSessions) * 100, 1);
31    }
32
33    /**
34     * @param array<string, int> $totals
35     */
36    public function calculateRateFromBreakdown(array $totals): float
37    {
38        $attended = (int) ($totals['present'] ?? 0) + (int) ($totals['recovery'] ?? 0);
39        $total    = array_sum($totals);
40
41        return $this->calculateRate($attended, $total);
42    }
43
44    /**
45     * @param array<string, mixed> $input
46     * @return array<string, mixed>
47     */
48    public function getIndexData(array $input, User $user): array
49    {
50        $filters  = $this->buildFilters($input, (int) $user->id);
51        $sessions = $this->getSessionRows($filters);
52
53        return [
54            'pageTitle' => 'Prezenta',
55            'pageDescription' => 'Planificare sesiuni, check-in rapid pe grupe si control asupra absentelor sau recovery sessions.',
56            'filters' => $filters,
57            'summary' => $this->buildIndexSummary($sessions),
58            'sessions' => $sessions,
59            'canCreate' => $filters['accessible_group_ids'] !== [],
60        ];
61    }
62
63    /**
64     * @param array<string, mixed> $input
65     * @param array<string, string> $errors
66     * @return array<string, mixed>
67     */
68    public function getFormData(User $user, array $input = [], array $errors = []): array
69    {
70        $userId          = (int) $user->id;
71        $primaryRole     = $this->authorizationService->getPrimaryRole($userId);
72        $lockedTrainerId   = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
73        $groupOptions    = $this->groupOptions($userId, $lockedTrainerId);
74        $coachOptions    = $this->coachOptions($lockedTrainerId);
75        $recoveryOptions = $this->recoverySessionOptions($userId);
76
77        $values = [
78            'academy_group_id' => (string) ($groupOptions[0]['id'] ?? ''),
79            'coach_id' => (string) ($lockedTrainerId ?? ''),
80            'session_date' => date('Y-m-d'),
81            'start_time' => '17:00',
82            'end_time' => '18:30',
83            'status' => 'scheduled',
84            'cancelled_reason' => '',
85            'recovery_for_session_id' => '',
86            'notes' => '',
87        ];
88
89        foreach ($input as $key => $value) {
90            if (array_key_exists($key, $values) && is_scalar($value)) {
91                $values[$key] = trim((string) $value);
92            }
93        }
94
95        if ($lockedTrainerId !== null) {
96            $values['coach_id'] = (string) $lockedTrainerId;
97        }
98
99        return [
100            'pageTitle' => 'Sesiune noua',
101            'pageDescription' => 'Configureaza o sedinta noua, inclusiv scenariile de anulare sau recovery session.',
102            'values' => $values,
103            'errors' => $errors,
104            'groupOptions' => $groupOptions,
105            'coachOptions' => $coachOptions,
106            'recoveryOptions' => $recoveryOptions,
107            'statusOptions' => session_status_options(),
108            'coachLocked' => $lockedTrainerId !== null,
109            'submitUrl' => url_to('attendance.create'),
110            'cancelUrl' => url_to('attendance.index'),
111        ];
112    }
113
114    /**
115     * @param array<string, mixed> $input
116     * @return array{success: bool, session_id?: int, errors?: array<string, string>, forbidden?: bool}
117     */
118    public function saveSession(array $input, User $user): array
119    {
120        $validation = service('validation');
121        $validation->setRules(config('Validation')->attendanceSession);
122
123        $payload = $this->normalizeSessionPayload($input);
124
125        if (! $validation->run($payload)) {
126            return [
127                'success' => false,
128                'errors' => $validation->getErrors(),
129            ];
130        }
131
132        $userId      = (int) $user->id;
133        $primaryRole = $this->authorizationService->getPrimaryRole($userId);
134        $lockedTrainer = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
135        $groupId     = (int) $payload['academy_group_id'];
136
137        if (! $this->authorizationService->userCanAccessGroup($userId, $groupId)) {
138            return ['success' => false, 'forbidden' => true];
139        }
140
141        if ($lockedTrainer !== null) {
142            $payload['coach_id'] = (string) $lockedTrainer;
143        }
144
145        $extraErrors = $this->validateSessionPayload($payload, $userId);
146        if ($extraErrors !== []) {
147            return ['success' => false, 'errors' => $extraErrors];
148        }
149
150        $now         = date('Y-m-d H:i:s');
151        $status      = $payload['status'];
152        $isCancelled = $status === 'cancelled' ? 1 : 0;
153
154        model(AttendanceSessionModel::class)->insert([
155            'academy_group_id' => $groupId,
156            'coach_id' => $payload['coach_id'] !== '' ? (int) $payload['coach_id'] : null,
157            'session_date' => $payload['session_date'],
158            'start_time' => $payload['start_time'] !== '' ? $payload['start_time'] . ':00' : null,
159            'end_time' => $payload['end_time'] !== '' ? $payload['end_time'] . ':00' : null,
160            'status' => $status,
161            'is_cancelled' => $isCancelled,
162            'cancelled_reason' => $payload['cancelled_reason'] !== '' ? $payload['cancelled_reason'] : null,
163            'recovery_for_session_id' => $payload['recovery_for_session_id'] !== '' ? (int) $payload['recovery_for_session_id'] : null,
164            'notes' => $payload['notes'] !== '' ? $payload['notes'] : null,
165            'created_at' => $now,
166            'updated_at' => $now,
167        ]);
168
169        $sessionId = (int) model(AttendanceSessionModel::class)->getInsertID();
170
171        if ($status === 'recovery') {
172            $this->recordRecoveryJourneyEvents($sessionId, $groupId, $userId, $payload['session_date'], $payload['recovery_for_session_id'] !== '' ? (int) $payload['recovery_for_session_id'] : null);
173        }
174
175        return [
176            'success' => true,
177            'session_id' => $sessionId,
178        ];
179    }
180
181    /**
182     * @return array<string, mixed>|null
183     */
184    public function getSessionData(int $sessionId, User $user): ?array
185    {
186        $session = $this->getSessionRow($sessionId);
187        if ($session === null || ! $this->authorizationService->userCanAccessSession((int) $user->id, $sessionId)) {
188            return null;
189        }
190
191        $roster    = $this->getSessionRoster($sessionId, (int) $session['academy_group_id']);
192        $breakdown = $this->buildRosterBreakdown($roster);
193
194        return [
195            'pageTitle' => 'Check-in sesiune',
196            'pageDescription' => 'Operare prezenta pe grupa, inclusiv absente motivate, nemotivate si recuperari.',
197            'session' => $session,
198            'roster' => $roster,
199            'breakdown' => $breakdown,
200            'recordOptions' => attendance_record_options(),
201            'saveUrl' => url_to('attendance.checkIn', $sessionId),
202            'backUrl' => url_to('attendance.index'),
203        ];
204    }
205
206    /**
207     * @param array<string, mixed> $input
208     * @return array{success: bool, errors?: array<string, string>, forbidden?: bool}
209     */
210    public function saveCheckIn(int $sessionId, array $input, User $user): array
211    {
212        $session = $this->getSessionRow($sessionId);
213        if ($session === null) {
214            return ['success' => false, 'errors' => ['general' => 'Sesiunea nu exista.']];
215        }
216
217        $userId = (int) $user->id;
218        if (! $this->authorizationService->userCanAccessSession($userId, $sessionId)) {
219            return ['success' => false, 'forbidden' => true];
220        }
221
222        if ((int) $session['is_cancelled'] === 1) {
223            return ['success' => false, 'errors' => ['general' => 'Sesiunea este anulata si nu poate primi check-in.']];
224        }
225
226        $records = $input['records'] ?? null;
227        if (! is_array($records)) {
228            return ['success' => false, 'errors' => ['general' => 'Nu au fost transmise inregistrari de prezenta.']];
229        }
230
231        $roster  = $this->getSessionRoster($sessionId, (int) $session['academy_group_id']);
232        $allowed = array_keys(attendance_record_options());
233        $now     = date('Y-m-d H:i:s');
234        $db      = $this->db();
235
236        $db->transStart();
237
238        foreach ($roster as $row) {
239            $childId      = (int) $row['id'];
240            $recordInput  = $records[$childId] ?? [];
241            $status       = trim((string) ($recordInput['status'] ?? ($row['attendance_status'] ?? 'present')));
242            $notes        = trim((string) ($recordInput['notes'] ?? ''));
243            $recordId     = $row['attendance_record_id'] !== null ? (int) $row['attendance_record_id'] : null;
244
245            if (! in_array($status, $allowed, true)) {
246                $db->transRollback();
247
248                return ['success' => false, 'errors' => ['general' => 'Unul dintre statusurile de prezenta nu este valid.']];
249            }
250
251            $payload = [
252                'attendance_session_id' => $sessionId,
253                'child_id' => $childId,
254                'status' => $status,
255                'checked_in_at' => in_array($status, ['present', 'recovery'], true)
256                    ? $session['session_date'] . ' ' . ($session['start_time'] ?: '17:00:00')
257                    : null,
258                'notes' => $notes !== '' ? $notes : null,
259                'updated_at' => $now,
260            ];
261
262            if ($recordId === null) {
263                $payload['created_at'] = $now;
264                model(AttendanceRecordModel::class)->insert($payload);
265            } else {
266                model(AttendanceRecordModel::class)->update($recordId, $payload);
267            }
268
269            if (in_array($status, ['absent_excused', 'absent_unexcused'], true)) {
270                $this->createAttendanceIssueIfNeeded($childId, $userId, $session, $status);
271            }
272        }
273
274        model(AttendanceSessionModel::class)->update($sessionId, [
275            'status' => $session['status'] === 'scheduled' ? 'completed' : $session['status'],
276            'updated_at' => $now,
277        ]);
278
279        $db->transComplete();
280
281        if (! $db->transStatus()) {
282            return ['success' => false, 'errors' => ['general' => 'Check-in-ul nu a putut fi salvat.']];
283        }
284
285        return ['success' => true];
286    }
287
288    /**
289     * @param array<string, mixed> $input
290     * @return array<string, mixed>
291     */
292    private function buildFilters(array $input, int $userId): array
293    {
294        $settings       = $this->settingsMap();
295        $primaryRole    = $this->authorizationService->getPrimaryRole($userId);
296        $lockedTrainerId  = $primaryRole === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
297        $selectedPeriod = (string) ($input['period'] ?? ($settings['default_dashboard_period'] ?? '30'));
298        $today          = new DateTimeImmutable('today');
299        [$dateStart, $dateEnd] = $this->resolveDates($selectedPeriod, $today);
300
301        $groupOptions  = $this->groupOptions($userId, $lockedTrainerId);
302        $coachOptions  = $this->coachOptions($lockedTrainerId);
303        $selectedGroup = (int) ($input['group_id'] ?? 0);
304        $selectedTrainer = $lockedTrainerId ?: (int) ($input['coach_id'] ?? 0);
305        $selectedStatus = (string) ($input['status'] ?? 'all');
306
307        if (! in_array($selectedStatus, ['all', 'scheduled', 'completed', 'cancelled', 'recovery'], true)) {
308            $selectedStatus = 'all';
309        }
310
311        return [
312            'current_role' => $primaryRole,
313            'coach_locked' => $lockedTrainerId !== null,
314            'selected_period' => $selectedPeriod,
315            'selected_group_id' => $selectedGroup,
316            'selected_coach_id' => $selectedTrainer,
317            'selected_status' => $selectedStatus,
318            'date_start' => $dateStart,
319            'date_end' => $dateEnd,
320            'period_options' => [
321                '7' => 'Ultimele 7 zile',
322                '30' => 'Ultimele 30 zile',
323                '90' => 'Ultimele 90 zile',
324                'month' => 'Luna curenta',
325            ],
326            'status_options' => [
327                'all' => 'Toate statusurile',
328                'scheduled' => 'Scheduled',
329                'completed' => 'Completed',
330                'cancelled' => 'Cancelled',
331                'recovery' => 'Recovery',
332            ],
333            'group_options' => $groupOptions,
334            'coach_options' => $coachOptions,
335            'accessible_group_ids' => array_values(array_map(static fn (array $row): int => (int) $row['id'], $groupOptions)),
336        ];
337    }
338
339    /**
340     * @return array{0: string, 1: string}
341     */
342    private function resolveDates(string $selectedPeriod, DateTimeImmutable $today): array
343    {
344        if ($selectedPeriod === 'month') {
345            return [
346                $today->modify('first day of this month')->format('Y-m-d'),
347                $today->format('Y-m-d'),
348            ];
349        }
350
351        $days = (int) $selectedPeriod;
352        if (! in_array($days, [7, 30, 90], true)) {
353            $days = 30;
354        }
355
356        return [
357            $today->modify('-' . ($days - 1) . ' days')->format('Y-m-d'),
358            $today->format('Y-m-d'),
359        ];
360    }
361
362    /**
363     * @param array<string, mixed> $filters
364     * @return list<array<string, mixed>>
365     */
366    private function getSessionRows(array $filters): array
367    {
368        if ($filters['coach_locked'] && $filters['accessible_group_ids'] === []) {
369            return [];
370        }
371
372        $builder = $this->db()
373            ->table('attendance_sessions s')
374            ->select('s.*, groups.name as group_name, groups.code as group_code, coaches.full_name as coach_name')
375            ->join('academy_groups groups', 'groups.id = s.academy_group_id')
376            ->join('coaches', 'coaches.id = s.coach_id', 'left')
377            ->where('s.session_date >=', $filters['date_start'])
378            ->where('s.session_date <=', $filters['date_end'])
379            ->orderBy('s.session_date', 'DESC')
380            ->orderBy('s.id', 'DESC');
381
382        if ($filters['coach_locked']) {
383            $builder->whereIn('s.academy_group_id', $filters['accessible_group_ids']);
384        }
385
386        if ($filters['selected_group_id'] > 0) {
387            $builder->where('s.academy_group_id', $filters['selected_group_id']);
388        }
389
390        if ($filters['selected_coach_id'] > 0) {
391            $builder->where('s.coach_id', $filters['selected_coach_id']);
392        }
393
394        if ($filters['selected_status'] !== 'all') {
395            $builder->where('s.status', $filters['selected_status']);
396        }
397
398        $sessions = $builder->get()->getResultArray();
399        if ($sessions === []) {
400            return [];
401        }
402
403        $sessionIds = array_values(array_map(static fn (array $row): int => (int) $row['id'], $sessions));
404        $groupIds   = array_values(array_unique(array_map(static fn (array $row): int => (int) $row['academy_group_id'], $sessions)));
405        $metrics    = $this->sessionMetricMap($sessionIds);
406        $rosters    = $this->groupRosterCountMap($groupIds);
407
408        foreach ($sessions as &$session) {
409            $sessionId                  = (int) $session['id'];
410            $groupId                    = (int) $session['academy_group_id'];
411            $stats                      = $metrics[$sessionId] ?? ['attended' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0, 'total' => 0];
412            $session['participants']    = $stats['total'] > 0 ? $stats['total'] : ($rosters[$groupId] ?? 0);
413            $session['attendance_rate'] = $this->calculateRate((int) $stats['attended'], (int) $stats['total']);
414            $session['attendance_label'] = format_percentage((float) $session['attendance_rate'], 1);
415            $session['breakdown']       = $stats;
416        }
417        unset($session);
418
419        return $sessions;
420    }
421
422    /**
423     * @param list<array<string, mixed>> $sessions
424     * @return list<array<string, mixed>>
425     */
426    private function buildIndexSummary(array $sessions): array
427    {
428        $attendanceRates = [];
429        $cancelled       = 0;
430        $completed       = 0;
431        $recovery        = 0;
432
433        foreach ($sessions as $session) {
434            if ($session['status'] === 'cancelled') {
435                $cancelled++;
436            }
437
438            if ($session['status'] === 'completed') {
439                $completed++;
440            }
441
442            if ($session['status'] === 'recovery') {
443                $recovery++;
444            }
445
446            if ((int) ($session['breakdown']['total'] ?? 0) > 0) {
447                $attendanceRates[] = (float) $session['attendance_rate'];
448            }
449        }
450
451        $avgAttendance = $attendanceRates === [] ? 0.0 : round(array_sum($attendanceRates) / count($attendanceRates), 1);
452
453        return [
454            ['label' => 'Sesiuni in selectie', 'value' => count($sessions), 'meta' => 'Toate sedintele filtrate'],
455            ['label' => 'Finalizate', 'value' => $completed, 'meta' => 'Sedinte complet operate'],
456            ['label' => 'Recovery', 'value' => $recovery, 'meta' => 'Sedinte de recuperare'],
457            ['label' => 'Anulate', 'value' => $cancelled, 'meta' => 'Sedinte anulate'],
458            ['label' => 'Prezenta medie', 'value' => format_percentage($avgAttendance, 1), 'meta' => 'Doar sesiuni cu check-in salvat'],
459        ];
460    }
461
462    /**
463     * @param array<string, string> $payload
464     * @return array<string, string>
465     */
466    private function validateSessionPayload(array $payload, int $userId): array
467    {
468        $errors = [];
469
470        if ($payload['start_time'] !== '' && $payload['end_time'] !== '' && strtotime($payload['start_time']) >= strtotime($payload['end_time'])) {
471            $errors['end_time'] = 'Ora de final trebuie sa fie dupa ora de start.';
472        }
473
474        if ($payload['status'] === 'cancelled' && $payload['cancelled_reason'] === '') {
475            $errors['cancelled_reason'] = 'Explica motivul anularii sedintei.';
476        }
477
478        if ($payload['status'] === 'recovery' && $payload['recovery_for_session_id'] === '') {
479            $errors['recovery_for_session_id'] = 'Selecteaza sedinta anulata pe care o recuperezi.';
480        }
481
482        if ($payload['recovery_for_session_id'] !== '' && ! $this->authorizationService->userCanAccessSession($userId, (int) $payload['recovery_for_session_id'])) {
483            $errors['recovery_for_session_id'] = 'Nu ai acces la sesiunea selectata pentru recovery.';
484        }
485
486        return $errors;
487    }
488
489    /**
490     * @param array<string, mixed> $input
491     * @return array<string, string>
492     */
493    private function normalizeSessionPayload(array $input): array
494    {
495        return [
496            'academy_group_id' => trim((string) ($input['academy_group_id'] ?? '')),
497            'coach_id' => trim((string) ($input['coach_id'] ?? '')),
498            'session_date' => trim((string) ($input['session_date'] ?? '')),
499            'start_time' => trim((string) ($input['start_time'] ?? '')),
500            'end_time' => trim((string) ($input['end_time'] ?? '')),
501            'status' => trim((string) ($input['status'] ?? 'scheduled')),
502            'cancelled_reason' => trim((string) ($input['cancelled_reason'] ?? '')),
503            'recovery_for_session_id' => trim((string) ($input['recovery_for_session_id'] ?? '')),
504            'notes' => trim((string) ($input['notes'] ?? '')),
505        ];
506    }
507
508    /**
509     * @return list<array<string, mixed>>
510     */
511    private function groupOptions(int $userId, ?int $lockedTrainerId = null): array
512    {
513        $builder = $this->db()
514            ->table('academy_groups groups')
515            ->select('groups.id, groups.name, groups.code, groups.primary_coach_id, coaches.full_name as coach_name')
516            ->join('coaches', 'coaches.id = groups.primary_coach_id', 'left')
517            ->where('groups.status', 'active')
518            ->orderBy('groups.name', 'ASC');
519
520        if ($lockedTrainerId !== null) {
521            $builder->where('groups.primary_coach_id', $lockedTrainerId);
522        } elseif (! $this->authorizationService->userHasRole($userId, 'admin')) {
523            return [];
524        }
525
526        return $builder->get()->getResultArray();
527    }
528
529    /**
530     * @return list<array<string, mixed>>
531     */
532    private function coachOptions(?int $lockedTrainerId = null): array
533    {
534        $builder = $this->db()
535            ->table('coaches')
536            ->select('id, full_name')
537            ->where('is_active', 1)
538            ->orderBy('full_name', 'ASC');
539
540        if ($lockedTrainerId !== null) {
541            $builder->where('id', $lockedTrainerId);
542        }
543
544        return $builder->get()->getResultArray();
545    }
546
547    /**
548     * @return list<array<string, mixed>>
549     */
550    private function recoverySessionOptions(int $userId): array
551    {
552        $builder = $this->db()
553            ->table('attendance_sessions s')
554            ->select('s.id, s.session_date, groups.name as group_name')
555            ->join('academy_groups groups', 'groups.id = s.academy_group_id')
556            ->where('s.is_cancelled', 1)
557            ->orderBy('s.session_date', 'DESC')
558            ->limit(12);
559
560        if ($this->authorizationService->userHasRole($userId, 'coach') && ! $this->authorizationService->userHasRole($userId, 'admin')) {
561            $groupIds = array_values(array_map(static fn (array $row): int => (int) $row['id'], $this->groupOptions($userId, $this->authorizationService->getCoachIdForUser($userId))));
562            if ($groupIds === []) {
563                return [];
564            }
565            $builder->whereIn('s.academy_group_id', $groupIds);
566        }
567
568        return $builder->get()->getResultArray();
569    }
570
571    /**
572     * @param list<int> $sessionIds
573     * @return array<int, array<string, int>>
574     */
575    private function sessionMetricMap(array $sessionIds): array
576    {
577        if ($sessionIds === []) {
578            return [];
579        }
580
581        $rows = $this->db()
582            ->table('attendance_records')
583            ->select("
584                attendance_session_id,
585                SUM(CASE WHEN status IN ('present', 'recovery') THEN 1 ELSE 0 END) as attended,
586                SUM(CASE WHEN status = 'recovery' THEN 1 ELSE 0 END) as recovery,
587                SUM(CASE WHEN status = 'absent_excused' THEN 1 ELSE 0 END) as absent_excused,
588                SUM(CASE WHEN status = 'absent_unexcused' THEN 1 ELSE 0 END) as absent_unexcused,
589                COUNT(*) as total
590            ", false)
591            ->whereIn('attendance_session_id', $sessionIds)
592            ->groupBy('attendance_session_id')
593            ->get()
594            ->getResultArray();
595
596        $map = [];
597        foreach ($rows as $row) {
598            $map[(int) $row['attendance_session_id']] = [
599                'attended' => (int) $row['attended'],
600                'recovery' => (int) $row['recovery'],
601                'absent_excused' => (int) $row['absent_excused'],
602                'absent_unexcused' => (int) $row['absent_unexcused'],
603                'total' => (int) $row['total'],
604            ];
605        }
606
607        return $map;
608    }
609
610    /**
611     * @param list<int> $groupIds
612     * @return array<int, int>
613     */
614    private function groupRosterCountMap(array $groupIds): array
615    {
616        if ($groupIds === []) {
617            return [];
618        }
619
620        $rows = $this->db()
621            ->table('children')
622            ->select('academy_group_id, COUNT(*) as total')
623            ->whereIn('academy_group_id', $groupIds)
624            ->where('status !=', 'left')
625            ->groupBy('academy_group_id')
626            ->get()
627            ->getResultArray();
628
629        $map = [];
630        foreach ($rows as $row) {
631            $map[(int) $row['academy_group_id']] = (int) $row['total'];
632        }
633
634        return $map;
635    }
636
637    /**
638     * @return array<string, mixed>|null
639     */
640    private function getSessionRow(int $sessionId): ?array
641    {
642        return $this->db()
643            ->table('attendance_sessions s')
644            ->select('s.*, groups.name as group_name, groups.code as group_code, groups.primary_coach_id as group_primary_coach_id, coaches.full_name as coach_name, recovery.session_date as recovery_source_date')
645            ->join('academy_groups groups', 'groups.id = s.academy_group_id')
646            ->join('coaches', 'coaches.id = s.coach_id', 'left')
647            ->join('attendance_sessions recovery', 'recovery.id = s.recovery_for_session_id', 'left')
648            ->where('s.id', $sessionId)
649            ->get()
650            ->getRowArray();
651    }
652
653    /**
654     * @return list<array<string, mixed>>
655     */
656    private function getSessionRoster(int $sessionId, int $groupId): array
657    {
658        $rows = $this->db()
659            ->table('children c')
660            ->select('c.id, c.full_name, c.status as child_status, levels.name as level_name, ar.id as attendance_record_id, ar.status as attendance_status, ar.notes as attendance_notes, ar.checked_in_at')
661            ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
662            ->join('attendance_records ar', 'ar.child_id = c.id AND ar.attendance_session_id = ' . $sessionId, 'left')
663            ->where('c.academy_group_id', $groupId)
664            ->where('c.status !=', 'left')
665            ->orderBy('c.full_name', 'ASC')
666            ->get()
667            ->getResultArray();
668
669        foreach ($rows as &$row) {
670            $row['selected_status'] = $row['attendance_status'] ?? 'present';
671        }
672        unset($row);
673
674        return $rows;
675    }
676
677    /**
678     * @param list<array<string, mixed>> $roster
679     * @return array<string, int|float>
680     */
681    private function buildRosterBreakdown(array $roster): array
682    {
683        $saved = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
684
685        foreach ($roster as $row) {
686            $status = $row['attendance_status'];
687            if ($status === null || ! isset($saved[$status])) {
688                continue;
689            }
690
691            $saved[$status]++;
692        }
693
694        $saved['participants'] = count($roster);
695        $saved['saved_records'] = array_sum(array_intersect_key($saved, array_flip(['present', 'recovery', 'absent_excused', 'absent_unexcused'])));
696        $saved['attendance_rate'] = $this->calculateRateFromBreakdown($saved);
697
698        return $saved;
699    }
700
701    private function recordRecoveryJourneyEvents(int $sessionId, int $groupId, int $userId, string $sessionDate, ?int $recoverySourceId): void
702    {
703        $children = $this->db()
704            ->table('children')
705            ->select('id')
706            ->where('academy_group_id', $groupId)
707            ->where('status !=', 'left')
708            ->get()
709            ->getResultArray();
710
711        foreach ($children as $child) {
712            $this->journeyService->recordEvent([
713                'child_id' => (int) $child['id'],
714                'event_type' => 'recovery_session_added',
715                'title' => 'Sesiune de recuperare programata',
716                'description' => 'A fost planificata o sesiune de recuperare pentru grupa copilului.',
717                'metadata' => json_encode([
718                    'attendance_session_id' => $sessionId,
719                    'recovery_for_session_id' => $recoverySourceId,
720                    'session_date' => $sessionDate,
721                ], JSON_UNESCAPED_UNICODE),
722                'created_by' => $userId,
723                'created_at' => $sessionDate . ' 08:00:00',
724            ]);
725        }
726    }
727
728    /**
729     * @param array<string, mixed> $session
730     */
731    private function createAttendanceIssueIfNeeded(int $childId, int $userId, array $session, string $status): void
732    {
733        $settings      = $this->settingsMap();
734        $windowDays    = max(7, (int) ($settings['default_dashboard_period'] ?? 30));
735        $threshold     = (float) ($settings['absence_alert_threshold'] ?? 25);
736        $sessionDate   = (string) $session['session_date'];
737        $windowStart   = (new DateTimeImmutable($sessionDate))->modify('-' . ($windowDays - 1) . ' days')->format('Y-m-d');
738        $attendanceRows = $this->db()
739            ->table('attendance_records ar')
740            ->select('ar.status')
741            ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
742            ->where('ar.child_id', $childId)
743            ->where('sessions.is_cancelled', 0)
744            ->where('sessions.session_date >=', $windowStart)
745            ->where('sessions.session_date <=', $sessionDate)
746            ->get()
747            ->getResultArray();
748
749        $breakdown = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
750        foreach ($attendanceRows as $row) {
751            $rowStatus = (string) $row['status'];
752            if (isset($breakdown[$rowStatus])) {
753                $breakdown[$rowStatus]++;
754            }
755        }
756
757        if (array_sum($breakdown) === 0) {
758            return;
759        }
760
761        $absenceRate = 100 - $this->calculateRateFromBreakdown($breakdown);
762        if ($absenceRate < $threshold) {
763            return;
764        }
765
766        $exists = $this->db()
767            ->table('journey_events')
768            ->select('id')
769            ->where('child_id', $childId)
770            ->where('event_type', 'attendance_issue')
771            ->where('created_at >=', $sessionDate . ' 00:00:00')
772            ->where('created_at <=', $sessionDate . ' 23:59:59')
773            ->get()
774            ->getRowArray();
775
776        if ($exists === null) {
777            $this->journeyService->recordEvent([
778                'child_id' => $childId,
779                'event_type' => 'attendance_issue',
780                'title' => 'Avertizare de prezenta',
781                'description' => 'Absenta a depasit pragul configurat pentru perioada operationala curenta.',
782                'metadata' => json_encode([
783                    'absence_rate' => round($absenceRate, 1),
784                    'window_start' => $windowStart,
785                    'window_end' => $sessionDate,
786                    'last_status' => $status,
787                ], JSON_UNESCAPED_UNICODE),
788                'created_by' => $userId,
789                'created_at' => $sessionDate . ' 20:30:00',
790            ]);
791        }
792
793        $this->createAttendanceNotifications($childId, $session, $absenceRate);
794    }
795
796    /**
797     * @param array<string, mixed> $session
798     */
799    private function createAttendanceNotifications(int $childId, array $session, float $absenceRate): void
800    {
801        $targetUserIds = [];
802
803        $adminRows = $this->db()
804            ->table('user_roles')
805            ->select('user_roles.user_id')
806            ->join('roles', 'roles.id = user_roles.role_id')
807            ->where('roles.slug', 'admin')
808            ->get()
809            ->getResultArray();
810
811        foreach ($adminRows as $row) {
812            $targetUserIds[] = (int) $row['user_id'];
813        }
814
815        if (! empty($session['coach_id'])) {
816            $coachUserId = $this->db()
817                ->table('coaches')
818                ->select('user_id')
819                ->where('id', (int) $session['coach_id'])
820                ->get()
821                ->getRowArray();
822
823            if ($coachUserId !== null && ! empty($coachUserId['user_id'])) {
824                $targetUserIds[] = (int) $coachUserId['user_id'];
825            }
826        }
827
828        $targetUserIds = array_values(array_unique($targetUserIds));
829
830        foreach ($targetUserIds as $targetUserId) {
831            $existing = $this->db()
832                ->table('notifications')
833                ->select('id')
834                ->where('user_id', $targetUserId)
835                ->where('child_id', $childId)
836                ->where('type', 'attendance_alert')
837                ->where('status', 'unread')
838                ->get()
839                ->getRowArray();
840
841            if ($existing !== null) {
842                continue;
843            }
844
845            model(NotificationModel::class)->insert([
846                'user_id' => $targetUserId,
847                'child_id' => $childId,
848                'type' => 'attendance_alert',
849                'title' => 'Absente peste prag',
850                'message' => 'Copilul a depasit pragul de absenta cu ' . format_percentage($absenceRate, 1) . ' in perioada recenta.',
851                'status' => 'unread',
852                'action_url' => url_to('children.show', $childId),
853                'created_at' => date('Y-m-d H:i:s'),
854                'read_at' => null,
855            ]);
856        }
857    }
858
859    /**
860     * @return array<string, string>
861     */
862    private function settingsMap(): array
863    {
864        $rows = $this->db()
865            ->table('settings')
866            ->select('`key`, value')
867            ->get()
868            ->getResultArray();
869
870        $settings = [];
871        foreach ($rows as $row) {
872            $settings[$row['key']] = $row['value'];
873        }
874
875        return $settings;
876    }
877
878    private function db()
879    {
880        return db_connect();
881    }
882}