diff --git a/backend/src/Scolarite/Application/Port/ParentChildrenReader.php b/backend/src/Scolarite/Application/Port/ParentChildrenReader.php new file mode 100644 index 0000000..d1af280 --- /dev/null +++ b/backend/src/Scolarite/Application/Port/ParentChildrenReader.php @@ -0,0 +1,20 @@ + + */ + public function childrenOf(string $guardianId, TenantId $tenantId): array; +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenSchedule/ChildScheduleDto.php b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/ChildScheduleDto.php new file mode 100644 index 0000000..4e1fd49 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/ChildScheduleDto.php @@ -0,0 +1,21 @@ + $slots + */ + public function __construct( + public string $childId, + public string $firstName, + public string $lastName, + public array $slots, + ) { + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandler.php b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandler.php new file mode 100644 index 0000000..52fbd50 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandler.php @@ -0,0 +1,137 @@ + */ + public function __invoke(GetChildrenScheduleQuery $query): array + { + $tenantId = TenantId::fromString($query->tenantId); + $allChildren = $this->parentChildrenReader->childrenOf($query->parentId, $tenantId); + + if ($allChildren === []) { + return []; + } + + // When a specific child is requested, only resolve that child's schedule + $children = $query->childId !== null + ? array_values(array_filter($allChildren, static fn (array $c): bool => $c['studentId'] === $query->childId)) + : $allChildren; + + if ($children === []) { + return []; + } + + $date = new DateTimeImmutable($query->date); + $weekStart = $this->mondayOfWeek($date); + $calendar = $this->calendarProvider->forCurrentYear($tenantId); + + $result = []; + + foreach ($children as $child) { + $classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId); + + if ($classId === null) { + $result[] = new ChildScheduleDto( + childId: $child['studentId'], + firstName: $child['firstName'], + lastName: $child['lastName'], + slots: [], + ); + + continue; + } + + $resolved = $this->scheduleResolver->resolveForWeek( + ClassId::fromString($classId), + $weekStart, + $tenantId, + $calendar, + ); + + $slots = $this->enrichSlots($resolved, $query->tenantId); + + $result[] = new ChildScheduleDto( + childId: $child['studentId'], + firstName: $child['firstName'], + lastName: $child['lastName'], + slots: $slots, + ); + } + + return $result; + } + + /** + * @param array $slots + * + * @return array + */ + private function enrichSlots(array $slots, string $tenantId): array + { + if ($slots === []) { + return []; + } + + $subjectIds = array_values(array_unique( + array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->subjectId, $slots), + )); + $teacherIds = array_values(array_unique( + array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots), + )); + + $subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds); + $teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds); + + return array_map( + static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved( + $s, + $subjectNames[(string) $s->subjectId] ?? '', + $teacherNames[(string) $s->teacherId] ?? '', + ), + $slots, + ); + } + + private function mondayOfWeek(DateTimeImmutable $date): DateTimeImmutable + { + $dayOfWeek = (int) $date->format('N'); + + if ($dayOfWeek === 1) { + return $date; + } + + return $date->modify('-' . ($dayOfWeek - 1) . ' days'); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleQuery.php b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleQuery.php new file mode 100644 index 0000000..0a433fd --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleQuery.php @@ -0,0 +1,24 @@ +findChildSchedule($childId, $date); + + $daySlots = array_values(array_filter( + $childSchedule->slots, + static fn (StudentScheduleSlotDto $s): bool => $s->date === $date, + )); + + return new JsonResponse(['data' => $this->serializeSlots($daySlots)]); + } + + /** + * EDT de la semaine d'un enfant. + */ + #[Route('/api/me/children/{childId}/schedule/week/{date}', name: 'api_parent_child_schedule_week', methods: ['GET'])] + public function childWeek(string $childId, string $date): JsonResponse + { + $childSchedule = $this->findChildSchedule($childId, $date); + + return new JsonResponse(['data' => $this->serializeSlots($childSchedule->slots)]); + } + + /** + * Résumé EDT du jour pour tous les enfants. + */ + #[Route('/api/me/children/schedule/summary', name: 'api_parent_children_schedule_summary', methods: ['GET'])] + public function summary(): JsonResponse + { + $now = new DateTimeImmutable(); + $today = $now->format('Y-m-d'); + $currentTime = $now->format('H:i'); + $children = $this->resolveAllChildren($today); + + $summaries = array_map(function (ChildScheduleDto $child) use ($today, $currentTime): array { + $todaySlots = array_values(array_filter( + $child->slots, + static fn (StudentScheduleSlotDto $s): bool => $s->date === $today, + )); + + usort($todaySlots, static fn (StudentScheduleSlotDto $a, StudentScheduleSlotDto $b): int => $a->startTime <=> $b->startTime); + + return [ + 'childId' => $child->childId, + 'firstName' => $child->firstName, + 'lastName' => $child->lastName, + 'todaySlots' => $this->serializeSlots($todaySlots), + 'nextClass' => $this->findNextSlot($todaySlots, $currentTime), + ]; + }, $children); + + return new JsonResponse(['data' => $summaries]); + } + + private function findChildSchedule(string $childId, string $date): ChildScheduleDto + { + $user = $this->getSecurityUser(); + + try { + $children = ($this->handler)(new GetChildrenScheduleQuery( + parentId: $user->userId(), + tenantId: $user->tenantId(), + date: $date, + childId: $childId, + )); + } catch (InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + if ($children === []) { + throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.'); + } + + return $children[0]; + } + + /** + * @return array + */ + private function resolveAllChildren(string $date): array + { + $user = $this->getSecurityUser(); + + try { + return ($this->handler)(new GetChildrenScheduleQuery( + parentId: $user->userId(), + tenantId: $user->tenantId(), + date: $date, + )); + } catch (InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + } + + /** + * @param array $todaySlots + * + * @return array|null + */ + private function findNextSlot(array $todaySlots, string $currentTime): ?array + { + foreach ($todaySlots as $slot) { + if ($slot->startTime > $currentTime) { + return $this->serializeSlot($slot); + } + } + + return null; + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException('Authentification requise.'); + } + + return $user; + } + + /** + * @param array $slots + * + * @return array> + */ + private function serializeSlots(array $slots): array + { + return array_map($this->serializeSlot(...), $slots); + } + + /** + * @return array + */ + private function serializeSlot(StudentScheduleSlotDto $slot): array + { + return [ + 'slotId' => $slot->slotId, + 'date' => $slot->date, + 'dayOfWeek' => $slot->dayOfWeek, + 'startTime' => $slot->startTime, + 'endTime' => $slot->endTime, + 'subjectId' => $slot->subjectId, + 'subjectName' => $slot->subjectName, + 'teacherId' => $slot->teacherId, + 'teacherName' => $slot->teacherName, + 'room' => $slot->room, + 'isModified' => $slot->isModified, + 'exceptionId' => $slot->exceptionId, + ]; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php b/backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php index ee17a6d..ec1029e 100644 --- a/backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php +++ b/backend/src/Scolarite/Infrastructure/Security/ScheduleSlotVoter.php @@ -69,6 +69,7 @@ final class ScheduleSlotVoter extends Voter Role::PROF->value, Role::VIE_SCOLAIRE->value, Role::ELEVE->value, + Role::PARENT->value, ]); } diff --git a/backend/src/Scolarite/Infrastructure/Service/GuardianParentChildrenReader.php b/backend/src/Scolarite/Infrastructure/Service/GuardianParentChildrenReader.php new file mode 100644 index 0000000..48bf34a --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/GuardianParentChildrenReader.php @@ -0,0 +1,46 @@ +studentGuardianRepository->findStudentsForGuardian( + UserId::fromString($guardianId), + $tenantId, + ); + + return array_map(function ($link): array { + $student = $this->userRepository->get($link->studentId); + + return [ + 'studentId' => (string) $link->studentId, + 'firstName' => $student->firstName, + 'lastName' => $student->lastName, + ]; + }, $links); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandlerTest.php new file mode 100644 index 0000000..00c4bf4 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandlerTest.php @@ -0,0 +1,287 @@ +slotRepository = new InMemoryScheduleSlotRepository(); + $this->exceptionRepository = new InMemoryScheduleExceptionRepository(); + } + + #[Test] + public function returnsEmptyWhenParentHasNoChildren(): void + { + $handler = $this->createHandler(children: []); + + $result = $handler(new GetChildrenScheduleQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + date: '2026-03-02', + )); + + self::assertSame([], $result); + } + + #[Test] + public function returnsScheduleForSingleChild(): void + { + $this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00'); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'], + ], + classMapping: [self::CHILD1_ID => self::CLASS1_ID], + ); + + $result = $handler(new GetChildrenScheduleQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + date: '2026-03-02', // Monday + )); + + self::assertCount(1, $result); + self::assertInstanceOf(ChildScheduleDto::class, $result[0]); + self::assertSame(self::CHILD1_ID, $result[0]->childId); + self::assertSame('Alice', $result[0]->firstName); + self::assertSame('Dupont', $result[0]->lastName); + self::assertCount(1, $result[0]->slots); + } + + #[Test] + public function returnsScheduleForMultipleChildren(): void + { + $this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00'); + $this->saveRecurringSlot(self::CLASS2_ID, DayOfWeek::MONDAY, '10:00', '11:00'); + $this->saveRecurringSlot(self::CLASS2_ID, DayOfWeek::TUESDAY, '14:00', '15:00'); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'], + ['studentId' => self::CHILD2_ID, 'firstName' => 'Bob', 'lastName' => 'Dupont'], + ], + classMapping: [ + self::CHILD1_ID => self::CLASS1_ID, + self::CHILD2_ID => self::CLASS2_ID, + ], + ); + + $result = $handler(new GetChildrenScheduleQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + date: '2026-03-02', + )); + + self::assertCount(2, $result); + self::assertSame(self::CHILD1_ID, $result[0]->childId); + self::assertCount(1, $result[0]->slots); + self::assertSame(self::CHILD2_ID, $result[1]->childId); + self::assertCount(2, $result[1]->slots); + } + + #[Test] + public function returnsEmptySlotsWhenChildHasNoClass(): void + { + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'], + ], + classMapping: [], + ); + + $result = $handler(new GetChildrenScheduleQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + date: '2026-03-02', + )); + + self::assertCount(1, $result); + self::assertSame(self::CHILD1_ID, $result[0]->childId); + self::assertSame([], $result[0]->slots); + } + + #[Test] + public function enrichesSlotsWithSubjectAndTeacherNames(): void + { + $this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00'); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'], + ], + classMapping: [self::CHILD1_ID => self::CLASS1_ID], + subjectNames: [self::SUBJECT_ID => 'Mathématiques'], + teacherNames: [self::TEACHER_ID => 'Jean Martin'], + ); + + $result = $handler(new GetChildrenScheduleQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + date: '2026-03-02', + )); + + self::assertSame('Mathématiques', $result[0]->slots[0]->subjectName); + self::assertSame('Jean Martin', $result[0]->slots[0]->teacherName); + } + + #[Test] + public function computesMondayFromAnyDayOfWeek(): void + { + $this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00'); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'], + ], + classMapping: [self::CHILD1_ID => self::CLASS1_ID], + ); + + $result = $handler(new GetChildrenScheduleQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + date: '2026-03-04', // Wednesday + )); + + self::assertCount(1, $result); + self::assertSame('2026-03-02', $result[0]->slots[0]->date); + } + + private function saveRecurringSlot( + string $classId, + DayOfWeek $day, + string $start, + string $end, + ?string $room = null, + ): void { + $slot = ScheduleSlot::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + dayOfWeek: $day, + timeSlot: new TimeSlot($start, $end), + room: $room, + isRecurring: true, + now: new DateTimeImmutable('2026-01-01'), + recurrenceStart: new DateTimeImmutable('2026-01-01'), + ); + + $this->slotRepository->save($slot); + } + + /** + * @param array $children + * @param array $classMapping studentId => classId + * @param array $subjectNames + * @param array $teacherNames + */ + private function createHandler( + array $children = [], + array $classMapping = [], + array $subjectNames = [], + array $teacherNames = [], + ): GetChildrenScheduleHandler { + $tenantId = TenantId::fromString(self::TENANT_ID); + + $parentChildrenReader = new class($children) implements ParentChildrenReader { + /** @param array $children */ + public function __construct(private array $children) + { + } + + public function childrenOf(string $guardianId, TenantId $tenantId): array + { + return $this->children; + } + }; + + $studentClassReader = new class($classMapping) implements StudentClassReader { + /** @param array $mapping */ + public function __construct(private array $mapping) + { + } + + public function currentClassId(string $studentId, TenantId $tenantId): ?string + { + return $this->mapping[$studentId] ?? null; + } + }; + + $calendarProvider = new class($tenantId) implements CurrentCalendarProvider { + public function __construct(private TenantId $tenantId) + { + } + + public function forCurrentYear(TenantId $tenantId): SchoolCalendar + { + return SchoolCalendar::initialiser($this->tenantId, AcademicYearId::generate()); + } + }; + + $displayReader = new class($subjectNames, $teacherNames) implements ScheduleDisplayReader { + /** @param array $subjects @param array $teachers */ + public function __construct( + private array $subjects, + private array $teachers, + ) { + } + + public function subjectNames(string $tenantId, string ...$subjectIds): array + { + return $this->subjects; + } + + public function teacherNames(string $tenantId, string ...$teacherIds): array + { + return $this->teachers; + } + }; + + return new GetChildrenScheduleHandler( + $parentChildrenReader, + $studentClassReader, + new ScheduleResolver($this->slotRepository, $this->exceptionRepository), + $calendarProvider, + $displayReader, + ); + } +} diff --git a/frontend/e2e/image-rights.spec.ts b/frontend/e2e/image-rights.spec.ts index 6c2270c..76414ab 100644 --- a/frontend/e2e/image-rights.spec.ts +++ b/frontend/e2e/image-rights.spec.ts @@ -157,6 +157,7 @@ test.describe('Image Rights Management', () => { await expect( page.getByRole('heading', { name: /élèves autorisés/i }) .or(page.getByRole('heading', { name: /élèves non autorisés/i })) + .first() ).toBeVisible(); // Stats bar @@ -186,14 +187,18 @@ test.describe('Image Rights Management', () => { // - The empty state shows (no authorized students found) const statsBarVisible = await page.locator('.stats-bar').isVisible(); if (statsBarVisible) { - const unauthorizedCount = await page.locator('.stat-count.stat-danger').textContent(); - expect(parseInt(unauthorizedCount ?? '0', 10)).toBe(0); + const dangerCount = await page.locator('.stat-count.stat-danger').count(); + if (dangerCount > 0) { + const unauthorizedCount = await page.locator('.stat-count.stat-danger').textContent(); + expect(parseInt(unauthorizedCount ?? '0', 10)).toBe(0); + } + // No stat-danger element means no unauthorized students — correct after filtering by "Autorisé" } else { await expect(page.locator('.empty-state')).toBeVisible(); } // Reset filters to restore original state - await page.getByRole('button', { name: /réinitialiser les filtres/i }).click(); + await page.getByRole('button', { name: 'Réinitialiser', exact: true }).click(); await waitForPageLoaded(page); // URL should no longer contain status filter diff --git a/frontend/e2e/parent-schedule.spec.ts b/frontend/e2e/parent-schedule.spec.ts new file mode 100644 index 0000000..bd2f766 --- /dev/null +++ b/frontend/e2e/parent-schedule.spec.ts @@ -0,0 +1,527 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const PARENT_EMAIL = 'e2e-parent-schedule@example.com'; +const PARENT_PASSWORD = 'ParentSchedule123'; +const STUDENT_EMAIL = 'e2e-parent-sched-student@example.com'; +const STUDENT_PASSWORD = 'StudentParentSched123'; +const STUDENT2_EMAIL = 'e2e-parent-sched-student2@example.com'; +const STUDENT2_PASSWORD = 'StudentParentSched2_123'; +const TEACHER_EMAIL = 'e2e-parent-sched-teacher@example.com'; +const TEACHER_PASSWORD = 'TeacherParentSched123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function currentWeekdayIso(): number { + const jsDay = new Date().getDay(); + if (jsDay === 0) return 5; // Sunday → seed for Friday + if (jsDay === 6) return 5; // Saturday → seed for Friday + return jsDay; +} + +function daysBackToSeededWeekday(): number { + const jsDay = new Date().getDay(); + if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday + if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday + return 0; +} + +function seededDayName(): string { + const jsDay = new Date().getDay(); + const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay; + return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!; +} + +async function navigateToSeededDay(page: import('@playwright/test').Page) { + const back = daysBackToSeededWeekday(); + if (back === 0) return; + + const targetDay = seededDayName(); + const targetPattern = new RegExp(targetDay, 'i'); + const prevBtn = page.getByLabel('Précédent'); + await expect(prevBtn).toBeVisible({ timeout: 10000 }); + + const deadline = Date.now() + 15000; + let navigated = false; + while (Date.now() < deadline && !navigated) { + for (let i = 0; i < back; i++) { + await prevBtn.click(); + } + await page.waitForTimeout(500); + const title = await page.locator('.day-title').textContent(); + if (title && targetPattern.test(title)) { + navigated = true; + } + } + + await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({ + timeout: 5000 + }); +} + +async function loginAsParent(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(PARENT_EMAIL); + await page.locator('#password').fill(PARENT_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +test.describe('Parent Schedule Consultation (Story 4.4)', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Clear caches to prevent stale data from previous runs + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache student_guardians.cache --env=dev 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pools may not exist + } + + // Create users (idempotent - returns existing if already created) + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`, + { encoding: 'utf-8' } + ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`, + { encoding: 'utf-8' } + ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE 2>&1`, + { encoding: 'utf-8' } + ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + + // Create classes + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-ParentSched-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-ParentSched-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Create subjects + try { + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-Maths', 'E2EPARMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-SVT', 'E2EPARSVT', '#22c55e', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Clean up schedule data + try { + runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name LIKE 'E2E-ParentSched-%' AND tenant_id = '${TENANT_ID}')`); + } catch { + // Table may not exist + } + + // Clean up calendar entries + try { + runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } + + // Assign students to classes + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + + `FROM users u, school_classes c ` + + `WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + + `FROM users u, school_classes c ` + + `WHERE u.email = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + // Clean up any existing guardian links for our parent + try { + runSql( + `DELETE FROM student_guardians WHERE guardian_id IN (SELECT id FROM users WHERE email = '${PARENT_EMAIL}' AND tenant_id = '${TENANT_ID}') AND tenant_id = '${TENANT_ID}'` + ); + } catch { + // Table may not exist + } + + // Create parent-student links + runSql( + `INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` + + `SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` + + `FROM users s, users p ` + + `WHERE s.email = '${STUDENT_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` + + `AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING` + ); + runSql( + `INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` + + `SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` + + `FROM users s, users p ` + + `WHERE s.email = '${STUDENT2_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` + + `AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING` + ); + + // Create schedule slots + const dayOfWeek = currentWeekdayIso(); + runSql( + `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '08:00', '09:00', 'Salle A1', true, NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + runSql( + `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:00', '11:00', 'Labo SVT', true, NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPARSVT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Late-night slot for child 1 (6A) — always "next" during normal test hours + runSql( + `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '23:00', '23:30', 'Salle A1', true, NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + clearCache(); + }); + + // ====================================================================== + // AC1: Single child view + // ====================================================================== + test.describe('AC1: Single child day view', () => { + test('parent can navigate to parent-schedule page', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + }); + + test('child selector shows children', async ({ page }) => { + await loginAsParent(page); + + // Intercept the API call to debug + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/me/children') && !resp.url().includes('schedule'), + { timeout: 30000 } + ); + + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + + const response = await responsePromise; + expect(response.status()).toBe(200); + + // Wait for child selector to finish loading + const childSelector = page.locator('.child-selector'); + await expect(childSelector).toBeVisible({ timeout: 15000 }); + }); + + test('day view shows schedule for selected child', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + await navigateToSeededDay(page); + + // Wait for slots to load + const slots = page.locator('[data-testid="schedule-slot"]'); + await expect(slots.first()).toBeVisible({ timeout: 20000 }); + }); + }); + + // ====================================================================== + // AC2: Multi-child view + // ====================================================================== + test.describe('AC2: Multi-child selection', () => { + test('can switch between children', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + + // If multiple children, buttons should be visible + const childButtons = page.locator('.child-button'); + const count = await childButtons.count(); + + if (count > 1) { + // Click second child + await childButtons.nth(1).click(); + await page.waitForTimeout(500); + + // Should update schedule data + await navigateToSeededDay(page); + const slots = page.locator('[data-testid="schedule-slot"]'); + await expect(slots.first()).toBeVisible({ timeout: 20000 }); + } + }); + }); + + // ====================================================================== + // AC3: Navigation (day/week views) + // ====================================================================== + test.describe('AC3: Navigation', () => { + test('day view is the default view', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + + const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' }); + await expect(dayButton).toHaveClass(/active/, { timeout: 5000 }); + }); + + test('can switch to week view', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + await navigateToSeededDay(page); + + // Wait for slots to load + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 20000 + }); + + // Switch to week view + const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' }); + await weekButton.click(); + + // Week headers should show + await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Ven', { exact: true })).toBeVisible(); + }); + + test('can navigate between days', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + await navigateToSeededDay(page); + + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 20000 + }); + + // Navigate forward and wait for the new day to load + await page.getByLabel('Suivant').click(); + // Wait for the day title to change, confirming navigation completed + await page.waitForTimeout(1500); + + // Navigate back to the original day + await page.getByLabel('Précédent').click(); + + // Wait for data to reload after navigation + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 30000 + }); + }); + }); + + // ====================================================================== + // AC1: Next class highlighting (P0) + // ====================================================================== + test.describe('AC1: Next class highlighting', () => { + test('next class is highlighted with badge on today view', async ({ page }) => { + // Next class highlighting only works when viewing today's date + const jsDay = new Date().getDay(); + test.skip(jsDay === 0 || jsDay === 6, 'Next class highlighting only works on weekdays'); + + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + + // Wait for schedule slots to load + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 20000 + }); + + // The 23:00 slot should always be "next" during normal test hours + const nextSlot = page.locator('.slot-item.next'); + await expect(nextSlot).toBeVisible({ timeout: 5000 }); + + // Verify the "Prochain" badge is displayed + await expect(nextSlot.locator('.next-badge')).toBeVisible(); + await expect(nextSlot.locator('.next-badge')).toHaveText('Prochain'); + }); + }); + + // ====================================================================== + // AC2: Multi-child content verification (P1) + // ====================================================================== + test.describe('AC2: Multi-child schedule content', () => { + test('switching children shows different subjects', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + + const childButtons = page.locator('.child-button'); + const count = await childButtons.count(); + test.skip(count < 2, 'Need at least 2 children for this test'); + + // Navigate to seeded day to see slots + await navigateToSeededDay(page); + + // First child (6A) should show Maths + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 20000 + }); + await expect(page.getByText('E2E-ParentSched-Maths').first()).toBeVisible({ + timeout: 5000 + }); + + // Switch to second child (5B) — should show SVT + await childButtons.nth(1).click(); + await navigateToSeededDay(page); + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 20000 + }); + await expect(page.getByText('E2E-ParentSched-SVT').first()).toBeVisible({ + timeout: 5000 + }); + }); + }); + + // ====================================================================== + // AC5: Offline mode + // ====================================================================== + test.describe('AC5: Offline mode', () => { + test('shows offline banner when network is lost', async ({ page, context }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await navigateToSeededDay(page); + + // Wait for schedule to load + await expect( + page.locator('[data-testid="schedule-slot"]').first() + ).toBeVisible({ timeout: 20000 }); + + // Go offline + await context.setOffline(true); + + const offlineBanner = page.locator('.offline-banner[role="status"]'); + await expect(offlineBanner).toBeVisible({ timeout: 5000 }); + await expect(offlineBanner.getByText('Hors ligne')).toBeVisible(); + + // Restore online + await context.setOffline(false); + await expect(offlineBanner).not.toBeVisible({ timeout: 5000 }); + }); + }); + + // ====================================================================== + // Navigation link + // ====================================================================== + test.describe('Navigation link', () => { + test('EDT enfants link is visible for parent role', async ({ page }) => { + await loginAsParent(page); + + const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' }); + await expect(navLink).toBeVisible({ timeout: 10000 }); + }); + + test('clicking EDT enfants link navigates to parent-schedule page', async ({ page }) => { + await loginAsParent(page); + + const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' }); + await expect(navLink).toBeVisible({ timeout: 10000 }); + await navLink.click(); + + await expect(page).toHaveURL(/\/dashboard\/parent-schedule/); + await expect( + page.getByRole('heading', { name: /emploi du temps des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + }); + }); +}); diff --git a/frontend/e2e/schedule-advanced.spec.ts b/frontend/e2e/schedule-advanced.spec.ts index 26a0a78..24efdac 100644 --- a/frontend/e2e/schedule-advanced.spec.ts +++ b/frontend/e2e/schedule-advanced.spec.ts @@ -104,7 +104,10 @@ function getWeekdayInCurrentWeek(isoDay: number): string { monday.setDate(now.getDate() - ((now.getDay() + 6) % 7)); const target = new Date(monday); target.setDate(monday.getDate() + (isoDay - 1)); - return target.toISOString().split('T')[0]!; + const y = target.getFullYear(); + const m = String(target.getMonth() + 1).padStart(2, '0'); + const d = String(target.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; } async function loginAsAdmin(page: import('@playwright/test').Page) { @@ -505,12 +508,12 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); + // Wait for the blocked date badge to appear — confirms API data is loaded + await expect(page.getByText('Jour férié test')).toBeVisible({ timeout: 20000 }); + // The third day-column (Wednesday) should have the blocked class const dayColumns = page.locator('.day-column'); - await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 10000 }); - - // Should display the reason badge in the header - await expect(page.getByText('Jour férié test')).toBeVisible(); + await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 5000 }); }); test('cannot create a slot on a blocked day', async ({ page }) => { diff --git a/frontend/e2e/student-schedule.spec.ts b/frontend/e2e/student-schedule.spec.ts index e726e44..53624d6 100644 --- a/frontend/e2e/student-schedule.spec.ts +++ b/frontend/e2e/student-schedule.spec.ts @@ -59,7 +59,7 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string } */ function currentWeekdayIso(): number { const jsDay = new Date().getDay(); // 0=Sun, 1=Mon...6=Sat - if (jsDay === 0) return 1; // Sunday → use Monday + if (jsDay === 0) return 5; // Sunday → use Friday if (jsDay === 6) return 5; // Saturday → use Friday return jsDay; } @@ -101,14 +101,13 @@ async function navigateToSeededDay(page: import('@playwright/test').Page) { const prevBtn = page.getByLabel('Précédent'); await expect(prevBtn).toBeVisible({ timeout: 10000 }); - // Retry clicking — on webkit, Svelte 5 event delegation needs time to hydrate - const deadline = Date.now() + 15000; + // Navigate one day at a time, waiting for each load to complete before clicking again + const deadline = Date.now() + 20000; let navigated = false; while (Date.now() < deadline && !navigated) { - for (let i = 0; i < back; i++) { - await prevBtn.click(); - } - await page.waitForTimeout(500); + await prevBtn.click(); + // Wait for the schedule API call to complete before checking/clicking again + await page.waitForTimeout(1500); const title = await page.locator('.day-title').textContent(); if (title && targetPattern.test(title)) { navigated = true; @@ -118,6 +117,9 @@ async function navigateToSeededDay(page: import('@playwright/test').Page) { await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({ timeout: 5000 }); + + // Wait for any in-flight schedule loads to settle after reaching target day + await page.waitForTimeout(2000); } async function loginAsStudent(page: import('@playwright/test').Page) { @@ -134,6 +136,16 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => { test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { + // Clear caches to prevent stale data from previous runs + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pools may not exist + } + // Create student user execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`, @@ -316,9 +328,9 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => { timeout: 15000 }); - // Navigate to next day + // Navigate to next day — wait for the load to settle before navigating back await page.getByLabel('Suivant').click(); - await page.waitForTimeout(300); + await page.waitForTimeout(1500); // Then navigate back await page.getByLabel('Précédent').click(); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte index 57b0190..0fe9fac 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte @@ -1,11 +1,17 @@ + +
+ {#if offline} +
+ + Hors ligne + {#if lastSync} + Dernière sync : {formatSyncDate(lastSync)} + {/if} +
+ {/if} + + {#if isLoading} +
+
+ Chargement... +
+ {:else if error} +
{error}
+ {:else if children.length === 0} +
Aucun enfant trouvé
+ {:else} +
+ {#each children as child (child.childId)} + + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/organisms/ParentSchedule/ParentScheduleView.svelte b/frontend/src/lib/components/organisms/ParentSchedule/ParentScheduleView.svelte new file mode 100644 index 0000000..a709b29 --- /dev/null +++ b/frontend/src/lib/components/organisms/ParentSchedule/ParentScheduleView.svelte @@ -0,0 +1,377 @@ + + +
+ + + + +
+
+ + +
+ + +
+ + + {#if offline} +
+ + Hors ligne + {#if lastSync} + Dernière sync : {formatSyncDate(lastSync)} + {/if} +
+ {/if} + + +
+ {#if !selectedChildId} + + {:else if isLoading} +
+
+ Chargement... +
+ {:else if error} +
+

{error}

+ +
+ {:else if viewMode === 'day'} + (selectedSlot = slot)} + /> + {:else} + (selectedSlot = slot)} + /> + {/if} +
+
+ + +{#if selectedSlot} + (selectedSlot = null)} /> +{/if} + + diff --git a/frontend/src/lib/components/organisms/StudentSchedule/ScheduleWidget.svelte b/frontend/src/lib/components/organisms/StudentSchedule/ScheduleWidget.svelte index 98c6df8..09d410d 100644 --- a/frontend/src/lib/components/organisms/StudentSchedule/ScheduleWidget.svelte +++ b/frontend/src/lib/components/organisms/StudentSchedule/ScheduleWidget.svelte @@ -1,5 +1,6 @@
diff --git a/frontend/src/lib/components/organisms/StudentSchedule/StudentSchedule.svelte b/frontend/src/lib/components/organisms/StudentSchedule/StudentSchedule.svelte index c026810..22f9d76 100644 --- a/frontend/src/lib/components/organisms/StudentSchedule/StudentSchedule.svelte +++ b/frontend/src/lib/components/organisms/StudentSchedule/StudentSchedule.svelte @@ -1,6 +1,7 @@
diff --git a/frontend/src/lib/features/schedule/api/parentSchedule.ts b/frontend/src/lib/features/schedule/api/parentSchedule.ts new file mode 100644 index 0000000..9113756 --- /dev/null +++ b/frontend/src/lib/features/schedule/api/parentSchedule.ts @@ -0,0 +1,56 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; +import type { ScheduleSlot } from './schedule'; + +export interface ChildScheduleSummary { + childId: string; + firstName: string; + lastName: string; + todaySlots: ScheduleSlot[]; + nextClass: ScheduleSlot | null; +} + +/** + * Récupère l'EDT du jour d'un enfant pour le parent connecté. + */ +export async function fetchChildDaySchedule(childId: string, date: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/children/${childId}/schedule/day/${date}`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement de l'EDT (${response.status})`); + } + + const json = await response.json(); + return json.data ?? []; +} + +/** + * Récupère l'EDT de la semaine d'un enfant pour le parent connecté. + */ +export async function fetchChildWeekSchedule(childId: string, date: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/children/${childId}/schedule/week/${date}`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement de l'EDT (${response.status})`); + } + + const json = await response.json(); + return json.data ?? []; +} + +/** + * Récupère le résumé EDT du jour pour tous les enfants du parent connecté. + */ +export async function fetchChildrenScheduleSummary(): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/children/schedule/summary`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement du résumé EDT (${response.status})`); + } + + const json = await response.json(); + return json.data ?? []; +} diff --git a/frontend/src/lib/features/schedule/formatSyncDate.ts b/frontend/src/lib/features/schedule/formatSyncDate.ts new file mode 100644 index 0000000..5f20bde --- /dev/null +++ b/frontend/src/lib/features/schedule/formatSyncDate.ts @@ -0,0 +1,13 @@ +/** + * Formats an ISO date string to a short French locale date+time display. + * Used by schedule components to show the last synchronization timestamp. + */ +export function formatSyncDate(iso: string | null): string { + if (!iso) return ''; + return new Date(iso).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 8ac64e1..9d24cc2 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -13,6 +13,7 @@ let logoUrl = $derived(getLogoUrl()); let pathname = $derived(page.url.pathname); let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE'); + let isParent = $derived(getActiveRole() === 'ROLE_PARENT'); // Load user roles on mount for multi-role context switching (FR5) // Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode @@ -104,6 +105,9 @@ {#if isEleve} Mon EDT {/if} + {#if isParent} + EDT enfants + {/if}