| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 71.99% covered (warning) | 71.99% | 347 / 482 | 62.96% covered (warning) | 62.96% | 17 / 27 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
ChildrenService | 71.99% covered (warning) | 71.99% | 347 / 482 | 62.96% covered (warning) | 62.96% | 17 / 27 | 239.03 | 0.00% covered (danger) | 0.00% | 0 / 1
__construct | 100.00% covered (success) | 100.00% | 4 / 4 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getIndexData | 100.00% covered (success) | 100.00% | 18 / 18 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
getFormData | 0.00% covered (danger) | 0.00% | 0 / 50 | 0.00% covered (danger) | 0.00% | 0 / 1 | 110 |
save | 82.26% covered (warning) | 82.26% | 51 / 62 | 0.00% covered (danger) | 0.00% | 0 / 1 | 17.43 |
getProfileData | 100.00% covered (success) | 100.00% | 40 / 40 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
buildIndexFilters | 100.00% covered (success) | 100.00% | 25 / 25 | 100.00% covered (success) | 100.00% | 1 / 1 | 4 |
getChildrenRows | 62.50% covered (warning) | 62.50% | 15 / 24 | 0.00% covered (danger) | 0.00% | 0 / 1 | 7.90 |
buildChildMetricMaps | 96.43% covered (success) | 96.43% | 27 / 28 | 0.00% covered (danger) | 0.00% | 0 / 1 | 5 |
formOptions | 0.00% covered (danger) | 0.00% | 0 / 14 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
normalizeChildPayload | 100.00% covered (success) | 100.00% | 16 / 16 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
syncChildGuardian | 94.12% covered (success) | 94.12% | 16 / 17 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2.00 |
syncPrimaryCoach | 75.00% covered (warning) | 75.00% | 18 / 24 | 0.00% covered (danger) | 0.00% | 0 / 1 | 5.39 |
recordJourneyEvents | 27.78% covered (danger) | 27.78% | 15 / 54 | 0.00% covered (danger) | 0.00% | 0 / 1 | 14.42 |
getAccessibleChild | 72.73% covered (warning) | 72.73% | 8 / 11 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6.73 |
getChildWithRelations | 100.00% covered (success) | 100.00% | 10 / 10 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getEvaluations | 100.00% covered (success) | 100.00% | 10 / 10 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getAttendanceRows | 100.00% covered (success) | 100.00% | 12 / 12 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getJourneyRows | 100.00% covered (success) | 100.00% | 7 / 7 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
getCriteriaProfile | 100.00% covered (success) | 100.00% | 12 / 12 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
buildScoreTrend | 100.00% covered (success) | 100.00% | 5 / 5 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
averageFinalScore | 100.00% covered (success) | 100.00% | 4 / 4 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
calculateAttendanceRate | 85.71% covered (warning) | 85.71% | 6 / 7 | 0.00% covered (danger) | 0.00% | 0 / 1 | 3.03 |
profileTabs | 100.00% covered (success) | 100.00% | 7 / 7 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
groupOptions | 100.00% covered (success) | 100.00% | 7 / 7 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
levelOptions | 100.00% covered (success) | 100.00% | 6 / 6 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
coachOptions | 100.00% covered (success) | 100.00% | 7 / 7 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
db | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |

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