Compare commits
2 Commits
bf753d1367
...
81e97c4f3b
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e97c4f3b | |||
| bda63bd98c |
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
@@ -44,15 +43,23 @@ final readonly class ChangeStudentClassHandler
|
||||
throw ClasseNotFoundException::withId($newClassId);
|
||||
}
|
||||
|
||||
// Trouver l'affectation existante
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Trouver l'affectation existante ou en créer une nouvelle
|
||||
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||
|
||||
if ($assignment === null) {
|
||||
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
|
||||
if ($assignment !== null) {
|
||||
$assignment->changerClasse($newClassId, $now);
|
||||
} else {
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
classId: $newClassId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$assignment->changerClasse($newClassId, $this->clock->now());
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
return $assignment;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class AffectationEleveNonTrouveeException extends DomainException
|
||||
{
|
||||
public static function pourEleve(UserId $studentId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Aucune affectation trouvée pour l\'élève "%s" cette année scolaire.',
|
||||
$studentId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
||||
@@ -80,8 +79,6 @@ final readonly class ChangeStudentClassProcessor implements ProcessorInterface
|
||||
$data->classLevel = $newClass->level?->value;
|
||||
|
||||
return $data;
|
||||
} catch (AffectationEleveNonTrouveeException) {
|
||||
throw new NotFoundHttpException('Élève non trouvé.');
|
||||
} catch (ClasseNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ interface ScheduleDisplayReader
|
||||
/**
|
||||
* @param string ...$subjectIds Identifiants des matières
|
||||
*
|
||||
* @return array<string, string> Map subjectId => nom de la matière
|
||||
* @return array<string, array{name: string, color: string|null}> 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Tests\Unit\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
@@ -74,13 +73,17 @@ final class ChangeStudentClassHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenAssignmentNotFound(): void
|
||||
public function itCreatesAssignmentWhenNoneExists(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440070');
|
||||
$newStudentId = '550e8400-e29b-41d4-a716-446655440070';
|
||||
$command = $this->createCommand(studentId: $newStudentId);
|
||||
|
||||
$this->expectException(AffectationEleveNonTrouveeException::class);
|
||||
$handler($command);
|
||||
$assignment = $handler($command);
|
||||
|
||||
self::assertTrue($assignment->studentId->equals(UserId::fromString($newStudentId)));
|
||||
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::NEW_CLASS_ID)));
|
||||
self::assertTrue($assignment->academicYearId->equals(AcademicYearId::fromString(self::ACADEMIC_YEAR_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
let {
|
||||
onChildSelected
|
||||
}: {
|
||||
onChildSelected?: (childId: string) => void;
|
||||
onChildSelected?: (childId: string | null) => void;
|
||||
} = $props();
|
||||
|
||||
let children = $state<Child[]>([]);
|
||||
@@ -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 @@
|
||||
<div class="child-selector">
|
||||
<span class="child-selector-label">Enfant :</span>
|
||||
<div class="child-selector-buttons">
|
||||
<button
|
||||
class="child-button"
|
||||
class:selected={selectedChildId === null}
|
||||
onclick={() => selectChild(null)}
|
||||
>
|
||||
Tous
|
||||
</button>
|
||||
{#each children as child (child.id)}
|
||||
<button
|
||||
class="child-button"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DemoData } from '$types';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
|
||||
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
|
||||
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DemoData } from '$types';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
||||
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChildScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { fetchChildrenScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { isOffline, getLastSyncDate, recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
@@ -43,6 +43,7 @@
|
||||
|
||||
try {
|
||||
children = await fetchChildrenScheduleSummary();
|
||||
recordSync();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchChildDaySchedule, fetchChildWeekSchedule } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { untrack } from 'svelte';
|
||||
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
|
||||
import MultiChildSummary from '$lib/components/organisms/ParentSchedule/MultiChildSummary.svelte';
|
||||
@@ -88,14 +88,16 @@
|
||||
};
|
||||
});
|
||||
|
||||
function handleChildSelected(childId: string) {
|
||||
function handleChildSelected(childId: string | null) {
|
||||
selectedChildId = childId;
|
||||
if (childId) {
|
||||
untrack(() => {
|
||||
loadSchedule();
|
||||
// Prefetch for offline support
|
||||
prefetchScheduleDays((date) => fetchChildDaySchedule(childId, date));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function navigateDay(offset: number) {
|
||||
const d = new Date(currentDate + 'T00:00:00');
|
||||
|
||||
@@ -44,7 +44,11 @@
|
||||
{:else}
|
||||
<ul class="slot-list">
|
||||
{#each slots as slot (slot.slotId + slot.date)}
|
||||
<li class="slot-item" class:next={slot.slotId === nextSlotId}>
|
||||
<li
|
||||
class="slot-item"
|
||||
class:next={slot.slotId === nextSlotId}
|
||||
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
|
||||
>
|
||||
<button
|
||||
class="slot-button"
|
||||
onclick={() => onSlotClick(slot)}
|
||||
@@ -123,12 +127,12 @@
|
||||
.slot-item {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border-left: 4px solid #e5e7eb;
|
||||
border-left: 4px solid var(--slot-color, #e5e7eb);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.slot-item.next {
|
||||
border-left-color: #3b82f6;
|
||||
--slot-color: #3b82f6;
|
||||
}
|
||||
|
||||
.slot-button {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
|
||||
let {
|
||||
slots = [],
|
||||
@@ -61,6 +61,7 @@
|
||||
class="slot-item"
|
||||
class:next={slot.slotId === nextSlotId}
|
||||
data-testid="schedule-slot"
|
||||
style:--slot-color={slot.subjectColor ?? 'transparent'}
|
||||
>
|
||||
<div class="slot-time">
|
||||
<span class="time-start">{slot.startTime}</span>
|
||||
@@ -144,12 +145,12 @@
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
align-items: center;
|
||||
border-left: 3px solid transparent;
|
||||
border-left: 3px solid var(--slot-color, transparent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slot-item.next {
|
||||
border-left-color: #3b82f6;
|
||||
--slot-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
aria-label="Fermer">×</button
|
||||
>
|
||||
|
||||
<h2 class="subject-name">{slot.subjectName}</h2>
|
||||
<h2 class="subject-name" class:has-color={slot.subjectColor} style:--slot-color={slot.subjectColor}>{slot.subjectName}</h2>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-row">
|
||||
@@ -149,6 +149,11 @@
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.subject-name.has-color {
|
||||
border-left: 4px solid var(--slot-color);
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { fetchDaySchedule, fetchWeekSchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import { untrack } from 'svelte';
|
||||
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
import DayView from './DayView.svelte';
|
||||
import WeekView from './WeekView.svelte';
|
||||
import SlotDetails from './SlotDetails.svelte';
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
class:modified={slot.isModified}
|
||||
onclick={() => onSlotClick(slot)}
|
||||
data-testid="week-slot"
|
||||
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
|
||||
>
|
||||
<span class="slot-time-mobile">{slot.startTime} - {slot.endTime}</span>
|
||||
<span class="slot-subject-mobile">{slot.subjectName}</span>
|
||||
@@ -97,6 +98,7 @@
|
||||
class:modified={slot.isModified}
|
||||
onclick={() => onSlotClick(slot)}
|
||||
data-testid="week-slot"
|
||||
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
|
||||
>
|
||||
<span class="week-slot-time">{slot.startTime}</span>
|
||||
<span class="week-slot-subject">{slot.subjectName}</span>
|
||||
@@ -182,6 +184,7 @@
|
||||
background: white;
|
||||
border: none;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
border-left: 4px solid var(--slot-color, #e5e7eb);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
@@ -283,6 +286,7 @@
|
||||
padding: 0.5rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid var(--slot-color, #bfdbfe);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ScheduleSlot {
|
||||
endTime: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
subjectColor: string | null;
|
||||
teacherId: string;
|
||||
teacherName: string;
|
||||
room: string | null;
|
||||
|
||||
@@ -2,6 +2,10 @@ import { browser } from '$app/environment';
|
||||
|
||||
const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
|
||||
|
||||
let lastSyncValue = $state<string | null>(
|
||||
browser ? localStorage.getItem(LAST_SYNC_KEY) : null
|
||||
);
|
||||
|
||||
/**
|
||||
* Vérifie si le navigateur est actuellement hors ligne.
|
||||
*/
|
||||
@@ -15,15 +19,16 @@ export function isOffline(): boolean {
|
||||
*/
|
||||
export function recordSync(): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(LAST_SYNC_KEY, new Date().toISOString());
|
||||
const now = new Date().toISOString();
|
||||
localStorage.setItem(LAST_SYNC_KEY, now);
|
||||
lastSyncValue = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la date de dernière synchronisation de l'EDT.
|
||||
* Récupère la date de dernière synchronisation de l'EDT (réactif via $state).
|
||||
*/
|
||||
export function getLastSyncDate(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(LAST_SYNC_KEY);
|
||||
return lastSyncValue;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,6 +62,7 @@
|
||||
let changeClassTarget = $state<Student | null>(null);
|
||||
let newClassForChange = $state('');
|
||||
let isChangingClass = $state(false);
|
||||
let changeClassError = $state<string | null>(null);
|
||||
|
||||
// Classes grouped by level for optgroup
|
||||
let classesByLevel = $derived.by(() => {
|
||||
@@ -300,7 +301,7 @@
|
||||
changeClassTarget = student;
|
||||
newClassForChange = '';
|
||||
showChangeClassModal = true;
|
||||
error = null;
|
||||
changeClassError = null;
|
||||
}
|
||||
|
||||
function closeChangeClassModal() {
|
||||
@@ -338,7 +339,7 @@
|
||||
successMessage = `${changeClassTarget.firstName} ${changeClassTarget.lastName} a été transféré vers ${targetClass?.name ?? 'la nouvelle classe'}.`;
|
||||
closeChangeClassModal();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors du changement de classe';
|
||||
changeClassError = e instanceof Error ? e.message : 'Erreur lors du changement de classe';
|
||||
} finally {
|
||||
isChangingClass = false;
|
||||
}
|
||||
@@ -720,6 +721,13 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if changeClassError}
|
||||
<div class="alert alert-error modal-error">
|
||||
{changeClassError}
|
||||
<button class="alert-close" onclick={() => (changeClassError = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if newClassForChange}
|
||||
{@const targetClass = classes.find((c) => c.id === newClassForChange)}
|
||||
<div class="change-confirm-info">
|
||||
@@ -1244,6 +1252,10 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.change-confirm-info {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #eff6ff;
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
// Demo child name for personalized messages
|
||||
let childName = $state('Emma');
|
||||
|
||||
function handleChildSelected(childId: string) {
|
||||
function handleChildSelected(childId: string | null) {
|
||||
selectedChildId = childId;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock('$app/environment', () => ({
|
||||
browser: true
|
||||
}));
|
||||
|
||||
import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
|
||||
describe('scheduleCache', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/svelte';
|
||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
|
||||
vi.mock('$lib/features/schedule/stores/scheduleCache', () => ({
|
||||
vi.mock('$lib/features/schedule/stores/scheduleCache.svelte', () => ({
|
||||
isOffline: vi.fn(() => false),
|
||||
getLastSyncDate: vi.fn(() => null)
|
||||
}));
|
||||
@@ -17,6 +17,7 @@ function makeSlot(overrides: Partial<ScheduleSlot> = {}): ScheduleSlot {
|
||||
endTime: '09:00',
|
||||
subjectId: 'sub-1',
|
||||
subjectName: 'Mathématiques',
|
||||
subjectColor: null,
|
||||
teacherId: 'teacher-1',
|
||||
teacherName: 'M. Dupont',
|
||||
room: 'Salle 101',
|
||||
@@ -100,6 +101,16 @@ describe('ScheduleWidget', () => {
|
||||
expect(container.querySelector('.slot-room')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies subject color as CSS variable on slot', () => {
|
||||
const slot = makeSlot({ subjectColor: '#e74c3c' });
|
||||
const { container } = render(ScheduleWidget, {
|
||||
props: { slots: [slot], nextSlotId: null }
|
||||
});
|
||||
|
||||
const item = container.querySelector('[data-testid="schedule-slot"]') as HTMLElement;
|
||||
expect(item.style.getPropertyValue('--slot-color')).toBe('#e74c3c');
|
||||
});
|
||||
|
||||
it('renders multiple slots with data-testid', () => {
|
||||
const slots = [
|
||||
makeSlot({ slotId: 'slot-1' }),
|
||||
|
||||
@@ -79,6 +79,36 @@ export default defineConfig({
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/me\/children\/schedule\/summary/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'parent-schedule-v1',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60
|
||||
},
|
||||
networkTimeoutSeconds: 5,
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/me\/children$/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'parent-children-v1',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60
|
||||
},
|
||||
networkTimeoutSeconds: 5,
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user