Compare commits

..

4 Commits

Author SHA1 Message Date
125d9d8806 fix: Corriger la disparition des créneaux lors d'un déplacement inter-jour
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
Lors d'un drag & drop d'un créneau vers un autre jour de la semaine,
le nouveau créneau devenait invisible car recurrenceStart était fixé à
la date d'occurrence (le jour source). Si le jour cible tombait avant
cette date dans la semaine, isActiveOnDate retournait false.

recurrenceStart est maintenant fixé au lundi de la semaine d'occurrence,
ce qui garantit la visibilité du créneau dès la semaine du déplacement.
2026-03-07 21:14:04 +01:00
39f8650b92 fix: Filtrer enseignants et matières par affectation lors de la création de créneaux
Quand une classe n'avait aucune affectation enseignant-matière, les
selects de la modale de création de créneau affichaient tous les
enseignants et toutes les matières au lieu d'une liste vide. Cela
permettait de soumettre des combinaisons invalides, produisant un
message d'erreur avec des UUID incompréhensibles.

Les dropdowns n'affichent plus que les enseignants/matières effectivement
affectés à la classe sélectionnée. Le message d'erreur backend est
reformulé sans UUID pour le cas où la validation frontend serait
contournée.
2026-03-07 21:14:04 +01:00
ba80e8cb57 fix: Permettre l'activation des comptes élèves de moins de 15 ans créés par l'admin
Lorsqu'un admin créait un élève de moins de 15 ans avec une date de
naissance, le compte ne pouvait pas être activé car le consentement
parental RGPD n'avait jamais été enregistré — aucun mécanisme ne le
permettait dans le parcours admin.

Ajout d'une case « Consentement parental obtenu » dans le formulaire
de création d'élève, affichée conditionnellement quand la date de
naissance indique un âge < 15 ans. L'admin confirme que l'établissement
a recueilli le consentement, qui est alors enregistré côté backend
lors de la création du compte.
2026-03-07 21:14:04 +01:00
36ceefb625 feat: Permettre aux élèves de consulter leur emploi du temps
Les élèves n'avaient aucun moyen de voir leur emploi du temps
depuis l'application. Cette fonctionnalité ajoute une page dédiée
avec deux modes de visualisation (jour et semaine), la navigation
temporelle, et le détail des cours au tap.

Le backend résout l'EDT de l'élève en chaînant : affectation classe →
créneaux récurrents + exceptions + calendrier scolaire → enrichissement
des noms (matières/enseignants). Le frontend utilise un cache offline
(Workbox NetworkFirst) pour rester consultable hors connexion.
2026-03-07 21:14:04 +01:00
45 changed files with 3882 additions and 123 deletions

View File

@@ -16,6 +16,8 @@ final readonly class CreateStudentCommand
public ?string $email = null, public ?string $email = null,
public ?string $dateNaissance = null, public ?string $dateNaissance = null,
public ?string $studentNumber = null, public ?string $studentNumber = null,
public bool $parentalConsent = false,
public ?string $consentRecordedBy = null,
) { ) {
} }
} }

View File

@@ -7,11 +7,13 @@ namespace App\Administration\Application\Command\CreateStudent;
use App\Administration\Domain\Exception\ClasseNotFoundException; use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException; use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment; use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId; use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId; use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\Email; use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role; use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User; use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\ClassAssignmentRepository; use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\UserRepository; use App\Administration\Domain\Repository\UserRepository;
@@ -31,6 +33,7 @@ final readonly class CreateStudentHandler
private ClassRepository $classRepository, private ClassRepository $classRepository,
private Connection $connection, private Connection $connection,
private Clock $clock, private Clock $clock,
private ConsentementParentalPolicy $consentementPolicy,
) { ) {
} }
@@ -94,6 +97,24 @@ final readonly class CreateStudentHandler
studentNumber: $command->studentNumber, studentNumber: $command->studentNumber,
); );
// Enregistrer le consentement parental si fourni par l'admin
if ($command->parentalConsent && $command->consentRecordedBy !== null) {
$dateNaissance = $command->dateNaissance !== null
? new DateTimeImmutable($command->dateNaissance)
: null;
if ($this->consentementPolicy->estRequis($dateNaissance)) {
$user->enregistrerConsentementParental(
ConsentementParental::accorder(
parentId: $command->consentRecordedBy,
eleveId: (string) $user->id,
at: $now,
ipAddress: 'admin-creation',
),
);
}
}
$this->userRepository->save($user); $this->userRepository->save($user);
// Affecter à la classe // Affecter à la classe

View File

@@ -13,10 +13,12 @@ use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\SchoolClass\ClassId; use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\StudentResource; use App\Administration\Infrastructure\Api\Resource\StudentResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\StudentVoter; use App\Administration\Infrastructure\Security\StudentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use Override; use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
@@ -37,6 +39,7 @@ final readonly class CreateStudentProcessor implements ProcessorInterface
private MessageBusInterface $eventBus, private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker, private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver, private CurrentAcademicYearResolver $academicYearResolver,
private Security $security,
) { ) {
} }
@@ -60,6 +63,13 @@ final readonly class CreateStudentProcessor implements ProcessorInterface
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
try { try {
$adminUserId = null;
$securityUser = $this->security->getUser();
if ($securityUser instanceof SecurityUser) {
$adminUserId = $securityUser->userId();
}
$command = new CreateStudentCommand( $command = new CreateStudentCommand(
tenantId: $tenantId, tenantId: $tenantId,
schoolName: $tenantConfig->subdomain, schoolName: $tenantConfig->subdomain,
@@ -70,6 +80,8 @@ final readonly class CreateStudentProcessor implements ProcessorInterface
email: $data->email, email: $data->email,
dateNaissance: $data->dateNaissance, dateNaissance: $data->dateNaissance,
studentNumber: $data->studentNumber, studentNumber: $data->studentNumber,
parentalConsent: $data->parentalConsent,
consentRecordedBy: $adminUserId,
); );
$user = ($this->handler)($command); $user = ($this->handler)($command);

View File

@@ -72,6 +72,8 @@ final class StudentResource
#[Assert\Regex(pattern: '/^[A-Za-z0-9]{11}$/', message: 'L\'INE doit contenir exactement 11 caractères alphanumériques.')] #[Assert\Regex(pattern: '/^[A-Za-z0-9]{11}$/', message: 'L\'INE doit contenir exactement 11 caractères alphanumériques.')]
public ?string $studentNumber = null; public ?string $studentNumber = null;
public bool $parentalConsent = false;
public static function fromDto(StudentWithClassDto $dto): self public static function fromDto(StudentWithClassDto $dto): self
{ {
$resource = new self(); $resource = new self();

View File

@@ -117,7 +117,12 @@ final readonly class UpdateRecurringSlotHandler
$slot->terminerRecurrenceLe($dayBefore, $now); $slot->terminerRecurrenceLe($dayBefore, $now);
$this->slotRepository->save($slot); $this->slotRepository->save($slot);
// Create new slot starting from the occurrence date // Use the Monday of the occurrence week as recurrenceStart so the new slot
// is visible in the current week even when moved to an earlier day-of-week.
$dayN = (int) $occurrenceDate->format('N');
$weekMonday = $occurrenceDate->modify('-' . ($dayN - 1) . ' days');
// Create new slot starting from the Monday of the occurrence week
$newSlot = ScheduleSlot::creer( $newSlot = ScheduleSlot::creer(
tenantId: $tenantId, tenantId: $tenantId,
classId: ClassId::fromString($command->classId), classId: ClassId::fromString($command->classId),
@@ -128,7 +133,7 @@ final readonly class UpdateRecurringSlotHandler
room: $command->room, room: $command->room,
isRecurring: true, isRecurring: true,
now: $now, now: $now,
recurrenceStart: $occurrenceDate, recurrenceStart: $weekMonday,
recurrenceEnd: $originalRecurrenceEnd, recurrenceEnd: $originalRecurrenceEnd,
); );

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Port;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour obtenir le calendrier scolaire de l'année courante.
*
* L'implémentation résout l'année académique courante de façon transparente.
*/
interface CurrentCalendarProvider
{
public function forCurrentYear(TenantId $tenantId): SchoolCalendar;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Port;
/**
* Port pour lire les noms d'affichage des matières et enseignants.
*
* Permet à la couche Application de résoudre les noms sans dépendre
* directement des repositories du bounded context Administration.
*/
interface ScheduleDisplayReader
{
/**
* @param string ...$subjectIds Identifiants des matières
*
* @return array<string, string> Map subjectId => nom de la matière
*/
public function subjectNames(string $tenantId, string ...$subjectIds): array;
/**
* @param string ...$teacherIds Identifiants des enseignants
*
* @return array<string, string> Map teacherId => "Prénom Nom"
*/
public function teacherNames(string $tenantId, string ...$teacherIds): array;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Port;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour résoudre la classe d'un élève pour l'année scolaire courante.
*
* L'implémentation résout l'année académique courante de façon transparente.
*/
interface StudentClassReader
{
/**
* @return string|null L'identifiant de la classe, ou null si l'élève n'est affecté à aucune classe
*/
public function currentClassId(string $studentId, TenantId $tenantId): ?string;
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentSchedule;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Service\ScheduleResolver;
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
use App\Shared\Domain\Tenant\TenantId;
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 GetStudentScheduleHandler
{
public function __construct(
private StudentClassReader $studentClassReader,
private ScheduleResolver $scheduleResolver,
private CurrentCalendarProvider $calendarProvider,
private ScheduleDisplayReader $displayReader,
) {
}
/** @return array<StudentScheduleSlotDto> */
public function __invoke(GetStudentScheduleQuery $query): array
{
$tenantId = TenantId::fromString($query->tenantId);
$classId = $this->studentClassReader->currentClassId($query->studentId, $tenantId);
if ($classId === null) {
return [];
}
$date = new DateTimeImmutable($query->date);
$weekStart = $this->mondayOfWeek($date);
$calendar = $this->calendarProvider->forCurrentYear($tenantId);
$resolved = $this->scheduleResolver->resolveForWeek(
ClassId::fromString($classId),
$weekStart,
$tenantId,
$calendar,
);
return $this->enrichSlots($resolved, $query->tenantId);
}
/**
* @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');
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentSchedule;
use DateTimeImmutable;
use InvalidArgumentException;
use function sprintf;
final readonly class GetStudentScheduleQuery
{
public function __construct(
public string $studentId,
public string $tenantId,
public string $date,
) {
if (DateTimeImmutable::createFromFormat('Y-m-d', $date) === false) {
throw new InvalidArgumentException(sprintf('Date invalide : "%s". Format attendu : YYYY-MM-DD.', $date));
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentSchedule;
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
final readonly class StudentScheduleSlotDto
{
public function __construct(
public string $slotId,
public string $date,
public int $dayOfWeek,
public string $startTime,
public string $endTime,
public string $subjectId,
public string $subjectName,
public string $teacherId,
public string $teacherName,
public ?string $room,
public bool $isModified,
public ?string $exceptionId,
) {
}
public static function fromResolved(
ResolvedScheduleSlot $slot,
string $subjectName,
string $teacherName,
): self {
return new self(
slotId: (string) $slot->slotId,
date: $slot->date->format('Y-m-d'),
dayOfWeek: $slot->dayOfWeek->value,
startTime: $slot->timeSlot->startTime,
endTime: $slot->timeSlot->endTime,
subjectId: (string) $slot->subjectId,
subjectName: $subjectName,
teacherId: (string) $slot->teacherId,
teacherName: $teacherName,
room: $slot->room,
isModified: $slot->isModified,
exceptionId: $slot->exceptionId !== null ? (string) $slot->exceptionId : null,
);
}
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleHandler;
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleQuery;
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 function date;
use InvalidArgumentException;
use RuntimeException;
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\ServiceUnavailableHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function usort;
/**
* Endpoints de consultation de l'emploi du temps pour l'élève connecté.
*
* @see Story 4.3 - Consultation EDT par l'Élève
* @see FR29 - Consulter emploi du temps (élève)
*/
#[IsGranted(ScheduleSlotVoter::VIEW)]
final readonly class StudentScheduleController
{
public function __construct(
private Security $security,
private GetStudentScheduleHandler $handler,
) {
}
/**
* EDT du jour pour l'élève connecté.
*/
#[Route('/api/me/schedule/day/{date}', name: 'api_student_schedule_day', methods: ['GET'])]
public function day(string $date): JsonResponse
{
$slots = $this->resolveSchedule($date);
$daySlots = array_values(array_filter(
$slots,
static fn (StudentScheduleSlotDto $s): bool => $s->date === $date,
));
return new JsonResponse(['data' => $this->serializeSlots($daySlots)]);
}
/**
* EDT de la semaine pour l'élève connecté.
*/
#[Route('/api/me/schedule/week/{date}', name: 'api_student_schedule_week', methods: ['GET'])]
public function week(string $date): JsonResponse
{
$slots = $this->resolveSchedule($date);
return new JsonResponse(['data' => $this->serializeSlots($slots)]);
}
/**
* Prochain cours pour l'élève connecté.
*/
#[Route('/api/me/schedule/next-class', name: 'api_student_schedule_next_class', methods: ['GET'])]
public function nextClass(): JsonResponse
{
$today = date('Y-m-d');
$now = date('H:i');
$slots = $this->resolveSchedule($today);
// Chercher le prochain cours : d'abord aujourd'hui après l'heure actuelle
$nextSlot = $this->findNextSlot($slots, $today, $now);
if ($nextSlot === null) {
return new JsonResponse(['data' => null]);
}
return new JsonResponse(['data' => $this->serializeSlot($nextSlot)]);
}
/**
* @return array<StudentScheduleSlotDto>
*/
private function resolveSchedule(string $date): array
{
$user = $this->getSecurityUser();
try {
return ($this->handler)(new GetStudentScheduleQuery(
studentId: $user->userId(),
tenantId: $user->tenantId(),
date: $date,
));
} catch (InvalidArgumentException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
} catch (RuntimeException $e) {
throw new ServiceUnavailableHttpException(null, "L'emploi du temps n'est pas encore disponible.", $e);
}
}
/**
* @param array<StudentScheduleSlotDto> $slots
*/
private function findNextSlot(array $slots, string $today, string $now): ?StudentScheduleSlotDto
{
$candidates = [];
foreach ($slots as $slot) {
// Cours aujourd'hui qui n'a pas encore commencé
if ($slot->date === $today && $slot->startTime > $now) {
$candidates[] = $slot;
continue;
}
// Cours des jours suivants de la semaine
if ($slot->date > $today) {
$candidates[] = $slot;
}
}
if ($candidates === []) {
return null;
}
usort($candidates, static function (StudentScheduleSlotDto $a, StudentScheduleSlotDto $b): int {
$cmp = $a->date <=> $b->date;
return $cmp !== 0 ? $cmp : $a->startTime <=> $b->startTime;
});
return $candidates[0];
}
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,
];
}
}

View File

@@ -96,8 +96,11 @@ final readonly class CreateScheduleSlotProcessor implements ProcessorInterface
} }
return $resource; return $resource;
} catch (EnseignantNonAffecteException $e) { } catch (EnseignantNonAffecteException) {
throw new UnprocessableEntityHttpException($e->getMessage()); throw new UnprocessableEntityHttpException(
"L'enseignant sélectionné n'est pas affecté à cette classe pour cette matière. "
. 'Veuillez vérifier les affectations enseignant-classe-matière.',
);
} catch (CreneauHoraireInvalideException|ValueError $e) { } catch (CreneauHoraireInvalideException|ValueError $e) {
throw new BadRequestHttpException($e->getMessage()); throw new BadRequestHttpException($e->getMessage());
} }

View File

@@ -105,8 +105,11 @@ final readonly class UpdateScheduleSlotProcessor implements ProcessorInterface
} }
return $resource; return $resource;
} catch (EnseignantNonAffecteException $e) { } catch (EnseignantNonAffecteException) {
throw new UnprocessableEntityHttpException($e->getMessage()); throw new UnprocessableEntityHttpException(
"L'enseignant sélectionné n'est pas affecté à cette classe pour cette matière. "
. 'Veuillez vérifier les affectations enseignant-classe-matière.',
);
} catch (ScheduleSlotNotFoundException|InvalidUuidStringException) { } catch (ScheduleSlotNotFoundException|InvalidUuidStringException) {
throw new NotFoundHttpException('Créneau non trouvé.'); throw new NotFoundHttpException('Créneau non trouvé.');
} catch (CreneauHoraireInvalideException|ValueError $e) { } catch (CreneauHoraireInvalideException|ValueError $e) {

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
* Voter pour les autorisations sur l'emploi du temps. * Voter pour les autorisations sur l'emploi du temps.
* *
* Seuls ADMIN et SUPER_ADMIN peuvent gérer l'EDT. * Seuls ADMIN et SUPER_ADMIN peuvent gérer l'EDT.
* PROF et VIE_SCOLAIRE peuvent le consulter. * PROF, VIE_SCOLAIRE et ELEVE peuvent le consulter.
* *
* @extends Voter<string, null> * @extends Voter<string, null>
*/ */
@@ -68,6 +68,7 @@ final class ScheduleSlotVoter extends Voter
Role::ADMIN->value, Role::ADMIN->value,
Role::PROF->value, Role::PROF->value,
Role::VIE_SCOLAIRE->value, Role::VIE_SCOLAIRE->value,
Role::ELEVE->value,
]); ]);
} }

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Shared\Domain\Tenant\TenantId;
use Override;
use RuntimeException;
/**
* Fournit le calendrier scolaire de l'année courante.
*/
final readonly class CurrentYearCalendarProvider implements CurrentCalendarProvider
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
$academicYearId = $this->academicYearResolver->resolve('current');
if ($academicYearId === null) {
throw new RuntimeException(
'Aucune année scolaire configurée pour le tenant ' . $tenantId . '. '
. 'Veuillez configurer une année scolaire avant de consulter l\'emploi du temps.',
);
}
$yearId = AcademicYearId::fromString($academicYearId);
return $this->calendarRepository->findByTenantAndYear($tenantId, $yearId)
?? SchoolCalendar::initialiser($tenantId, $yearId);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Shared\Domain\Tenant\TenantId;
use Override;
/**
* Résout la classe d'un élève pour l'année scolaire courante.
*/
final readonly class CurrentYearStudentClassReader implements StudentClassReader
{
public function __construct(
private ClassAssignmentRepository $classAssignmentRepository,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
$academicYearId = $this->academicYearResolver->resolve('current');
if ($academicYearId === null) {
return null;
}
$assignment = $this->classAssignmentRepository->findByStudent(
UserId::fromString($studentId),
AcademicYearId::fromString($academicYearId),
$tenantId,
);
if ($assignment === null) {
return null;
}
return (string) $assignment->classId;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Override;
/**
* Résout les noms d'affichage des matières et enseignants via des requêtes batch.
*/
final readonly class DoctrineScheduleDisplayReader implements ScheduleDisplayReader
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function subjectNames(string $tenantId, string ...$subjectIds): array
{
if ($subjectIds === []) {
return [];
}
$rows = $this->connection->fetchAllAssociative(
'SELECT id, name FROM subjects WHERE id IN (:ids) AND tenant_id = :tenantId',
['ids' => $subjectIds, 'tenantId' => $tenantId],
['ids' => ArrayParameterType::STRING],
);
$names = [];
foreach ($rows as $row) {
/** @var string $id */
$id = $row['id'];
/** @var string $name */
$name = $row['name'];
$names[$id] = $name;
}
return $names;
}
#[Override]
public function teacherNames(string $tenantId, string ...$teacherIds): array
{
if ($teacherIds === []) {
return [];
}
$rows = $this->connection->fetchAllAssociative(
'SELECT id, first_name, last_name FROM users WHERE id IN (:ids) AND tenant_id = :tenantId',
['ids' => $teacherIds, 'tenantId' => $tenantId],
['ids' => ArrayParameterType::STRING],
);
$names = [];
foreach ($rows as $row) {
/** @var string $id */
$id = $row['id'];
/** @var string $firstName */
$firstName = $row['first_name'];
/** @var string $lastName */
$lastName = $row['last_name'];
$names[$id] = $firstName . ' ' . $lastName;
}
return $names;
}
}

View File

@@ -189,6 +189,7 @@ final class CreateStudentHandlerTest extends TestCase
$this->classRepository, $this->classRepository,
$connection, $connection,
$this->clock, $this->clock,
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
); );
$command = $this->createCommand(); $command = $this->createCommand();
@@ -278,6 +279,7 @@ final class CreateStudentHandlerTest extends TestCase
$this->classRepository, $this->classRepository,
$this->connection, $this->connection,
$this->clock, $this->clock,
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
); );
} }

View File

@@ -119,6 +119,41 @@ final class UpdateRecurringSlotHandlerTest extends TestCase
self::assertTrue($result['newSlot']->teacherId->equals(UserId::fromString(self::NEW_TEACHER_ID))); self::assertTrue($result['newSlot']->teacherId->equals(UserId::fromString(self::NEW_TEACHER_ID)));
} }
#[Test]
public function allFutureCrossDayMoveSetsRecurrenceStartToWeekMonday(): void
{
// Wednesday slot — moving it to Monday
$slot = $this->createAndSaveSlot(
dayOfWeek: DayOfWeek::WEDNESDAY,
recurrenceStart: new DateTimeImmutable('2026-09-01'),
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
);
$command = new UpdateRecurringSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
occurrenceDate: '2026-10-14', // Wednesday
scope: 'all_future',
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
updatedBy: self::UPDATER_ID,
);
$result = ($this->handler)($command);
$newSlot = $result['newSlot'];
self::assertNotNull($newSlot);
self::assertSame(DayOfWeek::MONDAY, $newSlot->dayOfWeek);
// recurrenceStart must be the Monday of the occurrence week (2026-10-12),
// not the occurrence date (2026-10-14), so the slot is visible this week
self::assertSame('2026-10-12', $newSlot->recurrenceStart->format('Y-m-d'));
}
#[Test] #[Test]
public function allFutureWithNoOriginalEndUsesNullForNewSlotEnd(): void public function allFutureWithNoOriginalEndUsesNullForNewSlotEnd(): void
{ {
@@ -149,6 +184,7 @@ final class UpdateRecurringSlotHandlerTest extends TestCase
} }
private function createAndSaveSlot( private function createAndSaveSlot(
DayOfWeek $dayOfWeek = DayOfWeek::MONDAY,
?DateTimeImmutable $recurrenceStart = null, ?DateTimeImmutable $recurrenceStart = null,
?DateTimeImmutable $recurrenceEnd = null, ?DateTimeImmutable $recurrenceEnd = null,
): ScheduleSlot { ): ScheduleSlot {
@@ -157,7 +193,7 @@ final class UpdateRecurringSlotHandlerTest extends TestCase
classId: ClassId::fromString(self::CLASS_ID), classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID), subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID), teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: DayOfWeek::MONDAY, dayOfWeek: $dayOfWeek,
timeSlot: new TimeSlot('08:00', '09:00'), timeSlot: new TimeSlot('08:00', '09:00'),
room: null, room: null,
isRecurring: true, isRecurring: true,

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentSchedule;
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\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleHandler;
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleQuery;
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
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 GetStudentScheduleHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
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 returnsEmptyWhenStudentHasNoClass(): void
{
$handler = $this->createHandler(classId: null);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertSame([], $result);
}
#[Test]
public function returnsScheduleForStudentClass(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
$this->saveRecurringSlot(DayOfWeek::TUESDAY, '10:00', '11:00');
$handler = $this->createHandler(classId: self::CLASS_ID);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02', // Monday
));
self::assertCount(2, $result);
self::assertContainsOnlyInstancesOf(StudentScheduleSlotDto::class, $result);
}
#[Test]
public function enrichesSlotsWithSubjectAndTeacherNames(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
$handler = $this->createHandler(
classId: self::CLASS_ID,
subjectNames: [self::SUBJECT_ID => 'Mathématiques'],
teacherNames: [self::TEACHER_ID => 'Jean Dupont'],
);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertCount(1, $result);
self::assertSame('Mathématiques', $result[0]->subjectName);
self::assertSame('Jean Dupont', $result[0]->teacherName);
}
#[Test]
public function computesMondayFromAnyDayOfWeek(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
$handler = $this->createHandler(classId: self::CLASS_ID);
// Wednesday of the same week → should still return Monday's slot
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-04', // Wednesday
));
self::assertCount(1, $result);
self::assertSame('2026-03-02', $result[0]->date);
}
#[Test]
public function returnsCorrectDtoFields(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00', 'Salle 101');
$handler = $this->createHandler(
classId: self::CLASS_ID,
subjectNames: [self::SUBJECT_ID => 'Français'],
teacherNames: [self::TEACHER_ID => 'Marie Martin'],
);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertCount(1, $result);
$dto = $result[0];
self::assertSame('2026-03-02', $dto->date);
self::assertSame(1, $dto->dayOfWeek);
self::assertSame('08:00', $dto->startTime);
self::assertSame('09:00', $dto->endTime);
self::assertSame(self::SUBJECT_ID, $dto->subjectId);
self::assertSame('Français', $dto->subjectName);
self::assertSame(self::TEACHER_ID, $dto->teacherId);
self::assertSame('Marie Martin', $dto->teacherName);
self::assertSame('Salle 101', $dto->room);
self::assertFalse($dto->isModified);
self::assertNull($dto->exceptionId);
}
private function saveRecurringSlot(
DayOfWeek $day,
string $start,
string $end,
?string $room = null,
): void {
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
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<string, string> $subjectNames
* @param array<string, string> $teacherNames
*/
private function createHandler(
?string $classId = null,
array $subjectNames = [],
array $teacherNames = [],
): GetStudentScheduleHandler {
$tenantId = TenantId::fromString(self::TENANT_ID);
$studentClassReader = new class($classId) implements StudentClassReader {
public function __construct(private ?string $classId)
{
}
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
return $this->classId;
}
};
$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 GetStudentScheduleHandler(
$studentClassReader,
new ScheduleResolver($this->slotRepository, $this->exceptionRepository),
$calendarProvider,
$displayReader,
);
}
}

56
docs/stories/story-4.6.md Normal file
View File

@@ -0,0 +1,56 @@
# Story 4.6: Recherche autocomplete pour lier un parent à un élève
Status: ready-for-dev
## Story
As an administrateur,
I want to search for a parent by name or email when linking them to a student,
so that I don't need to know or copy-paste a UUID.
## Acceptance Criteria
1. **AC1 - Champ de recherche autocomplete** : Le champ "ID du parent" dans la modale "Ajouter un parent/tuteur" est remplacé par un champ de recherche avec autocomplete. L'admin tape au moins 2 caractères et voit une liste de suggestions de parents correspondants (nom, prénom, email).
2. **AC2 - Résultats de recherche** : Les suggestions affichent le prénom, nom et email de chaque parent trouvé. Seuls les utilisateurs ayant le rôle `ROLE_PARENT` du même tenant sont proposés.
3. **AC3 - Sélection** : L'admin clique sur une suggestion pour la sélectionner. Le parent sélectionné est affiché clairement dans le champ (nom + email). L'admin peut le désélectionner pour chercher à nouveau.
4. **AC4 - Debounce** : Les requêtes de recherche sont debounced (300ms minimum) pour éviter de surcharger l'API.
5. **AC5 - Feedback** : Un indicateur de chargement s'affiche pendant la recherche. Un message "Aucun parent trouvé" s'affiche si la recherche ne retourne aucun résultat.
## Tasks / Subtasks
- [ ] Task 1 - Backend : endpoint de recherche parents (AC: 1, 2)
- [ ] Créer un endpoint GET `/api/parents/search?q={query}` qui retourne les utilisateurs ROLE_PARENT du tenant courant, filtrés par nom/prénom/email
- [ ] Limiter les résultats à 10 suggestions maximum
- [ ] Protéger l'endpoint avec les autorisations admin
- [ ] Task 2 - Frontend : composant autocomplete (AC: 1, 3, 4, 5)
- [ ] Remplacer le champ UUID dans `GuardianList.svelte` par un composant de recherche autocomplete
- [ ] Implémenter le debounce (300ms) sur la saisie
- [ ] Afficher les résultats dans un dropdown avec prénom, nom, email
- [ ] Permettre la sélection/désélection d'un parent
- [ ] Afficher le loading et l'état vide
- [ ] Task 3 - Tests E2E (AC: 1-5)
- [ ] Tester la recherche autocomplete sur la fiche élève
- [ ] Tester la liaison parent-élève via le nouveau flux
## Dev Notes
### Composants existants à modifier
- `frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte` : remplacer le champ `<input type="text" placeholder="UUID du compte parent">` par un composant autocomplete
- Backend : ajouter un provider/endpoint pour la recherche de parents
### Contexte
Actuellement, pour lier un parent à un élève depuis la fiche élève (`/admin/students/{id}`), l'admin doit saisir manuellement l'UUID du compte parent. C'est inutilisable en pratique car personne ne connait les UUID par coeur.
### Contraintes
- La recherche doit être scoped au tenant courant
- Seuls les utilisateurs avec `ROLE_PARENT` doivent être retournés
- Ne pas proposer les parents déjà liés à cet élève

View File

@@ -0,0 +1,141 @@
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 STUDENT_EMAIL = 'e2e-dash-nav-student@example.com';
const STUDENT_PASSWORD = 'DashNavStudent123';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
test.describe('Dashboard Responsive Navigation', () => {
test.beforeAll(async () => {
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' }
);
});
// =========================================================================
// MOBILE (375x667)
// =========================================================================
test.describe('Mobile (375x667)', () => {
test.use({ viewport: { width: 375, height: 667 } });
test('shows hamburger button and hides desktop nav', async ({ page }) => {
await loginAsStudent(page);
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
await expect(hamburger).toBeVisible({ timeout: 10000 });
const desktopNav = page.locator('.desktop-nav');
await expect(desktopNav).not.toBeVisible();
});
test('opens drawer via hamburger and shows nav links', async ({ page }) => {
await loginAsStudent(page);
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// Should show navigation links
await expect(drawer.getByText('Tableau de bord')).toBeVisible();
await expect(drawer.getByText('Mon emploi du temps')).toBeVisible();
await expect(drawer.getByText('Paramètres')).toBeVisible();
});
test('closes drawer via close button', async ({ page }) => {
await loginAsStudent(page);
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
await page.getByRole('button', { name: /fermer le menu/i }).click();
await expect(drawer).not.toBeVisible();
});
test('closes drawer on overlay click', async ({ page }) => {
await loginAsStudent(page);
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
const overlay = page.locator('.mobile-overlay');
await overlay.click({ position: { x: 350, y: 300 } });
await expect(drawer).not.toBeVisible();
});
test('navigates via mobile drawer and closes it', async ({ page }) => {
await loginAsStudent(page);
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
await drawer.getByText('Mon emploi du temps').click();
await expect(drawer).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard\/schedule/);
});
test('shows logout button in drawer footer', async ({ page }) => {
await loginAsStudent(page);
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
const logoutButton = drawer.locator('.mobile-logout');
await expect(logoutButton).toBeVisible();
await expect(logoutButton).toHaveText(/déconnexion/i);
});
});
// =========================================================================
// DESKTOP (1280x800)
// =========================================================================
test.describe('Desktop (1280x800)', () => {
test.use({ viewport: { width: 1280, height: 800 } });
test('hides hamburger and shows desktop nav', async ({ page }) => {
await loginAsStudent(page);
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
await expect(hamburger).not.toBeVisible();
const desktopNav = page.locator('.desktop-nav');
await expect(desktopNav).toBeVisible({ timeout: 10000 });
});
test('desktop nav shows schedule link for student', async ({ page }) => {
await loginAsStudent(page);
const desktopNav = page.locator('.desktop-nav');
await expect(desktopNav.getByText('Mon EDT')).toBeVisible({ timeout: 10000 });
await expect(desktopNav.getByText('Tableau de bord')).toBeVisible();
});
});
});

View File

@@ -202,7 +202,7 @@ test.describe('Role-Based Access Control [P0]', () => {
// Teacher should not see admin-specific navigation in the dashboard layout // Teacher should not see admin-specific navigation in the dashboard layout
// The dashboard header should not have admin links like "Utilisateurs" // The dashboard header should not have admin links like "Utilisateurs"
const adminUsersLink = page.locator('.header-nav').getByRole('link', { name: 'Utilisateurs' }); const adminUsersLink = page.locator('.desktop-nav').getByRole('link', { name: 'Utilisateurs' });
await expect(adminUsersLink).not.toBeVisible(); await expect(adminUsersLink).not.toBeVisible();
}); });
}); });

View File

@@ -42,10 +42,12 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string }
const output = execSync( const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` + `docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` + `require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + `$t="${TENANT_ID}"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + `$dns="6ba7b810-9dad-11d1-80b4-00c04fd430c8"; ` +
`$ay="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($dns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + `$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();` + `echo Ramsey\\Uuid\\Uuid::uuid5($ay,"$t:$s-$e")->toString();` +
`' 2>&1`, `' 2>&1`,
{ encoding: 'utf-8' } { encoding: 'utf-8' }
).trim(); ).trim();
@@ -147,11 +149,13 @@ async function fillSlotForm(
if (className) { if (className) {
await dialog.locator('#slot-class').selectOption({ label: className }); await dialog.locator('#slot-class').selectOption({ label: className });
} }
// Wait for assignments to load — only the test teacher is assigned, // Wait for assignments to load, then select subject first (filters teachers)
// so the teacher dropdown filters down to 1 option const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions.first()).toBeAttached({ timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
// After subject selection, wait for teacher dropdown to be filtered
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])'); const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 }); await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 }); await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue); await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime); await dialog.locator('#slot-start').fill(startTime);
@@ -180,39 +184,31 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
const { schoolId, academicYearId } = resolveDeterministicIds(); const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test classes exist // Clean up stale test data (e.g. from previous runs with wrong school_id)
try { try {
runSql( runSql(`DELETE FROM schedule_slots WHERE class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
`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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
); runSql(`DELETE FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}'`);
runSql(`DELETE FROM subjects WHERE code IN ('E2SMATH','E2SFRA') AND tenant_id = '${TENANT_ID}'`);
} catch { } catch {
// May already exist // Tables may not exist
} }
try { // Create test classes
runSql( 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-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `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-Schedule-6A', '6ème', 'active', NOW(), NOW())`
);
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-Schedule-5A', '5ème', 'active', NOW(), NOW())`
); );
} catch {
// May already exist
}
// Ensure test subjects exist // Create test subjects
try {
runSql( 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-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW())`
); );
} catch {
// May already exist
}
try {
runSql( 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-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2SFRA', '#ef4444', 'active', NOW(), NOW())`
); );
} catch {
// May already exist
}
cleanupScheduleData(); cleanupScheduleData();
cleanupCalendarEntries(); cleanupCalendarEntries();

View File

@@ -42,10 +42,12 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string }
const output = execSync( const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` + `docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` + `require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + `$t="${TENANT_ID}"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + `$dns="6ba7b810-9dad-11d1-80b4-00c04fd430c8"; ` +
`$ay="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($dns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + `$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();` + `echo Ramsey\\Uuid\\Uuid::uuid5($ay,"$t:$s-$e")->toString();` +
`' 2>&1`, `' 2>&1`,
{ encoding: 'utf-8' } { encoding: 'utf-8' }
).trim(); ).trim();
@@ -114,11 +116,13 @@ async function fillSlotForm(
if (className) { if (className) {
await dialog.locator('#slot-class').selectOption({ label: className }); await dialog.locator('#slot-class').selectOption({ label: className });
} }
// Wait for assignments to load — only the test teacher is assigned, // Wait for assignments to load, then select subject first (filters teachers)
// so the teacher dropdown filters down to 1 option const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions.first()).toBeAttached({ timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
// After subject selection, wait for teacher dropdown to be filtered
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])'); const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 }); await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 }); await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue); await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime); await dialog.locator('#slot-start').fill(startTime);
@@ -147,40 +151,31 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)',
const { schoolId, academicYearId } = resolveDeterministicIds(); const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test class exists // Clean up stale test data (e.g. from previous runs with wrong school_id)
try { try {
runSql( runSql(`DELETE FROM schedule_slots WHERE class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
`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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
); runSql(`DELETE FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}'`);
runSql(`DELETE FROM subjects WHERE code IN ('E2SMATH','E2SFRA') AND tenant_id = '${TENANT_ID}'`);
} catch { } catch {
// May already exist // Tables may not exist
} }
// Ensure second test class exists (for conflict tests across classes) // Create test classes
try {
runSql( 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-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `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-Schedule-6A', '6ème', 'active', NOW(), NOW())`
);
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-Schedule-5A', '5ème', 'active', NOW(), NOW())`
); );
} catch {
// May already exist
}
// Ensure test subjects exist // Create test subjects
try {
runSql( 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-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW())`
); );
} catch {
// May already exist
}
try {
runSql( 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-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2SFRA', '#ef4444', 'active', NOW(), NOW())`
); );
} catch {
// May already exist
}
cleanupScheduleData(); cleanupScheduleData();
clearCache(); clearCache();
@@ -375,6 +370,78 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)',
await expect(page.locator('.slot-card').getByText('A101')).toBeVisible(); await expect(page.locator('.slot-card').getByText('A101')).toBeVisible();
}); });
test('subject field appears before teacher field in creation form', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Subject should appear before teacher in DOM order
const formGroups = dialog.locator('.form-group');
const labels = await formGroups.locator('label').allTextContents();
const subjectIndex = labels.findIndex((l) => l.includes('Matière'));
const teacherIndex = labels.findIndex((l) => l.includes('Enseignant'));
expect(subjectIndex).toBeLessThan(teacherIndex);
});
test('selecting a subject filters the teacher dropdown', async ({ page }) => {
const { academicYearId } = resolveDeterministicIds();
// Create a second teacher
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-sched-teacher2@example.com --password=Teacher2Pass123 --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
// Assign teacher1 to subject1, teacher2 to subject2 for class 6A
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c, (SELECT id FROM subjects WHERE code = 'E2SMATH' AND tenant_id = '${TENANT_ID}') s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c, (SELECT id FROM subjects WHERE code = 'E2SFRA' AND tenant_id = '${TENANT_ID}') s ` +
`WHERE u.email = 'e2e-sched-teacher2@example.com' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select class 6A — both subjects should be available
await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' });
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions).toHaveCount(2, { timeout: 15000 });
// Before selecting a subject, both teachers should be available
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(2, { timeout: 10000 });
// Select first subject (Français) — should filter to only teacher2
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
});
test('filters subjects and teachers by class assignment', async ({ page }) => { test('filters subjects and teachers by class assignment', async ({ page }) => {
const { academicYearId } = resolveDeterministicIds(); const { academicYearId } = resolveDeterministicIds();
@@ -434,9 +501,10 @@ test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () =>
const { schoolId, academicYearId } = resolveDeterministicIds(); const { schoolId, academicYearId } = resolveDeterministicIds();
// Only insert if not already created by first describe block
try { try {
runSql( 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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) SELECT gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM school_classes WHERE name = 'E2E-Schedule-6A' AND tenant_id = '${TENANT_ID}')`
); );
} catch { } catch {
// May already exist // May already exist
@@ -444,7 +512,7 @@ test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () =>
try { try {
runSql( 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-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) SELECT gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM subjects WHERE code = 'E2SMATH' AND tenant_id = '${TENANT_ID}')`
); );
} catch { } catch {
// May already exist // May already exist

View File

@@ -0,0 +1,556 @@
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 STUDENT_EMAIL = 'e2e-student-schedule@example.com';
const STUDENT_PASSWORD = 'StudentSchedule123';
const TEACHER_EMAIL = 'e2e-student-sched-teacher@example.com';
const TEACHER_PASSWORD = 'TeacherSchedule123';
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! };
}
/**
* Returns ISO day of week (1=Monday ... 5=Friday) for the current day,
* clamped to weekdays for schedule slot seeding.
*/
function currentWeekdayIso(): number {
const jsDay = new Date().getDay(); // 0=Sun, 1=Mon...6=Sat
if (jsDay === 0) return 1; // Sunday → use Monday
if (jsDay === 6) return 5; // Saturday → use Friday
return jsDay;
}
/**
* Returns the number of day-back navigations needed to reach the seeded weekday.
* 0 on weekdays, 1 on Saturday (→ Friday), 2 on Sunday (→ Friday).
*/
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;
}
/**
* Returns the French day name for the target seeded weekday.
*/
function seededDayName(): string {
const jsDay = new Date().getDay();
// Saturday → Friday, Sunday → Friday, else today
const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay;
return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!;
}
/**
* On weekends, navigate back to the weekday where schedule slots were seeded.
* Must be called after the schedule page has loaded.
*
* Webkit needs time after page render for Svelte 5 event delegation to hydrate.
* We retry clicking until the day title changes, with a timeout.
*/
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 });
// Retry clicking — on webkit, Svelte 5 event delegation needs time to hydrate
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 loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
test.describe('Student Schedule Consultation (Story 4.3)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create student user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
// Create teacher user
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();
// Ensure class exists
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-StudentSched-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure subject exists
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-StudentSched-Maths', 'E2ESTUMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
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-StudentSched-Français', 'E2ESTUFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Clean up schedule data for this tenant
try {
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-StudentSched-6A' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
// Clean up calendar entries to prevent holidays/vacations from blocking schedule resolution
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
// Assign student to class
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-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Create schedule slots for the class on today's weekday
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}, '09:00', '10:00', 'A101', true, NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESTUMATH' 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-StudentSched-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:15', '11:15', 'B202', true, NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESTUFRA' 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-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
);
clearCache();
});
// ======================================================================
// AC1: Day View
// ======================================================================
test.describe('AC1: Day View', () => {
test('student can navigate to schedule page', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await expect(
page.getByRole('heading', { name: /mon emploi du temps/i })
).toBeVisible({ timeout: 15000 });
});
test('day view is the default view', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await expect(
page.getByRole('heading', { name: /mon emploi du temps/i })
).toBeVisible({ timeout: 15000 });
// Day toggle should be active
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
});
test('day view shows schedule slots for today', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await expect(
page.getByRole('heading', { name: /mon emploi du temps/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 });
// Should see both slots
await expect(slots).toHaveCount(2);
// Verify slot content
await expect(page.getByText('E2E-StudentSched-Maths')).toBeVisible();
await expect(page.getByText('E2E-StudentSched-Français')).toBeVisible();
await expect(page.getByText('A101')).toBeVisible();
await expect(page.getByText('B202')).toBeVisible();
});
});
// ======================================================================
// AC2: Day Navigation
// ======================================================================
test.describe('AC2: Day Navigation', () => {
test('navigating to a day with no courses shows empty message', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await expect(
page.getByRole('heading', { name: /mon emploi du temps/i })
).toBeVisible({ timeout: 15000 });
// On weekends, the current day already has no courses — verify directly
const back = daysBackToSeededWeekday();
if (back > 0) {
await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 });
return;
}
// Wait for today's slots to fully load before navigating
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
});
// Navigate forward enough days to reach a day with no seeded slots.
// Slots are only seeded on today's weekday, so +1 day is guaranteed empty.
await page.getByLabel('Suivant').click();
// The day view should show the empty-state message
await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 });
});
test('can navigate to next day and back', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await expect(
page.getByRole('heading', { name: /mon emploi du temps/i })
).toBeVisible({ timeout: 15000 });
await navigateToSeededDay(page);
// Wait for slots to load on the seeded day
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
});
// Navigate to next day
await page.getByLabel('Suivant').click();
await page.waitForTimeout(300);
// Then navigate back
await page.getByLabel('Précédent').click();
// Slots should be visible again
const slots = page.locator('[data-testid="schedule-slot"]');
await expect(slots.first()).toBeVisible({ timeout: 20000 });
});
});
// ======================================================================
// AC3: Week View
// ======================================================================
test.describe('AC3: Week View', () => {
test('can switch to week view and see grid', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
// Wait for day view to load
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
});
// Switch to week view
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
await weekButton.click();
// Week grid should show day headers (proves week view rendered)
// Use exact match to avoid strict mode violation with mobile list labels ("Lun 2" etc.)
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Mar', { exact: true })).toBeVisible();
await expect(page.getByText('Mer', { exact: true })).toBeVisible();
await expect(page.getByText('Jeu', { exact: true })).toBeVisible();
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
// Week slots should be visible (scope to desktop grid to avoid hidden mobile slots)
const weekSlots = page.locator('.week-slot-desktop[data-testid="week-slot"]');
await expect(weekSlots.first()).toBeVisible({ timeout: 15000 });
});
test('week view shows mobile list layout on small viewport', async ({ page }) => {
// Resize to mobile
await page.setViewportSize({ width: 375, height: 667 });
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
// Wait for day view to load
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
});
// Switch to week view
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
await weekButton.click();
// Mobile list should be visible, desktop grid should be hidden
const weekList = page.locator('.week-list');
const weekGrid = page.locator('.week-grid');
await expect(weekList).toBeVisible({ timeout: 15000 });
await expect(weekGrid).not.toBeVisible();
// Should show day sections with slot count
await expect(page.getByText(/\d+ cours/).first()).toBeVisible();
// Week slots should be visible in mobile layout
const weekSlots = page.locator('[data-testid="week-slot"]');
await expect(weekSlots.first()).toBeVisible({ timeout: 15000 });
});
test('week view shows desktop grid on large viewport', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
// Wait for day view to load
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
});
// Switch to week view
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
await weekButton.click();
// Desktop grid should be visible, mobile list should be hidden
const weekList = page.locator('.week-list');
const weekGrid = page.locator('.week-grid');
await expect(weekGrid).toBeVisible({ timeout: 15000 });
await expect(weekList).not.toBeVisible();
});
test('can switch back to day view from week view', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
// Wait for day view to load
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
});
// Switch to week
await page.locator('.view-toggle button', { hasText: 'Semaine' }).click();
await expect(page.locator('.week-slot-desktop[data-testid="week-slot"]').first()).toBeVisible({
timeout: 15000
});
// Switch back to day
await page.locator('.view-toggle button', { hasText: 'Jour' }).click();
// Day slots should be visible again (proves day view rendered)
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
});
});
});
// ======================================================================
// AC4: Slot Details
// ======================================================================
test.describe('AC4: Slot Details', () => {
test('clicking a slot opens details modal', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
// Wait for slots to load
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
await expect(firstSlot).toBeVisible({ timeout: 15000 });
// Click the slot
await firstSlot.click();
// Modal should appear with course details
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Should show subject, teacher, room, time
await expect(dialog.getByText('E2E-StudentSched-Maths')).toBeVisible();
await expect(dialog.getByText('09:00 - 10:00')).toBeVisible();
await expect(dialog.getByText('A101')).toBeVisible();
});
test('details modal closes with Escape key', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
await expect(firstSlot).toBeVisible({ timeout: 15000 });
await firstSlot.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Close with Escape
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('details modal closes when clicking overlay', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
await expect(firstSlot).toBeVisible({ timeout: 15000 });
await firstSlot.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Close by clicking the overlay (outside the card)
await page.locator('.overlay').click({ position: { x: 10, y: 10 } });
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
});
// ======================================================================
// AC5: Offline Mode
// ======================================================================
test.describe('AC5: Offline Mode', () => {
test('shows offline banner when network is lost', async ({ page, context }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
// Wait for schedule to load (data is now cached by the browser)
await expect(
page.locator('[data-testid="schedule-slot"]').first()
).toBeVisible({ timeout: 15000 });
// Simulate going offline — triggers window 'offline' event
await context.setOffline(true);
// The offline banner should appear
const offlineBanner = page.locator('.offline-banner[role="status"]');
await expect(offlineBanner).toBeVisible({ timeout: 5000 });
await expect(offlineBanner.getByText('Hors ligne')).toBeVisible();
await expect(offlineBanner.getByText(/Dernière sync/)).toBeVisible();
// Restore online
await context.setOffline(false);
// Banner should disappear
await expect(offlineBanner).not.toBeVisible({ timeout: 5000 });
});
});
// ======================================================================
// Navigation link
// ======================================================================
test.describe('Navigation', () => {
test('schedule link is visible in dashboard header', async ({ page }) => {
await loginAsStudent(page);
const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' });
await expect(navLink).toBeVisible({ timeout: 10000 });
});
test('clicking schedule nav link navigates to schedule page', async ({ page }) => {
await loginAsStudent(page);
const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' });
await expect(navLink).toBeVisible({ timeout: 10000 });
await navLink.click();
await expect(page).toHaveURL(/\/dashboard\/schedule/);
await expect(
page.getByRole('heading', { name: /mon emploi du temps/i })
).toBeVisible({ timeout: 15000 });
});
});
});

View File

@@ -95,7 +95,9 @@ export default tseslint.config(
clearTimeout: 'readonly', clearTimeout: 'readonly',
DragEvent: 'readonly', DragEvent: 'readonly',
File: 'readonly', File: 'readonly',
Blob: 'readonly' Blob: 'readonly',
HTMLButtonElement: 'readonly',
MouseEvent: 'readonly'
} }
}, },
plugins: { plugins: {

View File

@@ -1,7 +1,12 @@
<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 { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte'; import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte'; import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
import { getActiveRole } from '$features/roles/roleContext.svelte';
let { let {
demoData, demoData,
@@ -14,6 +19,47 @@
hasRealData?: boolean; hasRealData?: boolean;
isMinor?: boolean; isMinor?: boolean;
} = $props(); } = $props();
let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE');
// Schedule widget state (AC1: "0 tap" — visible dès le dashboard)
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 loadTodaySchedule() {
scheduleLoading = true;
scheduleError = null;
try {
const today = formatLocalDate(new Date());
scheduleSlots = await fetchDaySchedule(today);
recordSync();
try {
const next = await fetchNextClass();
scheduleNextSlotId = next?.slotId ?? null;
} catch {
scheduleNextSlotId = null;
}
} catch (e) {
scheduleError = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
scheduleLoading = false;
}
}
if (isEleve) {
loadTodaySchedule();
}
</script> </script>
<div class="dashboard-student"> <div class="dashboard-student">
@@ -45,11 +91,18 @@
<!-- EDT Section --> <!-- EDT Section -->
<DashboardSection <DashboardSection
title="Mon emploi du temps" title="Mon emploi du temps"
subtitle={hasRealData ? "Aujourd'hui" : undefined} subtitle={isEleve ? "Aujourd'hui" : (hasRealData ? "Aujourd'hui" : undefined)}
isPlaceholder={!hasRealData} isPlaceholder={!isEleve && !hasRealData}
placeholderMessage={isMinor ? "Ton emploi du temps sera bientôt disponible" : "Votre emploi du temps sera bientôt disponible"} placeholderMessage={isMinor ? "Ton emploi du temps sera bientôt disponible" : "Votre emploi du temps sera bientôt disponible"}
> >
{#if hasRealData} {#if isEleve}
<ScheduleWidget
slots={scheduleSlots}
nextSlotId={scheduleNextSlotId}
isLoading={scheduleLoading}
error={scheduleError}
/>
{:else if hasRealData}
{#if isLoading} {#if isLoading}
<SkeletonList items={4} message="Chargement de l'emploi du temps..." /> <SkeletonList items={4} message="Chargement de l'emploi du temps..." />
{:else} {:else}

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
let {
slots = [],
date,
nextSlotId = null,
onSlotClick
}: {
slots: ScheduleSlot[];
date: string;
nextSlotId: string | null;
onSlotClick: (slot: ScheduleSlot) => void;
} = $props();
let dayLabel = $derived(
new Date(date + 'T00:00:00').toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long'
})
);
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}`;
}
let isToday = $derived(date === formatLocalDate(new Date()));
</script>
<div class="day-view">
<h2 class="day-title" class:today={isToday}>
{dayLabel}
{#if isToday}
<span class="today-badge">Aujourd'hui</span>
{/if}
</h2>
{#if slots.length === 0}
<div class="no-courses">Aucun cours ce jour</div>
{:else}
<ul class="slot-list">
{#each slots as slot (slot.slotId + slot.date)}
<li class="slot-item" class:next={slot.slotId === nextSlotId}>
<button
class="slot-button"
onclick={() => onSlotClick(slot)}
data-testid="schedule-slot"
>
<div class="slot-time">
<span class="time-start">{slot.startTime}</span>
<span class="time-separator">-</span>
<span class="time-end">{slot.endTime}</span>
</div>
<div class="slot-content">
<span class="slot-subject">{slot.subjectName}</span>
<span class="slot-meta">
{slot.teacherName}
{#if slot.room} &middot; {slot.room}{/if}
</span>
</div>
{#if slot.slotId === nextSlotId}
<span class="next-badge">Prochain</span>
{/if}
{#if slot.isModified}
<span class="modified-badge">Modifié</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.day-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.day-title {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0;
text-transform: capitalize;
}
.day-title.today {
color: #3b82f6;
}
.today-badge {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: #eff6ff;
color: #3b82f6;
border-radius: 1rem;
font-weight: 500;
margin-left: 0.5rem;
}
.no-courses {
padding: 2rem;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
.slot-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.slot-item {
border-radius: 0.75rem;
overflow: hidden;
border-left: 4px solid #e5e7eb;
transition: border-color 0.2s;
}
.slot-item.next {
border-left-color: #3b82f6;
}
.slot-button {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.75rem 1rem;
background: #f9fafb;
border: none;
cursor: pointer;
text-align: left;
position: relative;
transition: background 0.15s;
}
.slot-button:hover {
background: #f3f4f6;
}
.slot-item.next .slot-button {
background: #eff6ff;
}
.slot-time {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.5rem;
flex-shrink: 0;
}
.time-start {
font-weight: 600;
font-size: 0.875rem;
color: #1f2937;
}
.time-separator {
font-size: 0.625rem;
color: #9ca3af;
}
.time-end {
font-size: 0.75rem;
color: #6b7280;
}
.slot-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.slot-subject {
font-weight: 500;
color: #1f2937;
font-size: 0.9375rem;
}
.slot-meta {
font-size: 0.75rem;
color: #6b7280;
}
.next-badge,
.modified-badge {
position: absolute;
top: 0.375rem;
right: 0.5rem;
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 600;
text-transform: uppercase;
}
.next-badge {
background: #3b82f6;
color: white;
}
.modified-badge {
background: #f59e0b;
color: white;
top: auto;
bottom: 0.375rem;
}
</style>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
let {
slots = [],
nextSlotId = null,
isLoading = false,
error = null
}: {
slots: ScheduleSlot[];
nextSlotId: string | null;
isLoading?: boolean;
error?: string | null;
} = $props();
let offline = $state(isOffline());
let lastSync = $derived(getLastSyncDate());
$effect(() => {
function handleOnline() {
offline = false;
}
function handleOffline() {
offline = true;
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
});
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>
<div class="schedule-widget">
{#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">Chargement...</div>
{:else if error}
<div class="error">{error}</div>
{:else if slots.length === 0}
<div class="empty">Aucun cours aujourd'hui</div>
{:else}
<ul class="slot-list">
{#each slots as slot (slot.slotId + slot.date)}
<li
class="slot-item"
class:next={slot.slotId === nextSlotId}
data-testid="schedule-slot"
>
<div class="slot-time">
<span class="time-start">{slot.startTime}</span>
<span class="time-end">{slot.endTime}</span>
</div>
<div class="slot-content">
<span class="slot-subject">{slot.subjectName}</span>
<span class="slot-teacher">{slot.teacherName}</span>
</div>
{#if slot.room}
<span class="slot-room">{slot.room}</span>
{/if}
{#if slot.slotId === nextSlotId}
<span class="next-badge">Prochain</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style>
.schedule-widget {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.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,
.error,
.empty {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.error {
color: #dc2626;
}
.slot-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.slot-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
align-items: center;
border-left: 3px solid transparent;
position: relative;
}
.slot-item.next {
border-left-color: #3b82f6;
background: #eff6ff;
}
.slot-time {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3rem;
}
.time-start {
font-weight: 600;
color: #3b82f6;
font-size: 0.875rem;
}
.time-end {
font-size: 0.75rem;
color: #9ca3af;
}
.slot-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.slot-subject {
font-weight: 500;
color: #1f2937;
}
.slot-teacher {
font-size: 0.75rem;
color: #6b7280;
}
.slot-room {
font-size: 0.75rem;
color: #6b7280;
white-space: nowrap;
}
.next-badge {
position: absolute;
top: 0.25rem;
right: 0.5rem;
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: #3b82f6;
color: white;
border-radius: 0.25rem;
font-weight: 600;
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,185 @@
<script lang="ts">
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
let {
slot,
onClose
}: {
slot: ScheduleSlot;
onClose: () => void;
} = $props();
let dialogRef = $state<HTMLDivElement | null>(null);
let closeButtonRef = $state<HTMLButtonElement | null>(null);
let dayLabel = $derived(
new Date(slot.date + 'T00:00:00').toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long'
})
);
// Focus the close button on mount for accessibility
$effect(() => {
closeButtonRef?.focus();
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
// Focus trap: keep focus inside the dialog
if (event.key === 'Tab' && dialogRef) {
const focusable = dialogRef.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="overlay" onclick={handleOverlayClick} role="presentation">
<div
bind:this={dialogRef}
class="details-card"
role="dialog"
aria-modal="true"
aria-label="Détails du cours"
>
<button
bind:this={closeButtonRef}
class="close-button"
onclick={onClose}
aria-label="Fermer">&times;</button
>
<h2 class="subject-name">{slot.subjectName}</h2>
<div class="detail-grid">
<div class="detail-row">
<span class="detail-label">Horaire</span>
<span class="detail-value">{slot.startTime} - {slot.endTime}</span>
</div>
<div class="detail-row">
<span class="detail-label">Date</span>
<span class="detail-value" style="text-transform: capitalize">{dayLabel}</span>
</div>
<div class="detail-row">
<span class="detail-label">Enseignant</span>
<span class="detail-value">{slot.teacherName}</span>
</div>
{#if slot.room}
<div class="detail-row">
<span class="detail-label">Salle</span>
<span class="detail-value">{slot.room}</span>
</div>
{/if}
</div>
{#if slot.isModified}
<div class="modified-notice">
Ce cours a été modifié par rapport à l'emploi du temps habituel.
</div>
{/if}
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.details-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
max-width: 24rem;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.close-button {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.5rem;
color: #9ca3af;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.close-button:hover {
color: #374151;
}
.subject-name {
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 1.25rem 0;
padding-right: 2rem;
}
.detail-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
}
.detail-value {
font-size: 0.875rem;
color: #1f2937;
font-weight: 600;
}
.modified-notice {
margin-top: 1rem;
padding: 0.75rem;
background: #fefce8;
border: 1px solid #fcd34d;
border-radius: 0.5rem;
font-size: 0.75rem;
color: #92400e;
}
</style>

View File

@@ -0,0 +1,367 @@
<script lang="ts">
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import { fetchDaySchedule, fetchWeekSchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
import { untrack } from 'svelte';
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
import DayView from './DayView.svelte';
import WeekView from './WeekView.svelte';
import SlotDetails from './SlotDetails.svelte';
type ViewMode = 'day' | 'week';
let viewMode = $state<ViewMode>('day');
let currentDate = $state(todayStr());
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() {
isLoading = true;
error = null;
try {
if (viewMode === 'day') {
slots = await fetchDaySchedule(currentDate);
} else {
slots = await fetchWeekSchedule(currentDate);
}
recordSync();
// Load next class info
try {
const next = await fetchNextClass();
nextSlotId = next?.slotId ?? null;
} catch {
nextSlotId = null;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
isLoading = false;
}
}
// Initial load (in $effect to avoid SSR) + online/offline listener + background prefetch
$effect(() => {
untrack(() => {
loadSchedule();
// Prefetch next 30 days in background for offline support (AC5)
prefetchScheduleDays(fetchDaySchedule);
});
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 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 touchEndX = touch.clientX;
const diff = touchStartX - touchEndX;
const threshold = 50;
if (Math.abs(diff) > threshold) {
if (viewMode === 'day') {
navigateDay(diff > 0 ? 1 : -1);
} else {
navigateWeek(diff > 0 ? 1 : -1);
}
}
}
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>
<div class="student-schedule">
<!-- 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">&lsaquo;</button>
<button class="today-btn" onclick={goToToday}>Aujourd'hui</button>
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(1) : navigateWeek(1)} aria-label="Suivant">&rsaquo;</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"
>
{#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 (AC4) -->
{#if selectedSlot}
<SlotDetails slot={selectedSlot} onClose={() => (selectedSlot = null)} />
{/if}
<style>
.student-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;
}
.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>

View File

@@ -0,0 +1,319 @@
<script lang="ts">
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
let {
slots = [],
weekStart,
onSlotClick
}: {
slots: ScheduleSlot[];
weekStart: string;
onSlotClick: (slot: ScheduleSlot) => void;
} = $props();
const DAY_LABELS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'];
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}`;
}
let weekDays = $derived(
DAY_LABELS.map((label, i) => {
const d = new Date(weekStart + 'T00:00:00');
d.setDate(d.getDate() + i);
const dateStr = formatLocalDate(d);
return {
label,
date: dateStr,
dayNum: d.getDate(),
isToday: dateStr === formatLocalDate(new Date())
};
})
);
let slotsByDay = $derived(
weekDays.map((day) => ({
...day,
slots: slots
.filter((s) => s.date === day.date)
.sort((a, b) => a.startTime.localeCompare(b.startTime))
}))
);
</script>
<div class="week-view">
<!-- Mobile: liste verticale par jour -->
<div class="week-list">
{#each slotsByDay as day (day.date)}
<div class="day-section" class:today={day.isToday}>
<div class="day-header-mobile" class:today={day.isToday}>
<span class="day-label">{day.label} {day.dayNum}</span>
<span class="day-count">{day.slots.length} cours</span>
</div>
{#if day.slots.length === 0}
<div class="empty-day">Aucun cours</div>
{:else}
<div class="day-slots-mobile">
{#each day.slots as slot (slot.slotId + slot.date)}
<button
class="week-slot-mobile"
class:modified={slot.isModified}
onclick={() => onSlotClick(slot)}
data-testid="week-slot"
>
<span class="slot-time-mobile">{slot.startTime} - {slot.endTime}</span>
<span class="slot-subject-mobile">{slot.subjectName}</span>
{#if slot.room}
<span class="slot-room-mobile">{slot.room}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Desktop: grille 5 colonnes -->
<div class="week-grid">
{#each slotsByDay as day (day.date)}
<div class="day-column">
<div class="day-header-desktop" class:today={day.isToday}>
<span class="day-label">{day.label}</span>
<span class="day-num">{day.dayNum}</span>
</div>
<div class="day-slots">
{#if day.slots.length === 0}
<div class="empty-day-desktop">-</div>
{:else}
{#each day.slots as slot (slot.slotId + slot.date)}
<button
class="week-slot-desktop"
class:modified={slot.isModified}
onclick={() => onSlotClick(slot)}
data-testid="week-slot"
>
<span class="week-slot-time">{slot.startTime}</span>
<span class="week-slot-subject">{slot.subjectName}</span>
{#if slot.room}
<span class="week-slot-room">{slot.room}</span>
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/each}
</div>
</div>
<style>
/* ── Layout ── */
.week-view {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Mobile first: list visible, grid hidden */
.week-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.week-grid {
display: none;
}
/* ── Mobile: day sections ── */
.day-section {
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid #e5e7eb;
}
.day-section.today {
border-color: #3b82f6;
}
.day-header-mobile {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 0.75rem;
background: #f3f4f6;
font-size: 0.875rem;
font-weight: 600;
}
.day-header-mobile.today {
background: #3b82f6;
color: white;
}
.day-count {
font-size: 0.75rem;
font-weight: 400;
opacity: 0.7;
}
.empty-day {
text-align: center;
color: #9ca3af;
padding: 0.75rem;
font-size: 0.8125rem;
}
.day-slots-mobile {
display: flex;
flex-direction: column;
}
.week-slot-mobile {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: white;
border: none;
border-top: 1px solid #f3f4f6;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
width: 100%;
transition: background 0.15s;
}
.week-slot-mobile:hover {
background: #f9fafb;
}
.week-slot-mobile.modified {
background: #fefce8;
}
.slot-time-mobile {
font-weight: 600;
color: #3b82f6;
white-space: nowrap;
min-width: 6.5rem;
font-size: 0.8125rem;
}
.slot-subject-mobile {
font-weight: 500;
color: #1f2937;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-room-mobile {
color: #6b7280;
font-size: 0.75rem;
white-space: nowrap;
}
/* ── Desktop: 5-column grid ── */
@media (min-width: 768px) {
.week-list {
display: none;
}
.week-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
.day-column {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.day-header-desktop {
text-align: center;
padding: 0.5rem;
border-radius: 0.5rem;
background: #f3f4f6;
}
.day-header-desktop.today {
background: #3b82f6;
color: white;
}
.day-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.day-num {
display: block;
font-size: 1.125rem;
font-weight: 700;
}
.day-slots {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.empty-day-desktop {
text-align: center;
color: #d1d5db;
padding: 1rem;
font-size: 0.875rem;
}
.week-slot-desktop {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.375rem;
cursor: pointer;
text-align: left;
font-size: 0.75rem;
transition: background 0.15s;
}
.week-slot-desktop:hover {
background: #dbeafe;
}
.week-slot-desktop.modified {
border-color: #fcd34d;
background: #fefce8;
}
.week-slot-time {
font-weight: 600;
color: #3b82f6;
}
.week-slot-subject {
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.week-slot-room {
color: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,62 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface ScheduleSlot {
slotId: string;
date: string;
dayOfWeek: number;
startTime: string;
endTime: string;
subjectId: string;
subjectName: string;
teacherId: string;
teacherName: string;
room: string | null;
isModified: boolean;
exceptionId: string | null;
}
/**
* Récupère l'EDT du jour pour l'élève connecté.
*/
export async function fetchDaySchedule(date: string): Promise<ScheduleSlot[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/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 pour l'élève connecté.
*/
export async function fetchWeekSchedule(date: string): Promise<ScheduleSlot[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/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 prochain cours pour l'élève connecté.
*/
export async function fetchNextClass(): Promise<ScheduleSlot | null> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/schedule/next-class`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du prochain cours (${response.status})`);
}
const json = await response.json();
return json.data ?? null;
}

View File

@@ -0,0 +1,58 @@
import { browser } from '$app/environment';
const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
/**
* Vérifie si le navigateur est actuellement hors ligne.
*/
export function isOffline(): boolean {
if (!browser) return false;
return !navigator.onLine;
}
/**
* Enregistre la date de dernière synchronisation de l'EDT.
*/
export function recordSync(): void {
if (!browser) return;
localStorage.setItem(LAST_SYNC_KEY, new Date().toISOString());
}
/**
* Récupère la date de dernière synchronisation de l'EDT.
*/
export function getLastSyncDate(): string | null {
if (!browser) return null;
return localStorage.getItem(LAST_SYNC_KEY);
}
/**
* Pré-charge 30 jours d'EDT en cache Service Worker (7 passés + 23 futurs).
*
* Appelé en arrière-plan pour alimenter le cache offline (AC5).
* Les requêtes sont interceptées par le SW (NetworkFirst)
* et les réponses sont automatiquement mises en cache.
*/
export async function prefetchScheduleDays(
fetchFn: (date: string) => Promise<unknown>,
today: Date = new Date()
): Promise<void> {
const CONCURRENCY = 5;
const PAST_DAYS = 7;
const FUTURE_DAYS = 23;
const dates: string[] = [];
for (let i = -PAST_DAYS; i <= FUTURE_DAYS; i++) {
const date = new Date(today);
date.setDate(date.getDate() + i);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
dates.push(`${y}-${m}-${d}`);
}
for (let i = 0; i < dates.length; i += CONCURRENCY) {
const batch = dates.slice(i, i + CONCURRENCY);
await Promise.allSettled(batch.map((dateStr) => fetchFn(dateStr)));
}
}

View File

@@ -40,6 +40,7 @@ export interface CreateStudentData {
email?: string | undefined; email?: string | undefined;
dateNaissance?: string | undefined; dateNaissance?: string | undefined;
studentNumber?: string | undefined; studentNumber?: string | undefined;
parentalConsent?: boolean | undefined;
} }
/** /**
@@ -93,7 +94,7 @@ export async function fetchClasses(): Promise<SchoolClass[]> {
*/ */
export async function createStudent(studentData: CreateStudentData): Promise<Student> { export async function createStudent(studentData: CreateStudentData): Promise<Student> {
const apiUrl = getApiBaseUrl(); const apiUrl = getApiBaseUrl();
const body: Record<string, string> = { const body: Record<string, string | boolean> = {
firstName: studentData.firstName, firstName: studentData.firstName,
lastName: studentData.lastName, lastName: studentData.lastName,
classId: studentData.classId classId: studentData.classId
@@ -101,6 +102,7 @@ export async function createStudent(studentData: CreateStudentData): Promise<Stu
if (studentData.email) body['email'] = studentData.email; if (studentData.email) body['email'] = studentData.email;
if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance; if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance;
if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber; if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber;
if (studentData.parentalConsent) body['parentalConsent'] = true;
const response = await authenticatedFetch(`${apiUrl}/students`, { const response = await authenticatedFetch(`${apiUrl}/students`, {
method: 'POST', method: 'POST',

View File

@@ -142,7 +142,6 @@
if (!classId) return subjects; if (!classId) return subjects;
const filtered = assignments.filter((a) => a.classId === classId); const filtered = assignments.filter((a) => a.classId === classId);
const subjectIds = new Set(filtered.map((a) => a.subjectId)); const subjectIds = new Set(filtered.map((a) => a.subjectId));
if (subjectIds.size === 0) return subjects;
return subjects.filter((s) => subjectIds.has(s.id)); return subjects.filter((s) => subjectIds.has(s.id));
}); });
@@ -154,7 +153,6 @@
? assignments.filter((a) => a.classId === classId && a.subjectId === formSubjectId) ? assignments.filter((a) => a.classId === classId && a.subjectId === formSubjectId)
: assignments.filter((a) => a.classId === classId); : assignments.filter((a) => a.classId === classId);
const teacherIds = new Set(filtered.map((a) => a.teacherId)); const teacherIds = new Set(filtered.map((a) => a.teacherId));
if (teacherIds.size === 0) return teachers;
return teachers.filter((t) => teacherIds.has(t.id)); return teachers.filter((t) => teacherIds.has(t.id));
}); });
@@ -915,16 +913,6 @@
</select> </select>
</div> </div>
<div class="form-group">
<label for="slot-teacher">Enseignant *</label>
<select id="slot-teacher" bind:value={formTeacherId} required>
<option value="">-- Sélectionner --</option>
{#each availableTeachers as teacher (teacher.id)}
<option value={teacher.id}>{teacher.firstName} {teacher.lastName}</option>
{/each}
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="slot-subject">Matière *</label> <label for="slot-subject">Matière *</label>
<select id="slot-subject" bind:value={formSubjectId} required> <select id="slot-subject" bind:value={formSubjectId} required>
@@ -935,6 +923,16 @@
</select> </select>
</div> </div>
<div class="form-group">
<label for="slot-teacher">Enseignant *</label>
<select id="slot-teacher" bind:value={formTeacherId} required>
<option value="">-- Sélectionner --</option>
{#each availableTeachers as teacher (teacher.id)}
<option value={teacher.id}>{teacher.firstName} {teacher.lastName}</option>
{/each}
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="slot-day">Jour *</label> <label for="slot-day">Jour *</label>
<select id="slot-day" bind:value={formDayOfWeek} required> <select id="slot-day" bind:value={formDayOfWeek} required>

View File

@@ -39,11 +39,24 @@
let newClassId = $state(''); let newClassId = $state('');
let newDateNaissance = $state(''); let newDateNaissance = $state('');
let newStudentNumber = $state(''); let newStudentNumber = $state('');
let newParentalConsent = $state(false);
let isSubmitting = $state(false); let isSubmitting = $state(false);
let duplicateWarning = $state<string | null>(null); let duplicateWarning = $state<string | null>(null);
let duplicateConfirmed = $state(false); let duplicateConfirmed = $state(false);
let createAnother = $state(false); let createAnother = $state(false);
let needsParentalConsent = $derived(() => {
if (!newDateNaissance) return false;
const birth = new Date(newDateNaissance + 'T00:00:00');
const now = new Date();
let age = now.getFullYear() - birth.getFullYear();
const monthDiff = now.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
age--;
}
return age < 15;
});
// Change class modal state // Change class modal state
let showChangeClassModal = $state(false); let showChangeClassModal = $state(false);
let changeClassTarget = $state<Student | null>(null); let changeClassTarget = $state<Student | null>(null);
@@ -176,6 +189,7 @@
newClassId = ''; newClassId = '';
newDateNaissance = ''; newDateNaissance = '';
newStudentNumber = ''; newStudentNumber = '';
newParentalConsent = false;
duplicateWarning = null; duplicateWarning = null;
duplicateConfirmed = false; duplicateConfirmed = false;
createAnother = false; createAnother = false;
@@ -224,6 +238,7 @@
async function handleCreateStudent() { async function handleCreateStudent() {
if (!newFirstName.trim() || !newLastName.trim() || !newClassId) return; if (!newFirstName.trim() || !newLastName.trim() || !newClassId) return;
if (needsParentalConsent() && !newParentalConsent) return;
if (await checkDuplicate()) return; if (await checkDuplicate()) return;
@@ -236,7 +251,8 @@
classId: newClassId, classId: newClassId,
email: newEmail.trim() ? newEmail.trim().toLowerCase() : undefined, email: newEmail.trim() ? newEmail.trim().toLowerCase() : undefined,
dateNaissance: newDateNaissance || undefined, dateNaissance: newDateNaissance || undefined,
studentNumber: newStudentNumber.trim() || undefined studentNumber: newStudentNumber.trim() || undefined,
parentalConsent: newParentalConsent || undefined
}); });
// Optimistic update: only add to list if matching current filters // Optimistic update: only add to list if matching current filters
@@ -375,7 +391,8 @@
newLastName.trim() !== '' && newLastName.trim() !== '' &&
newClassId !== '' && newClassId !== '' &&
!isSubmitting && !isSubmitting &&
(!newStudentNumber.trim() || isValidINE(newStudentNumber.trim())) (!newStudentNumber.trim() || isValidINE(newStudentNumber.trim())) &&
(!needsParentalConsent() || newParentalConsent)
); );
</script> </script>
@@ -609,6 +626,19 @@
</div> </div>
</div> </div>
{#if needsParentalConsent()}
<div class="consent-notice">
<label class="consent-label">
<input type="checkbox" bind:checked={newParentalConsent} />
<span>Consentement parental obtenu</span>
</label>
<span class="field-hint">
Obligatoire pour les élèves de moins de 15 ans (RGPD).
Confirmez que l'établissement a recueilli le consentement parental.
</span>
</div>
{/if}
{#if duplicateWarning} {#if duplicateWarning}
<div class="duplicate-warning"> <div class="duplicate-warning">
<p>{duplicateWarning}</p> <p>{duplicateWarning}</p>
@@ -1160,6 +1190,33 @@
color: #9ca3af; color: #9ca3af;
} }
.consent-notice {
padding: 0.75rem;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 0.5rem;
}
.consent-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
color: #92400e;
cursor: pointer;
}
.consent-label input[type='checkbox'] {
width: 1rem;
height: 1rem;
accent-color: #f59e0b;
}
.consent-notice .field-hint {
margin-left: 1.5rem;
color: #92400e;
}
.field-error { .field-error {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;

View File

@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte'; import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte'; import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte'; import { fetchRoles, resetRoleContext, getActiveRole } from '$features/roles/roleContext.svelte';
import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte'; import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte';
let { children } = $props(); let { children } = $props();
let isLoggingOut = $state(false); let isLoggingOut = $state(false);
let mobileMenuOpen = $state(false);
let logoUrl = $derived(getLogoUrl()); let logoUrl = $derived(getLogoUrl());
let pathname = $derived(page.url.pathname);
let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE');
// 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
@@ -23,6 +27,20 @@
}); });
}); });
// Close menu on route change
$effect(() => {
void page.url.pathname;
mobileMenuOpen = false;
});
// Lock body scroll when mobile menu is open
$effect(() => {
document.body.style.overflow = mobileMenuOpen ? 'hidden' : '';
return () => {
document.body.style.overflow = '';
};
});
async function handleLogout() { async function handleLogout() {
isLoggingOut = true; isLoggingOut = true;
try { try {
@@ -41,8 +59,24 @@
function goSettings() { function goSettings() {
goto('/settings'); goto('/settings');
} }
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
function closeMobileMenu() {
mobileMenuOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && mobileMenuOpen) {
closeMobileMenu();
}
}
</script> </script>
<svelte:window onkeydown={handleKeydown} />
<div class="dashboard-layout"> <div class="dashboard-layout">
<header class="dashboard-header"> <header class="dashboard-header">
<div class="header-content"> <div class="header-content">
@@ -52,22 +86,84 @@
{/if} {/if}
<span class="logo-text">Classeo</span> <span class="logo-text">Classeo</span>
</button> </button>
<nav class="header-nav">
<button
class="hamburger-button"
onclick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-label="Ouvrir le menu de navigation"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<nav class="desktop-nav">
<RoleSwitcher /> <RoleSwitcher />
<a href="/dashboard" class="nav-link active">Tableau de bord</a> <a href="/dashboard" class="nav-link" class:active={pathname === '/dashboard'}>Tableau de bord</a>
<button class="nav-button" onclick={goSettings}>Parametres</button> {#if isEleve}
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
{/if}
<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}
<span class="spinner"></span> <span class="spinner"></span>
Deconnexion... Déconnexion...
{:else} {:else}
Deconnexion Déconnexion
{/if} {/if}
</button> </button>
</nav> </nav>
</div> </div>
</header> </header>
{#if mobileMenuOpen}
<div
class="mobile-overlay"
onclick={closeMobileMenu}
onkeydown={(e) => e.key === 'Enter' && closeMobileMenu()}
role="presentation"
></div>
<div class="mobile-drawer" role="dialog" aria-modal="true" aria-label="Menu de navigation">
<div class="mobile-drawer-header">
{#if logoUrl}
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
{/if}
<span class="logo-text">Classeo</span>
<button class="mobile-close" onclick={closeMobileMenu} aria-label="Fermer le menu">
&times;
</button>
</div>
<div class="mobile-drawer-body">
<div class="mobile-role-switcher">
<RoleSwitcher />
</div>
<a href="/dashboard" class="mobile-nav-link" class:active={pathname === '/dashboard'}>
Tableau de bord
</a>
{#if isEleve}
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
Mon emploi du temps
</a>
{/if}
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
</div>
<div class="mobile-drawer-footer">
<button
class="mobile-nav-link mobile-logout"
onclick={handleLogout}
disabled={isLoggingOut}
>
{#if isLoggingOut}
Déconnexion...
{:else}
Déconnexion
{/if}
</button>
</div>
</div>
{/if}
<main class="dashboard-main"> <main class="dashboard-main">
<div class="main-content"> <div class="main-content">
{@render children()} {@render children()}
@@ -86,7 +182,7 @@
.dashboard-header { .dashboard-header {
background: var(--surface-elevated, #fff); background: var(--surface-elevated, #fff);
border-bottom: 1px solid var(--border-subtle, #e2e8f0); border-bottom: 1px solid var(--border-subtle, #e2e8f0);
padding: 0 1.5rem; padding: 0 1rem;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
@@ -98,10 +194,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; height: 56px;
height: auto;
padding: 0.75rem 0;
gap: 0.75rem;
} }
.logo-button { .logo-button {
@@ -127,12 +220,38 @@
color: var(--accent-primary, #0ea5e9); color: var(--accent-primary, #0ea5e9);
} }
.header-nav { /* Hamburger — visible on mobile */
.hamburger-button {
display: flex; display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 40px;
height: 40px;
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 0.5rem;
}
.hamburger-button:hover {
background: var(--surface-primary, #f8fafc);
}
.hamburger-line {
display: block;
width: 20px;
height: 2px;
background: var(--text-secondary, #64748b);
border-radius: 1px;
}
/* Desktop nav — hidden on mobile */
.desktop-nav {
display: none;
align-items: center; align-items: center;
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -144,6 +263,7 @@
text-decoration: none; text-decoration: none;
border-radius: 0.5rem; border-radius: 0.5rem;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
} }
.nav-link:hover { .nav-link:hover {
@@ -166,6 +286,7 @@
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
} }
.nav-button:hover { .nav-button:hover {
@@ -186,6 +307,7 @@
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
} }
.logout-button:hover:not(:disabled) { .logout-button:hover:not(:disabled) {
@@ -198,6 +320,108 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Mobile overlay */
.mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
animation: fadeIn 0.2s ease-out;
}
/* Mobile drawer */
.mobile-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(300px, 85vw);
background: var(--surface-elevated, #fff);
z-index: 201;
display: flex;
flex-direction: column;
animation: slideInLeft 0.25s ease-out;
}
.mobile-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.mobile-close {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: var(--text-secondary, #64748b);
border-radius: 0.5rem;
}
.mobile-close:hover {
background: var(--surface-primary, #f8fafc);
color: var(--text-primary, #1f2937);
}
.mobile-drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem 0;
}
.mobile-role-switcher {
padding: 0.5rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
margin-bottom: 0.5rem;
}
.mobile-nav-link {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
text-decoration: none;
border: none;
background: none;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.mobile-nav-link:hover {
background: var(--surface-primary, #f8fafc);
color: var(--text-primary, #1f2937);
}
.mobile-nav-link.active {
color: var(--accent-primary, #0ea5e9);
border-left-color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
}
.mobile-logout {
color: var(--color-alert, #ef4444);
}
.mobile-logout:hover {
background: #fef2f2;
}
.mobile-drawer-footer {
border-top: 1px solid var(--border-subtle, #e2e8f0);
padding: 0.5rem 0;
}
.spinner { .spinner {
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -223,19 +447,44 @@
} }
} }
@media (min-width: 768px) { @keyframes fadeIn {
.header-content { from {
flex-wrap: nowrap; opacity: 0;
height: 64px; }
padding: 0; to {
gap: 0; opacity: 1;
}
} }
.header-nav { @keyframes slideInLeft {
width: auto; from {
flex-wrap: nowrap; transform: translateX(-100%);
gap: 1rem; }
justify-content: flex-start; to {
transform: translateX(0);
}
}
@media (min-width: 768px) {
.header-content {
height: 64px;
}
.hamburger-button {
display: none;
}
.desktop-nav {
display: flex;
}
.mobile-overlay,
.mobile-drawer {
display: none;
}
.dashboard-header {
padding: 0 1.5rem;
} }
.dashboard-main { .dashboard-main {

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import StudentSchedule from '$lib/components/organisms/StudentSchedule/StudentSchedule.svelte';
</script>
<svelte:head>
<title>Mon emploi du temps - Classeo</title>
</svelte:head>
<div class="schedule-page">
<header class="page-header">
<h1>Mon emploi du temps</h1>
</header>
<StudentSchedule />
</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>

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true
}));
import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
describe('scheduleCache', () => {
beforeEach(() => {
localStorage.clear();
});
describe('isOffline', () => {
it('returns false when navigator is online', () => {
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
expect(isOffline()).toBe(false);
});
it('returns true when navigator is offline', () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
expect(isOffline()).toBe(true);
});
});
describe('sync tracking', () => {
it('returns null when no sync has been recorded', () => {
expect(getLastSyncDate()).toBeNull();
});
it('records and retrieves the last sync date', () => {
recordSync();
const date = getLastSyncDate();
expect(date).not.toBeNull();
expect(new Date(date!).getTime()).toBeGreaterThan(0);
});
});
describe('prefetchScheduleDays', () => {
it('prefetches 7 past days + today + 23 future days (31 total)', async () => {
const fetchFn = vi.fn().mockResolvedValue({});
const today = new Date('2026-03-10');
await prefetchScheduleDays(fetchFn, today);
// 7 past + 1 today + 23 future = 31 calls
expect(fetchFn).toHaveBeenCalledTimes(31);
// First call: 7 days ago
expect(fetchFn).toHaveBeenCalledWith('2026-03-03');
// Today
expect(fetchFn).toHaveBeenCalledWith('2026-03-10');
// Last call: 23 days ahead
expect(fetchFn).toHaveBeenCalledWith('2026-04-02');
});
it('silently handles fetch failures', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValue({});
await expect(
prefetchScheduleDays(fetchFn, new Date('2026-03-02'))
).resolves.toBeUndefined();
expect(fetchFn).toHaveBeenCalledTimes(31);
});
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
vi.mock('$lib/features/schedule/stores/scheduleCache', () => ({
isOffline: vi.fn(() => false),
getLastSyncDate: vi.fn(() => null)
}));
function makeSlot(overrides: Partial<ScheduleSlot> = {}): ScheduleSlot {
return {
slotId: 'slot-1',
date: '2026-03-05',
dayOfWeek: 4,
startTime: '08:00',
endTime: '09:00',
subjectId: 'sub-1',
subjectName: 'Mathématiques',
teacherId: 'teacher-1',
teacherName: 'M. Dupont',
room: 'Salle 101',
isModified: false,
exceptionId: null,
...overrides
};
}
describe('ScheduleWidget', () => {
it('renders slots with subject, teacher, time and room', () => {
const slot = makeSlot();
render(ScheduleWidget, {
props: { slots: [slot], nextSlotId: null }
});
expect(screen.getByText('Mathématiques')).toBeTruthy();
expect(screen.getByText('M. Dupont')).toBeTruthy();
expect(screen.getByText('08:00')).toBeTruthy();
expect(screen.getByText('09:00')).toBeTruthy();
expect(screen.getByText('Salle 101')).toBeTruthy();
});
it('shows empty message when no slots', () => {
render(ScheduleWidget, {
props: { slots: [], nextSlotId: null }
});
expect(screen.getByText("Aucun cours aujourd'hui")).toBeTruthy();
});
it('shows loading state', () => {
render(ScheduleWidget, {
props: { slots: [], nextSlotId: null, isLoading: true }
});
expect(screen.getByText('Chargement...')).toBeTruthy();
});
it('shows error message', () => {
render(ScheduleWidget, {
props: { slots: [], nextSlotId: null, error: 'Erreur réseau' }
});
expect(screen.getByText('Erreur réseau')).toBeTruthy();
});
it('highlights next slot with "Prochain" badge', () => {
const slots = [
makeSlot({ slotId: 'slot-1', startTime: '08:00', endTime: '09:00' }),
makeSlot({
slotId: 'slot-2',
startTime: '10:00',
endTime: '11:00',
subjectName: 'Français',
teacherName: 'Mme Martin'
})
];
render(ScheduleWidget, {
props: { slots, nextSlotId: 'slot-2' }
});
expect(screen.getByText('Prochain')).toBeTruthy();
});
it('does not show "Prochain" badge when nextSlotId is null', () => {
render(ScheduleWidget, {
props: { slots: [makeSlot()], nextSlotId: null }
});
expect(screen.queryByText('Prochain')).toBeNull();
});
it('does not render room when room is null', () => {
const slot = makeSlot({ room: null });
const { container } = render(ScheduleWidget, {
props: { slots: [slot], nextSlotId: null }
});
expect(container.querySelector('.slot-room')).toBeNull();
});
it('renders multiple slots with data-testid', () => {
const slots = [
makeSlot({ slotId: 'slot-1' }),
makeSlot({ slotId: 'slot-2', subjectName: 'Français' }),
makeSlot({ slotId: 'slot-3', subjectName: 'Histoire' })
];
const { container } = render(ScheduleWidget, {
props: { slots, nextSlotId: null }
});
const items = container.querySelectorAll('[data-testid="schedule-slot"]');
expect(items.length).toBe(3);
});
});

View File

@@ -19,6 +19,7 @@ export default defineConfig({
background_color: '#ffffff', background_color: '#ffffff',
display: 'standalone', display: 'standalone',
start_url: '/', start_url: '/',
categories: ['education'],
icons: [ icons: [
{ {
src: 'pwa-192x192.png', src: 'pwa-192x192.png',
@@ -36,10 +37,35 @@ export default defineConfig({
type: 'image/png', type: 'image/png',
purpose: 'any maskable' purpose: 'any maskable'
} }
],
shortcuts: [
{
name: 'Mon emploi du temps',
short_name: 'EDT',
url: '/dashboard/schedule',
description: 'Consulter mon emploi du temps'
}
] ]
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'] globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'],
runtimeCaching: [
{
urlPattern: /\/api\/me\/schedule\//,
handler: 'NetworkFirst',
options: {
cacheName: 'schedule-v1',
expiration: {
maxEntries: 90,
maxAgeSeconds: 30 * 24 * 60 * 60
},
networkTimeoutSeconds: 5,
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}, },
devOptions: { devOptions: {
enabled: false, enabled: false,

View File

@@ -0,0 +1 @@
{"version":"4.0.18","results":[[":frontend/tests/unit/lib/components/molecules/SearchInput/SearchInput.test.ts",{"duration":0,"failed":true}]]}