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;
|
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||||
|
|
||||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
|
||||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
@@ -44,15 +43,23 @@ final readonly class ChangeStudentClassHandler
|
|||||||
throw ClasseNotFoundException::withId($newClassId);
|
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);
|
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||||
|
|
||||||
if ($assignment === null) {
|
if ($assignment !== null) {
|
||||||
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
|
$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);
|
$this->classAssignmentRepository->save($assignment);
|
||||||
|
|
||||||
return $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 ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
|
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
|
||||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
||||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
|
||||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
use App\Administration\Domain\Repository\ClassRepository;
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
||||||
@@ -80,8 +79,6 @@ final readonly class ChangeStudentClassProcessor implements ProcessorInterface
|
|||||||
$data->classLevel = $newClass->level?->value;
|
$data->classLevel = $newClass->level?->value;
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
} catch (AffectationEleveNonTrouveeException) {
|
|
||||||
throw new NotFoundHttpException('Élève non trouvé.');
|
|
||||||
} catch (ClasseNotFoundException $e) {
|
} catch (ClasseNotFoundException $e) {
|
||||||
throw new NotFoundHttpException($e->getMessage());
|
throw new NotFoundHttpException($e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ interface ScheduleDisplayReader
|
|||||||
/**
|
/**
|
||||||
* @param string ...$subjectIds Identifiants des matières
|
* @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
|
* @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),
|
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);
|
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
|
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
|
||||||
$s,
|
$s,
|
||||||
$subjectNames[(string) $s->subjectId] ?? '',
|
$subjects[(string) $s->subjectId]['name'] ?? '',
|
||||||
|
$subjects[(string) $s->subjectId]['color'] ?? null,
|
||||||
$teacherNames[(string) $s->teacherId] ?? '',
|
$teacherNames[(string) $s->teacherId] ?? '',
|
||||||
),
|
),
|
||||||
$slots,
|
$slots,
|
||||||
|
|||||||
@@ -74,13 +74,14 @@ final readonly class GetStudentScheduleHandler
|
|||||||
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots),
|
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);
|
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
|
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
|
||||||
$s,
|
$s,
|
||||||
$subjectNames[(string) $s->subjectId] ?? '',
|
$subjects[(string) $s->subjectId]['name'] ?? '',
|
||||||
|
$subjects[(string) $s->subjectId]['color'] ?? null,
|
||||||
$teacherNames[(string) $s->teacherId] ?? '',
|
$teacherNames[(string) $s->teacherId] ?? '',
|
||||||
),
|
),
|
||||||
$slots,
|
$slots,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ final readonly class StudentScheduleSlotDto
|
|||||||
public string $endTime,
|
public string $endTime,
|
||||||
public string $subjectId,
|
public string $subjectId,
|
||||||
public string $subjectName,
|
public string $subjectName,
|
||||||
|
public ?string $subjectColor,
|
||||||
public string $teacherId,
|
public string $teacherId,
|
||||||
public string $teacherName,
|
public string $teacherName,
|
||||||
public ?string $room,
|
public ?string $room,
|
||||||
@@ -27,6 +28,7 @@ final readonly class StudentScheduleSlotDto
|
|||||||
public static function fromResolved(
|
public static function fromResolved(
|
||||||
ResolvedScheduleSlot $slot,
|
ResolvedScheduleSlot $slot,
|
||||||
string $subjectName,
|
string $subjectName,
|
||||||
|
?string $subjectColor,
|
||||||
string $teacherName,
|
string $teacherName,
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
@@ -37,6 +39,7 @@ final readonly class StudentScheduleSlotDto
|
|||||||
endTime: $slot->timeSlot->endTime,
|
endTime: $slot->timeSlot->endTime,
|
||||||
subjectId: (string) $slot->subjectId,
|
subjectId: (string) $slot->subjectId,
|
||||||
subjectName: $subjectName,
|
subjectName: $subjectName,
|
||||||
|
subjectColor: $subjectColor,
|
||||||
teacherId: (string) $slot->teacherId,
|
teacherId: (string) $slot->teacherId,
|
||||||
teacherName: $teacherName,
|
teacherName: $teacherName,
|
||||||
room: $slot->room,
|
room: $slot->room,
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ use function usort;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoints de consultation de l'emploi du temps des enfants pour le parent connecté.
|
* 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)]
|
#[IsGranted(ScheduleSlotVoter::VIEW)]
|
||||||
final readonly class ParentScheduleController
|
final readonly class ParentScheduleController
|
||||||
@@ -190,6 +187,7 @@ final readonly class ParentScheduleController
|
|||||||
'endTime' => $slot->endTime,
|
'endTime' => $slot->endTime,
|
||||||
'subjectId' => $slot->subjectId,
|
'subjectId' => $slot->subjectId,
|
||||||
'subjectName' => $slot->subjectName,
|
'subjectName' => $slot->subjectName,
|
||||||
|
'subjectColor' => $slot->subjectColor,
|
||||||
'teacherId' => $slot->teacherId,
|
'teacherId' => $slot->teacherId,
|
||||||
'teacherName' => $slot->teacherName,
|
'teacherName' => $slot->teacherName,
|
||||||
'room' => $slot->room,
|
'room' => $slot->room,
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ use function usort;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoints de consultation de l'emploi du temps pour l'élève connecté.
|
* 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)]
|
#[IsGranted(ScheduleSlotVoter::VIEW)]
|
||||||
final readonly class StudentScheduleController
|
final readonly class StudentScheduleController
|
||||||
@@ -178,6 +175,7 @@ final readonly class StudentScheduleController
|
|||||||
'endTime' => $slot->endTime,
|
'endTime' => $slot->endTime,
|
||||||
'subjectId' => $slot->subjectId,
|
'subjectId' => $slot->subjectId,
|
||||||
'subjectName' => $slot->subjectName,
|
'subjectName' => $slot->subjectName,
|
||||||
|
'subjectColor' => $slot->subjectColor,
|
||||||
'teacherId' => $slot->teacherId,
|
'teacherId' => $slot->teacherId,
|
||||||
'teacherName' => $slot->teacherName,
|
'teacherName' => $slot->teacherName,
|
||||||
'room' => $slot->room,
|
'room' => $slot->room,
|
||||||
|
|||||||
@@ -20,29 +20,31 @@ final readonly class DoctrineScheduleDisplayReader implements ScheduleDisplayRea
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Override]
|
#[Override]
|
||||||
public function subjectNames(string $tenantId, string ...$subjectIds): array
|
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
|
||||||
{
|
{
|
||||||
if ($subjectIds === []) {
|
if ($subjectIds === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $this->connection->fetchAllAssociative(
|
$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' => $subjectIds, 'tenantId' => $tenantId],
|
||||||
['ids' => ArrayParameterType::STRING],
|
['ids' => ArrayParameterType::STRING],
|
||||||
);
|
);
|
||||||
|
|
||||||
$names = [];
|
$display = [];
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
/** @var string $id */
|
/** @var string $id */
|
||||||
$id = $row['id'];
|
$id = $row['id'];
|
||||||
/** @var string $name */
|
/** @var string $name */
|
||||||
$name = $row['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]
|
#[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\ChangeStudentClassCommand;
|
||||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
||||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
|
||||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
@@ -74,13 +73,17 @@ final class ChangeStudentClassHandlerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function itThrowsWhenAssignmentNotFound(): void
|
public function itCreatesAssignmentWhenNoneExists(): void
|
||||||
{
|
{
|
||||||
$handler = $this->createHandler();
|
$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);
|
$assignment = $handler($command);
|
||||||
$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]
|
#[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
|
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
|
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||||
|
|||||||
@@ -147,11 +147,11 @@ test.describe('Child Selector', () => {
|
|||||||
// Should display the label
|
// Should display the label
|
||||||
await expect(childSelector.locator('.child-selector-label')).toHaveText('Enfant :');
|
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');
|
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/);
|
await expect(buttons.first()).toHaveClass(/selected/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,18 +162,18 @@ test.describe('Child Selector', () => {
|
|||||||
await expect(childSelector).toBeVisible({ timeout: 10000 });
|
await expect(childSelector).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const buttons = childSelector.locator('.child-button');
|
const buttons = childSelector.locator('.child-button');
|
||||||
await expect(buttons).toHaveCount(2);
|
await expect(buttons).toHaveCount(3);
|
||||||
|
|
||||||
// First button should be selected initially
|
// "Tous" button (index 0) should be selected initially
|
||||||
await expect(buttons.first()).toHaveClass(/selected/);
|
await expect(buttons.nth(0)).toHaveClass(/selected/);
|
||||||
await expect(buttons.nth(1)).not.toHaveClass(/selected/);
|
await expect(buttons.nth(1)).not.toHaveClass(/selected/);
|
||||||
|
|
||||||
// Click second button
|
// Click first child button (index 1)
|
||||||
await buttons.nth(1).click();
|
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.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 }) => {
|
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('Parent Schedule Consultation (Story 4.4)', () => {
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
@@ -305,6 +320,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await selectFirstChild(page);
|
||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
// Wait for slots to load
|
// 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 })
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// If multiple children, buttons should be visible
|
// Multi-child: "Tous" + N children buttons
|
||||||
const childButtons = page.locator('.child-button');
|
const childButtons = page.locator('.child-button');
|
||||||
const count = await childButtons.count();
|
const count = await childButtons.count();
|
||||||
|
|
||||||
if (count > 1) {
|
if (count > 2) {
|
||||||
// Click second child
|
// Select first child (index 1, after "Tous")
|
||||||
await childButtons.nth(1).click();
|
await childButtons.nth(1).click();
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Should update schedule data
|
|
||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
const slots = page.locator('[data-testid="schedule-slot"]');
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
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(
|
await expect(
|
||||||
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await selectFirstChild(page);
|
||||||
|
|
||||||
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
|
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
|
||||||
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
|
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
|
||||||
@@ -362,6 +389,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await selectFirstChild(page);
|
||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
// Wait for slots to load
|
// Wait for slots to load
|
||||||
@@ -384,6 +412,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await selectFirstChild(page);
|
||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
@@ -419,6 +448,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await selectFirstChild(page);
|
||||||
|
|
||||||
// Wait for schedule slots to load
|
// Wait for schedule slots to load
|
||||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
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 childButtons = page.locator('.child-button');
|
||||||
const count = await childButtons.count();
|
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);
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
// First child (6A) should show Maths
|
// First child (6A) should show Maths
|
||||||
@@ -461,8 +493,8 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|||||||
timeout: 5000
|
timeout: 5000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch to second child (5B) — should show SVT
|
// Switch to second child (index 2) (5B) — should show SVT
|
||||||
await childButtons.nth(1).click();
|
await childButtons.nth(2).click();
|
||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
timeout: 20000
|
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 }) => {
|
test('shows offline banner when network is lost', async ({ page, context }) => {
|
||||||
await loginAsParent(page);
|
await loginAsParent(page);
|
||||||
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await selectFirstChild(page);
|
||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
// Wait for schedule to load
|
// Wait for schedule to load
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
let {
|
let {
|
||||||
onChildSelected
|
onChildSelected
|
||||||
}: {
|
}: {
|
||||||
onChildSelected?: (childId: string) => void;
|
onChildSelected?: (childId: string | null) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let children = $state<Child[]>([]);
|
let children = $state<Child[]>([]);
|
||||||
@@ -40,8 +40,9 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
children = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
children = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||||
|
|
||||||
|
// Auto-select only when there's exactly 1 child
|
||||||
const first = children[0];
|
const first = children[0];
|
||||||
if (first && !selectedChildId) {
|
if (first && !selectedChildId && children.length === 1) {
|
||||||
selectedChildId = first.studentId;
|
selectedChildId = first.studentId;
|
||||||
onChildSelected?.(first.studentId);
|
onChildSelected?.(first.studentId);
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectChild(childId: string) {
|
function selectChild(childId: string | null) {
|
||||||
selectedChildId = childId;
|
selectedChildId = childId;
|
||||||
onChildSelected?.(childId);
|
onChildSelected?.(childId);
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,13 @@
|
|||||||
<div class="child-selector">
|
<div class="child-selector">
|
||||||
<span class="child-selector-label">Enfant :</span>
|
<span class="child-selector-label">Enfant :</span>
|
||||||
<div class="child-selector-buttons">
|
<div class="child-selector-buttons">
|
||||||
|
<button
|
||||||
|
class="child-button"
|
||||||
|
class:selected={selectedChildId === null}
|
||||||
|
onclick={() => selectChild(null)}
|
||||||
|
>
|
||||||
|
Tous
|
||||||
|
</button>
|
||||||
{#each children as child (child.id)}
|
{#each children as child (child.id)}
|
||||||
<button
|
<button
|
||||||
class="child-button"
|
class="child-button"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { DemoData } from '$types';
|
import type { DemoData } from '$types';
|
||||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||||
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
|
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 SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
|
||||||
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
|
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
|
||||||
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { DemoData } from '$types';
|
import type { DemoData } from '$types';
|
||||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||||
import { fetchDaySchedule, fetchNextClass } 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 DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
||||||
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { ChildScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
import type { ChildScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
||||||
import { fetchChildrenScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
import { fetchChildrenScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
||||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
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';
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
children = await fetchChildrenScheduleSummary();
|
children = await fetchChildrenScheduleSummary();
|
||||||
|
recordSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||||
import { fetchChildDaySchedule, fetchChildWeekSchedule } from '$lib/features/schedule/api/parentSchedule';
|
import { fetchChildDaySchedule, fetchChildWeekSchedule } from '$lib/features/schedule/api/parentSchedule';
|
||||||
import { untrack } from 'svelte';
|
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 { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||||
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
|
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
|
||||||
import MultiChildSummary from '$lib/components/organisms/ParentSchedule/MultiChildSummary.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;
|
selectedChildId = childId;
|
||||||
|
if (childId) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
loadSchedule();
|
loadSchedule();
|
||||||
// Prefetch for offline support
|
// Prefetch for offline support
|
||||||
prefetchScheduleDays((date) => fetchChildDaySchedule(childId, date));
|
prefetchScheduleDays((date) => fetchChildDaySchedule(childId, date));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function navigateDay(offset: number) {
|
function navigateDay(offset: number) {
|
||||||
const d = new Date(currentDate + 'T00:00:00');
|
const d = new Date(currentDate + 'T00:00:00');
|
||||||
|
|||||||
@@ -44,7 +44,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<ul class="slot-list">
|
<ul class="slot-list">
|
||||||
{#each slots as slot (slot.slotId + slot.date)}
|
{#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
|
<button
|
||||||
class="slot-button"
|
class="slot-button"
|
||||||
onclick={() => onSlotClick(slot)}
|
onclick={() => onSlotClick(slot)}
|
||||||
@@ -123,12 +127,12 @@
|
|||||||
.slot-item {
|
.slot-item {
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-left: 4px solid #e5e7eb;
|
border-left: 4px solid var(--slot-color, #e5e7eb);
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-item.next {
|
.slot-item.next {
|
||||||
border-left-color: #3b82f6;
|
--slot-color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-button {
|
.slot-button {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
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 {
|
let {
|
||||||
slots = [],
|
slots = [],
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
class="slot-item"
|
class="slot-item"
|
||||||
class:next={slot.slotId === nextSlotId}
|
class:next={slot.slotId === nextSlotId}
|
||||||
data-testid="schedule-slot"
|
data-testid="schedule-slot"
|
||||||
|
style:--slot-color={slot.subjectColor ?? 'transparent'}
|
||||||
>
|
>
|
||||||
<div class="slot-time">
|
<div class="slot-time">
|
||||||
<span class="time-start">{slot.startTime}</span>
|
<span class="time-start">{slot.startTime}</span>
|
||||||
@@ -144,12 +145,12 @@
|
|||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid var(--slot-color, transparent);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-item.next {
|
.slot-item.next {
|
||||||
border-left-color: #3b82f6;
|
--slot-color: #3b82f6;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
aria-label="Fermer">×</button
|
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-grid">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
@@ -149,6 +149,11 @@
|
|||||||
padding-right: 2rem;
|
padding-right: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-name.has-color {
|
||||||
|
border-left: 4px solid var(--slot-color);
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { fetchDaySchedule, fetchWeekSchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
import { fetchDaySchedule, fetchWeekSchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
||||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||||
import { untrack } from 'svelte';
|
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 DayView from './DayView.svelte';
|
||||||
import WeekView from './WeekView.svelte';
|
import WeekView from './WeekView.svelte';
|
||||||
import SlotDetails from './SlotDetails.svelte';
|
import SlotDetails from './SlotDetails.svelte';
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
class:modified={slot.isModified}
|
class:modified={slot.isModified}
|
||||||
onclick={() => onSlotClick(slot)}
|
onclick={() => onSlotClick(slot)}
|
||||||
data-testid="week-slot"
|
data-testid="week-slot"
|
||||||
|
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
|
||||||
>
|
>
|
||||||
<span class="slot-time-mobile">{slot.startTime} - {slot.endTime}</span>
|
<span class="slot-time-mobile">{slot.startTime} - {slot.endTime}</span>
|
||||||
<span class="slot-subject-mobile">{slot.subjectName}</span>
|
<span class="slot-subject-mobile">{slot.subjectName}</span>
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
class:modified={slot.isModified}
|
class:modified={slot.isModified}
|
||||||
onclick={() => onSlotClick(slot)}
|
onclick={() => onSlotClick(slot)}
|
||||||
data-testid="week-slot"
|
data-testid="week-slot"
|
||||||
|
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
|
||||||
>
|
>
|
||||||
<span class="week-slot-time">{slot.startTime}</span>
|
<span class="week-slot-time">{slot.startTime}</span>
|
||||||
<span class="week-slot-subject">{slot.subjectName}</span>
|
<span class="week-slot-subject">{slot.subjectName}</span>
|
||||||
@@ -182,6 +184,7 @@
|
|||||||
background: white;
|
background: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #f3f4f6;
|
border-top: 1px solid #f3f4f6;
|
||||||
|
border-left: 4px solid var(--slot-color, #e5e7eb);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -283,6 +286,7 @@
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
border: 1px solid #bfdbfe;
|
border: 1px solid #bfdbfe;
|
||||||
|
border-left: 4px solid var(--slot-color, #bfdbfe);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface ScheduleSlot {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
subjectName: string;
|
subjectName: string;
|
||||||
|
subjectColor: string | null;
|
||||||
teacherId: string;
|
teacherId: string;
|
||||||
teacherName: string;
|
teacherName: string;
|
||||||
room: string | null;
|
room: string | null;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { browser } from '$app/environment';
|
|||||||
|
|
||||||
const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
|
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.
|
* Vérifie si le navigateur est actuellement hors ligne.
|
||||||
*/
|
*/
|
||||||
@@ -15,15 +19,16 @@ export function isOffline(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function recordSync(): void {
|
export function recordSync(): void {
|
||||||
if (!browser) return;
|
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 {
|
export function getLastSyncDate(): string | null {
|
||||||
if (!browser) return null;
|
return lastSyncValue;
|
||||||
return localStorage.getItem(LAST_SYNC_KEY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
let changeClassTarget = $state<Student | null>(null);
|
let changeClassTarget = $state<Student | null>(null);
|
||||||
let newClassForChange = $state('');
|
let newClassForChange = $state('');
|
||||||
let isChangingClass = $state(false);
|
let isChangingClass = $state(false);
|
||||||
|
let changeClassError = $state<string | null>(null);
|
||||||
|
|
||||||
// Classes grouped by level for optgroup
|
// Classes grouped by level for optgroup
|
||||||
let classesByLevel = $derived.by(() => {
|
let classesByLevel = $derived.by(() => {
|
||||||
@@ -300,7 +301,7 @@
|
|||||||
changeClassTarget = student;
|
changeClassTarget = student;
|
||||||
newClassForChange = '';
|
newClassForChange = '';
|
||||||
showChangeClassModal = true;
|
showChangeClassModal = true;
|
||||||
error = null;
|
changeClassError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChangeClassModal() {
|
function closeChangeClassModal() {
|
||||||
@@ -338,7 +339,7 @@
|
|||||||
successMessage = `${changeClassTarget.firstName} ${changeClassTarget.lastName} a été transféré vers ${targetClass?.name ?? 'la nouvelle classe'}.`;
|
successMessage = `${changeClassTarget.firstName} ${changeClassTarget.lastName} a été transféré vers ${targetClass?.name ?? 'la nouvelle classe'}.`;
|
||||||
closeChangeClassModal();
|
closeChangeClassModal();
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
isChangingClass = false;
|
isChangingClass = false;
|
||||||
}
|
}
|
||||||
@@ -720,6 +721,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if changeClassError}
|
||||||
|
<div class="alert alert-error modal-error">
|
||||||
|
{changeClassError}
|
||||||
|
<button class="alert-close" onclick={() => (changeClassError = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if newClassForChange}
|
{#if newClassForChange}
|
||||||
{@const targetClass = classes.find((c) => c.id === newClassForChange)}
|
{@const targetClass = classes.find((c) => c.id === newClassForChange)}
|
||||||
<div class="change-confirm-info">
|
<div class="change-confirm-info">
|
||||||
@@ -1244,6 +1252,10 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.change-confirm-info {
|
.change-confirm-info {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
// Demo child name for personalized messages
|
// Demo child name for personalized messages
|
||||||
let childName = $state('Emma');
|
let childName = $state('Emma');
|
||||||
|
|
||||||
function handleChildSelected(childId: string) {
|
function handleChildSelected(childId: string | null) {
|
||||||
selectedChildId = childId;
|
selectedChildId = childId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ vi.mock('$app/environment', () => ({
|
|||||||
browser: true
|
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', () => {
|
describe('scheduleCache', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/svelte';
|
|||||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
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),
|
isOffline: vi.fn(() => false),
|
||||||
getLastSyncDate: vi.fn(() => null)
|
getLastSyncDate: vi.fn(() => null)
|
||||||
}));
|
}));
|
||||||
@@ -17,6 +17,7 @@ function makeSlot(overrides: Partial<ScheduleSlot> = {}): ScheduleSlot {
|
|||||||
endTime: '09:00',
|
endTime: '09:00',
|
||||||
subjectId: 'sub-1',
|
subjectId: 'sub-1',
|
||||||
subjectName: 'Mathématiques',
|
subjectName: 'Mathématiques',
|
||||||
|
subjectColor: null,
|
||||||
teacherId: 'teacher-1',
|
teacherId: 'teacher-1',
|
||||||
teacherName: 'M. Dupont',
|
teacherName: 'M. Dupont',
|
||||||
room: 'Salle 101',
|
room: 'Salle 101',
|
||||||
@@ -100,6 +101,16 @@ describe('ScheduleWidget', () => {
|
|||||||
expect(container.querySelector('.slot-room')).toBeNull();
|
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', () => {
|
it('renders multiple slots with data-testid', () => {
|
||||||
const slots = [
|
const slots = [
|
||||||
makeSlot({ slotId: 'slot-1' }),
|
makeSlot({ slotId: 'slot-1' }),
|
||||||
|
|||||||
@@ -79,6 +79,36 @@ export default defineConfig({
|
|||||||
statuses: [0, 200]
|
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