Compare commits
4 Commits
ae640e91ac
...
125d9d8806
| Author | SHA1 | Date | |
|---|---|---|---|
| 125d9d8806 | |||
| 39f8650b92 | |||
| ba80e8cb57 | |||
| 36ceefb625 |
@@ -16,6 +16,8 @@ final readonly class CreateStudentCommand
|
||||
public ?string $email = null,
|
||||
public ?string $dateNaissance = null,
|
||||
public ?string $studentNumber = null,
|
||||
public bool $parentalConsent = false,
|
||||
public ?string $consentRecordedBy = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ namespace App\Administration\Application\Command\CreateStudent;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
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\ClassId;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
@@ -31,6 +33,7 @@ final readonly class CreateStudentHandler
|
||||
private ClassRepository $classRepository,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
private ConsentementParentalPolicy $consentementPolicy,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -94,6 +97,24 @@ final readonly class CreateStudentHandler
|
||||
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);
|
||||
|
||||
// Affecter à la classe
|
||||
|
||||
@@ -13,10 +13,12 @@ use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Security\StudentVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
@@ -37,6 +39,7 @@ final readonly class CreateStudentProcessor implements ProcessorInterface
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
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.');
|
||||
|
||||
try {
|
||||
$adminUserId = null;
|
||||
$securityUser = $this->security->getUser();
|
||||
|
||||
if ($securityUser instanceof SecurityUser) {
|
||||
$adminUserId = $securityUser->userId();
|
||||
}
|
||||
|
||||
$command = new CreateStudentCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolName: $tenantConfig->subdomain,
|
||||
@@ -70,6 +80,8 @@ final readonly class CreateStudentProcessor implements ProcessorInterface
|
||||
email: $data->email,
|
||||
dateNaissance: $data->dateNaissance,
|
||||
studentNumber: $data->studentNumber,
|
||||
parentalConsent: $data->parentalConsent,
|
||||
consentRecordedBy: $adminUserId,
|
||||
);
|
||||
|
||||
$user = ($this->handler)($command);
|
||||
|
||||
@@ -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.')]
|
||||
public ?string $studentNumber = null;
|
||||
|
||||
public bool $parentalConsent = false;
|
||||
|
||||
public static function fromDto(StudentWithClassDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
|
||||
@@ -117,7 +117,12 @@ final readonly class UpdateRecurringSlotHandler
|
||||
$slot->terminerRecurrenceLe($dayBefore, $now);
|
||||
$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(
|
||||
tenantId: $tenantId,
|
||||
classId: ClassId::fromString($command->classId),
|
||||
@@ -128,7 +133,7 @@ final readonly class UpdateRecurringSlotHandler
|
||||
room: $command->room,
|
||||
isRecurring: true,
|
||||
now: $now,
|
||||
recurrenceStart: $occurrenceDate,
|
||||
recurrenceStart: $weekMonday,
|
||||
recurrenceEnd: $originalRecurrenceEnd,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -96,8 +96,11 @@ final readonly class CreateScheduleSlotProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (EnseignantNonAffecteException $e) {
|
||||
throw new UnprocessableEntityHttpException($e->getMessage());
|
||||
} catch (EnseignantNonAffecteException) {
|
||||
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) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
@@ -105,8 +105,11 @@ final readonly class UpdateScheduleSlotProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (EnseignantNonAffecteException $e) {
|
||||
throw new UnprocessableEntityHttpException($e->getMessage());
|
||||
} catch (EnseignantNonAffecteException) {
|
||||
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) {
|
||||
throw new NotFoundHttpException('Créneau non trouvé.');
|
||||
} catch (CreneauHoraireInvalideException|ValueError $e) {
|
||||
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
* Voter pour les autorisations sur l'emploi du temps.
|
||||
*
|
||||
* 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>
|
||||
*/
|
||||
@@ -68,6 +68,7 @@ final class ScheduleSlotVoter extends Voter
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
Role::ELEVE->value,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,7 @@ final class CreateStudentHandlerTest extends TestCase
|
||||
$this->classRepository,
|
||||
$connection,
|
||||
$this->clock,
|
||||
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
|
||||
);
|
||||
|
||||
$command = $this->createCommand();
|
||||
@@ -278,6 +279,7 @@ final class CreateStudentHandlerTest extends TestCase
|
||||
$this->classRepository,
|
||||
$this->connection,
|
||||
$this->clock,
|
||||
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,41 @@ final class UpdateRecurringSlotHandlerTest extends TestCase
|
||||
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]
|
||||
public function allFutureWithNoOriginalEndUsesNullForNewSlotEnd(): void
|
||||
{
|
||||
@@ -149,6 +184,7 @@ final class UpdateRecurringSlotHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
private function createAndSaveSlot(
|
||||
DayOfWeek $dayOfWeek = DayOfWeek::MONDAY,
|
||||
?DateTimeImmutable $recurrenceStart = null,
|
||||
?DateTimeImmutable $recurrenceEnd = null,
|
||||
): ScheduleSlot {
|
||||
@@ -157,7 +193,7 @@ final class UpdateRecurringSlotHandlerTest extends TestCase
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
dayOfWeek: DayOfWeek::MONDAY,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: new TimeSlot('08:00', '09:00'),
|
||||
room: null,
|
||||
isRecurring: true,
|
||||
|
||||
@@ -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
56
docs/stories/story-4.6.md
Normal 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
|
||||
141
frontend/e2e/dashboard-responsive-nav.spec.ts
Normal file
141
frontend/e2e/dashboard-responsive-nav.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
|
||||
// Teacher should not see admin-specific navigation in the dashboard layout
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,10 +42,12 @@ 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"; ` +
|
||||
`$t="${TENANT_ID}"; ` +
|
||||
`$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; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ay,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
@@ -147,11 +149,13 @@ async function fillSlotForm(
|
||||
if (className) {
|
||||
await dialog.locator('#slot-class').selectOption({ label: className });
|
||||
}
|
||||
// Wait for assignments to load — only the test teacher is assigned,
|
||||
// so the teacher dropdown filters down to 1 option
|
||||
// Wait for assignments to load, then select subject first (filters teachers)
|
||||
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=""])');
|
||||
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-day').selectOption(dayValue);
|
||||
await dialog.locator('#slot-start').fill(startTime);
|
||||
@@ -180,39 +184,31 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
|
||||
// Ensure test classes exist
|
||||
// Clean up stale test data (e.g. from previous runs with wrong school_id)
|
||||
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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
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}')`);
|
||||
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 {
|
||||
// May already exist
|
||||
// Tables may not exist
|
||||
}
|
||||
|
||||
try {
|
||||
// Create test classes
|
||||
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
|
||||
try {
|
||||
// Create test subjects
|
||||
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(
|
||||
`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();
|
||||
cleanupCalendarEntries();
|
||||
|
||||
@@ -42,10 +42,12 @@ 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"; ` +
|
||||
`$t="${TENANT_ID}"; ` +
|
||||
`$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; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ay,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
@@ -114,11 +116,13 @@ async function fillSlotForm(
|
||||
if (className) {
|
||||
await dialog.locator('#slot-class').selectOption({ label: className });
|
||||
}
|
||||
// Wait for assignments to load — only the test teacher is assigned,
|
||||
// so the teacher dropdown filters down to 1 option
|
||||
// Wait for assignments to load, then select subject first (filters teachers)
|
||||
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=""])');
|
||||
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-day').selectOption(dayValue);
|
||||
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();
|
||||
|
||||
// Ensure test class exists
|
||||
// Clean up stale test data (e.g. from previous runs with wrong school_id)
|
||||
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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
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}')`);
|
||||
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 {
|
||||
// May already exist
|
||||
// Tables may not exist
|
||||
}
|
||||
|
||||
// Ensure second test class exists (for conflict tests across classes)
|
||||
try {
|
||||
// Create test classes
|
||||
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
|
||||
try {
|
||||
// Create test subjects
|
||||
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(
|
||||
`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();
|
||||
clearCache();
|
||||
@@ -375,6 +370,78 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)',
|
||||
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 }) => {
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
|
||||
@@ -434,9 +501,10 @@ test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () =>
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
|
||||
// Only insert if not already created by first describe block
|
||||
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-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 {
|
||||
// May already exist
|
||||
@@ -444,7 +512,7 @@ test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () =>
|
||||
|
||||
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-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 {
|
||||
// May already exist
|
||||
|
||||
556
frontend/e2e/student-schedule.spec.ts
Normal file
556
frontend/e2e/student-schedule.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,9 @@ export default tseslint.config(
|
||||
clearTimeout: 'readonly',
|
||||
DragEvent: 'readonly',
|
||||
File: 'readonly',
|
||||
Blob: 'readonly'
|
||||
Blob: 'readonly',
|
||||
HTMLButtonElement: 'readonly',
|
||||
MouseEvent: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
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 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 {
|
||||
demoData,
|
||||
@@ -14,6 +19,47 @@
|
||||
hasRealData?: boolean;
|
||||
isMinor?: boolean;
|
||||
} = $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>
|
||||
|
||||
<div class="dashboard-student">
|
||||
@@ -45,11 +91,18 @@
|
||||
<!-- EDT Section -->
|
||||
<DashboardSection
|
||||
title="Mon emploi du temps"
|
||||
subtitle={hasRealData ? "Aujourd'hui" : undefined}
|
||||
isPlaceholder={!hasRealData}
|
||||
subtitle={isEleve ? "Aujourd'hui" : (hasRealData ? "Aujourd'hui" : undefined)}
|
||||
isPlaceholder={!isEleve && !hasRealData}
|
||||
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}
|
||||
<SkeletonList items={4} message="Chargement de l'emploi du temps..." />
|
||||
{:else}
|
||||
|
||||
@@ -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} · {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>
|
||||
@@ -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>
|
||||
@@ -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">×</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>
|
||||
@@ -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">‹</button>
|
||||
<button class="today-btn" onclick={goToToday}>Aujourd'hui</button>
|
||||
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(1) : navigateWeek(1)} aria-label="Suivant">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offline indicator -->
|
||||
{#if offline}
|
||||
<div class="offline-banner" role="status">
|
||||
<span class="offline-dot"></span>
|
||||
<span>Hors ligne</span>
|
||||
{#if lastSync}
|
||||
<span class="sync-date">Dernière sync : {formatSyncDate(lastSync)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content area with swipe -->
|
||||
<div
|
||||
class="schedule-content"
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchend={handleTouchEnd}
|
||||
role="region"
|
||||
aria-label="Emploi du temps"
|
||||
>
|
||||
{#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>
|
||||
@@ -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>
|
||||
62
frontend/src/lib/features/schedule/api/schedule.ts
Normal file
62
frontend/src/lib/features/schedule/api/schedule.ts
Normal 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;
|
||||
}
|
||||
58
frontend/src/lib/features/schedule/stores/scheduleCache.ts
Normal file
58
frontend/src/lib/features/schedule/stores/scheduleCache.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export interface CreateStudentData {
|
||||
email?: string | undefined;
|
||||
dateNaissance?: 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> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const body: Record<string, string> = {
|
||||
const body: Record<string, string | boolean> = {
|
||||
firstName: studentData.firstName,
|
||||
lastName: studentData.lastName,
|
||||
classId: studentData.classId
|
||||
@@ -101,6 +102,7 @@ export async function createStudent(studentData: CreateStudentData): Promise<Stu
|
||||
if (studentData.email) body['email'] = studentData.email;
|
||||
if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance;
|
||||
if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber;
|
||||
if (studentData.parentalConsent) body['parentalConsent'] = true;
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/students`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -142,7 +142,6 @@
|
||||
if (!classId) return subjects;
|
||||
const filtered = assignments.filter((a) => a.classId === classId);
|
||||
const subjectIds = new Set(filtered.map((a) => a.subjectId));
|
||||
if (subjectIds.size === 0) return subjects;
|
||||
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);
|
||||
const teacherIds = new Set(filtered.map((a) => a.teacherId));
|
||||
if (teacherIds.size === 0) return teachers;
|
||||
return teachers.filter((t) => teacherIds.has(t.id));
|
||||
});
|
||||
|
||||
@@ -915,16 +913,6 @@
|
||||
</select>
|
||||
</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">
|
||||
<label for="slot-subject">Matière *</label>
|
||||
<select id="slot-subject" bind:value={formSubjectId} required>
|
||||
@@ -935,6 +923,16 @@
|
||||
</select>
|
||||
</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">
|
||||
<label for="slot-day">Jour *</label>
|
||||
<select id="slot-day" bind:value={formDayOfWeek} required>
|
||||
|
||||
@@ -39,11 +39,24 @@
|
||||
let newClassId = $state('');
|
||||
let newDateNaissance = $state('');
|
||||
let newStudentNumber = $state('');
|
||||
let newParentalConsent = $state(false);
|
||||
let isSubmitting = $state(false);
|
||||
let duplicateWarning = $state<string | null>(null);
|
||||
let duplicateConfirmed = $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
|
||||
let showChangeClassModal = $state(false);
|
||||
let changeClassTarget = $state<Student | null>(null);
|
||||
@@ -176,6 +189,7 @@
|
||||
newClassId = '';
|
||||
newDateNaissance = '';
|
||||
newStudentNumber = '';
|
||||
newParentalConsent = false;
|
||||
duplicateWarning = null;
|
||||
duplicateConfirmed = false;
|
||||
createAnother = false;
|
||||
@@ -224,6 +238,7 @@
|
||||
|
||||
async function handleCreateStudent() {
|
||||
if (!newFirstName.trim() || !newLastName.trim() || !newClassId) return;
|
||||
if (needsParentalConsent() && !newParentalConsent) return;
|
||||
|
||||
if (await checkDuplicate()) return;
|
||||
|
||||
@@ -236,7 +251,8 @@
|
||||
classId: newClassId,
|
||||
email: newEmail.trim() ? newEmail.trim().toLowerCase() : 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
|
||||
@@ -375,7 +391,8 @@
|
||||
newLastName.trim() !== '' &&
|
||||
newClassId !== '' &&
|
||||
!isSubmitting &&
|
||||
(!newStudentNumber.trim() || isValidINE(newStudentNumber.trim()))
|
||||
(!newStudentNumber.trim() || isValidINE(newStudentNumber.trim())) &&
|
||||
(!needsParentalConsent() || newParentalConsent)
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -609,6 +626,19 @@
|
||||
</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}
|
||||
<div class="duplicate-warning">
|
||||
<p>{duplicateWarning}</p>
|
||||
@@ -1160,6 +1190,33 @@
|
||||
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 {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.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';
|
||||
|
||||
let { children } = $props();
|
||||
let isLoggingOut = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
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)
|
||||
// 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() {
|
||||
isLoggingOut = true;
|
||||
try {
|
||||
@@ -41,8 +59,24 @@
|
||||
function goSettings() {
|
||||
goto('/settings');
|
||||
}
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && mobileMenuOpen) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<header class="dashboard-header">
|
||||
<div class="header-content">
|
||||
@@ -52,22 +86,84 @@
|
||||
{/if}
|
||||
<span class="logo-text">Classeo</span>
|
||||
</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 />
|
||||
<a href="/dashboard" class="nav-link active">Tableau de bord</a>
|
||||
<button class="nav-button" onclick={goSettings}>Parametres</button>
|
||||
<a href="/dashboard" class="nav-link" class:active={pathname === '/dashboard'}>Tableau de bord</a>
|
||||
{#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}>
|
||||
{#if isLoggingOut}
|
||||
<span class="spinner"></span>
|
||||
Deconnexion...
|
||||
Déconnexion...
|
||||
{:else}
|
||||
Deconnexion
|
||||
Déconnexion
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</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">
|
||||
×
|
||||
</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">
|
||||
<div class="main-content">
|
||||
{@render children()}
|
||||
@@ -86,7 +182,7 @@
|
||||
.dashboard-header {
|
||||
background: var(--surface-elevated, #fff);
|
||||
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||
padding: 0 1.5rem;
|
||||
padding: 0 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@@ -98,10 +194,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 0.75rem 0;
|
||||
gap: 0.75rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.logo-button {
|
||||
@@ -127,12 +220,38 @@
|
||||
color: var(--accent-primary, #0ea5e9);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
/* Hamburger — visible on mobile */
|
||||
.hamburger-button {
|
||||
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;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -144,6 +263,7 @@
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@@ -166,6 +286,7 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
@@ -186,6 +307,7 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logout-button:hover:not(:disabled) {
|
||||
@@ -198,6 +320,108 @@
|
||||
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 {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -223,19 +447,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-content {
|
||||
flex-wrap: nowrap;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
width: auto;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1rem;
|
||||
justify-content: flex-start;
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
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 {
|
||||
|
||||
29
frontend/src/routes/dashboard/schedule/+page.svelte
Normal file
29
frontend/src/routes/dashboard/schedule/+page.svelte
Normal 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>
|
||||
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal file
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
categories: ['education'],
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
@@ -36,10 +37,35 @@ export default defineConfig({
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'Mon emploi du temps',
|
||||
short_name: 'EDT',
|
||||
url: '/dashboard/schedule',
|
||||
description: 'Consulter mon emploi du temps'
|
||||
}
|
||||
]
|
||||
},
|
||||
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: {
|
||||
enabled: false,
|
||||
|
||||
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"4.0.18","results":[[":frontend/tests/unit/lib/components/molecules/SearchInput/SearchInput.test.ts",{"duration":0,"failed":true}]]}
|
||||
Reference in New Issue
Block a user