From 81e97c4f3b625f7e5b30e00a7d2689e785be3b6d Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Mon, 9 Mar 2026 11:20:50 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Afficher=20la=20couleur=20des=20mati?= =?UTF-8?q?=C3=A8res=20dans=20l'emploi=20du=20temps=20=C3=A9l=C3=A8ve=20et?= =?UTF-8?q?=20parent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'admin pouvait attribuer une couleur à chaque matière, mais cette couleur n'était utilisée que dans la vue admin de l'emploi du temps. Les APIs élève et parent ne renvoyaient pas cette information, ce qui donnait un affichage générique (gris/bleu) pour tous les créneaux. L'API renvoie désormais subjectColor dans chaque créneau, et les vues jour/semaine/widget/détails affichent la bordure colorée correspondante. Le marqueur "Prochain cours" conserve sa priorité visuelle via une surcharge CSS variable. --- .../Port/ScheduleDisplayReader.php | 4 +- .../GetChildrenScheduleHandler.php | 5 +- .../GetStudentScheduleHandler.php | 5 +- .../StudentScheduleSlotDto.php | 3 + .../Controller/ParentScheduleController.php | 4 +- .../Controller/StudentScheduleController.php | 4 +- .../Service/DoctrineScheduleDisplayReader.php | 12 ++-- .../GetChildrenScheduleHandlerTest.php | 9 ++- .../GetStudentScheduleHandlerTest.php | 9 ++- frontend/e2e/child-selector.spec.ts | 18 +++--- frontend/e2e/parent-schedule.spec.ts | 57 +++++++++++++++---- .../ChildSelector/ChildSelector.svelte | 14 ++++- .../Dashboard/DashboardParent.svelte | 2 +- .../Dashboard/DashboardStudent.svelte | 2 +- .../ParentSchedule/MultiChildSummary.svelte | 3 +- .../ParentSchedule/ParentScheduleView.svelte | 16 +++--- .../organisms/StudentSchedule/DayView.svelte | 10 +++- .../StudentSchedule/ScheduleWidget.svelte | 7 ++- .../StudentSchedule/SlotDetails.svelte | 7 ++- .../StudentSchedule/StudentSchedule.svelte | 2 +- .../organisms/StudentSchedule/WeekView.svelte | 4 ++ .../src/lib/features/schedule/api/schedule.ts | 1 + ...heduleCache.ts => scheduleCache.svelte.ts} | 13 +++-- frontend/src/routes/dashboard/+page.svelte | 2 +- .../features/schedule/scheduleCache.test.ts | 2 +- .../StudentSchedule/ScheduleWidget.test.ts | 13 ++++- frontend/vite.config.ts | 30 ++++++++++ 27 files changed, 188 insertions(+), 70 deletions(-) rename frontend/src/lib/features/schedule/stores/{scheduleCache.ts => scheduleCache.svelte.ts} (82%) diff --git a/backend/src/Scolarite/Application/Port/ScheduleDisplayReader.php b/backend/src/Scolarite/Application/Port/ScheduleDisplayReader.php index c59a211..b89dbb3 100644 --- a/backend/src/Scolarite/Application/Port/ScheduleDisplayReader.php +++ b/backend/src/Scolarite/Application/Port/ScheduleDisplayReader.php @@ -15,9 +15,9 @@ interface ScheduleDisplayReader /** * @param string ...$subjectIds Identifiants des matières * - * @return array Map subjectId => nom de la matière + * @return array Map subjectId => {name, color} */ - public function subjectNames(string $tenantId, string ...$subjectIds): array; + public function subjectDisplay(string $tenantId, string ...$subjectIds): array; /** * @param string ...$teacherIds Identifiants des enseignants diff --git a/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandler.php b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandler.php index 52fbd50..1a6c152 100644 --- a/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandler.php +++ b/backend/src/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandler.php @@ -111,13 +111,14 @@ final readonly class GetChildrenScheduleHandler array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots), )); - $subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds); + $subjects = $this->displayReader->subjectDisplay($tenantId, ...$subjectIds); $teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds); return array_map( static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved( $s, - $subjectNames[(string) $s->subjectId] ?? '', + $subjects[(string) $s->subjectId]['name'] ?? '', + $subjects[(string) $s->subjectId]['color'] ?? null, $teacherNames[(string) $s->teacherId] ?? '', ), $slots, diff --git a/backend/src/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandler.php b/backend/src/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandler.php index fb148fe..8bede05 100644 --- a/backend/src/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandler.php +++ b/backend/src/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandler.php @@ -74,13 +74,14 @@ final readonly class GetStudentScheduleHandler array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots), )); - $subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds); + $subjects = $this->displayReader->subjectDisplay($tenantId, ...$subjectIds); $teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds); return array_map( static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved( $s, - $subjectNames[(string) $s->subjectId] ?? '', + $subjects[(string) $s->subjectId]['name'] ?? '', + $subjects[(string) $s->subjectId]['color'] ?? null, $teacherNames[(string) $s->teacherId] ?? '', ), $slots, diff --git a/backend/src/Scolarite/Application/Query/GetStudentSchedule/StudentScheduleSlotDto.php b/backend/src/Scolarite/Application/Query/GetStudentSchedule/StudentScheduleSlotDto.php index 398b07b..5504185 100644 --- a/backend/src/Scolarite/Application/Query/GetStudentSchedule/StudentScheduleSlotDto.php +++ b/backend/src/Scolarite/Application/Query/GetStudentSchedule/StudentScheduleSlotDto.php @@ -16,6 +16,7 @@ final readonly class StudentScheduleSlotDto public string $endTime, public string $subjectId, public string $subjectName, + public ?string $subjectColor, public string $teacherId, public string $teacherName, public ?string $room, @@ -27,6 +28,7 @@ final readonly class StudentScheduleSlotDto public static function fromResolved( ResolvedScheduleSlot $slot, string $subjectName, + ?string $subjectColor, string $teacherName, ): self { return new self( @@ -37,6 +39,7 @@ final readonly class StudentScheduleSlotDto endTime: $slot->timeSlot->endTime, subjectId: (string) $slot->subjectId, subjectName: $subjectName, + subjectColor: $subjectColor, teacherId: (string) $slot->teacherId, teacherName: $teacherName, room: $slot->room, diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/ParentScheduleController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentScheduleController.php index 94d3062..2e09f06 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/ParentScheduleController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/ParentScheduleController.php @@ -29,9 +29,6 @@ use function usort; /** * Endpoints de consultation de l'emploi du temps des enfants pour le parent connecté. - * - * @see Story 4.4 - Consultation EDT par le Parent - * @see FR30 - Consulter emploi du temps enfant (parent) */ #[IsGranted(ScheduleSlotVoter::VIEW)] final readonly class ParentScheduleController @@ -190,6 +187,7 @@ final readonly class ParentScheduleController 'endTime' => $slot->endTime, 'subjectId' => $slot->subjectId, 'subjectName' => $slot->subjectName, + 'subjectColor' => $slot->subjectColor, 'teacherId' => $slot->teacherId, 'teacherName' => $slot->teacherName, 'room' => $slot->room, diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentScheduleController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentScheduleController.php index b48e73f..7590a3c 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentScheduleController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentScheduleController.php @@ -29,9 +29,6 @@ use function usort; /** * Endpoints de consultation de l'emploi du temps pour l'élève connecté. - * - * @see Story 4.3 - Consultation EDT par l'Élève - * @see FR29 - Consulter emploi du temps (élève) */ #[IsGranted(ScheduleSlotVoter::VIEW)] final readonly class StudentScheduleController @@ -178,6 +175,7 @@ final readonly class StudentScheduleController 'endTime' => $slot->endTime, 'subjectId' => $slot->subjectId, 'subjectName' => $slot->subjectName, + 'subjectColor' => $slot->subjectColor, 'teacherId' => $slot->teacherId, 'teacherName' => $slot->teacherName, 'room' => $slot->room, diff --git a/backend/src/Scolarite/Infrastructure/Service/DoctrineScheduleDisplayReader.php b/backend/src/Scolarite/Infrastructure/Service/DoctrineScheduleDisplayReader.php index 90115d5..a53af54 100644 --- a/backend/src/Scolarite/Infrastructure/Service/DoctrineScheduleDisplayReader.php +++ b/backend/src/Scolarite/Infrastructure/Service/DoctrineScheduleDisplayReader.php @@ -20,29 +20,31 @@ final readonly class DoctrineScheduleDisplayReader implements ScheduleDisplayRea } #[Override] - public function subjectNames(string $tenantId, string ...$subjectIds): array + public function subjectDisplay(string $tenantId, string ...$subjectIds): array { if ($subjectIds === []) { return []; } $rows = $this->connection->fetchAllAssociative( - 'SELECT id, name FROM subjects WHERE id IN (:ids) AND tenant_id = :tenantId', + 'SELECT id, name, color FROM subjects WHERE id IN (:ids) AND tenant_id = :tenantId', ['ids' => $subjectIds, 'tenantId' => $tenantId], ['ids' => ArrayParameterType::STRING], ); - $names = []; + $display = []; foreach ($rows as $row) { /** @var string $id */ $id = $row['id']; /** @var string $name */ $name = $row['name']; - $names[$id] = $name; + /** @var string|null $color */ + $color = $row['color']; + $display[$id] = ['name' => $name, 'color' => $color]; } - return $names; + return $display; } #[Override] diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandlerTest.php index 00c4bf4..3f54a8f 100644 --- a/backend/tests/Unit/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenSchedule/GetChildrenScheduleHandlerTest.php @@ -265,9 +265,14 @@ final class GetChildrenScheduleHandlerTest extends TestCase ) { } - public function subjectNames(string $tenantId, string ...$subjectIds): array + public function subjectDisplay(string $tenantId, string ...$subjectIds): array { - return $this->subjects; + $display = []; + foreach ($this->subjects as $id => $name) { + $display[$id] = ['name' => $name, 'color' => null]; + } + + return $display; } public function teacherNames(string $tenantId, string ...$teacherIds): array diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandlerTest.php index 00b2f07..c598848 100644 --- a/backend/tests/Unit/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Query/GetStudentSchedule/GetStudentScheduleHandlerTest.php @@ -210,9 +210,14 @@ final class GetStudentScheduleHandlerTest extends TestCase ) { } - public function subjectNames(string $tenantId, string ...$subjectIds): array + public function subjectDisplay(string $tenantId, string ...$subjectIds): array { - return $this->subjects; + $display = []; + foreach ($this->subjects as $id => $name) { + $display[$id] = ['name' => $name, 'color' => null]; + } + + return $display; } public function teacherNames(string $tenantId, string ...$teacherIds): array diff --git a/frontend/e2e/child-selector.spec.ts b/frontend/e2e/child-selector.spec.ts index 1f71f7b..9cc8c47 100644 --- a/frontend/e2e/child-selector.spec.ts +++ b/frontend/e2e/child-selector.spec.ts @@ -147,11 +147,11 @@ test.describe('Child Selector', () => { // Should display the label await expect(childSelector.locator('.child-selector-label')).toHaveText('Enfant :'); - // Should have 2 child buttons + // Should have 3 buttons: "Tous" + 2 children const buttons = childSelector.locator('.child-button'); - await expect(buttons).toHaveCount(2); + await expect(buttons).toHaveCount(3); - // First child should be auto-selected + // "Tous" button should be selected initially (no child auto-selected) await expect(buttons.first()).toHaveClass(/selected/); }); @@ -162,18 +162,18 @@ test.describe('Child Selector', () => { await expect(childSelector).toBeVisible({ timeout: 10000 }); const buttons = childSelector.locator('.child-button'); - await expect(buttons).toHaveCount(2); + await expect(buttons).toHaveCount(3); - // First button should be selected initially - await expect(buttons.first()).toHaveClass(/selected/); + // "Tous" button (index 0) should be selected initially + await expect(buttons.nth(0)).toHaveClass(/selected/); await expect(buttons.nth(1)).not.toHaveClass(/selected/); - // Click second button + // Click first child button (index 1) await buttons.nth(1).click(); - // Second button should now be selected, first should not + // First child should now be selected, "Tous" should not await expect(buttons.nth(1)).toHaveClass(/selected/); - await expect(buttons.first()).not.toHaveClass(/selected/); + await expect(buttons.nth(0)).not.toHaveClass(/selected/); }); test('[P1] parent with single child should see static child name', async ({ browser, page }) => { diff --git a/frontend/e2e/parent-schedule.spec.ts b/frontend/e2e/parent-schedule.spec.ts index bd2f766..4179b98 100644 --- a/frontend/e2e/parent-schedule.spec.ts +++ b/frontend/e2e/parent-schedule.spec.ts @@ -114,6 +114,21 @@ async function loginAsParent(page: import('@playwright/test').Page) { ]); } +/** + * Multi-child parents land on the summary view (no child auto-selected). + * This helper selects the first actual child (skipping the "Tous" button). + */ +async function selectFirstChild(page: import('@playwright/test').Page) { + const childButtons = page.locator('.child-button'); + await expect(childButtons.first()).toBeVisible({ timeout: 15000 }); + const count = await childButtons.count(); + if (count > 2) { + // Multi-child: "Tous" is at index 0, first child at index 1 + await childButtons.nth(1).click(); + } + // Single child is auto-selected, nothing to do +} + test.describe('Parent Schedule Consultation (Story 4.4)', () => { test.describe.configure({ mode: 'serial' }); @@ -305,6 +320,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); + await selectFirstChild(page); await navigateToSeededDay(page); // Wait for slots to load @@ -324,19 +340,29 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); - // If multiple children, buttons should be visible + // Multi-child: "Tous" + N children buttons const childButtons = page.locator('.child-button'); const count = await childButtons.count(); - if (count > 1) { - // Click second child + if (count > 2) { + // Select first child (index 1, after "Tous") 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 }); + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 20000 + }); + + // Switch to second child (index 2) + await childButtons.nth(2).click(); + await page.waitForTimeout(500); + await navigateToSeededDay(page); + await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ + timeout: 20000 + }); + + // Switch back to "Tous" summary view + await childButtons.nth(0).click(); + await expect(page.locator('.multi-child-summary')).toBeVisible({ timeout: 10000 }); } }); }); @@ -351,6 +377,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); + await selectFirstChild(page); const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' }); await expect(dayButton).toHaveClass(/active/, { timeout: 5000 }); @@ -362,6 +389,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); + await selectFirstChild(page); await navigateToSeededDay(page); // Wait for slots to load @@ -384,6 +412,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); + await selectFirstChild(page); await navigateToSeededDay(page); await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ @@ -419,6 +448,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); + await selectFirstChild(page); // Wait for schedule slots to load await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ @@ -448,9 +478,11 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { const childButtons = page.locator('.child-button'); const count = await childButtons.count(); - test.skip(count < 2, 'Need at least 2 children for this test'); + // "Tous" + at least 2 children = 3 buttons minimum + test.skip(count < 3, 'Need at least 2 children for this test'); - // Navigate to seeded day to see slots + // Select first child (index 1, after "Tous") + await childButtons.nth(1).click(); await navigateToSeededDay(page); // First child (6A) should show Maths @@ -461,8 +493,8 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { timeout: 5000 }); - // Switch to second child (5B) — should show SVT - await childButtons.nth(1).click(); + // Switch to second child (index 2) (5B) — should show SVT + await childButtons.nth(2).click(); await navigateToSeededDay(page); await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 @@ -480,6 +512,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => { test('shows offline banner when network is lost', async ({ page, context }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); + await selectFirstChild(page); await navigateToSeededDay(page); // Wait for schedule to load diff --git a/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte b/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte index 8eee988..215946c 100644 --- a/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte +++ b/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte @@ -14,7 +14,7 @@ let { onChildSelected }: { - onChildSelected?: (childId: string) => void; + onChildSelected?: (childId: string | null) => void; } = $props(); let children = $state([]); @@ -40,8 +40,9 @@ const data = await response.json(); children = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []); + // Auto-select only when there's exactly 1 child const first = children[0]; - if (first && !selectedChildId) { + if (first && !selectedChildId && children.length === 1) { selectedChildId = first.studentId; onChildSelected?.(first.studentId); } @@ -52,7 +53,7 @@ } } - function selectChild(childId: string) { + function selectChild(childId: string | null) { selectedChildId = childId; onChildSelected?.(childId); } @@ -75,6 +76,13 @@
Enfant :
+ {#each children as child (child.id)}