| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 69.26% covered (warning) | 69.26% | 320 / 462 | 50.00% covered (danger) | 50.00% | 13 / 26 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
ReportService | 69.26% covered (warning) | 69.26% | 320 / 462 | 50.00% covered (danger) | 50.00% | 13 / 26 | 376.86 | 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 |
getIndexData | 100.00% covered (success) | 100.00% | 9 / 9 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getExportData | 100.00% covered (success) | 100.00% | 6 / 6 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
buildCsv | 88.57% covered (warning) | 88.57% | 31 / 35 | 0.00% covered (danger) | 0.00% | 0 / 1 | 10.15 |
buildPayload | 100.00% covered (success) | 100.00% | 7 / 7 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
buildFilters | 94.34% covered (success) | 94.34% | 50 / 53 | 0.00% covered (danger) | 0.00% | 0 / 1 | 7.01 |
resolveDates | 58.33% covered (warning) | 58.33% | 7 / 12 | 0.00% covered (danger) | 0.00% | 0 / 1 | 3.65 |
buildReport | 60.00% covered (warning) | 60.00% | 6 / 10 | 0.00% covered (danger) | 0.00% | 0 / 1 | 1.06 |
scopedChildren | 85.71% covered (warning) | 85.71% | 12 / 14 | 0.00% covered (danger) | 0.00% | 0 / 1 | 4.05 |
reportChildrenByLevel | 100.00% covered (success) | 100.00% | 24 / 24 | 100.00% covered (success) | 100.00% | 1 / 1 | 5 |
reportChildrenByCoach | 0.00% covered (danger) | 0.00% | 0 / 24 | 0.00% covered (danger) | 0.00% | 0 / 1 | 30 |
reportPromotionStatus | 100.00% covered (success) | 100.00% | 24 / 24 | 100.00% covered (success) | 100.00% | 1 / 1 | 7 |
reportHighAbsence | 0.00% covered (danger) | 0.00% | 0 / 43 | 0.00% covered (danger) | 0.00% | 0 / 1 | 90 |
reportEvaluations | 0.00% covered (danger) | 0.00% | 0 / 31 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
reportChildProgress | 73.85% covered (warning) | 73.85% | 48 / 65 | 0.00% covered (danger) | 0.00% | 0 / 1 | 11.79 |
latestFullEvaluations | 94.44% covered (success) | 94.44% | 17 / 18 | 0.00% covered (danger) | 0.00% | 0 / 1 | 4.00 |
coachOptions | 100.00% covered (success) | 100.00% | 8 / 8 | 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 |
childOptions | 100.00% covered (success) | 100.00% | 7 / 7 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
settingsMap | 100.00% covered (success) | 100.00% | 9 / 9 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
buildExportUrls | 100.00% covered (success) | 100.00% | 5 / 5 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
exportQueryFromFilters | 100.00% covered (success) | 100.00% | 8 / 8 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
buildAppliedFilters | 82.76% covered (warning) | 82.76% | 24 / 29 | 0.00% covered (danger) | 0.00% | 0 / 1 | 5.13 |
findOptionLabel | 83.33% covered (warning) | 83.33% | 5 / 6 | 0.00% covered (danger) | 0.00% | 0 / 1 | 4.07 |
buildFileBasename | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
stringValue | 60.00% covered (warning) | 60.00% | 3 / 5 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6.60 |

1 | <?php
2 |
3 | namespace App\Services;
4 |
5 | use CodeIgniter\Shield\Entities\User;
6 | use DateTimeImmutable;
7 |
8 | class ReportService
9 | {
10 | private AttendanceService $attendanceService;
11 | private AuthorizationService $authorizationService;
12 |
13 | public function __construct()
14 | {
15 | helper(['academy', 'url']);
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 getIndexData(array $input, User $user): array
26 | {
27 | $payload = $this->buildPayload($input, (int) $user->id);
28 |
29 | return [
30 | 'pageTitle' => 'Rapoarte',
31 | 'pageDescription' => 'Set de rapoarte operationale filtrabile pentru administratie, training si discutii de promovare.',
32 | 'filters' => $payload['filters'],
33 | 'report' => $payload['report'],
34 | 'appliedFilters' => $payload['applied_filters'],
35 | 'exportUrls' => $this->buildExportUrls($payload['filters']),
36 | ];
37 | }
38 |
39 | /**
40 | * @param array<string, mixed> $input
41 | * @return array<string, mixed>
42 | */
43 | public function getExportData(array $input, User $user): array
44 | {
45 | $payload = $this->buildPayload($input, (int) $user->id);
46 | $generatedAt = new DateTimeImmutable('now');
47 |
48 | return array_merge($payload, [
49 | 'generated_at_label' => $generatedAt->format('d.m.Y H:i'),
50 | 'file_basename' => $this->buildFileBasename($payload['filters'], $generatedAt),
51 | ]);
52 | }
53 |
54 | /**
55 | * @param array<string, mixed> $exportData
56 | */
57 | public function buildCsv(array $exportData): string
58 | {
59 | $stream = fopen('php://temp', 'r+');
60 | if ($stream === false) {
61 | return '';
62 | }
63 |
64 | fwrite($stream, "\xEF\xBB\xBF");
65 |
66 | fputcsv($stream, [$exportData['report']['title'] ?? 'Raport'], ';');
67 | fputcsv($stream, ['Descriere', (string) ($exportData['report']['description'] ?? '')], ';');
68 | fputcsv($stream, ['Generat la', (string) ($exportData['generated_at_label'] ?? '')], ';');
69 |
70 | foreach ((array) ($exportData['applied_filters'] ?? []) as $filter) {
71 | fputcsv($stream, [(string) ($filter['label'] ?? ''), (string) ($filter['value'] ?? '')], ';');
72 | }
73 |
74 | if (! empty($exportData['report']['summary'])) {
75 | fwrite($stream, PHP_EOL);
76 | fputcsv($stream, ['Rezumat'], ';');
77 | foreach ($exportData['report']['summary'] as $item) {
78 | fputcsv($stream, [
79 | (string) ($item['label'] ?? ''),
80 | $this->stringValue($item['value'] ?? null),
81 | (string) ($item['meta'] ?? ''),
82 | ], ';');
83 | }
84 | }
85 |
86 | fwrite($stream, PHP_EOL);
87 |
88 | $columns = (array) ($exportData['report']['columns'] ?? []);
89 | $rows = (array) ($exportData['report']['rows'] ?? []);
90 |
91 | if ($rows === []) {
92 | $columns = $columns !== [] ? $columns : ['Mesaj'];
93 | fputcsv($stream, $columns, ';');
94 | fputcsv($stream, [(string) ($exportData['report']['empty'] ?? 'Nu exista date pentru export.')], ';');
95 | } else {
96 | fputcsv($stream, $columns, ';');
97 | foreach ($rows as $row) {
98 | $line = [];
99 | foreach ($columns as $column) {
100 | $line[] = $this->stringValue($row[$column] ?? '-');
101 | }
102 |
103 | fputcsv($stream, $line, ';');
104 | }
105 | }
106 |
107 | rewind($stream);
108 | $csv = stream_get_contents($stream) ?: '';
109 | fclose($stream);
110 |
111 | return $csv;
112 | }
113 |
114 | /**
115 | * @param array<string, mixed> $input
116 | * @return array<string, mixed>
117 | */
118 | private function buildPayload(array $input, int $userId): array
119 | {
120 | $filters = $this->buildFilters($input, $userId);
121 | $report = $this->buildReport($filters);
122 |
123 | return [
124 | 'filters' => $filters,
125 | 'report' => $report,
126 | 'applied_filters' => $this->buildAppliedFilters($filters),
127 | ];
128 | }
129 |
130 | /**
131 | * @param array<string, mixed> $input
132 | * @return array<string, mixed>
133 | */
134 | private function buildFilters(array $input, int $userId): array
135 | {
136 | $settings = $this->settingsMap();
137 | $role = $this->authorizationService->getPrimaryRole($userId);
138 | $lockedTrainerId = $role === 'coach' ? $this->authorizationService->getCoachIdForUser($userId) : null;
139 | $selectedReport = (string) ($input['report'] ?? 'children-by-level');
140 | $selectedPeriod = (string) ($input['period'] ?? ($settings['default_dashboard_period'] ?? '30'));
141 | if ($selectedPeriod !== 'month' && ! in_array((int) $selectedPeriod, [7, 30, 90], true)) {
142 | $selectedPeriod = '30';
143 | }
144 | [$dateStart, $dateEnd] = $this->resolveDates($selectedPeriod, new DateTimeImmutable('today'));
145 | $selectedTrainer = $lockedTrainerId ?: (int) ($input['coach_id'] ?? 0);
146 | $selectedLevel = (int) ($input['level_id'] ?? 0);
147 | $selectedStatus = (string) ($input['child_status'] ?? 'all');
148 | $selectedChild = (int) ($input['child_id'] ?? 0);
149 |
150 | if (! in_array($selectedReport, ['children-by-level', 'children-by-coach', 'ready-for-promotion', 'almost-ready', 'high-absence', 'evaluations-period', 'child-progress'], true)) {
151 | $selectedReport = 'children-by-level';
152 | }
153 |
154 | if (! in_array($selectedStatus, ['all', 'active', 'pause', 'left'], true)) {
155 | $selectedStatus = 'all';
156 | }
157 |
158 | return [
159 | 'current_role' => $role,
160 | 'coach_locked' => $lockedTrainerId !== null,
161 | 'selected_report' => $selectedReport,
162 | 'selected_period' => $selectedPeriod,
163 | 'selected_coach_id' => $selectedTrainer,
164 | 'selected_level_id' => $selectedLevel,
165 | 'selected_child_status' => $selectedStatus,
166 | 'selected_child_id' => $selectedChild,
167 | 'date_start' => $dateStart,
168 | 'date_end' => $dateEnd,
169 | 'report_options' => [
170 | 'children-by-level' => 'Copii pe nivel',
171 | 'children-by-coach' => 'Copii pe trainer',
172 | 'ready-for-promotion' => 'Ready pentru avansare',
173 | 'almost-ready' => 'Aproape gata',
174 | 'high-absence' => 'Absente ridicate',
175 | 'evaluations-period' => 'Evaluari pe perioada',
176 | 'child-progress' => 'Progres pe copil',
177 | ],
178 | 'period_options' => [
179 | '7' => 'Ultimele 7 zile',
180 | '30' => 'Ultimele 30 zile',
181 | '90' => 'Ultimele 90 zile',
182 | 'month' => 'Luna curenta',
183 | ],
184 | 'status_options' => [
185 | 'all' => 'Toate statusurile',
186 | 'active' => 'Activ',
187 | 'pause' => 'Pauza',
188 | 'left' => 'Plecat',
189 | ],
190 | 'coach_options' => $this->coachOptions($lockedTrainerId),
191 | 'level_options' => $this->levelOptions(),
192 | 'child_options' => $this->childOptions($lockedTrainerId),
193 | 'absence_alert_threshold' => (int) ($settings['absence_alert_threshold'] ?? 25),
194 | ];
195 | }
196 |
197 | /**
198 | * @return array{0: string, 1: string}
199 | */
200 | private function resolveDates(string $selectedPeriod, DateTimeImmutable $today): array
201 | {
202 | if ($selectedPeriod === 'month') {
203 | return [
204 | $today->modify('first day of this month')->format('Y-m-d'),
205 | $today->format('Y-m-d'),
206 | ];
207 | }
208 |
209 | $days = (int) $selectedPeriod;
210 | if (! in_array($days, [7, 30, 90], true)) {
211 | $days = 30;
212 | }
213 |
214 | return [
215 | $today->modify('-' . ($days - 1) . ' days')->format('Y-m-d'),
216 | $today->format('Y-m-d'),
217 | ];
218 | }
219 |
220 | /**
221 | * @param array<string, mixed> $filters
222 | * @return array<string, mixed>
223 | */
224 | private function buildReport(array $filters): array
225 | {
226 | $children = $this->scopedChildren($filters);
227 |
228 | return match ($filters['selected_report']) {
229 | 'children-by-coach' => $this->reportChildrenByCoach($children),
230 | 'ready-for-promotion' => $this->reportPromotionStatus($children, 'READY'),
231 | 'almost-ready' => $this->reportPromotionStatus($children, 'ALMOST READY'),
232 | 'high-absence' => $this->reportHighAbsence($children, $filters),
233 | 'evaluations-period' => $this->reportEvaluations($children, $filters),
234 | 'child-progress' => $this->reportChildProgress($children, $filters),
235 | default => $this->reportChildrenByLevel($children),
236 | };
237 | }
238 |
239 | /**
240 | * @param array<string, mixed> $filters
241 | * @return list<array<string, mixed>>
242 | */
243 | private function scopedChildren(array $filters): array
244 | {
245 | $builder = db_connect()
246 | ->table('children c')
247 | ->select('c.id, c.full_name, c.status, levels.name as level_name, groups.name as group_name, coaches.full_name as coach_name')
248 | ->join('academy_levels levels', 'levels.id = c.academy_level_id', 'left')
249 | ->join('academy_groups groups', 'groups.id = c.academy_group_id', 'left')
250 | ->join('coaches', 'coaches.id = c.primary_coach_id', 'left')
251 | ->orderBy('c.full_name', 'ASC');
252 |
253 | if ($filters['selected_coach_id'] > 0) {
254 | $builder->where('c.primary_coach_id', $filters['selected_coach_id']);
255 | }
256 |
257 | if ($filters['selected_level_id'] > 0) {
258 | $builder->where('c.academy_level_id', $filters['selected_level_id']);
259 | }
260 |
261 | if ($filters['selected_child_status'] !== 'all') {
262 | $builder->where('c.status', $filters['selected_child_status']);
263 | }
264 |
265 | return $builder->get()->getResultArray();
266 | }
267 |
268 | /**
269 | * @param list<array<string, mixed>> $children
270 | * @return array<string, mixed>
271 | */
272 | private function reportChildrenByLevel(array $children): array
273 | {
274 | $grouped = [];
275 | foreach ($children as $child) {
276 | $level = $child['level_name'] ?: 'Neasignat';
277 | if (! isset($grouped[$level])) {
278 | $grouped[$level] = ['level_name' => $level, 'children' => 0, 'active' => 0];
279 | }
280 |
281 | $grouped[$level]['children']++;
282 | if ($child['status'] === 'active') {
283 | $grouped[$level]['active']++;
284 | }
285 | }
286 | ksort($grouped);
287 |
288 | return [
289 | 'title' => 'Copii pe nivel',
290 | 'description' => 'Structura curenta a academiei pe niveluri active si statusuri operationale.',
291 | 'columns' => ['Nivel', 'Total copii', 'Activi'],
292 | 'rows' => array_values(array_map(static fn (array $row): array => [
293 | 'Nivel' => $row['level_name'],
294 | 'Total copii' => (string) $row['children'],
295 | 'Activi' => (string) $row['active'],
296 | ], $grouped)),
297 | 'summary' => [
298 | ['label' => 'Niveluri cu copii', 'value' => count($grouped), 'meta' => 'Doar niveluri cu date in selectie'],
299 | ['label' => 'Copii in raport', 'value' => count($children), 'meta' => 'Total randuri incluse'],
300 | ],
301 | 'empty' => 'Nu exista copii pentru filtrele selectate.',
302 | ];
303 | }
304 |
305 | /**
306 | * @param list<array<string, mixed>> $children
307 | * @return array<string, mixed>
308 | */
309 | private function reportChildrenByCoach(array $children): array
310 | {
311 | $grouped = [];
312 | foreach ($children as $child) {
313 | $coach = $child['coach_name'] ?: 'Neasignat';
314 | if (! isset($grouped[$coach])) {
315 | $grouped[$coach] = ['coach_name' => $coach, 'children' => 0, 'active' => 0];
316 | }
317 |
318 | $grouped[$coach]['children']++;
319 | if ($child['status'] === 'active') {
320 | $grouped[$coach]['active']++;
321 | }
322 | }
323 | ksort($grouped);
324 |
325 | return [
326 | 'title' => 'Copii pe trainer',
327 | 'description' => 'Incarcarea actuala pe trainer, folosind asignarea principala a copilului.',
328 | 'columns' => ['Trainer', 'Total copii', 'Activi'],
329 | 'rows' => array_values(array_map(static fn (array $row): array => [
330 | 'Trainer' => $row['coach_name'],
331 | 'Total copii' => (string) $row['children'],
332 | 'Activi' => (string) $row['active'],
333 | ], $grouped)),
334 | 'summary' => [
335 | ['label' => 'Traineri in selectie', 'value' => count($grouped), 'meta' => 'Cu cel putin un copil in raport'],
336 | ['label' => 'Copii activi', 'value' => count(array_filter($children, static fn (array $child): bool => $child['status'] === 'active')), 'meta' => 'Activi in selectie'],
337 | ],
338 | 'empty' => 'Nu exista copii pentru filtrele selectate.',
339 | ];
340 | }
341 |
342 | /**
343 | * @param list<array<string, mixed>> $children
344 | * @return array<string, mixed>
345 | */
346 | private function reportPromotionStatus(array $children, string $targetStatus): array
347 | {
348 | $childIds = array_values(array_map(static fn (array $child): int => (int) $child['id'], $children));
349 | $latest = $this->latestFullEvaluations($childIds);
350 | $rows = [];
351 |
352 | foreach ($children as $child) {
353 | $evaluation = $latest[(int) $child['id']] ?? null;
354 | if ($evaluation === null || $evaluation['final_status'] !== $targetStatus) {
355 | continue;
356 | }
357 |
358 | $rows[] = [
359 | 'Copil' => $child['full_name'],
360 | 'Nivel' => $child['level_name'] ?: '-',
361 | 'Trainer' => $child['coach_name'] ?: '-',
362 | 'Data evaluarii' => $evaluation['evaluation_date'],
363 | 'Scor final' => format_score($evaluation['final_score']),
364 | ];
365 | }
366 |
367 | return [
368 | 'title' => $targetStatus === 'READY' ? 'Copii ready pentru avansare' : 'Copii aproape gata',
369 | 'description' => 'Ultimul full evaluation este folosit ca sursa de adevar pentru statusul de promovare.',
370 | 'columns' => ['Copil', 'Nivel', 'Trainer', 'Data evaluarii', 'Scor final'],
371 | 'rows' => $rows,
372 | 'summary' => [
373 | ['label' => 'Copii in raport', 'value' => count($rows), 'meta' => $targetStatus],
374 | ],
375 | 'empty' => 'Nu exista copii care sa corespunda acestui status in selectie.',
376 | ];
377 | }
378 |
379 | /**
380 | * @param list<array<string, mixed>> $children
381 | * @param array<string, mixed> $filters
382 | * @return array<string, mixed>
383 | */
384 | private function reportHighAbsence(array $children, array $filters): array
385 | {
386 | $childIds = array_values(array_map(static fn (array $child): int => (int) $child['id'], $children));
387 | $rows = db_connect()
388 | ->table('attendance_records ar')
389 | ->select('ar.child_id, ar.status')
390 | ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
391 | ->whereIn('ar.child_id', $childIds === [] ? [0] : $childIds)
392 | ->where('sessions.is_cancelled', 0)
393 | ->where('sessions.session_date >=', $filters['date_start'])
394 | ->where('sessions.session_date <=', $filters['date_end'])
395 | ->get()
396 | ->getResultArray();
397 |
398 | $byChild = [];
399 | foreach ($rows as $row) {
400 | $childId = (int) $row['child_id'];
401 | if (! isset($byChild[$childId])) {
402 | $byChild[$childId] = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
403 | }
404 |
405 | $byChild[$childId][$row['status']]++;
406 | }
407 |
408 | $reportRows = [];
409 | foreach ($children as $child) {
410 | $breakdown = $byChild[(int) $child['id']] ?? ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
411 | if (array_sum($breakdown) === 0) {
412 | continue;
413 | }
414 |
415 | $attendance = $this->attendanceService->calculateRateFromBreakdown($breakdown);
416 | $absence = 100 - $attendance;
417 |
418 | if ($absence < (float) $filters['absence_alert_threshold']) {
419 | continue;
420 | }
421 |
422 | $reportRows[] = [
423 | 'Copil' => $child['full_name'],
424 | 'Grupa' => $child['group_name'] ?: '-',
425 | 'Trainer' => $child['coach_name'] ?: '-',
426 | 'Prezenta' => format_percentage($attendance, 1),
427 | 'Absenta' => format_percentage($absence, 1),
428 | ];
429 | }
430 |
431 | return [
432 | 'title' => 'Copii cu absente ridicate',
433 | 'description' => 'Absenta este calculata in perioada filtrata, excluzand sesiunile anulate.',
434 | 'columns' => ['Copil', 'Grupa', 'Trainer', 'Prezenta', 'Absenta'],
435 | 'rows' => $reportRows,
436 | 'summary' => [
437 | ['label' => 'Peste prag', 'value' => count($reportRows), 'meta' => 'Prag ' . format_percentage((float) $filters['absence_alert_threshold'], 0)],
438 | ],
439 | 'empty' => 'Nu exista copii cu absente peste prag in perioada filtrata.',
440 | ];
441 | }
442 |
443 | /**
444 | * @param list<array<string, mixed>> $children
445 | * @param array<string, mixed> $filters
446 | * @return array<string, mixed>
447 | */
448 | private function reportEvaluations(array $children, array $filters): array
449 | {
450 | $childIds = array_values(array_map(static fn (array $child): int => (int) $child['id'], $children));
451 | $rows = db_connect()
452 | ->table('evaluations e')
453 | ->select('e.evaluation_date, e.final_score, e.final_status, children.full_name as child_name, types.name as type_name, coaches.full_name as evaluator_name')
454 | ->join('children', 'children.id = e.child_id')
455 | ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
456 | ->join('coaches', 'coaches.id = e.evaluator_coach_id', 'left')
457 | ->whereIn('e.child_id', $childIds === [] ? [0] : $childIds)
458 | ->where('e.evaluation_date >=', $filters['date_start'])
459 | ->where('e.evaluation_date <=', $filters['date_end'])
460 | ->orderBy('e.evaluation_date', 'DESC')
461 | ->orderBy('e.id', 'DESC')
462 | ->get()
463 | ->getResultArray();
464 |
465 | return [
466 | 'title' => 'Evaluari pe perioada',
467 | 'description' => 'Lista cronologica de evaluari quick si full pentru perioada selectata.',
468 | 'columns' => ['Data', 'Copil', 'Tip', 'Evaluator', 'Scor', 'Status'],
469 | 'rows' => array_map(static fn (array $row): array => [
470 | 'Data' => $row['evaluation_date'],
471 | 'Copil' => $row['child_name'],
472 | 'Tip' => $row['type_name'],
473 | 'Evaluator' => $row['evaluator_name'] ?: '-',
474 | 'Scor' => format_score($row['final_score']),
475 | 'Status' => status_label($row['final_status']),
476 | ], $rows),
477 | 'summary' => [
478 | ['label' => 'Evaluari in raport', 'value' => count($rows), 'meta' => $filters['date_start'] . ' - ' . $filters['date_end']],
479 | ],
480 | 'empty' => 'Nu exista evaluari in perioada selectata.',
481 | ];
482 | }
483 |
484 | /**
485 | * @param list<array<string, mixed>> $children
486 | * @param array<string, mixed> $filters
487 | * @return array<string, mixed>
488 | */
489 | private function reportChildProgress(array $children, array $filters): array
490 | {
491 | $selectedChildId = (int) $filters['selected_child_id'];
492 | if ($selectedChildId === 0 && $children !== []) {
493 | $selectedChildId = (int) $children[0]['id'];
494 | }
495 |
496 | if ($selectedChildId === 0) {
497 | return [
498 | 'title' => 'Progres pe copil',
499 | 'description' => 'Selecteaza un copil din filtru pentru a vedea istoricul evaluarilor si prezentei.',
500 | 'columns' => [],
501 | 'rows' => [],
502 | 'summary' => [],
503 | 'empty' => 'Nu exista copii disponibili pentru acest raport.',
504 | ];
505 | }
506 |
507 | $selectedChild = array_values(array_filter($children, static fn (array $child): bool => (int) $child['id'] === $selectedChildId))[0] ?? null;
508 | if ($selectedChild === null) {
509 | return [
510 | 'title' => 'Progres pe copil',
511 | 'description' => 'Copilul selectat nu este disponibil in aria curenta de acces.',
512 | 'columns' => [],
513 | 'rows' => [],
514 | 'summary' => [],
515 | 'empty' => 'Selectia curenta nu este valida.',
516 | ];
517 | }
518 |
519 | $evaluations = db_connect()
520 | ->table('evaluations e')
521 | ->select('e.evaluation_date, e.final_score, e.final_status, types.name as type_name')
522 | ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
523 | ->where('e.child_id', $selectedChildId)
524 | ->orderBy('e.evaluation_date', 'DESC')
525 | ->orderBy('e.id', 'DESC')
526 | ->get()
527 | ->getResultArray();
528 |
529 | $attendanceRows = db_connect()
530 | ->table('attendance_records ar')
531 | ->select('ar.status')
532 | ->join('attendance_sessions sessions', 'sessions.id = ar.attendance_session_id')
533 | ->where('ar.child_id', $selectedChildId)
534 | ->where('sessions.is_cancelled', 0)
535 | ->where('sessions.session_date >=', $filters['date_start'])
536 | ->where('sessions.session_date <=', $filters['date_end'])
537 | ->get()
538 | ->getResultArray();
539 |
540 | $breakdown = ['present' => 0, 'recovery' => 0, 'absent_excused' => 0, 'absent_unexcused' => 0];
541 | foreach ($attendanceRows as $row) {
542 | $status = (string) $row['status'];
543 | if (isset($breakdown[$status])) {
544 | $breakdown[$status]++;
545 | }
546 | }
547 |
548 | $avgScore = $evaluations === [] ? null : round(array_sum(array_map(static fn (array $row): float => (float) $row['final_score'], $evaluations)) / count($evaluations), 2);
549 |
550 | return [
551 | 'title' => 'Progres copil: ' . $selectedChild['full_name'],
552 | 'description' => 'Istoric de evaluari si rata de prezenta in perioada selectata.',
553 | 'columns' => ['Data', 'Tip', 'Scor', 'Status'],
554 | 'rows' => array_map(static fn (array $row): array => [
555 | 'Data' => $row['evaluation_date'],
556 | 'Tip' => $row['type_name'],
557 | 'Scor' => format_score($row['final_score']),
558 | 'Status' => status_label($row['final_status']),
559 | ], $evaluations),
560 | 'summary' => [
561 | ['label' => 'Copil selectat', 'value' => $selectedChild['full_name'], 'meta' => ($selectedChild['level_name'] ?: '-') . ' / ' . ($selectedChild['coach_name'] ?: '-')],
562 | ['label' => 'Scor mediu', 'value' => format_score($avgScore), 'meta' => 'Toate evaluarile disponibile'],
563 | ['label' => 'Prezenta', 'value' => format_percentage($this->attendanceService->calculateRateFromBreakdown($breakdown), 1), 'meta' => $filters['date_start'] . ' - ' . $filters['date_end']],
564 | ],
565 | 'empty' => 'Copilul selectat nu are evaluari inregistrate.',
566 | 'detailUrl' => url_to('children.show', $selectedChildId),
567 | ];
568 | }
569 |
570 | /**
571 | * @param list<int> $childIds
572 | * @return array<int, array<string, mixed>>
573 | */
574 | private function latestFullEvaluations(array $childIds): array
575 | {
576 | if ($childIds === []) {
577 | return [];
578 | }
579 |
580 | $rows = db_connect()
581 | ->table('evaluations e')
582 | ->select('e.child_id, e.evaluation_date, e.final_score, e.final_status')
583 | ->join('evaluation_types types', 'types.id = e.evaluation_type_id')
584 | ->where('types.slug', 'full-evaluation')
585 | ->whereIn('e.child_id', $childIds)
586 | ->orderBy('e.evaluation_date', 'DESC')
587 | ->orderBy('e.id', 'DESC')
588 | ->get()
589 | ->getResultArray();
590 |
591 | $latest = [];
592 | foreach ($rows as $row) {
593 | $childId = (int) $row['child_id'];
594 | if (! isset($latest[$childId])) {
595 | $latest[$childId] = $row;
596 | }
597 | }
598 |
599 | return $latest;
600 | }
601 |
602 | /**
603 | * @return list<array<string, mixed>>
604 | */
605 | private function coachOptions(?int $lockedTrainerId): array
606 | {
607 | $builder = db_connect()
608 | ->table('coaches')
609 | ->select('id, full_name')
610 | ->where('is_active', 1)
611 | ->orderBy('full_name', 'ASC');
612 |
613 | if ($lockedTrainerId !== null) {
614 | $builder->where('id', $lockedTrainerId);
615 | }
616 |
617 | return $builder->get()->getResultArray();
618 | }
619 |
620 | /**
621 | * @return list<array<string, mixed>>
622 | */
623 | private function levelOptions(): array
624 | {
625 | return db_connect()
626 | ->table('academy_levels')
627 | ->select('id, name')
628 | ->orderBy('sort_order', 'ASC')
629 | ->get()
630 | ->getResultArray();
631 | }
632 |
633 | /**
634 | * @return list<array<string, mixed>>
635 | */
636 | private function childOptions(?int $lockedTrainerId): array
637 | {
638 | $builder = db_connect()
639 | ->table('children')
640 | ->select('id, full_name')
641 | ->orderBy('full_name', 'ASC');
642 |
643 | if ($lockedTrainerId !== null) {
644 | $builder->where('primary_coach_id', $lockedTrainerId);
645 | }
646 |
647 | return $builder->get()->getResultArray();
648 | }
649 |
650 | /**
651 | * @return array<string, string>
652 | */
653 | private function settingsMap(): array
654 | {
655 | $rows = db_connect()
656 | ->table('settings')
657 | ->select('`key`, value')
658 | ->get()
659 | ->getResultArray();
660 |
661 | $settings = [];
662 | foreach ($rows as $row) {
663 | $settings[$row['key']] = $row['value'];
664 | }
665 |
666 | return $settings;
667 | }
668 |
669 | /**
670 | * @param array<string, mixed> $filters
671 | * @return array<string, string>
672 | */
673 | private function buildExportUrls(array $filters): array
674 | {
675 | $query = http_build_query($this->exportQueryFromFilters($filters));
676 |
677 | return [
678 | 'csv' => url_to('reports.export', 'csv') . ($query !== '' ? '?' . $query : ''),
679 | 'pdf' => url_to('reports.export', 'pdf') . ($query !== '' ? '?' . $query : ''),
680 | ];
681 | }
682 |
683 | /**
684 | * @param array<string, mixed> $filters
685 | * @return array<string, int|string>
686 | */
687 | private function exportQueryFromFilters(array $filters): array
688 | {
689 | return [
690 | 'report' => (string) $filters['selected_report'],
691 | 'period' => (string) $filters['selected_period'],
692 | 'coach_id' => (int) $filters['selected_coach_id'],
693 | 'level_id' => (int) $filters['selected_level_id'],
694 | 'child_status' => (string) $filters['selected_child_status'],
695 | 'child_id' => (int) $filters['selected_child_id'],
696 | ];
697 | }
698 |
699 | /**
700 | * @param array<string, mixed> $filters
701 | * @return list<array{label: string, value: string}>
702 | */
703 | private function buildAppliedFilters(array $filters): array
704 | {
705 | $items = [
706 | [
707 | 'label' => 'Raport',
708 | 'value' => (string) ($filters['report_options'][$filters['selected_report']] ?? $filters['selected_report']),
709 | ],
710 | [
711 | 'label' => 'Perioada',
712 | 'value' => (string) ($filters['period_options'][$filters['selected_period']] ?? $filters['selected_period']),
713 | ],
714 | [
715 | 'label' => 'Interval',
716 | 'value' => (string) $filters['date_start'] . ' - ' . (string) $filters['date_end'],
717 | ],
718 | ];
719 |
720 | $coachName = $this->findOptionLabel((array) $filters['coach_options'], (int) $filters['selected_coach_id'], 'full_name');
721 | if ($coachName !== null) {
722 | $items[] = ['label' => 'Trainer', 'value' => $coachName];
723 | }
724 |
725 | $levelName = $this->findOptionLabel((array) $filters['level_options'], (int) $filters['selected_level_id'], 'name');
726 | if ($levelName !== null) {
727 | $items[] = ['label' => 'Nivel', 'value' => $levelName];
728 | }
729 |
730 | if ((string) $filters['selected_child_status'] !== 'all') {
731 | $items[] = [
732 | 'label' => 'Status copil',
733 | 'value' => (string) ($filters['status_options'][$filters['selected_child_status']] ?? $filters['selected_child_status']),
734 | ];
735 | }
736 |
737 | $childName = $this->findOptionLabel((array) $filters['child_options'], (int) $filters['selected_child_id'], 'full_name');
738 | if ($childName !== null) {
739 | $items[] = ['label' => 'Copil', 'value' => $childName];
740 | }
741 |
742 | return $items;
743 | }
744 |
745 | /**
746 | * @param list<array<string, mixed>> $options
747 | */
748 | private function findOptionLabel(array $options, int $selectedId, string $labelKey): ?string
749 | {
750 | if ($selectedId <= 0) {
751 | return null;
752 | }
753 |
754 | foreach ($options as $option) {
755 | if ((int) ($option['id'] ?? 0) === $selectedId) {
756 | return (string) ($option[$labelKey] ?? '');
757 | }
758 | }
759 |
760 | return null;
761 | }
762 |
763 | /**
764 | * @param array<string, mixed> $filters
765 | */
766 | private function buildFileBasename(array $filters, DateTimeImmutable $generatedAt): string
767 | {
768 | return 'avh-kids-' . url_title((string) $filters['selected_report'], '-', true) . '-' . $generatedAt->format('Ymd-Hi');
769 | }
770 |
771 | private function stringValue(mixed $value): string
772 | {
773 | if ($value === null || $value === '') {
774 | return '-';
775 | }
776 |
777 | if (is_scalar($value)) {
778 | return (string) $value;
779 | }
780 |
781 | return json_encode($value, JSON_UNESCAPED_UNICODE) ?: '-';
782 | }
783 | }