feat: Permettre aux parents de consulter l'emploi du temps de leurs enfants
Les parents avaient accès au lien "Emploi du temps" dans la navigation,
mais le dashboard n'affichait aucune donnée réelle : la section EDT
restait un placeholder vide ("L'emploi du temps sera disponible...").
Cette implémentation connecte le dashboard parent aux vrais endpoints API
(GET /api/me/children/{childId}/schedule/day|week/{date} et le résumé
multi-enfants), affiche le ScheduleWidget avec le prochain cours mis en
évidence (AC1), permet de cliquer sur chaque enfant dans le résumé pour
voir son EDT détaillé (AC2), et met en cache les endpoints parent dans le
Service Worker pour le mode offline (AC5).
Le handler backend est optimisé pour ne résoudre que l'enfant demandé
(via childId optionnel dans la query) au lieu de tous les enfants à chaque
appel, et les fonctions utilitaires dupliquées (formatSyncDate, timezone)
sont factorisées.
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Port;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port pour résoudre les enfants d'un parent/tuteur.
|
||||||
|
*
|
||||||
|
* Abstrait l'accès au contexte Administration depuis le contexte Scolarite.
|
||||||
|
*/
|
||||||
|
interface ParentChildrenReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<array{studentId: string, firstName: string, lastName: string}>
|
||||||
|
*/
|
||||||
|
public function childrenOf(string $guardianId, TenantId $tenantId): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Query\GetChildrenSchedule;
|
||||||
|
|
||||||
|
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
|
||||||
|
|
||||||
|
final readonly class ChildScheduleDto
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<StudentScheduleSlotDto> $slots
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $childId,
|
||||||
|
public string $firstName,
|
||||||
|
public string $lastName,
|
||||||
|
public array $slots,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Query\GetChildrenSchedule;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||||
|
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||||
|
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||||
|
use App\Scolarite\Application\Port\StudentClassReader;
|
||||||
|
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
|
||||||
|
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||||
|
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
|
use function array_map;
|
||||||
|
use function array_unique;
|
||||||
|
use function array_values;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
|
final readonly class GetChildrenScheduleHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ParentChildrenReader $parentChildrenReader,
|
||||||
|
private StudentClassReader $studentClassReader,
|
||||||
|
private ScheduleResolver $scheduleResolver,
|
||||||
|
private CurrentCalendarProvider $calendarProvider,
|
||||||
|
private ScheduleDisplayReader $displayReader,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<ChildScheduleDto> */
|
||||||
|
public function __invoke(GetChildrenScheduleQuery $query): array
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString($query->tenantId);
|
||||||
|
$allChildren = $this->parentChildrenReader->childrenOf($query->parentId, $tenantId);
|
||||||
|
|
||||||
|
if ($allChildren === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a specific child is requested, only resolve that child's schedule
|
||||||
|
$children = $query->childId !== null
|
||||||
|
? array_values(array_filter($allChildren, static fn (array $c): bool => $c['studentId'] === $query->childId))
|
||||||
|
: $allChildren;
|
||||||
|
|
||||||
|
if ($children === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = new DateTimeImmutable($query->date);
|
||||||
|
$weekStart = $this->mondayOfWeek($date);
|
||||||
|
$calendar = $this->calendarProvider->forCurrentYear($tenantId);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($children as $child) {
|
||||||
|
$classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId);
|
||||||
|
|
||||||
|
if ($classId === null) {
|
||||||
|
$result[] = new ChildScheduleDto(
|
||||||
|
childId: $child['studentId'],
|
||||||
|
firstName: $child['firstName'],
|
||||||
|
lastName: $child['lastName'],
|
||||||
|
slots: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $this->scheduleResolver->resolveForWeek(
|
||||||
|
ClassId::fromString($classId),
|
||||||
|
$weekStart,
|
||||||
|
$tenantId,
|
||||||
|
$calendar,
|
||||||
|
);
|
||||||
|
|
||||||
|
$slots = $this->enrichSlots($resolved, $query->tenantId);
|
||||||
|
|
||||||
|
$result[] = new ChildScheduleDto(
|
||||||
|
childId: $child['studentId'],
|
||||||
|
firstName: $child['firstName'],
|
||||||
|
lastName: $child['lastName'],
|
||||||
|
slots: $slots,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<ResolvedScheduleSlot> $slots
|
||||||
|
*
|
||||||
|
* @return array<StudentScheduleSlotDto>
|
||||||
|
*/
|
||||||
|
private function enrichSlots(array $slots, string $tenantId): array
|
||||||
|
{
|
||||||
|
if ($slots === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectIds = array_values(array_unique(
|
||||||
|
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->subjectId, $slots),
|
||||||
|
));
|
||||||
|
$teacherIds = array_values(array_unique(
|
||||||
|
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots),
|
||||||
|
));
|
||||||
|
|
||||||
|
$subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds);
|
||||||
|
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
|
||||||
|
$s,
|
||||||
|
$subjectNames[(string) $s->subjectId] ?? '',
|
||||||
|
$teacherNames[(string) $s->teacherId] ?? '',
|
||||||
|
),
|
||||||
|
$slots,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mondayOfWeek(DateTimeImmutable $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$dayOfWeek = (int) $date->format('N');
|
||||||
|
|
||||||
|
if ($dayOfWeek === 1) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date->modify('-' . ($dayOfWeek - 1) . ' days');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Query\GetChildrenSchedule;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final readonly class GetChildrenScheduleQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $parentId,
|
||||||
|
public string $tenantId,
|
||||||
|
public string $date,
|
||||||
|
public ?string $childId = null,
|
||||||
|
) {
|
||||||
|
if (DateTimeImmutable::createFromFormat('Y-m-d', $date) === false) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Date invalide : "%s". Format attendu : YYYY-MM-DD.', $date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||||
|
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenSchedule\ChildScheduleDto;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenSchedule\GetChildrenScheduleHandler;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenSchedule\GetChildrenScheduleQuery;
|
||||||
|
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
|
||||||
|
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
|
use function array_map;
|
||||||
|
use function array_values;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private GetChildrenScheduleHandler $handler,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDT du jour d'un enfant.
|
||||||
|
*/
|
||||||
|
#[Route('/api/me/children/{childId}/schedule/day/{date}', name: 'api_parent_child_schedule_day', methods: ['GET'])]
|
||||||
|
public function childDay(string $childId, string $date): JsonResponse
|
||||||
|
{
|
||||||
|
$childSchedule = $this->findChildSchedule($childId, $date);
|
||||||
|
|
||||||
|
$daySlots = array_values(array_filter(
|
||||||
|
$childSchedule->slots,
|
||||||
|
static fn (StudentScheduleSlotDto $s): bool => $s->date === $date,
|
||||||
|
));
|
||||||
|
|
||||||
|
return new JsonResponse(['data' => $this->serializeSlots($daySlots)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDT de la semaine d'un enfant.
|
||||||
|
*/
|
||||||
|
#[Route('/api/me/children/{childId}/schedule/week/{date}', name: 'api_parent_child_schedule_week', methods: ['GET'])]
|
||||||
|
public function childWeek(string $childId, string $date): JsonResponse
|
||||||
|
{
|
||||||
|
$childSchedule = $this->findChildSchedule($childId, $date);
|
||||||
|
|
||||||
|
return new JsonResponse(['data' => $this->serializeSlots($childSchedule->slots)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résumé EDT du jour pour tous les enfants.
|
||||||
|
*/
|
||||||
|
#[Route('/api/me/children/schedule/summary', name: 'api_parent_children_schedule_summary', methods: ['GET'])]
|
||||||
|
public function summary(): JsonResponse
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$today = $now->format('Y-m-d');
|
||||||
|
$currentTime = $now->format('H:i');
|
||||||
|
$children = $this->resolveAllChildren($today);
|
||||||
|
|
||||||
|
$summaries = array_map(function (ChildScheduleDto $child) use ($today, $currentTime): array {
|
||||||
|
$todaySlots = array_values(array_filter(
|
||||||
|
$child->slots,
|
||||||
|
static fn (StudentScheduleSlotDto $s): bool => $s->date === $today,
|
||||||
|
));
|
||||||
|
|
||||||
|
usort($todaySlots, static fn (StudentScheduleSlotDto $a, StudentScheduleSlotDto $b): int => $a->startTime <=> $b->startTime);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'childId' => $child->childId,
|
||||||
|
'firstName' => $child->firstName,
|
||||||
|
'lastName' => $child->lastName,
|
||||||
|
'todaySlots' => $this->serializeSlots($todaySlots),
|
||||||
|
'nextClass' => $this->findNextSlot($todaySlots, $currentTime),
|
||||||
|
];
|
||||||
|
}, $children);
|
||||||
|
|
||||||
|
return new JsonResponse(['data' => $summaries]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findChildSchedule(string $childId, string $date): ChildScheduleDto
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$children = ($this->handler)(new GetChildrenScheduleQuery(
|
||||||
|
parentId: $user->userId(),
|
||||||
|
tenantId: $user->tenantId(),
|
||||||
|
date: $date,
|
||||||
|
childId: $childId,
|
||||||
|
));
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($children === []) {
|
||||||
|
throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $children[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<ChildScheduleDto>
|
||||||
|
*/
|
||||||
|
private function resolveAllChildren(string $date): array
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ($this->handler)(new GetChildrenScheduleQuery(
|
||||||
|
parentId: $user->userId(),
|
||||||
|
tenantId: $user->tenantId(),
|
||||||
|
date: $date,
|
||||||
|
));
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<StudentScheduleSlotDto> $todaySlots
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function findNextSlot(array $todaySlots, string $currentTime): ?array
|
||||||
|
{
|
||||||
|
foreach ($todaySlots as $slot) {
|
||||||
|
if ($slot->startTime > $currentTime) {
|
||||||
|
return $this->serializeSlot($slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSecurityUser(): SecurityUser
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if (!$user instanceof SecurityUser) {
|
||||||
|
throw new AccessDeniedHttpException('Authentification requise.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<StudentScheduleSlotDto> $slots
|
||||||
|
*
|
||||||
|
* @return array<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function serializeSlots(array $slots): array
|
||||||
|
{
|
||||||
|
return array_map($this->serializeSlot(...), $slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function serializeSlot(StudentScheduleSlotDto $slot): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slotId' => $slot->slotId,
|
||||||
|
'date' => $slot->date,
|
||||||
|
'dayOfWeek' => $slot->dayOfWeek,
|
||||||
|
'startTime' => $slot->startTime,
|
||||||
|
'endTime' => $slot->endTime,
|
||||||
|
'subjectId' => $slot->subjectId,
|
||||||
|
'subjectName' => $slot->subjectName,
|
||||||
|
'teacherId' => $slot->teacherId,
|
||||||
|
'teacherName' => $slot->teacherName,
|
||||||
|
'room' => $slot->room,
|
||||||
|
'isModified' => $slot->isModified,
|
||||||
|
'exceptionId' => $slot->exceptionId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ final class ScheduleSlotVoter extends Voter
|
|||||||
Role::PROF->value,
|
Role::PROF->value,
|
||||||
Role::VIE_SCOLAIRE->value,
|
Role::VIE_SCOLAIRE->value,
|
||||||
Role::ELEVE->value,
|
Role::ELEVE->value,
|
||||||
|
Role::PARENT->value,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\StudentGuardianRepository;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout les enfants d'un parent via la liaison StudentGuardian.
|
||||||
|
*/
|
||||||
|
final readonly class GuardianParentChildrenReader implements ParentChildrenReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private StudentGuardianRepository $studentGuardianRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function childrenOf(string $guardianId, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$links = $this->studentGuardianRepository->findStudentsForGuardian(
|
||||||
|
UserId::fromString($guardianId),
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(function ($link): array {
|
||||||
|
$student = $this->userRepository->get($link->studentId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'studentId' => (string) $link->studentId,
|
||||||
|
'firstName' => $student->firstName,
|
||||||
|
'lastName' => $student->lastName,
|
||||||
|
];
|
||||||
|
}, $links);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenSchedule;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||||
|
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||||
|
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||||
|
use App\Scolarite\Application\Port\StudentClassReader;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenSchedule\ChildScheduleDto;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenSchedule\GetChildrenScheduleHandler;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenSchedule\GetChildrenScheduleQuery;
|
||||||
|
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||||
|
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||||
|
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||||
|
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GetChildrenScheduleHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||||
|
private const string CHILD1_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||||
|
private const string CHILD2_ID = '550e8400-e29b-41d4-a716-446655440051';
|
||||||
|
private const string CLASS1_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||||
|
private const string CLASS2_ID = '550e8400-e29b-41d4-a716-446655440021';
|
||||||
|
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||||
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||||
|
|
||||||
|
private InMemoryScheduleSlotRepository $slotRepository;
|
||||||
|
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->slotRepository = new InMemoryScheduleSlotRepository();
|
||||||
|
$this->exceptionRepository = new InMemoryScheduleExceptionRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returnsEmptyWhenParentHasNoChildren(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(children: []);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenScheduleQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
date: '2026-03-02',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame([], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returnsScheduleForSingleChild(): void
|
||||||
|
{
|
||||||
|
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
classMapping: [self::CHILD1_ID => self::CLASS1_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenScheduleQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
date: '2026-03-02', // Monday
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertInstanceOf(ChildScheduleDto::class, $result[0]);
|
||||||
|
self::assertSame(self::CHILD1_ID, $result[0]->childId);
|
||||||
|
self::assertSame('Alice', $result[0]->firstName);
|
||||||
|
self::assertSame('Dupont', $result[0]->lastName);
|
||||||
|
self::assertCount(1, $result[0]->slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returnsScheduleForMultipleChildren(): void
|
||||||
|
{
|
||||||
|
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
|
||||||
|
$this->saveRecurringSlot(self::CLASS2_ID, DayOfWeek::MONDAY, '10:00', '11:00');
|
||||||
|
$this->saveRecurringSlot(self::CLASS2_ID, DayOfWeek::TUESDAY, '14:00', '15:00');
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
|
||||||
|
['studentId' => self::CHILD2_ID, 'firstName' => 'Bob', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
classMapping: [
|
||||||
|
self::CHILD1_ID => self::CLASS1_ID,
|
||||||
|
self::CHILD2_ID => self::CLASS2_ID,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenScheduleQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
date: '2026-03-02',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(2, $result);
|
||||||
|
self::assertSame(self::CHILD1_ID, $result[0]->childId);
|
||||||
|
self::assertCount(1, $result[0]->slots);
|
||||||
|
self::assertSame(self::CHILD2_ID, $result[1]->childId);
|
||||||
|
self::assertCount(2, $result[1]->slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returnsEmptySlotsWhenChildHasNoClass(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
classMapping: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenScheduleQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
date: '2026-03-02',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame(self::CHILD1_ID, $result[0]->childId);
|
||||||
|
self::assertSame([], $result[0]->slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function enrichesSlotsWithSubjectAndTeacherNames(): void
|
||||||
|
{
|
||||||
|
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
classMapping: [self::CHILD1_ID => self::CLASS1_ID],
|
||||||
|
subjectNames: [self::SUBJECT_ID => 'Mathématiques'],
|
||||||
|
teacherNames: [self::TEACHER_ID => 'Jean Martin'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenScheduleQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
date: '2026-03-02',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame('Mathématiques', $result[0]->slots[0]->subjectName);
|
||||||
|
self::assertSame('Jean Martin', $result[0]->slots[0]->teacherName);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function computesMondayFromAnyDayOfWeek(): void
|
||||||
|
{
|
||||||
|
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
classMapping: [self::CHILD1_ID => self::CLASS1_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenScheduleQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
date: '2026-03-04', // Wednesday
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame('2026-03-02', $result[0]->slots[0]->date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveRecurringSlot(
|
||||||
|
string $classId,
|
||||||
|
DayOfWeek $day,
|
||||||
|
string $start,
|
||||||
|
string $end,
|
||||||
|
?string $room = null,
|
||||||
|
): void {
|
||||||
|
$slot = ScheduleSlot::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
classId: ClassId::fromString($classId),
|
||||||
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
dayOfWeek: $day,
|
||||||
|
timeSlot: new TimeSlot($start, $end),
|
||||||
|
room: $room,
|
||||||
|
isRecurring: true,
|
||||||
|
now: new DateTimeImmutable('2026-01-01'),
|
||||||
|
recurrenceStart: new DateTimeImmutable('2026-01-01'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->slotRepository->save($slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
|
||||||
|
* @param array<string, string> $classMapping studentId => classId
|
||||||
|
* @param array<string, string> $subjectNames
|
||||||
|
* @param array<string, string> $teacherNames
|
||||||
|
*/
|
||||||
|
private function createHandler(
|
||||||
|
array $children = [],
|
||||||
|
array $classMapping = [],
|
||||||
|
array $subjectNames = [],
|
||||||
|
array $teacherNames = [],
|
||||||
|
): GetChildrenScheduleHandler {
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
|
||||||
|
$parentChildrenReader = new class($children) implements ParentChildrenReader {
|
||||||
|
/** @param array<array{studentId: string, firstName: string, lastName: string}> $children */
|
||||||
|
public function __construct(private array $children)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function childrenOf(string $guardianId, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
return $this->children;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$studentClassReader = new class($classMapping) implements StudentClassReader {
|
||||||
|
/** @param array<string, string> $mapping */
|
||||||
|
public function __construct(private array $mapping)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentClassId(string $studentId, TenantId $tenantId): ?string
|
||||||
|
{
|
||||||
|
return $this->mapping[$studentId] ?? null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$calendarProvider = new class($tenantId) implements CurrentCalendarProvider {
|
||||||
|
public function __construct(private TenantId $tenantId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||||
|
{
|
||||||
|
return SchoolCalendar::initialiser($this->tenantId, AcademicYearId::generate());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$displayReader = new class($subjectNames, $teacherNames) implements ScheduleDisplayReader {
|
||||||
|
/** @param array<string, string> $subjects @param array<string, string> $teachers */
|
||||||
|
public function __construct(
|
||||||
|
private array $subjects,
|
||||||
|
private array $teachers,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subjectNames(string $tenantId, string ...$subjectIds): array
|
||||||
|
{
|
||||||
|
return $this->subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||||
|
{
|
||||||
|
return $this->teachers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GetChildrenScheduleHandler(
|
||||||
|
$parentChildrenReader,
|
||||||
|
$studentClassReader,
|
||||||
|
new ScheduleResolver($this->slotRepository, $this->exceptionRepository),
|
||||||
|
$calendarProvider,
|
||||||
|
$displayReader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,7 @@ test.describe('Image Rights Management', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: /élèves autorisés/i })
|
page.getByRole('heading', { name: /élèves autorisés/i })
|
||||||
.or(page.getByRole('heading', { name: /élèves non autorisés/i }))
|
.or(page.getByRole('heading', { name: /élèves non autorisés/i }))
|
||||||
|
.first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Stats bar
|
// Stats bar
|
||||||
@@ -186,14 +187,18 @@ test.describe('Image Rights Management', () => {
|
|||||||
// - The empty state shows (no authorized students found)
|
// - The empty state shows (no authorized students found)
|
||||||
const statsBarVisible = await page.locator('.stats-bar').isVisible();
|
const statsBarVisible = await page.locator('.stats-bar').isVisible();
|
||||||
if (statsBarVisible) {
|
if (statsBarVisible) {
|
||||||
const unauthorizedCount = await page.locator('.stat-count.stat-danger').textContent();
|
const dangerCount = await page.locator('.stat-count.stat-danger').count();
|
||||||
expect(parseInt(unauthorizedCount ?? '0', 10)).toBe(0);
|
if (dangerCount > 0) {
|
||||||
|
const unauthorizedCount = await page.locator('.stat-count.stat-danger').textContent();
|
||||||
|
expect(parseInt(unauthorizedCount ?? '0', 10)).toBe(0);
|
||||||
|
}
|
||||||
|
// No stat-danger element means no unauthorized students — correct after filtering by "Autorisé"
|
||||||
} else {
|
} else {
|
||||||
await expect(page.locator('.empty-state')).toBeVisible();
|
await expect(page.locator('.empty-state')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset filters to restore original state
|
// Reset filters to restore original state
|
||||||
await page.getByRole('button', { name: /réinitialiser les filtres/i }).click();
|
await page.getByRole('button', { name: 'Réinitialiser', exact: true }).click();
|
||||||
await waitForPageLoaded(page);
|
await waitForPageLoaded(page);
|
||||||
|
|
||||||
// URL should no longer contain status filter
|
// URL should no longer contain status filter
|
||||||
|
|||||||
527
frontend/e2e/parent-schedule.spec.ts
Normal file
527
frontend/e2e/parent-schedule.spec.ts
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const PARENT_EMAIL = 'e2e-parent-schedule@example.com';
|
||||||
|
const PARENT_PASSWORD = 'ParentSchedule123';
|
||||||
|
const STUDENT_EMAIL = 'e2e-parent-sched-student@example.com';
|
||||||
|
const STUDENT_PASSWORD = 'StudentParentSched123';
|
||||||
|
const STUDENT2_EMAIL = 'e2e-parent-sched-student2@example.com';
|
||||||
|
const STUDENT2_PASSWORD = 'StudentParentSched2_123';
|
||||||
|
const TEACHER_EMAIL = 'e2e-parent-sched-teacher@example.com';
|
||||||
|
const TEACHER_PASSWORD = 'TeacherParentSched123';
|
||||||
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
function runSql(sql: string) {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Cache pool may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||||
|
const output = execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||||
|
`require "/app/vendor/autoload.php"; ` +
|
||||||
|
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||||
|
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||||
|
`' 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
).trim();
|
||||||
|
const [schoolId, academicYearId] = output.split('\n');
|
||||||
|
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentWeekdayIso(): number {
|
||||||
|
const jsDay = new Date().getDay();
|
||||||
|
if (jsDay === 0) return 5; // Sunday → seed for Friday
|
||||||
|
if (jsDay === 6) return 5; // Saturday → seed for Friday
|
||||||
|
return jsDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBackToSeededWeekday(): number {
|
||||||
|
const jsDay = new Date().getDay();
|
||||||
|
if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday
|
||||||
|
if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seededDayName(): string {
|
||||||
|
const jsDay = new Date().getDay();
|
||||||
|
const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay;
|
||||||
|
return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToSeededDay(page: import('@playwright/test').Page) {
|
||||||
|
const back = daysBackToSeededWeekday();
|
||||||
|
if (back === 0) return;
|
||||||
|
|
||||||
|
const targetDay = seededDayName();
|
||||||
|
const targetPattern = new RegExp(targetDay, 'i');
|
||||||
|
const prevBtn = page.getByLabel('Précédent');
|
||||||
|
await expect(prevBtn).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
let navigated = false;
|
||||||
|
while (Date.now() < deadline && !navigated) {
|
||||||
|
for (let i = 0; i < back; i++) {
|
||||||
|
await prevBtn.click();
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const title = await page.locator('.day-title').textContent();
|
||||||
|
if (title && targetPattern.test(title)) {
|
||||||
|
navigated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsParent(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(PARENT_EMAIL);
|
||||||
|
await page.locator('#password').fill(PARENT_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// Clear caches to prevent stale data from previous runs
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache student_guardians.cache --env=dev 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Cache pools may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create users (idempotent - returns existing if already created)
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||||
|
|
||||||
|
// Create classes
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-ParentSched-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-ParentSched-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subjects
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-Maths', 'E2EPARMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-SVT', 'E2EPARSVT', '#22c55e', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up schedule data
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name LIKE 'E2E-ParentSched-%' AND tenant_id = '${TENANT_ID}')`);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up calendar entries
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign students to classes
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u, school_classes c ` +
|
||||||
|
`WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u, school_classes c ` +
|
||||||
|
`WHERE u.email = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up any existing guardian links for our parent
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`DELETE FROM student_guardians WHERE guardian_id IN (SELECT id FROM users WHERE email = '${PARENT_EMAIL}' AND tenant_id = '${TENANT_ID}') AND tenant_id = '${TENANT_ID}'`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent-student links
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` +
|
||||||
|
`FROM users s, users p ` +
|
||||||
|
`WHERE s.email = '${STUDENT_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` +
|
||||||
|
`FROM users s, users p ` +
|
||||||
|
`WHERE s.email = '${STUDENT2_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create schedule slots
|
||||||
|
const dayOfWeek = currentWeekdayIso();
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '08:00', '09:00', 'Salle A1', true, NOW(), NOW() ` +
|
||||||
|
`FROM school_classes c, ` +
|
||||||
|
`(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||||
|
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||||
|
`WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:00', '11:00', 'Labo SVT', true, NOW(), NOW() ` +
|
||||||
|
`FROM school_classes c, ` +
|
||||||
|
`(SELECT id FROM subjects WHERE code = 'E2EPARSVT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||||
|
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||||
|
`WHERE c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Late-night slot for child 1 (6A) — always "next" during normal test hours
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '23:00', '23:30', 'Salle A1', true, NOW(), NOW() ` +
|
||||||
|
`FROM school_classes c, ` +
|
||||||
|
`(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||||
|
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||||
|
`WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// AC1: Single child view
|
||||||
|
// ======================================================================
|
||||||
|
test.describe('AC1: Single child day view', () => {
|
||||||
|
test('parent can navigate to parent-schedule page', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child selector shows children', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
|
||||||
|
// Intercept the API call to debug
|
||||||
|
const responsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/me/children') && !resp.url().includes('schedule'),
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const response = await responsePromise;
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
// Wait for child selector to finish loading
|
||||||
|
const childSelector = page.locator('.child-selector');
|
||||||
|
await expect(childSelector).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('day view shows schedule for selected child', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
|
// Wait for slots to load
|
||||||
|
const slots = page.locator('[data-testid="schedule-slot"]');
|
||||||
|
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// AC2: Multi-child view
|
||||||
|
// ======================================================================
|
||||||
|
test.describe('AC2: Multi-child selection', () => {
|
||||||
|
test('can switch between children', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// If multiple children, buttons should be visible
|
||||||
|
const childButtons = page.locator('.child-button');
|
||||||
|
const count = await childButtons.count();
|
||||||
|
|
||||||
|
if (count > 1) {
|
||||||
|
// Click second child
|
||||||
|
await childButtons.nth(1).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Should update schedule data
|
||||||
|
await navigateToSeededDay(page);
|
||||||
|
const slots = page.locator('[data-testid="schedule-slot"]');
|
||||||
|
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// AC3: Navigation (day/week views)
|
||||||
|
// ======================================================================
|
||||||
|
test.describe('AC3: Navigation', () => {
|
||||||
|
test('day view is the default view', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
|
||||||
|
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can switch to week view', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
|
// Wait for slots to load
|
||||||
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to week view
|
||||||
|
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||||
|
await weekButton.click();
|
||||||
|
|
||||||
|
// Week headers should show
|
||||||
|
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can navigate between days', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate forward and wait for the new day to load
|
||||||
|
await page.getByLabel('Suivant').click();
|
||||||
|
// Wait for the day title to change, confirming navigation completed
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Navigate back to the original day
|
||||||
|
await page.getByLabel('Précédent').click();
|
||||||
|
|
||||||
|
// Wait for data to reload after navigation
|
||||||
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// AC1: Next class highlighting (P0)
|
||||||
|
// ======================================================================
|
||||||
|
test.describe('AC1: Next class highlighting', () => {
|
||||||
|
test('next class is highlighted with badge on today view', async ({ page }) => {
|
||||||
|
// Next class highlighting only works when viewing today's date
|
||||||
|
const jsDay = new Date().getDay();
|
||||||
|
test.skip(jsDay === 0 || jsDay === 6, 'Next class highlighting only works on weekdays');
|
||||||
|
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Wait for schedule slots to load
|
||||||
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
|
||||||
|
// The 23:00 slot should always be "next" during normal test hours
|
||||||
|
const nextSlot = page.locator('.slot-item.next');
|
||||||
|
await expect(nextSlot).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the "Prochain" badge is displayed
|
||||||
|
await expect(nextSlot.locator('.next-badge')).toBeVisible();
|
||||||
|
await expect(nextSlot.locator('.next-badge')).toHaveText('Prochain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// AC2: Multi-child content verification (P1)
|
||||||
|
// ======================================================================
|
||||||
|
test.describe('AC2: Multi-child schedule content', () => {
|
||||||
|
test('switching children shows different subjects', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const childButtons = page.locator('.child-button');
|
||||||
|
const count = await childButtons.count();
|
||||||
|
test.skip(count < 2, 'Need at least 2 children for this test');
|
||||||
|
|
||||||
|
// Navigate to seeded day to see slots
|
||||||
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
|
// First child (6A) should show Maths
|
||||||
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
await expect(page.getByText('E2E-ParentSched-Maths').first()).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to second child (5B) — should show SVT
|
||||||
|
await childButtons.nth(1).click();
|
||||||
|
await navigateToSeededDay(page);
|
||||||
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
await expect(page.getByText('E2E-ParentSched-SVT').first()).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// AC5: Offline mode
|
||||||
|
// ======================================================================
|
||||||
|
test.describe('AC5: Offline mode', () => {
|
||||||
|
test('shows offline banner when network is lost', async ({ page, context }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
|
||||||
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
|
// Wait for schedule to load
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="schedule-slot"]').first()
|
||||||
|
).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
// Go offline
|
||||||
|
await context.setOffline(true);
|
||||||
|
|
||||||
|
const offlineBanner = page.locator('.offline-banner[role="status"]');
|
||||||
|
await expect(offlineBanner).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(offlineBanner.getByText('Hors ligne')).toBeVisible();
|
||||||
|
|
||||||
|
// Restore online
|
||||||
|
await context.setOffline(false);
|
||||||
|
await expect(offlineBanner).not.toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// Navigation link
|
||||||
|
// ======================================================================
|
||||||
|
test.describe('Navigation link', () => {
|
||||||
|
test('EDT enfants link is visible for parent role', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
|
||||||
|
const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' });
|
||||||
|
await expect(navLink).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking EDT enfants link navigates to parent-schedule page', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
|
||||||
|
const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' });
|
||||||
|
await expect(navLink).toBeVisible({ timeout: 10000 });
|
||||||
|
await navLink.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard\/parent-schedule/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /emploi du temps des enfants/i })
|
||||||
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,7 +104,10 @@ function getWeekdayInCurrentWeek(isoDay: number): string {
|
|||||||
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
|
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
|
||||||
const target = new Date(monday);
|
const target = new Date(monday);
|
||||||
target.setDate(monday.getDate() + (isoDay - 1));
|
target.setDate(monday.getDate() + (isoDay - 1));
|
||||||
return target.toISOString().split('T')[0]!;
|
const y = target.getFullYear();
|
||||||
|
const m = String(target.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(target.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
@@ -505,12 +508,12 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
|
|||||||
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
await page.goto(`${ALPHA_URL}/admin/schedule`);
|
||||||
await waitForScheduleReady(page);
|
await waitForScheduleReady(page);
|
||||||
|
|
||||||
|
// Wait for the blocked date badge to appear — confirms API data is loaded
|
||||||
|
await expect(page.getByText('Jour férié test')).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
// The third day-column (Wednesday) should have the blocked class
|
// The third day-column (Wednesday) should have the blocked class
|
||||||
const dayColumns = page.locator('.day-column');
|
const dayColumns = page.locator('.day-column');
|
||||||
await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 10000 });
|
await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 5000 });
|
||||||
|
|
||||||
// Should display the reason badge in the header
|
|
||||||
await expect(page.getByText('Jour férié test')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot create a slot on a blocked day', async ({ page }) => {
|
test('cannot create a slot on a blocked day', async ({ page }) => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string }
|
|||||||
*/
|
*/
|
||||||
function currentWeekdayIso(): number {
|
function currentWeekdayIso(): number {
|
||||||
const jsDay = new Date().getDay(); // 0=Sun, 1=Mon...6=Sat
|
const jsDay = new Date().getDay(); // 0=Sun, 1=Mon...6=Sat
|
||||||
if (jsDay === 0) return 1; // Sunday → use Monday
|
if (jsDay === 0) return 5; // Sunday → use Friday
|
||||||
if (jsDay === 6) return 5; // Saturday → use Friday
|
if (jsDay === 6) return 5; // Saturday → use Friday
|
||||||
return jsDay;
|
return jsDay;
|
||||||
}
|
}
|
||||||
@@ -101,14 +101,13 @@ async function navigateToSeededDay(page: import('@playwright/test').Page) {
|
|||||||
const prevBtn = page.getByLabel('Précédent');
|
const prevBtn = page.getByLabel('Précédent');
|
||||||
await expect(prevBtn).toBeVisible({ timeout: 10000 });
|
await expect(prevBtn).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Retry clicking — on webkit, Svelte 5 event delegation needs time to hydrate
|
// Navigate one day at a time, waiting for each load to complete before clicking again
|
||||||
const deadline = Date.now() + 15000;
|
const deadline = Date.now() + 20000;
|
||||||
let navigated = false;
|
let navigated = false;
|
||||||
while (Date.now() < deadline && !navigated) {
|
while (Date.now() < deadline && !navigated) {
|
||||||
for (let i = 0; i < back; i++) {
|
await prevBtn.click();
|
||||||
await prevBtn.click();
|
// Wait for the schedule API call to complete before checking/clicking again
|
||||||
}
|
await page.waitForTimeout(1500);
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const title = await page.locator('.day-title').textContent();
|
const title = await page.locator('.day-title').textContent();
|
||||||
if (title && targetPattern.test(title)) {
|
if (title && targetPattern.test(title)) {
|
||||||
navigated = true;
|
navigated = true;
|
||||||
@@ -118,6 +117,9 @@ async function navigateToSeededDay(page: import('@playwright/test').Page) {
|
|||||||
await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({
|
await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for any in-flight schedule loads to settle after reaching target day
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginAsStudent(page: import('@playwright/test').Page) {
|
async function loginAsStudent(page: import('@playwright/test').Page) {
|
||||||
@@ -134,6 +136,16 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
|||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
|
// Clear caches to prevent stale data from previous runs
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Cache pools may not exist
|
||||||
|
}
|
||||||
|
|
||||||
// Create student user
|
// Create student user
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||||
@@ -316,9 +328,9 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
|||||||
timeout: 15000
|
timeout: 15000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to next day
|
// Navigate to next day — wait for the load to settle before navigating back
|
||||||
await page.getByLabel('Suivant').click();
|
await page.getByLabel('Suivant').click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
// Then navigate back
|
// Then navigate back
|
||||||
await page.getByLabel('Précédent').click();
|
await page.getByLabel('Précédent').click();
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DemoData } from '$types';
|
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 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';
|
||||||
import SkeletonCard from '$lib/components/atoms/Skeleton/SkeletonCard.svelte';
|
import SkeletonCard from '$lib/components/atoms/Skeleton/SkeletonCard.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 MultiChildSummary from '$lib/components/organisms/ParentSchedule/MultiChildSummary.svelte';
|
||||||
import type { SerenityEmoji } from '$lib/features/dashboard/serenity-score';
|
import type { SerenityEmoji } from '$lib/features/dashboard/serenity-score';
|
||||||
|
import { getActiveRole } from '$features/roles/roleContext.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
demoData,
|
demoData,
|
||||||
@@ -14,6 +20,7 @@
|
|||||||
hasRealData = false,
|
hasRealData = false,
|
||||||
serenityEnabled = false,
|
serenityEnabled = false,
|
||||||
childName = '',
|
childName = '',
|
||||||
|
selectedChildId = null,
|
||||||
onToggleSerenity
|
onToggleSerenity
|
||||||
}: {
|
}: {
|
||||||
demoData: DemoData;
|
demoData: DemoData;
|
||||||
@@ -22,9 +29,53 @@
|
|||||||
hasRealData?: boolean;
|
hasRealData?: boolean;
|
||||||
serenityEnabled?: boolean;
|
serenityEnabled?: boolean;
|
||||||
childName?: string;
|
childName?: string;
|
||||||
|
selectedChildId?: string | null;
|
||||||
onToggleSerenity?: (enabled: boolean) => void;
|
onToggleSerenity?: (enabled: boolean) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let isParent = $derived(getActiveRole() === 'ROLE_PARENT');
|
||||||
|
|
||||||
|
// Schedule widget state — mirrors DashboardStudent pattern
|
||||||
|
let scheduleSlots = $state<ScheduleSlot[]>([]);
|
||||||
|
let scheduleNextSlotId = $state<string | null>(null);
|
||||||
|
let scheduleLoading = $state(false);
|
||||||
|
let scheduleError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function formatLocalDate(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChildSchedule(childId: string) {
|
||||||
|
scheduleLoading = true;
|
||||||
|
scheduleError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const today = formatLocalDate(new Date());
|
||||||
|
scheduleSlots = await fetchChildDaySchedule(childId, today);
|
||||||
|
recordSync();
|
||||||
|
|
||||||
|
// Compute next slot
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
const next = scheduleSlots.find((s) => s.date === today && s.startTime > currentTime);
|
||||||
|
scheduleNextSlotId = next?.slotId ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
scheduleError = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||||
|
} finally {
|
||||||
|
scheduleLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load schedule when selectedChildId changes
|
||||||
|
$effect(() => {
|
||||||
|
if (isParent && selectedChildId) {
|
||||||
|
loadChildSchedule(selectedChildId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let showExplainer = $state(false);
|
let showExplainer = $state(false);
|
||||||
|
|
||||||
const isDemo = $derived(!hasRealData);
|
const isDemo = $derived(!hasRealData);
|
||||||
@@ -74,11 +125,20 @@
|
|||||||
<!-- EDT Section -->
|
<!-- EDT Section -->
|
||||||
<DashboardSection
|
<DashboardSection
|
||||||
title="Emploi du temps"
|
title="Emploi du temps"
|
||||||
subtitle={hasRealData ? "Aujourd'hui" : undefined}
|
subtitle={isParent ? "Aujourd'hui" : (hasRealData ? "Aujourd'hui" : undefined)}
|
||||||
isPlaceholder={!hasRealData}
|
isPlaceholder={!isParent && !hasRealData}
|
||||||
placeholderMessage="L'emploi du temps sera disponible une fois les cours configurés"
|
placeholderMessage="L'emploi du temps sera disponible une fois les cours configurés"
|
||||||
>
|
>
|
||||||
{#if hasRealData}
|
{#if isParent && selectedChildId}
|
||||||
|
<ScheduleWidget
|
||||||
|
slots={scheduleSlots}
|
||||||
|
nextSlotId={scheduleNextSlotId}
|
||||||
|
isLoading={scheduleLoading}
|
||||||
|
error={scheduleError}
|
||||||
|
/>
|
||||||
|
{:else if isParent}
|
||||||
|
<MultiChildSummary />
|
||||||
|
{:else if hasRealData}
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<SkeletonList items={4} message={childName ? `Chargement de l'emploi du temps de ${childName}...` : "Chargement de l'emploi du temps..."} />
|
<SkeletonList items={4} message={childName ? `Chargement de l'emploi du temps de ${childName}...` : "Chargement de l'emploi du temps..."} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 { untrack } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onChildClick
|
||||||
|
}: {
|
||||||
|
onChildClick?: (childId: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let children = $state<ChildScheduleSummary[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let offline = $state(isOffline());
|
||||||
|
let lastSync = $derived(getLastSyncDate());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
untrack(() => loadSummary());
|
||||||
|
|
||||||
|
function handleOnline() {
|
||||||
|
offline = false;
|
||||||
|
loadSummary();
|
||||||
|
}
|
||||||
|
function handleOffline() {
|
||||||
|
offline = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
children = await fetchChildrenScheduleSummary();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="multi-child-summary">
|
||||||
|
{#if offline}
|
||||||
|
<div class="offline-banner" role="status">
|
||||||
|
<span class="offline-dot"></span>
|
||||||
|
<span>Hors ligne</span>
|
||||||
|
{#if lastSync}
|
||||||
|
<span class="sync-date">Dernière sync : {formatSyncDate(lastSync)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Chargement...</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{:else if children.length === 0}
|
||||||
|
<div class="empty">Aucun enfant trouvé</div>
|
||||||
|
{:else}
|
||||||
|
<div class="children-grid">
|
||||||
|
{#each children as child (child.childId)}
|
||||||
|
<button
|
||||||
|
class="child-card"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onChildClick?.(child.childId)}
|
||||||
|
>
|
||||||
|
<h4 class="child-name">{child.firstName} {child.lastName}</h4>
|
||||||
|
|
||||||
|
{#if child.nextClass}
|
||||||
|
<div class="next-class">
|
||||||
|
<span class="next-label">Prochain cours</span>
|
||||||
|
<span class="next-subject">{child.nextClass.subjectName}</span>
|
||||||
|
<span class="next-time">{child.nextClass.startTime} - {child.nextClass.endTime}</span>
|
||||||
|
{#if child.nextClass.room}
|
||||||
|
<span class="next-room">{child.nextClass.room}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if child.todaySlots.length === 0}
|
||||||
|
<p class="no-class">Pas de cours aujourd'hui</p>
|
||||||
|
{:else}
|
||||||
|
<p class="no-class">Plus de cours aujourd'hui</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if child.todaySlots.length > 0}
|
||||||
|
<div class="today-count">
|
||||||
|
{child.todaySlots.length} cours aujourd'hui
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if onChildClick}
|
||||||
|
<span class="detail-link">Voir le détail</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.multi-child-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-date {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 1rem;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.children-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-card {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-card:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-class {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0.625rem;
|
||||||
|
background: #eff6ff;
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-subject {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-room {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-class {
|
||||||
|
margin: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||||
|
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
|
||||||
|
import MultiChildSummary from '$lib/components/organisms/ParentSchedule/MultiChildSummary.svelte';
|
||||||
|
import DayView from '$lib/components/organisms/StudentSchedule/DayView.svelte';
|
||||||
|
import WeekView from '$lib/components/organisms/StudentSchedule/WeekView.svelte';
|
||||||
|
import SlotDetails from '$lib/components/organisms/StudentSchedule/SlotDetails.svelte';
|
||||||
|
|
||||||
|
type ViewMode = 'day' | 'week';
|
||||||
|
|
||||||
|
let viewMode = $state<ViewMode>('day');
|
||||||
|
let currentDate = $state(todayStr());
|
||||||
|
let selectedChildId = $state<string | null>(null);
|
||||||
|
let slots = $state<ScheduleSlot[]>([]);
|
||||||
|
let nextSlotId = $state<string | null>(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let selectedSlot = $state<ScheduleSlot | null>(null);
|
||||||
|
let offline = $state(isOffline());
|
||||||
|
let lastSync = $derived(getLastSyncDate());
|
||||||
|
|
||||||
|
function formatLocalDate(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr(): string {
|
||||||
|
return formatLocalDate(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function mondayOfWeek(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
d.setDate(diff);
|
||||||
|
return formatLocalDate(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchedule() {
|
||||||
|
if (!selectedChildId) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
slots = await fetchChildDaySchedule(selectedChildId, currentDate);
|
||||||
|
} else {
|
||||||
|
slots = await fetchChildWeekSchedule(selectedChildId, currentDate);
|
||||||
|
}
|
||||||
|
recordSync();
|
||||||
|
|
||||||
|
// Compute next class from today's slots
|
||||||
|
const today = todayStr();
|
||||||
|
const d = new Date();
|
||||||
|
const now = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
const next = slots.find((s) => s.date === today && s.startTime > now);
|
||||||
|
nextSlotId = next?.slotId ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online/offline listener
|
||||||
|
$effect(() => {
|
||||||
|
function handleOnline() {
|
||||||
|
offline = false;
|
||||||
|
loadSchedule();
|
||||||
|
}
|
||||||
|
function handleOffline() {
|
||||||
|
offline = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChildSelected(childId: string) {
|
||||||
|
selectedChildId = childId;
|
||||||
|
untrack(() => {
|
||||||
|
loadSchedule();
|
||||||
|
// Prefetch for offline support
|
||||||
|
prefetchScheduleDays((date) => fetchChildDaySchedule(childId, date));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateDay(offset: number) {
|
||||||
|
const d = new Date(currentDate + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() + offset);
|
||||||
|
currentDate = formatLocalDate(d);
|
||||||
|
loadSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateWeek(offset: number) {
|
||||||
|
const d = new Date(currentDate + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() + offset * 7);
|
||||||
|
currentDate = formatLocalDate(d);
|
||||||
|
loadSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToToday() {
|
||||||
|
currentDate = todayStr();
|
||||||
|
loadSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewMode(mode: ViewMode) {
|
||||||
|
viewMode = mode;
|
||||||
|
loadSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe detection
|
||||||
|
let touchStartX = $state(0);
|
||||||
|
|
||||||
|
function handleTouchStart(e: globalThis.TouchEvent) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (touch) touchStartX = touch.clientX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(e: globalThis.TouchEvent) {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
const diff = touchStartX - touch.clientX;
|
||||||
|
const threshold = 50;
|
||||||
|
|
||||||
|
if (Math.abs(diff) > threshold) {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
navigateDay(diff > 0 ? 1 : -1);
|
||||||
|
} else {
|
||||||
|
navigateWeek(diff > 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="parent-schedule">
|
||||||
|
<!-- Child selector -->
|
||||||
|
<ChildSelector onChildSelected={handleChildSelected} />
|
||||||
|
|
||||||
|
<!-- Header bar -->
|
||||||
|
<div class="schedule-header">
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class:active={viewMode === 'day'} aria-pressed={viewMode === 'day'} onclick={() => setViewMode('day')}>Jour</button>
|
||||||
|
<button class:active={viewMode === 'week'} aria-pressed={viewMode === 'week'} onclick={() => setViewMode('week')}>Semaine</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-controls">
|
||||||
|
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(-1) : navigateWeek(-1)} aria-label="Précédent">‹</button>
|
||||||
|
<button class="today-btn" onclick={goToToday}>Aujourd'hui</button>
|
||||||
|
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(1) : navigateWeek(1)} aria-label="Suivant">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offline indicator -->
|
||||||
|
{#if offline}
|
||||||
|
<div class="offline-banner" role="status">
|
||||||
|
<span class="offline-dot"></span>
|
||||||
|
<span>Hors ligne</span>
|
||||||
|
{#if lastSync}
|
||||||
|
<span class="sync-date">Dernière sync : {formatSyncDate(lastSync)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Content area with swipe -->
|
||||||
|
<div
|
||||||
|
class="schedule-content"
|
||||||
|
ontouchstart={handleTouchStart}
|
||||||
|
ontouchend={handleTouchEnd}
|
||||||
|
role="region"
|
||||||
|
aria-label="Emploi du temps de l'enfant"
|
||||||
|
>
|
||||||
|
{#if !selectedChildId}
|
||||||
|
<MultiChildSummary onChildClick={handleChildSelected} />
|
||||||
|
{:else if isLoading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Chargement...</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error-state">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button onclick={loadSchedule}>Réessayer</button>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'day'}
|
||||||
|
<DayView
|
||||||
|
{slots}
|
||||||
|
date={currentDate}
|
||||||
|
{nextSlotId}
|
||||||
|
onSlotClick={(slot) => (selectedSlot = slot)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<WeekView
|
||||||
|
{slots}
|
||||||
|
weekStart={mondayOfWeek(currentDate)}
|
||||||
|
onSlotClick={(slot) => (selectedSlot = slot)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot detail modal -->
|
||||||
|
{#if selectedSlot}
|
||||||
|
<SlotDetails slot={selectedSlot} onClose={() => (selectedSlot = null)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.parent-schedule {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle button {
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle button.active {
|
||||||
|
background: white;
|
||||||
|
color: #1f2937;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #374151;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-date {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-content {
|
||||||
|
min-height: 200px;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<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 { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
|
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -34,16 +35,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatSyncDate(iso: string | null): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="schedule-widget">
|
<div class="schedule-widget">
|
||||||
|
|||||||
@@ -1,6 +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 { 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 { 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';
|
||||||
import DayView from './DayView.svelte';
|
import DayView from './DayView.svelte';
|
||||||
@@ -137,15 +138,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSyncDate(iso: string | null): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
return new Date(iso).toLocaleString('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="student-schedule">
|
<div class="student-schedule">
|
||||||
|
|||||||
56
frontend/src/lib/features/schedule/api/parentSchedule.ts
Normal file
56
frontend/src/lib/features/schedule/api/parentSchedule.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { getApiBaseUrl } from '$lib/api';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
import type { ScheduleSlot } from './schedule';
|
||||||
|
|
||||||
|
export interface ChildScheduleSummary {
|
||||||
|
childId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
todaySlots: ScheduleSlot[];
|
||||||
|
nextClass: ScheduleSlot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'EDT du jour d'un enfant pour le parent connecté.
|
||||||
|
*/
|
||||||
|
export async function fetchChildDaySchedule(childId: string, date: string): Promise<ScheduleSlot[]> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/me/children/${childId}/schedule/day/${date}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement de l'EDT (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'EDT de la semaine d'un enfant pour le parent connecté.
|
||||||
|
*/
|
||||||
|
export async function fetchChildWeekSchedule(childId: string, date: string): Promise<ScheduleSlot[]> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/me/children/${childId}/schedule/week/${date}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement de l'EDT (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le résumé EDT du jour pour tous les enfants du parent connecté.
|
||||||
|
*/
|
||||||
|
export async function fetchChildrenScheduleSummary(): Promise<ChildScheduleSummary[]> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/me/children/schedule/summary`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement du résumé EDT (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.data ?? [];
|
||||||
|
}
|
||||||
13
frontend/src/lib/features/schedule/formatSyncDate.ts
Normal file
13
frontend/src/lib/features/schedule/formatSyncDate.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Formats an ISO date string to a short French locale date+time display.
|
||||||
|
* Used by schedule components to show the last synchronization timestamp.
|
||||||
|
*/
|
||||||
|
export function formatSyncDate(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
let logoUrl = $derived(getLogoUrl());
|
let logoUrl = $derived(getLogoUrl());
|
||||||
let pathname = $derived(page.url.pathname);
|
let pathname = $derived(page.url.pathname);
|
||||||
let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE');
|
let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE');
|
||||||
|
let isParent = $derived(getActiveRole() === 'ROLE_PARENT');
|
||||||
|
|
||||||
// Load user roles on mount for multi-role context switching (FR5)
|
// Load user roles on mount for multi-role context switching (FR5)
|
||||||
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
|
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
|
||||||
@@ -104,6 +105,9 @@
|
|||||||
{#if isEleve}
|
{#if isEleve}
|
||||||
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
|
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isParent}
|
||||||
|
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a>
|
||||||
|
{/if}
|
||||||
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||||
{#if isLoggingOut}
|
{#if isLoggingOut}
|
||||||
@@ -146,6 +150,11 @@
|
|||||||
Mon emploi du temps
|
Mon emploi du temps
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isParent}
|
||||||
|
<a href="/dashboard/parent-schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-schedule'}>
|
||||||
|
EDT enfants
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-drawer-footer">
|
<div class="mobile-drawer-footer">
|
||||||
|
|||||||
@@ -43,14 +43,14 @@
|
|||||||
// Use demo data for now (no real data available yet)
|
// Use demo data for now (no real data available yet)
|
||||||
const hasRealData = false;
|
const hasRealData = false;
|
||||||
|
|
||||||
// Selected child for parent dashboard (will drive data fetching when real API is connected)
|
// Selected child for parent dashboard
|
||||||
let _selectedChildId = $state<string | null>(null);
|
let selectedChildId = $state<string | null>(null);
|
||||||
|
|
||||||
// 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) {
|
||||||
_selectedChildId = childId;
|
selectedChildId = childId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleSerenity(enabled: boolean) {
|
function handleToggleSerenity(enabled: boolean) {
|
||||||
@@ -99,6 +99,7 @@
|
|||||||
{hasRealData}
|
{hasRealData}
|
||||||
{serenityEnabled}
|
{serenityEnabled}
|
||||||
{childName}
|
{childName}
|
||||||
|
{selectedChildId}
|
||||||
onToggleSerenity={handleToggleSerenity}
|
onToggleSerenity={handleToggleSerenity}
|
||||||
/>
|
/>
|
||||||
{:else if dashboardView === 'teacher'}
|
{:else if dashboardView === 'teacher'}
|
||||||
|
|||||||
29
frontend/src/routes/dashboard/parent-schedule/+page.svelte
Normal file
29
frontend/src/routes/dashboard/parent-schedule/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ParentScheduleView from '$lib/components/organisms/ParentSchedule/ParentScheduleView.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Emploi du temps des enfants - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="schedule-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Emploi du temps des enfants</h1>
|
||||||
|
</header>
|
||||||
|
<ParentScheduleView />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.schedule-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -64,6 +64,21 @@ export default defineConfig({
|
|||||||
statuses: [0, 200]
|
statuses: [0, 200]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\/api\/me\/children\/.*\/schedule\//,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'parent-schedule-v1',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 180,
|
||||||
|
maxAgeSeconds: 30 * 24 * 60 * 60
|
||||||
|
},
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user