diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 8dd1307..2666f12 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -179,6 +179,9 @@ services: App\Scolarite\Domain\Repository\ScheduleSlotRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository + App\Scolarite\Domain\Repository\ScheduleExceptionRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleExceptionRepository + App\Scolarite\Domain\Service\ScheduleConflictDetector: autowire: true diff --git a/backend/migrations/Version20260304103700.php b/backend/migrations/Version20260304103700.php new file mode 100644 index 0000000..feb8d72 --- /dev/null +++ b/backend/migrations/Version20260304103700.php @@ -0,0 +1,53 @@ +addSql('ALTER TABLE schedule_slots ADD COLUMN recurrence_start DATE'); + $this->addSql('ALTER TABLE schedule_slots ADD COLUMN recurrence_end DATE'); + + // Table des exceptions (modifications/annulations ponctuelles) + $this->addSql(' + CREATE TABLE schedule_exceptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + slot_id UUID NOT NULL REFERENCES schedule_slots(id) ON DELETE CASCADE, + exception_date DATE NOT NULL, + exception_type VARCHAR(20) NOT NULL, + new_start_time VARCHAR(5), + new_end_time VARCHAR(5), + new_room VARCHAR(50), + new_teacher_id UUID REFERENCES users(id), + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES users(id), + UNIQUE (slot_id, exception_date) + ) + '); + + $this->addSql('CREATE INDEX idx_exceptions_slot ON schedule_exceptions(slot_id)'); + $this->addSql('CREATE INDEX idx_exceptions_date ON schedule_exceptions(exception_date)'); + $this->addSql('CREATE INDEX idx_exceptions_tenant ON schedule_exceptions(tenant_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS schedule_exceptions'); + $this->addSql('ALTER TABLE schedule_slots DROP COLUMN IF EXISTS recurrence_start'); + $this->addSql('ALTER TABLE schedule_slots DROP COLUMN IF EXISTS recurrence_end'); + } +} diff --git a/backend/src/Scolarite/Application/Command/CreateScheduleException/CreateScheduleExceptionCommand.php b/backend/src/Scolarite/Application/Command/CreateScheduleException/CreateScheduleExceptionCommand.php new file mode 100644 index 0000000..e3e2b89 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/CreateScheduleException/CreateScheduleExceptionCommand.php @@ -0,0 +1,22 @@ +tenantId); + $slotId = ScheduleSlotId::fromString($command->slotId); + + $slot = $this->slotRepository->get($slotId, $tenantId); + + $type = ScheduleExceptionType::from($command->type); + $exceptionDate = new DateTimeImmutable($command->exceptionDate); + + if (!$slot->isActiveOnDate($exceptionDate)) { + throw DateExceptionInvalideException::pourSlotEtDate($slotId, $exceptionDate); + } + $createdBy = UserId::fromString($command->createdBy); + + $exception = match ($type) { + ScheduleExceptionType::CANCELLED => ScheduleException::annuler( + tenantId: $tenantId, + slotId: $slotId, + exceptionDate: $exceptionDate, + reason: $command->reason, + createdBy: $createdBy, + now: $this->clock->now(), + ), + ScheduleExceptionType::MODIFIED => ScheduleException::modifier( + tenantId: $tenantId, + slotId: $slotId, + exceptionDate: $exceptionDate, + newTimeSlot: $command->newStartTime !== null && $command->newEndTime !== null + ? new TimeSlot($command->newStartTime, $command->newEndTime) + : null, + newRoom: $command->newRoom, + newTeacherId: $command->newTeacherId !== null + ? UserId::fromString($command->newTeacherId) + : null, + reason: $command->reason, + createdBy: $createdBy, + now: $this->clock->now(), + ), + }; + + $this->exceptionRepository->save($exception); + + return $exception; + } +} diff --git a/backend/src/Scolarite/Application/Command/TruncateSlotRecurrence/TruncateSlotRecurrenceCommand.php b/backend/src/Scolarite/Application/Command/TruncateSlotRecurrence/TruncateSlotRecurrenceCommand.php new file mode 100644 index 0000000..816e99f --- /dev/null +++ b/backend/src/Scolarite/Application/Command/TruncateSlotRecurrence/TruncateSlotRecurrenceCommand.php @@ -0,0 +1,16 @@ +tenantId); + $slotId = ScheduleSlotId::fromString($command->slotId); + $slot = $this->slotRepository->get($slotId, $tenantId); + $fromDate = new DateTimeImmutable($command->fromDate); + + if (!$slot->isActiveOnDate($fromDate)) { + throw DateExceptionInvalideException::pourSlotEtDate($slotId, $fromDate); + } + + $now = $this->clock->now(); + $dayBefore = $fromDate->modify('-1 day'); + $slot->terminerRecurrenceLe($dayBefore, $now); + $this->slotRepository->save($slot); + } +} diff --git a/backend/src/Scolarite/Application/Command/UpdateRecurringSlot/UpdateRecurringSlotCommand.php b/backend/src/Scolarite/Application/Command/UpdateRecurringSlot/UpdateRecurringSlotCommand.php new file mode 100644 index 0000000..20bf55f --- /dev/null +++ b/backend/src/Scolarite/Application/Command/UpdateRecurringSlot/UpdateRecurringSlotCommand.php @@ -0,0 +1,27 @@ +tenantId); + $slotId = ScheduleSlotId::fromString($command->slotId); + $slot = $this->slotRepository->get($slotId, $tenantId); + $now = $this->clock->now(); + $occurrenceDate = new DateTimeImmutable($command->occurrenceDate); + $newTimeSlot = new TimeSlot($command->startTime, $command->endTime); + + if (!$slot->isActiveOnDate($occurrenceDate)) { + throw DateExceptionInvalideException::pourSlotEtDate($slotId, $occurrenceDate); + } + + if ($command->scope === 'this_occurrence') { + return $this->handleThisOccurrence( + $tenantId, + $slotId, + $occurrenceDate, + $newTimeSlot, + $command->room, + UserId::fromString($command->teacherId), + UserId::fromString($command->updatedBy), + $now, + ); + } + + // all_future: end current recurrence, create new slot + return $this->handleAllFuture( + $slot, + $tenantId, + $occurrenceDate, + $command, + $now, + ); + } + + /** + * @return array{exception: ScheduleException, newSlot: null} + */ + private function handleThisOccurrence( + TenantId $tenantId, + ScheduleSlotId $slotId, + DateTimeImmutable $occurrenceDate, + TimeSlot $newTimeSlot, + ?string $newRoom, + UserId $newTeacherId, + UserId $createdBy, + DateTimeImmutable $now, + ): array { + $exception = ScheduleException::modifier( + tenantId: $tenantId, + slotId: $slotId, + exceptionDate: $occurrenceDate, + newTimeSlot: $newTimeSlot, + newRoom: $newRoom, + newTeacherId: $newTeacherId, + reason: null, + createdBy: $createdBy, + now: $now, + ); + + $this->exceptionRepository->save($exception); + + return ['exception' => $exception, 'newSlot' => null]; + } + + /** + * @return array{exception: null, newSlot: ScheduleSlot} + */ + private function handleAllFuture( + ScheduleSlot $slot, + TenantId $tenantId, + DateTimeImmutable $occurrenceDate, + UpdateRecurringSlotCommand $command, + DateTimeImmutable $now, + ): array { + // Save original end date before modifying + $originalRecurrenceEnd = $slot->recurrenceEnd; + + // End original slot one day before the change + $dayBefore = $occurrenceDate->modify('-1 day'); + $slot->terminerRecurrenceLe($dayBefore, $now); + $this->slotRepository->save($slot); + + // Create new slot starting from the occurrence date + $newSlot = ScheduleSlot::creer( + tenantId: $tenantId, + classId: ClassId::fromString($command->classId), + subjectId: SubjectId::fromString($command->subjectId), + teacherId: UserId::fromString($command->teacherId), + dayOfWeek: DayOfWeek::from($command->dayOfWeek), + timeSlot: new TimeSlot($command->startTime, $command->endTime), + room: $command->room, + isRecurring: true, + now: $now, + recurrenceStart: $occurrenceDate, + recurrenceEnd: $originalRecurrenceEnd, + ); + + $this->slotRepository->save($newSlot); + + return ['exception' => null, 'newSlot' => $newSlot]; + } +} diff --git a/backend/src/Scolarite/Application/Service/ScheduleResolver.php b/backend/src/Scolarite/Application/Service/ScheduleResolver.php new file mode 100644 index 0000000..2f48e15 --- /dev/null +++ b/backend/src/Scolarite/Application/Service/ScheduleResolver.php @@ -0,0 +1,92 @@ + + */ + public function resolveForWeek( + ClassId $classId, + DateTimeImmutable $weekStart, + TenantId $tenantId, + SchoolCalendar $calendar, + ): array { + $slots = $this->slotRepository->findRecurringByClass($classId, $tenantId); + $weekEnd = $weekStart->modify('+6 days'); + + $exceptionsIndex = $this->indexExceptions( + $this->exceptionRepository->findForDateRange($tenantId, $weekStart, $weekEnd), + ); + + $resolved = []; + + for ($day = 0; $day < 7; ++$day) { + $date = $weekStart->modify("+{$day} days"); + + if (!$calendar->estJourOuvre($date)) { + continue; + } + + foreach ($slots as $slot) { + if (!$slot->isActiveOnDate($date)) { + continue; + } + + $key = (string) $slot->id . '_' . $date->format('Y-m-d'); + $exception = $exceptionsIndex[$key] ?? null; + + if ($exception?->isCancelled()) { + continue; + } + + $resolved[] = $exception !== null + ? ResolvedScheduleSlot::fromSlotWithException($slot, $exception) + : ResolvedScheduleSlot::fromSlot($slot, $date); + } + } + + return $resolved; + } + + /** + * @param array $exceptions + * + * @return array + */ + private function indexExceptions(array $exceptions): array + { + $index = []; + + foreach ($exceptions as $exception) { + $key = (string) $exception->slotId . '_' . $exception->exceptionDate->format('Y-m-d'); + $index[$key] = $exception; + } + + return $index; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/DateExceptionInvalideException.php b/backend/src/Scolarite/Domain/Exception/DateExceptionInvalideException.php new file mode 100644 index 0000000..a8cac64 --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/DateExceptionInvalideException.php @@ -0,0 +1,20 @@ +format('Y-m-d')} n'est pas valide pour le créneau $slotId " + . '(mauvais jour de la semaine ou hors bornes de récurrence).', + ); + } +} diff --git a/backend/src/Scolarite/Domain/Model/Schedule/ResolvedScheduleSlot.php b/backend/src/Scolarite/Domain/Model/Schedule/ResolvedScheduleSlot.php new file mode 100644 index 0000000..fc184ec --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Schedule/ResolvedScheduleSlot.php @@ -0,0 +1,77 @@ +id, + tenantId: $slot->tenantId, + classId: $slot->classId, + subjectId: $slot->subjectId, + teacherId: $slot->teacherId, + dayOfWeek: $slot->dayOfWeek, + timeSlot: $slot->timeSlot, + room: $slot->room, + date: $date, + isModified: false, + exceptionId: null, + ); + } + + /** + * Crée un créneau résolu avec une exception (modification ponctuelle). + */ + public static function fromSlotWithException( + ScheduleSlot $slot, + ScheduleException $exception, + ): self { + return new self( + slotId: $slot->id, + tenantId: $slot->tenantId, + classId: $slot->classId, + subjectId: $slot->subjectId, + teacherId: $exception->newTeacherId ?? $slot->teacherId, + dayOfWeek: $slot->dayOfWeek, + timeSlot: $exception->newTimeSlot ?? $slot->timeSlot, + room: $exception->newRoom ?? $slot->room, + date: $exception->exceptionDate, + isModified: true, + exceptionId: $exception->id, + ); + } +} diff --git a/backend/src/Scolarite/Domain/Model/Schedule/ScheduleException.php b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleException.php new file mode 100644 index 0000000..39e544c --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleException.php @@ -0,0 +1,134 @@ +type === ScheduleExceptionType::CANCELLED; + } + + public function isModified(): bool + { + return $this->type === ScheduleExceptionType::MODIFIED; + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + ScheduleExceptionId $id, + TenantId $tenantId, + ScheduleSlotId $slotId, + DateTimeImmutable $exceptionDate, + ScheduleExceptionType $type, + ?TimeSlot $newTimeSlot, + ?string $newRoom, + ?UserId $newTeacherId, + ?string $reason, + UserId $createdBy, + DateTimeImmutable $createdAt, + ): self { + return new self( + id: $id, + tenantId: $tenantId, + slotId: $slotId, + exceptionDate: $exceptionDate, + type: $type, + newTimeSlot: $newTimeSlot, + newRoom: $newRoom, + newTeacherId: $newTeacherId, + reason: $reason, + createdBy: $createdBy, + createdAt: $createdAt, + ); + } +} diff --git a/backend/src/Scolarite/Domain/Model/Schedule/ScheduleExceptionId.php b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleExceptionId.php new file mode 100644 index 0000000..0338612 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Schedule/ScheduleExceptionId.php @@ -0,0 +1,11 @@ +updatedAt = $createdAt; } @@ -54,6 +56,8 @@ final class ScheduleSlot extends AggregateRoot ?string $room, bool $isRecurring, DateTimeImmutable $now, + ?DateTimeImmutable $recurrenceStart = null, + ?DateTimeImmutable $recurrenceEnd = null, ): self { $room = $room !== '' ? $room : null; @@ -68,6 +72,8 @@ final class ScheduleSlot extends AggregateRoot room: $room, isRecurring: $isRecurring, createdAt: $now, + recurrenceStart: $recurrenceStart, + recurrenceEnd: $recurrenceEnd, ); $slot->recordEvent(new CoursCree( @@ -133,6 +139,44 @@ final class ScheduleSlot extends AggregateRoot )); } + /** + * Termine la récurrence à une date donnée (pour le scénario "modifier toutes les futures"). + */ + public function terminerRecurrenceLe(DateTimeImmutable $date, DateTimeImmutable $at): void + { + $this->recurrenceEnd = $date; + $this->updatedAt = $at; + } + + /** + * Vérifie si ce créneau récurrent est actif à une date donnée. + * + * Un créneau est actif si : récurrent, même jour de la semaine, + * et la date est dans les bornes de récurrence. + */ + public function isActiveOnDate(DateTimeImmutable $date): bool + { + if (!$this->isRecurring) { + return false; + } + + if (DayOfWeek::from((int) $date->format('N')) !== $this->dayOfWeek) { + return false; + } + + $dateOnly = $date->format('Y-m-d'); + + if ($this->recurrenceStart !== null && $dateOnly < $this->recurrenceStart->format('Y-m-d')) { + return false; + } + + if ($this->recurrenceEnd !== null && $dateOnly > $this->recurrenceEnd->format('Y-m-d')) { + return false; + } + + return true; + } + /** * Vérifie si ce créneau entre en conflit temporel avec un autre sur le même jour. */ @@ -159,6 +203,8 @@ final class ScheduleSlot extends AggregateRoot bool $isRecurring, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt, + ?DateTimeImmutable $recurrenceStart = null, + ?DateTimeImmutable $recurrenceEnd = null, ): self { $slot = new self( id: $id, @@ -171,6 +217,8 @@ final class ScheduleSlot extends AggregateRoot room: $room, isRecurring: $isRecurring, createdAt: $createdAt, + recurrenceStart: $recurrenceStart, + recurrenceEnd: $recurrenceEnd, ); $slot->updatedAt = $updatedAt; diff --git a/backend/src/Scolarite/Domain/Repository/ScheduleExceptionRepository.php b/backend/src/Scolarite/Domain/Repository/ScheduleExceptionRepository.php new file mode 100644 index 0000000..71c814e --- /dev/null +++ b/backend/src/Scolarite/Domain/Repository/ScheduleExceptionRepository.php @@ -0,0 +1,45 @@ + + */ + public function findForSlotBetweenDates( + ScheduleSlotId $slotId, + DateTimeImmutable $startDate, + DateTimeImmutable $endDate, + TenantId $tenantId, + ): array; + + /** + * @return array + */ + public function findForDateRange( + TenantId $tenantId, + DateTimeImmutable $startDate, + DateTimeImmutable $endDate, + ): array; + + public function delete(ScheduleExceptionId $id, TenantId $tenantId): void; +} diff --git a/backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php b/backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php index 20c2683..1550fc8 100644 --- a/backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php +++ b/backend/src/Scolarite/Domain/Repository/ScheduleSlotRepository.php @@ -26,6 +26,9 @@ interface ScheduleSlotRepository /** @return array */ public function findByClass(ClassId $classId, TenantId $tenantId): array; + /** @return array */ + public function findRecurringByClass(ClassId $classId, TenantId $tenantId): array; + /** @return array */ public function findByTeacher(UserId $teacherId, TenantId $tenantId): array; diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/CancelOccurrenceProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/CancelOccurrenceProcessor.php new file mode 100644 index 0000000..0091ae1 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/CancelOccurrenceProcessor.php @@ -0,0 +1,104 @@ + + */ +final readonly class CancelOccurrenceProcessor implements ProcessorInterface +{ + public function __construct( + private CreateScheduleExceptionHandler $cancelHandler, + private TruncateSlotRecurrenceHandler $truncateHandler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private TokenStorageInterface $tokenStorage, + private RequestStack $requestStack, + ) { + } + + /** + * @param ScheduleOccurrenceResource|null $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleOccurrenceResource + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::DELETE)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $slotId */ + $slotId = $uriVariables['id'] ?? ''; + /** @var string $date */ + $date = $uriVariables['date'] ?? ''; + + $user = $this->tokenStorage->getToken()?->getUser(); + $userId = $user instanceof SecurityUser ? $user->userId() : ''; + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + $scope = $this->requestStack->getCurrentRequest()?->query->getString('scope', 'this_occurrence'); + + try { + if ($scope === 'all_future') { + ($this->truncateHandler)(new TruncateSlotRecurrenceCommand( + tenantId: $tenantId, + slotId: $slotId, + fromDate: $date, + updatedBy: $userId, + )); + + $type = 'truncated'; + } else { + ($this->cancelHandler)(new CreateScheduleExceptionCommand( + tenantId: $tenantId, + slotId: $slotId, + exceptionDate: $date, + type: 'cancelled', + createdBy: $userId, + reason: $data?->reason, + )); + + $type = 'cancelled'; + } + + $resource = new ScheduleOccurrenceResource(); + $resource->id = $slotId . '_' . $date; + $resource->slotId = $slotId; + $resource->date = $date; + $resource->type = $type; + + return $resource; + } catch (ScheduleSlotNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (DateExceptionInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/ModifyOccurrenceProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/ModifyOccurrenceProcessor.php new file mode 100644 index 0000000..6ea5e56 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/ModifyOccurrenceProcessor.php @@ -0,0 +1,93 @@ + + */ +final readonly class ModifyOccurrenceProcessor implements ProcessorInterface +{ + public function __construct( + private UpdateRecurringSlotHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private TokenStorageInterface $tokenStorage, + ) { + } + + /** + * @param ScheduleOccurrenceResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleOccurrenceResource + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::EDIT)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $slotId */ + $slotId = $uriVariables['id'] ?? ''; + /** @var string $date */ + $date = $uriVariables['date'] ?? ''; + + $user = $this->tokenStorage->getToken()?->getUser(); + $userId = $user instanceof SecurityUser ? $user->userId() : ''; + + try { + $command = new UpdateRecurringSlotCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + slotId: $slotId, + occurrenceDate: $date, + scope: $data->scope ?? 'this_occurrence', + classId: $data->classId ?? '', + subjectId: $data->subjectId ?? '', + teacherId: $data->teacherId ?? '', + dayOfWeek: $data->dayOfWeek ?? 1, + startTime: $data->startTime ?? '', + endTime: $data->endTime ?? '', + room: $data->room, + updatedBy: $userId, + ); + + $result = ($this->handler)($command); + + $resource = new ScheduleOccurrenceResource(); + $resource->id = $slotId . '_' . $date; + $resource->slotId = $slotId; + $resource->date = $date; + $resource->scope = $data->scope; + $resource->type = $result['exception'] !== null ? 'modified' : 'recurring_updated'; + + return $resource; + } catch (ScheduleSlotNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (DateExceptionInvalideException|ValueError $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/ResolvedScheduleWeekProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/ResolvedScheduleWeekProvider.php new file mode 100644 index 0000000..70d2e18 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/ResolvedScheduleWeekProvider.php @@ -0,0 +1,99 @@ + + */ +final readonly class ResolvedScheduleWeekProvider implements ProviderInterface +{ + public function __construct( + private ScheduleResolver $scheduleResolver, + private SchoolCalendarRepository $calendarRepository, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + /** @return array */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) { + throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps."); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + if (!isset($filters['classId'])) { + throw new BadRequestHttpException('Le paramètre classId est requis.'); + } + + /** @var string $dateParam */ + $dateParam = $uriVariables['date'] ?? ''; + $weekStart = new DateTimeImmutable($dateParam); + + $tenantId = TenantId::fromString((string) $this->tenantContext->getCurrentTenantId()); + $classId = ClassId::fromString((string) $filters['classId']); + + $calendar = $this->loadCalendar($tenantId); + + // TODO: cache-aside par classId+weekStart si la perf le nécessite + $resolved = $this->scheduleResolver->resolveForWeek( + $classId, + $weekStart, + $tenantId, + $calendar, + ); + + return array_map(ResolvedScheduleSlotResource::fromDomain(...), $resolved); + } + + private function loadCalendar(TenantId $tenantId): SchoolCalendar + { + $academicYearId = $this->academicYearResolver->resolve('current'); + + if ($academicYearId === null) { + return SchoolCalendar::initialiser($tenantId, AcademicYearId::generate()); + } + + $calendar = $this->calendarRepository->findByTenantAndYear( + $tenantId, + AcademicYearId::fromString($academicYearId), + ); + + return $calendar ?? SchoolCalendar::initialiser($tenantId, AcademicYearId::generate()); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/ResolvedScheduleSlotResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/ResolvedScheduleSlotResource.php new file mode 100644 index 0000000..0864400 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/ResolvedScheduleSlotResource.php @@ -0,0 +1,81 @@ + new Link( + fromClass: self::class, + identifiers: ['date'], + ), + ], + provider: ResolvedScheduleWeekProvider::class, + name: 'get_resolved_schedule_week', + ), + ], +)] +final class ResolvedScheduleSlotResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + public ?string $slotId = null; + + public ?string $classId = null; + + public ?string $subjectId = null; + + public ?string $teacherId = null; + + public ?int $dayOfWeek = null; + + public ?string $startTime = null; + + public ?string $endTime = null; + + public ?string $room = null; + + public ?string $date = null; + + public ?bool $isModified = null; + + public ?string $exceptionId = null; + + public static function fromDomain(ResolvedScheduleSlot $resolved): self + { + $resource = new self(); + $resource->id = (string) $resolved->slotId . '_' . $resolved->date->format('Y-m-d'); + $resource->slotId = (string) $resolved->slotId; + $resource->classId = (string) $resolved->classId; + $resource->subjectId = (string) $resolved->subjectId; + $resource->teacherId = (string) $resolved->teacherId; + $resource->dayOfWeek = $resolved->dayOfWeek->value; + $resource->startTime = $resolved->timeSlot->startTime; + $resource->endTime = $resolved->timeSlot->endTime; + $resource->room = $resolved->room; + $resource->date = $resolved->date->format('Y-m-d'); + $resource->isModified = $resolved->isModified; + $resource->exceptionId = $resolved->exceptionId !== null ? (string) $resolved->exceptionId : null; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/ScheduleOccurrenceResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/ScheduleOccurrenceResource.php new file mode 100644 index 0000000..4db8315 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/ScheduleOccurrenceResource.php @@ -0,0 +1,71 @@ +connection->executeStatement( + 'INSERT INTO schedule_exceptions (id, tenant_id, slot_id, exception_date, exception_type, new_start_time, new_end_time, new_room, new_teacher_id, reason, created_at, created_by) + VALUES (:id, :tenant_id, :slot_id, :exception_date, :exception_type, :new_start_time, :new_end_time, :new_room, :new_teacher_id, :reason, :created_at, :created_by) + ON CONFLICT (slot_id, exception_date) DO UPDATE SET + exception_type = EXCLUDED.exception_type, + new_start_time = EXCLUDED.new_start_time, + new_end_time = EXCLUDED.new_end_time, + new_room = EXCLUDED.new_room, + new_teacher_id = EXCLUDED.new_teacher_id, + reason = EXCLUDED.reason', + [ + 'id' => (string) $exception->id, + 'tenant_id' => (string) $exception->tenantId, + 'slot_id' => (string) $exception->slotId, + 'exception_date' => $exception->exceptionDate->format('Y-m-d'), + 'exception_type' => $exception->type->value, + 'new_start_time' => $exception->newTimeSlot?->startTime, + 'new_end_time' => $exception->newTimeSlot?->endTime, + 'new_room' => $exception->newRoom, + 'new_teacher_id' => $exception->newTeacherId !== null ? (string) $exception->newTeacherId : null, + 'reason' => $exception->reason, + 'created_at' => $exception->createdAt->format(DateTimeImmutable::ATOM), + 'created_by' => (string) $exception->createdBy, + ], + ); + } + + #[Override] + public function findById(ScheduleExceptionId $id, TenantId $tenantId): ?ScheduleException + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM schedule_exceptions WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findForSlotAndDate( + ScheduleSlotId $slotId, + DateTimeImmutable $date, + TenantId $tenantId, + ): ?ScheduleException { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM schedule_exceptions + WHERE slot_id = :slot_id AND exception_date = :exception_date AND tenant_id = :tenant_id', + [ + 'slot_id' => (string) $slotId, + 'exception_date' => $date->format('Y-m-d'), + 'tenant_id' => (string) $tenantId, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findForSlotBetweenDates( + ScheduleSlotId $slotId, + DateTimeImmutable $startDate, + DateTimeImmutable $endDate, + TenantId $tenantId, + ): array { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM schedule_exceptions + WHERE slot_id = :slot_id + AND exception_date >= :start_date + AND exception_date <= :end_date + AND tenant_id = :tenant_id + ORDER BY exception_date', + [ + 'slot_id' => (string) $slotId, + 'start_date' => $startDate->format('Y-m-d'), + 'end_date' => $endDate->format('Y-m-d'), + 'tenant_id' => (string) $tenantId, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function findForDateRange( + TenantId $tenantId, + DateTimeImmutable $startDate, + DateTimeImmutable $endDate, + ): array { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM schedule_exceptions + WHERE tenant_id = :tenant_id + AND exception_date >= :start_date + AND exception_date <= :end_date + ORDER BY exception_date', + [ + 'tenant_id' => (string) $tenantId, + 'start_date' => $startDate->format('Y-m-d'), + 'end_date' => $endDate->format('Y-m-d'), + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function delete(ScheduleExceptionId $id, TenantId $tenantId): void + { + $this->connection->executeStatement( + 'DELETE FROM schedule_exceptions WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + } + + /** + * @param array $row + */ + private function hydrate(array $row): ScheduleException + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $slotId */ + $slotId = $row['slot_id']; + /** @var string $exceptionDate */ + $exceptionDate = $row['exception_date']; + /** @var string $exceptionType */ + $exceptionType = $row['exception_type']; + /** @var string|null $newStartTime */ + $newStartTime = $row['new_start_time'] ?? null; + /** @var string|null $newEndTime */ + $newEndTime = $row['new_end_time'] ?? null; + /** @var string|null $newRoom */ + $newRoom = $row['new_room'] ?? null; + /** @var string|null $newTeacherId */ + $newTeacherId = $row['new_teacher_id'] ?? null; + /** @var string|null $reason */ + $reason = $row['reason'] ?? null; + /** @var string $createdBy */ + $createdBy = $row['created_by']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + + return ScheduleException::reconstitute( + id: ScheduleExceptionId::fromString($id), + tenantId: TenantId::fromString($tenantId), + slotId: ScheduleSlotId::fromString($slotId), + exceptionDate: new DateTimeImmutable($exceptionDate), + type: ScheduleExceptionType::from($exceptionType), + newTimeSlot: $newStartTime !== null && $newEndTime !== null + ? new TimeSlot($newStartTime, $newEndTime) + : null, + newRoom: $newRoom, + newTeacherId: $newTeacherId !== null ? UserId::fromString($newTeacherId) : null, + reason: $reason, + createdBy: UserId::fromString($createdBy), + createdAt: new DateTimeImmutable($createdAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php index 7aa2e3b..3b41f77 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineScheduleSlotRepository.php @@ -32,8 +32,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos public function save(ScheduleSlot $slot): void { $this->connection->executeStatement( - '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) - VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :day_of_week, :start_time, :end_time, :room, :is_recurring, :created_at, :updated_at) + 'INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, recurrence_start, recurrence_end, created_at, updated_at) + VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :day_of_week, :start_time, :end_time, :room, :is_recurring, :recurrence_start, :recurrence_end, :created_at, :updated_at) ON CONFLICT (id) DO UPDATE SET class_id = EXCLUDED.class_id, subject_id = EXCLUDED.subject_id, @@ -42,6 +42,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos start_time = EXCLUDED.start_time, end_time = EXCLUDED.end_time, room = EXCLUDED.room, + recurrence_start = EXCLUDED.recurrence_start, + recurrence_end = EXCLUDED.recurrence_end, updated_at = EXCLUDED.updated_at', [ 'id' => (string) $slot->id, @@ -54,6 +56,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos 'end_time' => $slot->timeSlot->endTime, 'room' => $slot->room, 'is_recurring' => $slot->isRecurring ? 'true' : 'false', + 'recurrence_start' => $slot->recurrenceStart?->format('Y-m-d'), + 'recurrence_end' => $slot->recurrenceEnd?->format('Y-m-d'), 'created_at' => $slot->createdAt->format(DateTimeImmutable::ATOM), 'updated_at' => $slot->updatedAt->format(DateTimeImmutable::ATOM), ], @@ -109,6 +113,19 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos return $this->hydrateMany($rows); } + #[Override] + public function findRecurringByClass(ClassId $classId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM schedule_slots + WHERE class_id = :class_id AND tenant_id = :tenant_id AND is_recurring = true + ORDER BY day_of_week, start_time', + ['class_id' => (string) $classId, 'tenant_id' => (string) $tenantId], + ); + + return $this->hydrateMany($rows); + } + #[Override] public function findByTeacher(UserId $teacherId, TenantId $tenantId): array { @@ -256,6 +273,10 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos $room = $row['room']; /** @var bool $isRecurring */ $isRecurring = $row['is_recurring']; + /** @var string|null $recurrenceStart */ + $recurrenceStart = $row['recurrence_start'] ?? null; + /** @var string|null $recurrenceEnd */ + $recurrenceEnd = $row['recurrence_end'] ?? null; /** @var string $createdAt */ $createdAt = $row['created_at']; /** @var string $updatedAt */ @@ -273,6 +294,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos isRecurring: (bool) $isRecurring, createdAt: new DateTimeImmutable($createdAt), updatedAt: new DateTimeImmutable($updatedAt), + recurrenceStart: $recurrenceStart !== null ? new DateTimeImmutable($recurrenceStart) : null, + recurrenceEnd: $recurrenceEnd !== null ? new DateTimeImmutable($recurrenceEnd) : null, ); } } diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleExceptionRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleExceptionRepository.php new file mode 100644 index 0000000..c097b96 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleExceptionRepository.php @@ -0,0 +1,107 @@ + */ + private array $byId = []; + + #[Override] + public function save(ScheduleException $exception): void + { + $this->byId[(string) $exception->id] = $exception; + } + + #[Override] + public function findById(ScheduleExceptionId $id, TenantId $tenantId): ?ScheduleException + { + $exception = $this->byId[(string) $id] ?? null; + + if ($exception !== null && !$exception->tenantId->equals($tenantId)) { + return null; + } + + return $exception; + } + + #[Override] + public function findForSlotAndDate( + ScheduleSlotId $slotId, + DateTimeImmutable $date, + TenantId $tenantId, + ): ?ScheduleException { + $dateStr = $date->format('Y-m-d'); + + foreach ($this->byId as $exception) { + if ($exception->tenantId->equals($tenantId) + && $exception->slotId->equals($slotId) + && $exception->exceptionDate->format('Y-m-d') === $dateStr + ) { + return $exception; + } + } + + return null; + } + + #[Override] + public function findForSlotBetweenDates( + ScheduleSlotId $slotId, + DateTimeImmutable $startDate, + DateTimeImmutable $endDate, + TenantId $tenantId, + ): array { + $start = $startDate->format('Y-m-d'); + $end = $endDate->format('Y-m-d'); + + return array_values(array_filter( + $this->byId, + static fn (ScheduleException $e): bool => $e->tenantId->equals($tenantId) + && $e->slotId->equals($slotId) + && $e->exceptionDate->format('Y-m-d') >= $start + && $e->exceptionDate->format('Y-m-d') <= $end, + )); + } + + #[Override] + public function findForDateRange( + TenantId $tenantId, + DateTimeImmutable $startDate, + DateTimeImmutable $endDate, + ): array { + $start = $startDate->format('Y-m-d'); + $end = $endDate->format('Y-m-d'); + + return array_values(array_filter( + $this->byId, + static fn (ScheduleException $e): bool => $e->tenantId->equals($tenantId) + && $e->exceptionDate->format('Y-m-d') >= $start + && $e->exceptionDate->format('Y-m-d') <= $end, + )); + } + + #[Override] + public function delete(ScheduleExceptionId $id, TenantId $tenantId): void + { + $exception = $this->findById($id, $tenantId); + + if ($exception !== null) { + unset($this->byId[(string) $id]); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php index b5bc5b8..8bd23c0 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryScheduleSlotRepository.php @@ -74,6 +74,17 @@ final class InMemoryScheduleSlotRepository implements ScheduleSlotRepository )); } + #[Override] + public function findRecurringByClass(ClassId $classId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId) + && $s->classId->equals($classId) + && $s->isRecurring, + )); + } + #[Override] public function findByTeacher(UserId $teacherId, TenantId $tenantId): array { diff --git a/backend/tests/Unit/Scolarite/Application/Command/CreateScheduleException/CreateScheduleExceptionHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/CreateScheduleException/CreateScheduleExceptionHandlerTest.php new file mode 100644 index 0000000..2321b8d --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/CreateScheduleException/CreateScheduleExceptionHandlerTest.php @@ -0,0 +1,212 @@ +slotRepository = new InMemoryScheduleSlotRepository(); + $this->exceptionRepository = new InMemoryScheduleExceptionRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-01 10:00:00'); + } + }; + + $this->handler = new CreateScheduleExceptionHandler( + $this->slotRepository, + $this->exceptionRepository, + $clock, + ); + } + + #[Test] + public function cancelOccurrenceCreatesACancelledException(): void + { + $slot = $this->createAndSaveSlot(); + + $command = new CreateScheduleExceptionCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + exceptionDate: '2026-10-05', + type: 'cancelled', + reason: 'Grève', + createdBy: self::CREATOR_ID, + ); + + $exception = ($this->handler)($command); + + self::assertSame(ScheduleExceptionType::CANCELLED, $exception->type); + self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d')); + self::assertSame('Grève', $exception->reason); + self::assertNull($exception->newTimeSlot); + } + + #[Test] + public function modifyOccurrenceCreatesAModifiedException(): void + { + $slot = $this->createAndSaveSlot(); + $newTeacherId = '550e8400-e29b-41d4-a716-446655440011'; + + $command = new CreateScheduleExceptionCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + exceptionDate: '2026-10-05', + type: 'modified', + newStartTime: '10:00', + newEndTime: '11:00', + newRoom: 'Salle 301', + newTeacherId: $newTeacherId, + reason: 'Changement de salle', + createdBy: self::CREATOR_ID, + ); + + $exception = ($this->handler)($command); + + self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type); + self::assertNotNull($exception->newTimeSlot); + self::assertSame('10:00', $exception->newTimeSlot->startTime); + self::assertSame('11:00', $exception->newTimeSlot->endTime); + self::assertSame('Salle 301', $exception->newRoom); + self::assertTrue($exception->newTeacherId->equals(UserId::fromString($newTeacherId))); + } + + #[Test] + public function exceptionIsSavedToRepository(): void + { + $slot = $this->createAndSaveSlot(); + + $command = new CreateScheduleExceptionCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + exceptionDate: '2026-10-05', + type: 'cancelled', + createdBy: self::CREATOR_ID, + ); + + $exception = ($this->handler)($command); + $tenantId = TenantId::fromString(self::TENANT_ID); + + $found = $this->exceptionRepository->findById($exception->id, $tenantId); + self::assertNotNull($found); + } + + #[Test] + public function throwsWhenSlotNotFound(): void + { + $this->expectException(\App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException::class); + + $command = new CreateScheduleExceptionCommand( + tenantId: self::TENANT_ID, + slotId: '550e8400-e29b-41d4-a716-446655440099', + exceptionDate: '2026-10-05', + type: 'cancelled', + createdBy: self::CREATOR_ID, + ); + + ($this->handler)($command); + } + + #[Test] + public function throwsWhenDateIsWrongDayOfWeek(): void + { + $slot = $this->createAndSaveSlot(); + + $this->expectException(DateExceptionInvalideException::class); + + // Slot is MONDAY, but 2026-10-06 is a Tuesday + $command = new CreateScheduleExceptionCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + exceptionDate: '2026-10-06', + type: 'cancelled', + createdBy: self::CREATOR_ID, + ); + + ($this->handler)($command); + } + + #[Test] + public function throwsWhenDateIsOutsideRecurrenceBounds(): 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: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + recurrenceStart: new DateTimeImmutable('2026-09-07'), + recurrenceEnd: new DateTimeImmutable('2026-09-28'), + ); + $this->slotRepository->save($slot); + + $this->expectException(DateExceptionInvalideException::class); + + // 2026-10-05 is a Monday but after recurrenceEnd (2026-09-28) + $command = new CreateScheduleExceptionCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + exceptionDate: '2026-10-05', + type: 'cancelled', + createdBy: self::CREATOR_ID, + ); + + ($this->handler)($command); + } + + private function createAndSaveSlot(): ScheduleSlot + { + $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: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + ); + + $this->slotRepository->save($slot); + + return $slot; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/TruncateSlotRecurrence/TruncateSlotRecurrenceHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/TruncateSlotRecurrence/TruncateSlotRecurrenceHandlerTest.php new file mode 100644 index 0000000..9aa2ca3 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/TruncateSlotRecurrence/TruncateSlotRecurrenceHandlerTest.php @@ -0,0 +1,114 @@ +slotRepository = new InMemoryScheduleSlotRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-01 10:00:00'); + } + }; + + $this->handler = new TruncateSlotRecurrenceHandler( + $this->slotRepository, + $clock, + ); + } + + #[Test] + public function truncatesRecurrenceOneDayBeforeGivenDate(): void + { + $slot = $this->createAndSaveSlot( + recurrenceStart: new DateTimeImmutable('2026-09-01'), + recurrenceEnd: new DateTimeImmutable('2027-07-04'), + ); + + $command = new TruncateSlotRecurrenceCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + fromDate: '2026-10-05', + updatedBy: self::UPDATER_ID, + ); + + ($this->handler)($command); + + $tenantId = TenantId::fromString(self::TENANT_ID); + $updated = $this->slotRepository->get($slot->id, $tenantId); + self::assertSame('2026-10-04', $updated->recurrenceEnd->format('Y-m-d')); + } + + #[Test] + public function throwsWhenDateIsNotActiveForSlot(): void + { + $slot = $this->createAndSaveSlot( + recurrenceStart: new DateTimeImmutable('2026-09-01'), + recurrenceEnd: new DateTimeImmutable('2026-09-28'), + ); + + $command = new TruncateSlotRecurrenceCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + fromDate: '2026-10-05', + updatedBy: self::UPDATER_ID, + ); + + $this->expectException(DateExceptionInvalideException::class); + ($this->handler)($command); + } + + private function createAndSaveSlot( + ?DateTimeImmutable $recurrenceStart = null, + ?DateTimeImmutable $recurrenceEnd = null, + ): ScheduleSlot { + $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: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + recurrenceStart: $recurrenceStart, + recurrenceEnd: $recurrenceEnd, + ); + + $this->slotRepository->save($slot); + + return $slot; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/UpdateRecurringSlot/UpdateRecurringSlotHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/UpdateRecurringSlot/UpdateRecurringSlotHandlerTest.php new file mode 100644 index 0000000..45d6a91 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/UpdateRecurringSlot/UpdateRecurringSlotHandlerTest.php @@ -0,0 +1,173 @@ +slotRepository = new InMemoryScheduleSlotRepository(); + $this->exceptionRepository = new InMemoryScheduleExceptionRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-10-01 10:00:00'); + } + }; + + $this->handler = new UpdateRecurringSlotHandler( + $this->slotRepository, + $this->exceptionRepository, + $clock, + ); + } + + #[Test] + public function thisOccurrenceCreatesAnException(): void + { + $slot = $this->createAndSaveSlot(); + + $command = new UpdateRecurringSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + occurrenceDate: '2026-10-05', + scope: 'this_occurrence', + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::NEW_TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '10:00', + endTime: '11:00', + room: 'Salle 301', + updatedBy: self::UPDATER_ID, + ); + + $result = ($this->handler)($command); + + self::assertNotNull($result['exception']); + self::assertSame(ScheduleExceptionType::MODIFIED, $result['exception']->type); + self::assertSame('10:00', $result['exception']->newTimeSlot->startTime); + self::assertSame('Salle 301', $result['exception']->newRoom); + } + + #[Test] + public function allFutureEndCurrentRecurrenceAndCreatesNewSlot(): void + { + $slot = $this->createAndSaveSlot( + 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-12', + scope: 'all_future', + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::NEW_TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '10:00', + endTime: '11:00', + room: 'Salle 301', + updatedBy: self::UPDATER_ID, + ); + + $result = ($this->handler)($command); + + // Original slot's recurrence ends before the change date + $tenantId = TenantId::fromString(self::TENANT_ID); + $updatedOriginal = $this->slotRepository->get($slot->id, $tenantId); + self::assertSame('2026-10-11', $updatedOriginal->recurrenceEnd->format('Y-m-d')); + + // New slot starts from the change date + self::assertNotNull($result['newSlot']); + self::assertSame('2026-10-12', $result['newSlot']->recurrenceStart->format('Y-m-d')); + self::assertSame('2027-07-04', $result['newSlot']->recurrenceEnd->format('Y-m-d')); + self::assertSame('10:00', $result['newSlot']->timeSlot->startTime); + self::assertTrue($result['newSlot']->teacherId->equals(UserId::fromString(self::NEW_TEACHER_ID))); + } + + #[Test] + public function allFutureWithNoOriginalEndUsesNullForNewSlotEnd(): void + { + $slot = $this->createAndSaveSlot( + recurrenceStart: new DateTimeImmutable('2026-09-01'), + recurrenceEnd: null, + ); + + $command = new UpdateRecurringSlotCommand( + tenantId: self::TENANT_ID, + slotId: (string) $slot->id, + occurrenceDate: '2026-10-12', + scope: 'all_future', + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + dayOfWeek: DayOfWeek::MONDAY->value, + startTime: '10:00', + endTime: '11:00', + room: null, + updatedBy: self::UPDATER_ID, + ); + + $result = ($this->handler)($command); + + self::assertNotNull($result['newSlot']); + self::assertNull($result['newSlot']->recurrenceEnd); + } + + private function createAndSaveSlot( + ?DateTimeImmutable $recurrenceStart = null, + ?DateTimeImmutable $recurrenceEnd = null, + ): ScheduleSlot { + $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: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + recurrenceStart: $recurrenceStart, + recurrenceEnd: $recurrenceEnd, + ); + + $this->slotRepository->save($slot); + + return $slot; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Service/ScheduleResolverTest.php b/backend/tests/Unit/Scolarite/Application/Service/ScheduleResolverTest.php new file mode 100644 index 0000000..3823f2f --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Service/ScheduleResolverTest.php @@ -0,0 +1,375 @@ +slotRepository = new InMemoryScheduleSlotRepository(); + $this->exceptionRepository = new InMemoryScheduleExceptionRepository(); + $this->resolver = new ScheduleResolver( + $this->slotRepository, + $this->exceptionRepository, + ); + } + + #[Test] + public function resolveForWeekReturnsRecurringSlotsForSchoolDays(): void + { + $slot = $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00'); + + $calendar = $this->createCalendar(); + // 2026-09-07 is a Monday + $weekStart = new DateTimeImmutable('2026-09-07'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + self::assertCount(1, $resolved); + self::assertTrue($resolved[0]->slotId->equals($slot->id)); + self::assertSame('2026-09-07', $resolved[0]->date->format('Y-m-d')); + self::assertFalse($resolved[0]->isModified); + } + + #[Test] + public function resolveForWeekReturnsMultipleSlotsOnDifferentDays(): void + { + $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00'); + $this->createAndSaveSlot(DayOfWeek::WEDNESDAY, '10:00', '11:00'); + $this->createAndSaveSlot(DayOfWeek::FRIDAY, '14:00', '15:00'); + + $calendar = $this->createCalendar(); + $weekStart = new DateTimeImmutable('2026-09-07'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + self::assertCount(3, $resolved); + } + + #[Test] + public function resolveForWeekSkipsCancelledOccurrences(): void + { + $slot = $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00'); + + $exception = ScheduleException::annuler( + tenantId: TenantId::fromString(self::TENANT_ID), + slotId: $slot->id, + exceptionDate: new DateTimeImmutable('2026-09-07'), + reason: 'Grève', + createdBy: UserId::fromString(self::CREATOR_ID), + now: new DateTimeImmutable('2026-09-01 10:00:00'), + ); + $this->exceptionRepository->save($exception); + + $calendar = $this->createCalendar(); + $weekStart = new DateTimeImmutable('2026-09-07'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + self::assertCount(0, $resolved); + } + + #[Test] + public function resolveForWeekAppliesModifiedExceptionOverrides(): void + { + $slot = $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00'); + + $newTimeSlot = new TimeSlot('10:00', '11:00'); + $newTeacherId = UserId::fromString('550e8400-e29b-41d4-a716-446655440011'); + $exception = ScheduleException::modifier( + tenantId: TenantId::fromString(self::TENANT_ID), + slotId: $slot->id, + exceptionDate: new DateTimeImmutable('2026-09-07'), + newTimeSlot: $newTimeSlot, + newRoom: 'Salle 301', + newTeacherId: $newTeacherId, + reason: 'Changement de salle', + createdBy: UserId::fromString(self::CREATOR_ID), + now: new DateTimeImmutable('2026-09-01 10:00:00'), + ); + $this->exceptionRepository->save($exception); + + $calendar = $this->createCalendar(); + $weekStart = new DateTimeImmutable('2026-09-07'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + self::assertCount(1, $resolved); + self::assertTrue($resolved[0]->isModified); + self::assertSame('10:00', $resolved[0]->timeSlot->startTime); + self::assertSame('11:00', $resolved[0]->timeSlot->endTime); + self::assertSame('Salle 301', $resolved[0]->room); + self::assertTrue($resolved[0]->teacherId->equals($newTeacherId)); + } + + #[Test] + public function resolveForWeekSkipsNonSchoolDays(): void + { + // Saturday slot — should not appear (weekend) + $this->createAndSaveSlot(DayOfWeek::SATURDAY, '08:00', '09:00'); + + $calendar = $this->createCalendar(); + $weekStart = new DateTimeImmutable('2026-09-07'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + self::assertCount(0, $resolved); + } + + #[Test] + public function resolveForWeekSkipsVacationDays(): void + { + // Monday slot, but this Monday is during vacation + $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00'); + + $calendar = $this->createCalendarWithVacation( + new DateTimeImmutable('2026-10-19'), + new DateTimeImmutable('2026-11-01'), + ); + + // Week starting Monday Oct 19 (during vacation) + $weekStart = new DateTimeImmutable('2026-10-19'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + self::assertCount(0, $resolved); + } + + #[Test] + public function resolveForWeekSkipsHolidays(): void + { + // Monday slot + $this->createAndSaveSlot(DayOfWeek::MONDAY, '08:00', '09:00'); + // Wednesday slot + $this->createAndSaveSlot(DayOfWeek::WEDNESDAY, '10:00', '11:00'); + + // Nov 11 2026 is a Wednesday (holiday: Armistice) + $calendar = $this->createCalendarWithHoliday(new DateTimeImmutable('2026-11-11')); + + // Week of Nov 9 2026 (Monday) + $weekStart = new DateTimeImmutable('2026-11-09'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + // Monday should appear, Wednesday (holiday) should not + self::assertCount(1, $resolved); + self::assertSame('2026-11-09', $resolved[0]->date->format('Y-m-d')); + } + + #[Test] + public function resolveForWeekRespectsRecurrenceBounds(): void + { + // Slot with recurrence starting Sep 15 + $tenantId = TenantId::fromString(self::TENANT_ID); + $slot = ScheduleSlot::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + dayOfWeek: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + recurrenceStart: new DateTimeImmutable('2026-09-15'), + recurrenceEnd: new DateTimeImmutable('2027-07-04'), + ); + $this->slotRepository->save($slot); + + $calendar = $this->createCalendar(); + + // Week of Sep 7 — before recurrence start + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + new DateTimeImmutable('2026-09-07'), + $tenantId, + $calendar, + ); + self::assertCount(0, $resolved); + + // Week of Sep 15 — within recurrence bounds + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + new DateTimeImmutable('2026-09-15'), + $tenantId, + $calendar, + ); + self::assertCount(1, $resolved); + } + + #[Test] + public function resolveForWeekIgnoresNonRecurringSlots(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $slot = ScheduleSlot::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + dayOfWeek: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: false, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + ); + $this->slotRepository->save($slot); + + $calendar = $this->createCalendar(); + $weekStart = new DateTimeImmutable('2026-09-07'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + $tenantId, + $calendar, + ); + + self::assertCount(0, $resolved); + } + + #[Test] + public function resolveForWeekReturnsEmptyForClassWithNoSlots(): void + { + $calendar = $this->createCalendar(); + $weekStart = new DateTimeImmutable('2026-09-07'); + + $resolved = $this->resolver->resolveForWeek( + ClassId::fromString(self::CLASS_ID), + $weekStart, + TenantId::fromString(self::TENANT_ID), + $calendar, + ); + + self::assertCount(0, $resolved); + } + + private function createAndSaveSlot( + DayOfWeek $dayOfWeek, + string $startTime, + string $endTime, + ?string $room = null, + ): ScheduleSlot { + $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: $dayOfWeek, + timeSlot: new TimeSlot($startTime, $endTime), + room: $room, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + ); + + $this->slotRepository->save($slot); + + return $slot; + } + + private function createCalendar(): SchoolCalendar + { + return SchoolCalendar::reconstitute( + tenantId: TenantId::fromString(self::TENANT_ID), + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440090'), + zone: null, + entries: [], + ); + } + + private function createCalendarWithVacation( + DateTimeImmutable $start, + DateTimeImmutable $end, + ): SchoolCalendar { + $calendar = $this->createCalendar(); + $calendar->ajouterEntree(new CalendarEntry( + id: CalendarEntryId::generate(), + type: CalendarEntryType::VACATION, + startDate: $start, + endDate: $end, + label: 'Vacances de la Toussaint', + )); + + return $calendar; + } + + private function createCalendarWithHoliday(DateTimeImmutable $date): SchoolCalendar + { + $calendar = $this->createCalendar(); + $calendar->ajouterEntree(new CalendarEntry( + id: CalendarEntryId::generate(), + type: CalendarEntryType::HOLIDAY, + startDate: $date, + endDate: $date, + label: 'Armistice', + )); + + return $calendar; + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleExceptionTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleExceptionTest.php new file mode 100644 index 0000000..2becafb --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleExceptionTest.php @@ -0,0 +1,168 @@ +tenantId->equals(TenantId::fromString(self::TENANT_ID))); + self::assertTrue($exception->slotId->equals(ScheduleSlotId::fromString(self::SLOT_ID))); + self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d')); + self::assertSame(ScheduleExceptionType::CANCELLED, $exception->type); + self::assertSame('Grève', $exception->reason); + self::assertNull($exception->newTimeSlot); + self::assertNull($exception->newRoom); + self::assertNull($exception->newTeacherId); + self::assertTrue($exception->createdBy->equals(UserId::fromString(self::CREATOR_ID))); + } + + #[Test] + public function modifierCreatesAModifiedExceptionWithNewValues(): void + { + $newTimeSlot = new TimeSlot('10:00', '11:00'); + $newTeacherId = UserId::fromString(self::TEACHER_ID); + + $exception = ScheduleException::modifier( + tenantId: TenantId::fromString(self::TENANT_ID), + slotId: ScheduleSlotId::fromString(self::SLOT_ID), + exceptionDate: new DateTimeImmutable('2026-10-05'), + newTimeSlot: $newTimeSlot, + newRoom: 'Salle 301', + newTeacherId: $newTeacherId, + reason: 'Changement de salle', + createdBy: UserId::fromString(self::CREATOR_ID), + now: new DateTimeImmutable('2026-10-01 10:00:00'), + ); + + self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type); + self::assertNotNull($exception->newTimeSlot); + self::assertSame('10:00', $exception->newTimeSlot->startTime); + self::assertSame('11:00', $exception->newTimeSlot->endTime); + self::assertSame('Salle 301', $exception->newRoom); + self::assertTrue($exception->newTeacherId->equals($newTeacherId)); + } + + #[Test] + public function modifierWithPartialOverridesKeepsNullsForUnchangedFields(): void + { + $exception = ScheduleException::modifier( + tenantId: TenantId::fromString(self::TENANT_ID), + slotId: ScheduleSlotId::fromString(self::SLOT_ID), + exceptionDate: new DateTimeImmutable('2026-10-05'), + newTimeSlot: null, + newRoom: 'Salle 302', + newTeacherId: null, + reason: null, + createdBy: UserId::fromString(self::CREATOR_ID), + now: new DateTimeImmutable('2026-10-01 10:00:00'), + ); + + self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type); + self::assertNull($exception->newTimeSlot); + self::assertSame('Salle 302', $exception->newRoom); + self::assertNull($exception->newTeacherId); + self::assertNull($exception->reason); + } + + #[Test] + public function isCancelledReturnsTrueForCancelledType(): void + { + $exception = ScheduleException::annuler( + tenantId: TenantId::fromString(self::TENANT_ID), + slotId: ScheduleSlotId::fromString(self::SLOT_ID), + exceptionDate: new DateTimeImmutable('2026-10-05'), + reason: null, + createdBy: UserId::fromString(self::CREATOR_ID), + now: new DateTimeImmutable('2026-10-01 10:00:00'), + ); + + self::assertTrue($exception->isCancelled()); + self::assertFalse($exception->isModified()); + } + + #[Test] + public function isModifiedReturnsTrueForModifiedType(): void + { + $exception = ScheduleException::modifier( + tenantId: TenantId::fromString(self::TENANT_ID), + slotId: ScheduleSlotId::fromString(self::SLOT_ID), + exceptionDate: new DateTimeImmutable('2026-10-05'), + newTimeSlot: new TimeSlot('14:00', '15:00'), + newRoom: null, + newTeacherId: null, + reason: null, + createdBy: UserId::fromString(self::CREATOR_ID), + now: new DateTimeImmutable('2026-10-01 10:00:00'), + ); + + self::assertTrue($exception->isModified()); + self::assertFalse($exception->isCancelled()); + } + + #[Test] + public function reconstituteRestoresAllPropertiesWithoutEvents(): void + { + $id = ScheduleExceptionId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $slotId = ScheduleSlotId::fromString(self::SLOT_ID); + $newTimeSlot = new TimeSlot('11:00', '12:00'); + $newTeacherId = UserId::fromString(self::TEACHER_ID); + $createdBy = UserId::fromString(self::CREATOR_ID); + $createdAt = new DateTimeImmutable('2026-10-01 10:00:00'); + + $exception = ScheduleException::reconstitute( + id: $id, + tenantId: $tenantId, + slotId: $slotId, + exceptionDate: new DateTimeImmutable('2026-10-05'), + type: ScheduleExceptionType::MODIFIED, + newTimeSlot: $newTimeSlot, + newRoom: 'Salle 303', + newTeacherId: $newTeacherId, + reason: 'Interversion', + createdBy: $createdBy, + createdAt: $createdAt, + ); + + self::assertTrue($exception->id->equals($id)); + self::assertTrue($exception->tenantId->equals($tenantId)); + self::assertTrue($exception->slotId->equals($slotId)); + self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d')); + self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type); + self::assertSame('11:00', $exception->newTimeSlot->startTime); + self::assertSame('Salle 303', $exception->newRoom); + self::assertTrue($exception->newTeacherId->equals($newTeacherId)); + self::assertSame('Interversion', $exception->reason); + self::assertTrue($exception->createdBy->equals($createdBy)); + self::assertEquals($createdAt, $exception->createdAt); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php index bf70954..6251a99 100644 --- a/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php +++ b/backend/tests/Unit/Scolarite/Domain/Model/Schedule/ScheduleSlotTest.php @@ -176,6 +176,161 @@ final class ScheduleSlotTest extends TestCase self::assertEmpty($slot->pullDomainEvents()); } + #[Test] + public function creerWithRecurrenceBoundsSetsDates(): void + { + $recurrenceStart = new DateTimeImmutable('2026-09-01'); + $recurrenceEnd = new DateTimeImmutable('2027-07-04'); + + $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: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + recurrenceStart: $recurrenceStart, + recurrenceEnd: $recurrenceEnd, + ); + + self::assertEquals($recurrenceStart, $slot->recurrenceStart); + self::assertEquals($recurrenceEnd, $slot->recurrenceEnd); + } + + #[Test] + public function creerWithoutRecurrenceBoundsDefaultsToNull(): void + { + $slot = $this->createSlot(); + + self::assertNull($slot->recurrenceStart); + self::assertNull($slot->recurrenceEnd); + } + + #[Test] + public function isActiveOnDateReturnsTrueForMatchingDayWithinBounds(): void + { + $slot = $this->createRecurringSlot( + dayOfWeek: DayOfWeek::MONDAY, + recurrenceStart: new DateTimeImmutable('2026-09-01'), + recurrenceEnd: new DateTimeImmutable('2027-07-04'), + ); + + // 2026-09-07 is a Monday within bounds + self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07'))); + } + + #[Test] + public function isActiveOnDateReturnsFalseForWrongDayOfWeek(): void + { + $slot = $this->createRecurringSlot( + dayOfWeek: DayOfWeek::MONDAY, + recurrenceStart: new DateTimeImmutable('2026-09-01'), + recurrenceEnd: new DateTimeImmutable('2027-07-04'), + ); + + // 2026-09-08 is a Tuesday + self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-09-08'))); + } + + #[Test] + public function isActiveOnDateReturnsFalseBeforeRecurrenceStart(): void + { + $slot = $this->createRecurringSlot( + dayOfWeek: DayOfWeek::MONDAY, + recurrenceStart: new DateTimeImmutable('2026-09-01'), + recurrenceEnd: new DateTimeImmutable('2027-07-04'), + ); + + // 2026-08-25 is a Monday but before start + self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-08-25'))); + } + + #[Test] + public function isActiveOnDateReturnsFalseAfterRecurrenceEnd(): void + { + $slot = $this->createRecurringSlot( + dayOfWeek: DayOfWeek::MONDAY, + recurrenceStart: new DateTimeImmutable('2026-09-01'), + recurrenceEnd: new DateTimeImmutable('2027-07-04'), + ); + + // 2027-07-07 is a Monday but after end + self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2027-07-07'))); + } + + #[Test] + public function isActiveOnDateReturnsTrueWithNoBounds(): void + { + $slot = $this->createRecurringSlot( + dayOfWeek: DayOfWeek::MONDAY, + recurrenceStart: null, + recurrenceEnd: null, + ); + + // Any Monday should work + self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07'))); + } + + #[Test] + public function isActiveOnDateReturnsFalseForNonRecurringSlot(): 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: DayOfWeek::MONDAY, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: false, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + ); + + self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07'))); + } + + #[Test] + public function isActiveOnDateIncludesBoundaryDates(): void + { + $slot = $this->createRecurringSlot( + dayOfWeek: DayOfWeek::MONDAY, + recurrenceStart: new DateTimeImmutable('2026-09-07'), + recurrenceEnd: new DateTimeImmutable('2026-09-07'), + ); + + // Exact start = exact end date + self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07'))); + } + + #[Test] + public function reconstituteRestoresRecurrenceBounds(): void + { + $recurrenceStart = new DateTimeImmutable('2026-09-01'); + $recurrenceEnd = new DateTimeImmutable('2027-07-04'); + + $slot = ScheduleSlot::reconstitute( + id: ScheduleSlotId::generate(), + 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: DayOfWeek::FRIDAY, + timeSlot: new TimeSlot('14:00', '15:30'), + room: null, + isRecurring: true, + createdAt: new DateTimeImmutable('2026-03-01 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-02 14:00:00'), + recurrenceStart: $recurrenceStart, + recurrenceEnd: $recurrenceEnd, + ); + + self::assertEquals($recurrenceStart, $slot->recurrenceStart); + self::assertEquals($recurrenceEnd, $slot->recurrenceEnd); + } + private function createSlot(?string $room = null): ScheduleSlot { return ScheduleSlot::creer( @@ -190,4 +345,24 @@ final class ScheduleSlotTest extends TestCase now: new DateTimeImmutable('2026-03-01 10:00:00'), ); } + + private function createRecurringSlot( + DayOfWeek $dayOfWeek, + ?DateTimeImmutable $recurrenceStart, + ?DateTimeImmutable $recurrenceEnd, + ): ScheduleSlot { + return 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: $dayOfWeek, + timeSlot: new TimeSlot('08:00', '09:00'), + room: null, + isRecurring: true, + now: new DateTimeImmutable('2026-09-01 10:00:00'), + recurrenceStart: $recurrenceStart, + recurrenceEnd: $recurrenceEnd, + ); + } } diff --git a/frontend/e2e/schedule-advanced.spec.ts b/frontend/e2e/schedule-advanced.spec.ts index 35c66ca..904b702 100644 --- a/frontend/e2e/schedule-advanced.spec.ts +++ b/frontend/e2e/schedule-advanced.spec.ts @@ -120,11 +120,18 @@ async function waitForScheduleReady(page: import('@playwright/test').Page) { timeout: 15000 }); // Wait for either the grid or the empty state to appear - await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({ + await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({ timeout: 15000 }); } +async function chooseScopeAndEdit(page: import('@playwright/test').Page) { + // Scope modal appears - choose "this occurrence" + const scopeModal = page.getByRole('dialog'); + await expect(scopeModal).toBeVisible({ timeout: 10000 }); + await scopeModal.getByText('Cette occurrence uniquement').click(); +} + async function fillSlotForm( dialog: import('@playwright/test').Locator, options: { @@ -228,7 +235,7 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story // AC3: Slot Modification & Deletion // ========================================================================== test.describe('AC3: Slot Modification & Deletion', () => { - test('clicking a slot opens edit modal', async ({ page }) => { + test('clicking a slot opens scope modal then edit modal', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); @@ -246,11 +253,13 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story await dialog.getByRole('button', { name: /créer/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); - // Click on the created slot + // Click on the created slot — scope modal should appear const slotCard = page.locator('.slot-card').first(); await expect(slotCard).toBeVisible({ timeout: 10000 }); await slotCard.click(); + await chooseScopeAndEdit(page); + // Edit modal should appear const editDialog = page.getByRole('dialog'); await expect(editDialog).toBeVisible({ timeout: 10000 }); @@ -277,17 +286,24 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story await dialog.getByRole('button', { name: /créer/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); - // Click on the slot to edit + // Click on the slot — scope modal then edit modal const slotCard = page.locator('.slot-card').first(); await expect(slotCard).toBeVisible({ timeout: 10000 }); await slotCard.click(); + await chooseScopeAndEdit(page); + dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); - // Click delete button + // Click delete button — opens scope modal again for delete scope await dialog.getByRole('button', { name: /supprimer/i }).click(); + // Scope modal appears for delete action + const scopeModal = page.locator('.modal-scope'); + await expect(scopeModal).toBeVisible({ timeout: 10000 }); + await scopeModal.getByText('Cette occurrence uniquement').click(); + // Confirmation modal should appear const deleteModal = page.getByRole('alertdialog'); await expect(deleteModal).toBeVisible({ timeout: 10000 }); @@ -323,9 +339,11 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story await expect(slotCard).toBeVisible({ timeout: 10000 }); await expect(slotCard.getByText('A101')).toBeVisible(); - // Click to open edit modal + // Click to open scope modal then edit modal await slotCard.click(); + await chooseScopeAndEdit(page); + dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); await expect( @@ -520,5 +538,61 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story const dialog = page.getByRole('dialog'); await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); + + test('recurring slots do not appear on vacation days in next week (AC4)', async ({ + page + }) => { + // Compute next week Thursday (use local format to avoid UTC shift) + const thisThursday = new Date(getWeekdayInCurrentWeek(4) + 'T00:00:00'); + const nextThursday = new Date(thisThursday); + nextThursday.setDate(thisThursday.getDate() + 7); + const y = nextThursday.getFullYear(); + const m = String(nextThursday.getMonth() + 1).padStart(2, '0'); + const d = String(nextThursday.getDate()).padStart(2, '0'); + const nextThursdayStr = `${y}-${m}-${d}`; + + // Clean calendar entries and seed a vacation on next Thursday only + cleanupCalendarEntries(); + seedBlockedDate(nextThursdayStr, 'Vacances AC4', 'vacation'); + clearCache(); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Create a Thursday slot (if none exists) so we have a recurring slot on Thursday + const dayColumns = page.locator('.day-column'); + // Check if Thursday column (index 3) already has a slot + const thursdaySlots = dayColumns.nth(3).locator('.slot-card'); + const existingCount = await thursdaySlots.count(); + + if (existingCount === 0) { + // Create a slot on Thursday + const thursdayCell = dayColumns.nth(3).locator('.time-cell').first(); + await thursdayCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await fillSlotForm(dialog, { dayValue: '4', startTime: '09:00', endTime: '10:00' }); + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + await waitForScheduleReady(page); + } + + // Verify slot is visible on current week's Thursday + await expect(dayColumns.nth(3).locator('.slot-card').first()).toBeVisible({ + timeout: 10000 + }); + + // Navigate to next week + await page.getByRole('button', { name: 'Semaine suivante' }).click(); + await waitForScheduleReady(page); + + // Next week Thursday should be blocked + await expect(dayColumns.nth(3)).toHaveClass(/day-blocked/, { timeout: 10000 }); + + // No slot card should appear on the blocked Thursday + await expect(dayColumns.nth(3).locator('.slot-card')).toHaveCount(0); + }); }); }); diff --git a/frontend/e2e/schedule.spec.ts b/frontend/e2e/schedule.spec.ts index f7ea8dd..3cbb8e0 100644 --- a/frontend/e2e/schedule.spec.ts +++ b/frontend/e2e/schedule.spec.ts @@ -94,7 +94,7 @@ async function waitForScheduleReady(page: import('@playwright/test').Page) { timeout: 15000 }); // Wait for either the grid or the empty state to appear - await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({ + await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({ timeout: 15000 }); } @@ -415,3 +415,244 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)', }); }); }); + +test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 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(); + + 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` + ); + } 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-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + cleanupScheduleData(); + clearCache(); + }); + + test.beforeEach(async () => { + cleanupScheduleData(); + try { + runSql(`DELETE FROM schedule_exceptions WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } + try { + runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } + seedTeacherAssignments(); + clearCache(); + }); + + // ========================================================================== + // AC2: Week Navigation + // ========================================================================== + test.describe('AC2: Week Navigation', () => { + test('displays week navigation controls', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Week navigation should be visible + const weekNav = page.locator('.week-nav'); + await expect(weekNav).toBeVisible(); + + // Previous/next buttons + await expect(weekNav.getByLabel('Semaine précédente')).toBeVisible(); + await expect(weekNav.getByLabel('Semaine suivante')).toBeVisible(); + + // Week label + await expect(weekNav.locator('.week-label')).toBeVisible(); + }); + + test('can navigate to next and previous weeks', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + const weekLabel = page.locator('.week-label'); + const initialLabel = await weekLabel.textContent(); + + // Navigate to next week + await page.getByLabel('Semaine suivante').click(); + await expect(weekLabel).not.toHaveText(initialLabel!, { timeout: 5000 }); + const nextLabel = await weekLabel.textContent(); + + // Navigate back + await page.getByLabel('Semaine précédente').click(); + await expect(weekLabel).toHaveText(initialLabel!, { timeout: 5000 }); + + // Navigate to next again + await page.getByLabel('Semaine suivante').click(); + await expect(weekLabel).toHaveText(nextLabel!, { timeout: 5000 }); + }); + + test('today button appears when not on current week', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // "Aujourd'hui" button should not be visible on current week + await expect(page.locator('.week-nav-today')).not.toBeVisible(); + + // Navigate away + await page.getByLabel('Semaine suivante').click(); + + // "Aujourd'hui" button should appear + await expect(page.locator('.week-nav-today')).toBeVisible({ timeout: 5000 }); + + // Click it to go back + await page.locator('.week-nav-today').click(); + + // Should disappear again + await expect(page.locator('.week-nav-today')).not.toBeVisible({ timeout: 5000 }); + }); + }); + + // ========================================================================== + // AC1/AC2: Recurring indicator + // ========================================================================== + test.describe('AC1: Recurring Indicator', () => { + test('recurring slots show recurring badge', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Create a slot first + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(dialog, { room: 'B201' }); + await dialog.getByRole('button', { name: /créer/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Slot card should have the recurring badge + const slotCard = page.locator('.slot-card'); + await expect(slotCard).toBeVisible({ timeout: 10000 }); + await expect(slotCard.locator('.slot-badge-recurring')).toBeVisible(); + }); + }); + + // ========================================================================== + // AC3: Scope Choice Modal + // ========================================================================== + test.describe('AC3: Scope Choice Modal', () => { + test('clicking a slot opens scope choice modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Create a slot + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + const createDialog = page.getByRole('dialog'); + await expect(createDialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(createDialog, { room: 'C301' }); + await createDialog.getByRole('button', { name: /créer/i }).click(); + await expect(createDialog).not.toBeVisible({ timeout: 10000 }); + + // Click on the slot card + const slotCard = page.locator('.slot-card'); + await expect(slotCard).toBeVisible({ timeout: 10000 }); + await slotCard.click(); + + // Scope modal should appear + const scopeModal = page.locator('.modal-scope'); + await expect(scopeModal).toBeVisible({ timeout: 10000 }); + await expect(scopeModal.getByText('Cette occurrence uniquement')).toBeVisible(); + await expect(scopeModal.getByText('Toutes les occurrences futures')).toBeVisible(); + }); + + test('scope modal can be closed with Escape', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Create a slot + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + const createDialog = page.getByRole('dialog'); + await expect(createDialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(createDialog); + await createDialog.getByRole('button', { name: /créer/i }).click(); + await expect(createDialog).not.toBeVisible({ timeout: 10000 }); + + // Click on the slot card + const slotCard = page.locator('.slot-card'); + await expect(slotCard).toBeVisible({ timeout: 10000 }); + await slotCard.click(); + + // Scope modal appears + const scopeModal = page.locator('.modal-scope'); + await expect(scopeModal).toBeVisible({ timeout: 10000 }); + + // Close with Escape + await page.keyboard.press('Escape'); + await expect(scopeModal).not.toBeVisible({ timeout: 5000 }); + }); + + test('choosing "this occurrence" opens edit form', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/schedule`); + await waitForScheduleReady(page); + + // Create a slot + const timeCell = page.locator('.time-cell').first(); + await timeCell.click(); + const createDialog = page.getByRole('dialog'); + await expect(createDialog).toBeVisible({ timeout: 10000 }); + + await fillSlotForm(createDialog, { room: 'D401' }); + await createDialog.getByRole('button', { name: /créer/i }).click(); + await expect(createDialog).not.toBeVisible({ timeout: 10000 }); + + // Click on the slot card + const slotCard = page.locator('.slot-card'); + await expect(slotCard).toBeVisible({ timeout: 10000 }); + await slotCard.click(); + + // Choose "this occurrence" + const scopeModal = page.locator('.modal-scope'); + await expect(scopeModal).toBeVisible({ timeout: 10000 }); + await scopeModal.getByText('Cette occurrence uniquement').click(); + + // Edit form should appear + const editDialog = page.getByRole('dialog'); + await expect(editDialog).toBeVisible({ timeout: 10000 }); + await expect( + editDialog.getByRole('heading', { name: /modifier le créneau/i }) + ).toBeVisible(); + }); + }); +}); diff --git a/frontend/src/routes/admin/schedule/+page.svelte b/frontend/src/routes/admin/schedule/+page.svelte index f24ea98..43310ee 100644 --- a/frontend/src/routes/admin/schedule/+page.svelte +++ b/frontend/src/routes/admin/schedule/+page.svelte @@ -4,19 +4,6 @@ import { untrack } from 'svelte'; // Types - interface ScheduleSlot { - id: string; - classId: string; - subjectId: string; - teacherId: string; - dayOfWeek: number; - startTime: string; - endTime: string; - room: string | null; - isRecurring: boolean; - conflicts?: Array<{ type: string; description: string; slotId: string }>; - } - interface SchoolClass { id: string; name: string; @@ -51,6 +38,21 @@ type: string; } + interface ResolvedSlot { + id: string; + slotId: string; + classId: string; + subjectId: string; + teacherId: string; + dayOfWeek: number; + startTime: string; + endTime: string; + room: string | null; + date: string; + isModified: boolean; + exceptionId: string | null; + } + // Constants const DAYS = [ { value: 1, label: 'Lundi' }, @@ -68,8 +70,22 @@ TIME_SLOTS.push(`${String(h).padStart(2, '0')}:30`); } + 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 getInitialMonday(): string { + const now = new Date(); + const d = new Date(now); + d.setDate(now.getDate() - ((now.getDay() + 6) % 7)); + return formatLocalDate(d); + } + // State - let slots = $state([]); + let slots = $state([]); let classes = $state([]); let subjects = $state([]); let teachers = $state([]); @@ -84,9 +100,13 @@ let selectedClassId = $state(''); let selectedTeacherFilter = $state(''); + // Week navigation + let currentWeekStart = $state(getInitialMonday()); + // Create/Edit modal let showSlotModal = $state(false); - let editingSlot = $state(null); + let editingSlot = $state(null); + let editScope = $state<'this_occurrence' | 'all_future'>('this_occurrence'); let formClassId = $state(''); let formSubjectId = $state(''); let formTeacherId = $state(''); @@ -100,11 +120,18 @@ // Delete modal let showDeleteModal = $state(false); - let slotToDelete = $state(null); + let slotToDelete = $state(null); let isDeleting = $state(false); + let deleteScope = $state<'this_occurrence' | 'all_future'>('this_occurrence'); + + // Scope choice modal + let showScopeModal = $state(false); + let scopeAction = $state<'edit' | 'delete' | 'move'>('edit'); + let scopeSlot = $state(null); // Drag state - let draggedSlot = $state(null); + let draggedSlot = $state(null); + let pendingDrop = $state<{ slot: ResolvedSlot; day: number; time: string } | null>(null); // Mobile: selected day tab let mobileSelectedDay = $state(1); @@ -144,6 +171,16 @@ let subjectMap = $derived(new Map(subjects.map((s) => [s.id, s]))); let teacherMap = $derived(new Map(teachers.map((t) => [t.id, t]))); + let weekLabel = $derived.by(() => { + const start = new Date(currentWeekStart + 'T00:00:00'); + const end = new Date(start); + end.setDate(start.getDate() + 4); + const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long' }; + const optsY: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' }; + return `${start.toLocaleDateString('fr-FR', opts)} - ${end.toLocaleDateString('fr-FR', optsY)}`; + }); + + let isCurrentWeek = $derived(currentWeekStart === getInitialMonday()); // Load on mount $effect(() => { @@ -189,11 +226,15 @@ } } - // Load slots whenever filter changes + // Load slots whenever filter or week changes $effect(() => { const classId = selectedClassId; const teacherId = selectedTeacherFilter; - untrack(() => loadSlots(classId, teacherId)); + const _week = currentWeekStart; + untrack(() => { + loadSlots(classId, teacherId); + loadBlockedDates(); + }); }); // Load assignments when class filter or form class changes @@ -219,20 +260,21 @@ try { isSlotsLoading = true; const apiUrl = getApiBaseUrl(); - const params = new URLSearchParams(); - if (classId) params.set('classId', classId); - if (teacherId) params.set('teacherId', teacherId); - const response = await authenticatedFetch( - `${apiUrl}/schedule/slots?${params.toString()}`, - { signal: controller.signal } - ); + const url = classId + ? `${apiUrl}/schedule/week/${currentWeekStart}?classId=${classId}` + : `${apiUrl}/schedule/slots?teacherId=${teacherId}`; + + const response = await authenticatedFetch(url, { signal: controller.signal }); if (controller.signal.aborted) return; if (!response.ok) throw new Error('Erreur lors du chargement de l\u2019emploi du temps.'); const data = await response.json(); - slots = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []); + const items: ResolvedSlot[] = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []); + + // Apply teacher filter client-side when using resolved API + slots = teacherId ? items.filter((s) => s.teacherId === teacherId) : items; } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') return; error = e instanceof Error ? e.message : 'Erreur inconnue.'; @@ -265,14 +307,12 @@ } function getCurrentWeekDates(): Map { - const now = new Date(); - const monday = new Date(now); - monday.setDate(now.getDate() - ((now.getDay() + 6) % 7)); + const monday = new Date(currentWeekStart + 'T00:00:00'); const dates = new Map(); for (let i = 0; i < 5; i++) { const d = new Date(monday); d.setDate(monday.getDate() + i); - dates.set(i + 1, d.toISOString().split('T')[0]!); + dates.set(i + 1, formatLocalDate(d)); } return dates; } @@ -302,12 +342,29 @@ } } + // Week navigation + function prevWeek() { + const d = new Date(currentWeekStart + 'T00:00:00'); + d.setDate(d.getDate() - 7); + currentWeekStart = formatLocalDate(d); + } + + function nextWeek() { + const d = new Date(currentWeekStart + 'T00:00:00'); + d.setDate(d.getDate() + 7); + currentWeekStart = formatLocalDate(d); + } + + function goToThisWeek() { + currentWeekStart = getInitialMonday(); + } + // Grid helpers - function getSlotTop(slot: ScheduleSlot): number { + function getSlotTop(slot: ResolvedSlot): number { return timeToMinutes(slot.startTime) - timeToMinutes(`${String(HOURS_START).padStart(2, '0')}:00`); } - function getSlotHeight(slot: ScheduleSlot): number { + function getSlotHeight(slot: ResolvedSlot): number { return timeToMinutes(slot.endTime) - timeToMinutes(slot.startTime); } @@ -338,7 +395,7 @@ } // Unique slots for a day column (deduplicated by slot id) - function getSlotsForDay(day: number): ScheduleSlot[] { + function getSlotsForDay(day: number): ResolvedSlot[] { return slots.filter((s) => s.dayOfWeek === day); } @@ -361,7 +418,34 @@ showSlotModal = true; } - function openEditModal(slot: ScheduleSlot) { + function handleSlotClick(slot: ResolvedSlot) { + scopeSlot = slot; + scopeAction = 'edit'; + showScopeModal = true; + } + + function handleScopeChoice(scope: 'this_occurrence' | 'all_future') { + showScopeModal = false; + if (scopeAction === 'edit') { + editScope = scope; + openEditModal(scopeSlot!); + } else if (scopeAction === 'delete') { + deleteScope = scope; + openDeleteModal(scopeSlot!); + } else if (scopeAction === 'move' && pendingDrop) { + executeDrop(pendingDrop.slot, pendingDrop.day, pendingDrop.time, scope); + pendingDrop = null; + } + scopeSlot = null; + } + + function closeScopeModal() { + showScopeModal = false; + scopeSlot = null; + pendingDrop = null; + } + + function openEditModal(slot: ResolvedSlot) { editingSlot = slot; formClassId = slot.classId; formSubjectId = slot.subjectId; @@ -381,11 +465,19 @@ formConflicts = []; } - function openDeleteModal(slot: ScheduleSlot) { + function openDeleteModal(slot: ResolvedSlot) { slotToDelete = slot; showDeleteModal = true; } + function handleDeleteFromEdit() { + const slot = editingSlot!; + closeSlotModal(); + scopeSlot = slot; + scopeAction = 'delete'; + showScopeModal = true; + } + function closeDeleteModal() { showDeleteModal = false; slotToDelete = null; @@ -413,11 +505,14 @@ }; const response = editingSlot - ? await authenticatedFetch(`${apiUrl}/schedule/slots/${editingSlot.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/merge-patch+json' }, - body: JSON.stringify(body) - }) + ? await authenticatedFetch( + `${apiUrl}/schedule/slots/${editingSlot.slotId}/occurrence/${editingSlot.date}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, scope: editScope }) + } + ) : await authenticatedFetch(`${apiUrl}/schedule/slots`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -431,7 +526,7 @@ ); } - const result: ScheduleSlot = await response.json(); + const result = await response.json(); // Conflits détectés et non forcés : garder la modale ouverte if (result.conflicts && result.conflicts.length > 0 && !formForceConflicts) { @@ -461,16 +556,23 @@ error = null; const apiUrl = getApiBaseUrl(); - const response = await authenticatedFetch(`${apiUrl}/schedule/slots/${slotToDelete.id}`, { - method: 'DELETE' - }); + const occurrenceDate = slotToDelete.date; + if (!occurrenceDate) return; + + const deleteUrl = deleteScope === 'all_future' + ? `${apiUrl}/schedule/slots/${slotToDelete.slotId}/occurrence/${occurrenceDate}?scope=all_future` + : `${apiUrl}/schedule/slots/${slotToDelete.slotId}/occurrence/${occurrenceDate}`; + + const response = await authenticatedFetch(deleteUrl, { method: 'DELETE' }); if (!response.ok) { const data = await response.json().catch(() => null); throw new Error(data?.message ?? data?.detail ?? 'Erreur lors de la suppression.'); } - successMessage = 'Créneau supprimé.'; + successMessage = deleteScope === 'this_occurrence' + ? 'Occurrence annulée.' + : 'Occurrences futures supprimées.'; closeDeleteModal(); await loadSlots(selectedClassId, selectedTeacherFilter); @@ -485,11 +587,11 @@ } // Drag & Drop - function handleDragStart(event: DragEvent, slot: ScheduleSlot) { + function handleDragStart(event: DragEvent, slot: ResolvedSlot) { draggedSlot = slot; if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', slot.id); + event.dataTransfer.setData('text/plain', slot.slotId); } } @@ -501,14 +603,31 @@ } } - async function handleDrop(event: DragEvent, day: number, time: string) { + function handleDrop(event: DragEvent, day: number, time: string) { event.preventDefault(); if (!draggedSlot) return; const slot = draggedSlot; draggedSlot = null; - // Calculate new end time preserving duration + if (day !== slot.dayOfWeek) { + // Cross-day moves require splitting the recurrence — skip scope modal + executeDrop(slot, day, time, 'all_future'); + return; + } + + pendingDrop = { slot, day, time }; + scopeSlot = slot; + scopeAction = 'move'; + showScopeModal = true; + } + + async function executeDrop( + slot: ResolvedSlot, + day: number, + time: string, + scope: 'this_occurrence' | 'all_future' + ) { const duration = timeToMinutes(slot.endTime) - timeToMinutes(slot.startTime); const newEndMinutes = timeToMinutes(time) + duration; const newEndH = Math.floor(newEndMinutes / 60); @@ -519,16 +638,23 @@ error = null; const apiUrl = getApiBaseUrl(); - const response = await authenticatedFetch(`${apiUrl}/schedule/slots/${slot.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/merge-patch+json' }, - body: JSON.stringify({ - dayOfWeek: day, - startTime: time, - endTime: newEndTime, - forceConflicts: false - }) - }); + const response = await authenticatedFetch( + `${apiUrl}/schedule/slots/${slot.slotId}/occurrence/${slot.date}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + classId: slot.classId, + subjectId: slot.subjectId, + teacherId: slot.teacherId, + dayOfWeek: day, + startTime: time, + endTime: newEndTime, + room: slot.room, + scope + }) + } + ); if (!response.ok) { const data = await response.json().catch(() => null); @@ -537,7 +663,7 @@ ); } - const result: ScheduleSlot = await response.json(); + const result: { conflicts?: Array<{ description: string }> } = await response.json(); if (result.conflicts && result.conflicts.length > 0) { error = `Conflit détecté : ${result.conflicts.map((c) => c.description).join(', ')}`; } else { @@ -560,7 +686,8 @@ { if (e.key === 'Escape') { - if (showDeleteModal) closeDeleteModal(); + if (showScopeModal) closeScopeModal(); + else if (showDeleteModal) closeDeleteModal(); else if (showSlotModal) closeSlotModal(); } }} /> @@ -613,6 +740,16 @@ + +
+ + {weekLabel} + + {#if !isCurrentWeek} + + {/if} +
+ {#if isLoading}
@@ -690,6 +827,7 @@
handleDragStart(e, slot)} ondragend={handleDragEnd} - onclick={(e) => { e.stopPropagation(); openEditModal(slot); }} + onclick={(e) => { e.stopPropagation(); handleSlotClick(slot); }} role="button" tabindex="0" - onkeydown={(e) => { if (e.key === 'Enter') openEditModal(slot); }} + onkeydown={(e) => { if (e.key === 'Enter') handleSlotClick(slot); }} title="{getSubjectName(slot.subjectId)} - {getTeacherName(slot.teacherId)}" > - {getSubjectName(slot.subjectId)} +
+ {getSubjectName(slot.subjectId)} + {#if slot.isModified} + M + {:else} + + {/if} +
{getTeacherName(slot.teacherId)} {#if slot.room} {slot.room} @@ -852,7 +997,7 @@ {/if} + +{#if showScopeModal && scopeSlot} + + +{/if} +