feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
Les administrateurs devaient recréer manuellement l'emploi du temps chaque semaine. Cette implémentation introduit un système de récurrence hebdomadaire avec gestion des exceptions par occurrence, permettant de modifier ou annuler un cours spécifique sans affecter les autres semaines. Le ScheduleResolver calcule dynamiquement l'EDT réel en combinant les créneaux récurrents, les exceptions ponctuelles et le calendrier scolaire (vacances/fériés).
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
53
backend/migrations/Version20260304103700.php
Normal file
53
backend/migrations/Version20260304103700.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260304103700 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Story 4.2 : Récurrences hebdomadaires — ajout bornes récurrence et table exceptions';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Ajout des bornes de récurrence sur schedule_slots
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleException;
|
||||
|
||||
final readonly class CreateScheduleExceptionCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
public string $exceptionDate,
|
||||
public string $type,
|
||||
public string $createdBy,
|
||||
public ?string $newStartTime = null,
|
||||
public ?string $newEndTime = null,
|
||||
public ?string $newRoom = null,
|
||||
public ?string $newTeacherId = null,
|
||||
public ?string $reason = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleException;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateScheduleExceptionHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $slotRepository,
|
||||
private ScheduleExceptionRepository $exceptionRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateScheduleExceptionCommand $command): ScheduleException
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\TruncateSlotRecurrence;
|
||||
|
||||
final readonly class TruncateSlotRecurrenceCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
public string $fromDate,
|
||||
public string $updatedBy,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\TruncateSlotRecurrence;
|
||||
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class TruncateSlotRecurrenceHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $slotRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(TruncateSlotRecurrenceCommand $command): void
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateRecurringSlot;
|
||||
|
||||
final readonly class UpdateRecurringSlotCommand
|
||||
{
|
||||
/**
|
||||
* @param string $scope 'this_occurrence' | 'all_future'
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
public string $occurrenceDate,
|
||||
public string $scope,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public string $updatedBy,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateRecurringSlot;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateRecurringSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $slotRepository,
|
||||
private ScheduleExceptionRepository $exceptionRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{exception: ?ScheduleException, newSlot: ?ScheduleSlot}
|
||||
*/
|
||||
public function __invoke(UpdateRecurringSlotCommand $command): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Résout l'emploi du temps réel d'une classe pour une semaine donnée.
|
||||
*
|
||||
* Combine les créneaux récurrents (semaine type), les exceptions ponctuelles,
|
||||
* et le calendrier scolaire pour produire la liste des cours effectifs.
|
||||
*/
|
||||
final readonly class ScheduleResolver
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $slotRepository,
|
||||
private ScheduleExceptionRepository $exceptionRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ResolvedScheduleSlot>
|
||||
*/
|
||||
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<ScheduleException> $exceptions
|
||||
*
|
||||
* @return array<string, ScheduleException>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
final class DateExceptionInvalideException extends RuntimeException
|
||||
{
|
||||
public static function pourSlotEtDate(ScheduleSlotId $slotId, DateTimeImmutable $date): self
|
||||
{
|
||||
return new self(
|
||||
"La date {$date->format('Y-m-d')} n'est pas valide pour le créneau $slotId "
|
||||
. '(mauvais jour de la semaine ou hors bornes de récurrence).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Représentation résolue d'un créneau pour une date donnée.
|
||||
*
|
||||
* Combine le créneau template (récurrent) avec ses éventuelles exceptions
|
||||
* pour donner l'état réel du cours à une date précise.
|
||||
*/
|
||||
final readonly class ResolvedScheduleSlot
|
||||
{
|
||||
public function __construct(
|
||||
public ScheduleSlotId $slotId,
|
||||
public TenantId $tenantId,
|
||||
public ClassId $classId,
|
||||
public SubjectId $subjectId,
|
||||
public UserId $teacherId,
|
||||
public DayOfWeek $dayOfWeek,
|
||||
public TimeSlot $timeSlot,
|
||||
public ?string $room,
|
||||
public DateTimeImmutable $date,
|
||||
public bool $isModified,
|
||||
public ?ScheduleExceptionId $exceptionId,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un créneau résolu à partir du template (pas d'exception).
|
||||
*/
|
||||
public static function fromSlot(ScheduleSlot $slot, DateTimeImmutable $date): self
|
||||
{
|
||||
return new self(
|
||||
slotId: $slot->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Représente une exception ponctuelle sur un créneau récurrent.
|
||||
*
|
||||
* Une exception peut être une annulation (cancelled) ou une modification
|
||||
* (modified) pour une date précise. Cela permet de gérer les changements
|
||||
* ponctuels sans altérer la semaine type.
|
||||
*
|
||||
* @see FR27: Définir une semaine type qui se répète automatiquement
|
||||
*/
|
||||
final class ScheduleException
|
||||
{
|
||||
private function __construct(
|
||||
public private(set) ScheduleExceptionId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) ScheduleSlotId $slotId,
|
||||
public private(set) DateTimeImmutable $exceptionDate,
|
||||
public private(set) ScheduleExceptionType $type,
|
||||
public private(set) ?TimeSlot $newTimeSlot,
|
||||
public private(set) ?string $newRoom,
|
||||
public private(set) ?UserId $newTeacherId,
|
||||
public private(set) ?string $reason,
|
||||
public private(set) UserId $createdBy,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule un cours récurrent pour une date donnée.
|
||||
*/
|
||||
public static function annuler(
|
||||
TenantId $tenantId,
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $exceptionDate,
|
||||
?string $reason,
|
||||
UserId $createdBy,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
return new self(
|
||||
id: ScheduleExceptionId::generate(),
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
exceptionDate: $exceptionDate,
|
||||
type: ScheduleExceptionType::CANCELLED,
|
||||
newTimeSlot: null,
|
||||
newRoom: null,
|
||||
newTeacherId: null,
|
||||
reason: $reason,
|
||||
createdBy: $createdBy,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie un cours récurrent pour une date donnée.
|
||||
*
|
||||
* Les champs null signifient "pas de changement par rapport au créneau original".
|
||||
*/
|
||||
public static function modifier(
|
||||
TenantId $tenantId,
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $exceptionDate,
|
||||
?TimeSlot $newTimeSlot,
|
||||
?string $newRoom,
|
||||
?UserId $newTeacherId,
|
||||
?string $reason,
|
||||
UserId $createdBy,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
return new self(
|
||||
id: ScheduleExceptionId::generate(),
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
exceptionDate: $exceptionDate,
|
||||
type: ScheduleExceptionType::MODIFIED,
|
||||
newTimeSlot: $newTimeSlot,
|
||||
newRoom: $newRoom,
|
||||
newTeacherId: $newTeacherId,
|
||||
reason: $reason,
|
||||
createdBy: $createdBy,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
public function isCancelled(): bool
|
||||
{
|
||||
return $this->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ScheduleExceptionId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
enum ScheduleExceptionType: string
|
||||
{
|
||||
case MODIFIED = 'modified';
|
||||
case CANCELLED = 'cancelled';
|
||||
}
|
||||
@@ -37,6 +37,8 @@ final class ScheduleSlot extends AggregateRoot
|
||||
public private(set) ?string $room,
|
||||
public private(set) bool $isRecurring,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
public private(set) ?DateTimeImmutable $recurrenceStart = null,
|
||||
public private(set) ?DateTimeImmutable $recurrenceEnd = null,
|
||||
) {
|
||||
$this->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;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface ScheduleExceptionRepository
|
||||
{
|
||||
public function save(ScheduleException $exception): void;
|
||||
|
||||
public function findById(ScheduleExceptionId $id, TenantId $tenantId): ?ScheduleException;
|
||||
|
||||
public function findForSlotAndDate(
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $date,
|
||||
TenantId $tenantId,
|
||||
): ?ScheduleException;
|
||||
|
||||
/**
|
||||
* @return array<ScheduleException>
|
||||
*/
|
||||
public function findForSlotBetweenDates(
|
||||
ScheduleSlotId $slotId,
|
||||
DateTimeImmutable $startDate,
|
||||
DateTimeImmutable $endDate,
|
||||
TenantId $tenantId,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* @return array<ScheduleException>
|
||||
*/
|
||||
public function findForDateRange(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $startDate,
|
||||
DateTimeImmutable $endDate,
|
||||
): array;
|
||||
|
||||
public function delete(ScheduleExceptionId $id, TenantId $tenantId): void;
|
||||
}
|
||||
@@ -26,6 +26,9 @@ interface ScheduleSlotRepository
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findRecurringByClass(ClassId $classId, TenantId $tenantId): array;
|
||||
|
||||
/** @return array<ScheduleSlot> */
|
||||
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array;
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionCommand;
|
||||
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionHandler;
|
||||
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceCommand;
|
||||
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleOccurrenceResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ScheduleOccurrenceResource, ScheduleOccurrenceResource>
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotCommand;
|
||||
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleOccurrenceResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ScheduleOccurrenceResource, ScheduleOccurrenceResource>
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ResolvedScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Provider pour l'emploi du temps résolu d'une semaine.
|
||||
*
|
||||
* @implements ProviderInterface<ResolvedScheduleSlotResource>
|
||||
*/
|
||||
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<ResolvedScheduleSlotResource> */
|
||||
#[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<string, string> $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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\ResolvedScheduleWeekProvider;
|
||||
|
||||
/**
|
||||
* API Resource pour l'emploi du temps résolu (récurrences + exceptions + calendrier).
|
||||
*
|
||||
* @see Story 4.2 - Récurrences Hebdomadaires
|
||||
* @see FR27 - Définir une semaine type qui se répète automatiquement
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ResolvedScheduleSlot',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/schedule/week/{date}',
|
||||
uriVariables: [
|
||||
'date' => 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\CancelOccurrenceProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\ModifyOccurrenceProcessor;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour les modifications ponctuelles d'occurrences de cours récurrents.
|
||||
*
|
||||
* @see Story 4.2 - Récurrences Hebdomadaires (AC3, AC5)
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ScheduleOccurrence',
|
||||
operations: [
|
||||
new Put(
|
||||
uriTemplate: '/schedule/slots/{id}/occurrence/{date}',
|
||||
read: false,
|
||||
processor: ModifyOccurrenceProcessor::class,
|
||||
name: 'modify_schedule_occurrence',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/schedule/slots/{id}/occurrence/{date}',
|
||||
read: false,
|
||||
processor: CancelOccurrenceProcessor::class,
|
||||
name: 'cancel_schedule_occurrence',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ScheduleOccurrenceResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
/**
|
||||
* 'this_occurrence' (modifier uniquement cette date) ou 'all_future' (toutes les futures).
|
||||
*/
|
||||
#[Assert\Choice(choices: ['this_occurrence', 'all_future'], message: 'Le scope doit être "this_occurrence" ou "all_future".')]
|
||||
public ?string $scope = 'this_occurrence';
|
||||
|
||||
public ?string $classId = null;
|
||||
|
||||
public ?string $subjectId = null;
|
||||
|
||||
public ?string $teacherId = null;
|
||||
|
||||
public ?int $dayOfWeek = null;
|
||||
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $startTime = null;
|
||||
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $endTime = null;
|
||||
|
||||
public ?string $room = null;
|
||||
|
||||
public ?string $reason = null;
|
||||
|
||||
public ?string $type = null;
|
||||
|
||||
public ?string $date = null;
|
||||
|
||||
public ?string $slotId = null;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineScheduleExceptionRepository implements ScheduleExceptionRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(ScheduleException $exception): void
|
||||
{
|
||||
$this->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<string, mixed> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_values;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
|
||||
final class InMemoryScheduleExceptionRepository implements ScheduleExceptionRepository
|
||||
{
|
||||
/** @var array<string, ScheduleException> */
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\CreateScheduleException;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionCommand;
|
||||
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CreateScheduleExceptionHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string CREATOR_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||
private CreateScheduleExceptionHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\TruncateSlotRecurrence;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceCommand;
|
||||
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TruncateSlotRecurrenceHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string UPDATER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private TruncateSlotRecurrenceHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\UpdateRecurringSlot;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotCommand;
|
||||
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotHandler;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UpdateRecurringSlotHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string NEW_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440011';
|
||||
private const string UPDATER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||
private UpdateRecurringSlotHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScheduleResolverTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string CREATOR_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||
private ScheduleResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScheduleExceptionTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string SLOT_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string CREATOR_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
#[Test]
|
||||
public function annulerCreatesACancelledExceptionWithCorrectProperties(): void
|
||||
{
|
||||
$exception = ScheduleException::annuler(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
|
||||
exceptionDate: new DateTimeImmutable('2026-10-05'),
|
||||
reason: 'Grève',
|
||||
createdBy: UserId::fromString(self::CREATOR_ID),
|
||||
now: new DateTimeImmutable('2026-10-01 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($exception->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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ScheduleSlot[]>([]);
|
||||
let slots = $state<ResolvedSlot[]>([]);
|
||||
let classes = $state<SchoolClass[]>([]);
|
||||
let subjects = $state<Subject[]>([]);
|
||||
let teachers = $state<User[]>([]);
|
||||
@@ -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<ScheduleSlot | null>(null);
|
||||
let editingSlot = $state<ResolvedSlot | null>(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<ScheduleSlot | null>(null);
|
||||
let slotToDelete = $state<ResolvedSlot | null>(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<ResolvedSlot | null>(null);
|
||||
|
||||
// Drag state
|
||||
let draggedSlot = $state<ScheduleSlot | null>(null);
|
||||
let draggedSlot = $state<ResolvedSlot | null>(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<number, string> {
|
||||
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<number, string>();
|
||||
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 @@
|
||||
|
||||
<svelte:window onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showDeleteModal) closeDeleteModal();
|
||||
if (showScopeModal) closeScopeModal();
|
||||
else if (showDeleteModal) closeDeleteModal();
|
||||
else if (showSlotModal) closeSlotModal();
|
||||
}
|
||||
}} />
|
||||
@@ -613,6 +740,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week navigation -->
|
||||
<div class="week-nav" aria-label="Navigation par semaine">
|
||||
<button class="week-nav-btn" onclick={prevWeek} aria-label="Semaine précédente"><</button>
|
||||
<span class="week-label">{weekLabel}</span>
|
||||
<button class="week-nav-btn" onclick={nextWeek} aria-label="Semaine suivante">></button>
|
||||
{#if !isCurrentWeek}
|
||||
<button class="week-nav-today" onclick={goToThisWeek}>Aujourd'hui</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
@@ -690,6 +827,7 @@
|
||||
<div
|
||||
class="slot-card"
|
||||
class:dragging={draggedSlot?.id === slot.id}
|
||||
class:slot-modified={slot.isModified}
|
||||
style="
|
||||
top: {getSlotTop(slot)}px;
|
||||
height: {getSlotHeight(slot)}px;
|
||||
@@ -700,13 +838,20 @@
|
||||
draggable="true"
|
||||
ondragstart={(e) => 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)}"
|
||||
>
|
||||
<span class="slot-subject">{getSubjectName(slot.subjectId)}</span>
|
||||
<div class="slot-header-row">
|
||||
<span class="slot-subject">{getSubjectName(slot.subjectId)}</span>
|
||||
{#if slot.isModified}
|
||||
<span class="slot-badge slot-badge-modified" title="Occurrence modifiée">M</span>
|
||||
{:else}
|
||||
<span class="slot-badge slot-badge-recurring" title="Cours récurrent">↻</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="slot-teacher">{getTeacherName(slot.teacherId)}</span>
|
||||
{#if slot.room}
|
||||
<span class="slot-room">{slot.room}</span>
|
||||
@@ -852,7 +997,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
onclick={() => { const slot = editingSlot!; closeSlotModal(); openDeleteModal(slot); }}
|
||||
onclick={handleDeleteFromEdit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Supprimer
|
||||
@@ -884,18 +1029,31 @@
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header class="modal-header modal-header-danger">
|
||||
<h2 id="delete-title">Supprimer le créneau</h2>
|
||||
<h2 id="delete-title">
|
||||
{deleteScope === 'this_occurrence' ? 'Annuler cette occurrence' : 'Supprimer le créneau'}
|
||||
</h2>
|
||||
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">×</button>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<p id="delete-description">
|
||||
Voulez-vous supprimer le créneau de
|
||||
<strong>{getSubjectName(slotToDelete!.subjectId)}</strong>
|
||||
({DAYS.find((d) => d.value === slotToDelete!.dayOfWeek)?.label}
|
||||
{slotToDelete!.startTime} - {slotToDelete!.endTime}) ?
|
||||
{#if deleteScope === 'this_occurrence'}
|
||||
Voulez-vous annuler le cours de
|
||||
<strong>{getSubjectName(slotToDelete!.subjectId)}</strong>
|
||||
du {slotToDelete!.date} ({DAYS.find((d) => d.value === slotToDelete!.dayOfWeek)?.label}
|
||||
{slotToDelete!.startTime} - {slotToDelete!.endTime}) ?
|
||||
{:else}
|
||||
Voulez-vous supprimer le créneau récurrent de
|
||||
<strong>{getSubjectName(slotToDelete!.subjectId)}</strong>
|
||||
({DAYS.find((d) => d.value === slotToDelete!.dayOfWeek)?.label}
|
||||
{slotToDelete!.startTime} - {slotToDelete!.endTime}) ?
|
||||
{/if}
|
||||
</p>
|
||||
<p class="delete-warning">
|
||||
{deleteScope === 'this_occurrence'
|
||||
? 'Le cours sera annulé uniquement pour cette date.'
|
||||
: 'Cette action supprimera toutes les occurrences futures.'}
|
||||
</p>
|
||||
<p class="delete-warning">Cette action est irréversible.</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
@@ -924,6 +1082,59 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scope Choice Modal -->
|
||||
{#if showScopeModal && scopeSlot}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onclick={closeScopeModal} role="presentation">
|
||||
<div
|
||||
class="modal modal-scope"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="scope-title"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header class="modal-header">
|
||||
<h2 id="scope-title">
|
||||
{scopeAction === 'edit' ? 'Modifier le cours' : 'Supprimer le cours'}
|
||||
</h2>
|
||||
<button class="modal-close" onclick={closeScopeModal} aria-label="Fermer">×</button>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="scope-description">
|
||||
Ce cours de <strong>{getSubjectName(scopeSlot.subjectId)}</strong> est récurrent.
|
||||
Que souhaitez-vous {scopeAction === 'edit' ? 'modifier' : 'supprimer'} ?
|
||||
</p>
|
||||
|
||||
<div class="scope-choices">
|
||||
<button
|
||||
class="scope-choice"
|
||||
onclick={() => handleScopeChoice('this_occurrence')}
|
||||
>
|
||||
<span class="scope-choice-title">Cette occurrence uniquement</span>
|
||||
<span class="scope-choice-desc">
|
||||
{scopeAction === 'edit'
|
||||
? 'Modifie uniquement le cours du ' + scopeSlot.date
|
||||
: 'Annule uniquement le cours du ' + scopeSlot.date}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="scope-choice"
|
||||
onclick={() => handleScopeChoice('all_future')}
|
||||
>
|
||||
<span class="scope-choice-title">Toutes les occurrences futures</span>
|
||||
<span class="scope-choice-desc">
|
||||
{scopeAction === 'edit'
|
||||
? 'Modifie ce cours et toutes les semaines suivantes'
|
||||
: 'Supprime ce cours et toutes les semaines suivantes'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.schedule-page {
|
||||
padding: 1rem;
|
||||
@@ -1451,6 +1662,130 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Week navigation */
|
||||
.week-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.week-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.week-nav-btn:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.week-label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.week-nav-today {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #3b82f6;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.week-nav-today:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Slot indicators */
|
||||
.slot-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.slot-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.55rem;
|
||||
line-height: 1;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.slot-badge-recurring {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.slot-badge-modified {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.slot-modified {
|
||||
border-left-style: dashed !important;
|
||||
}
|
||||
|
||||
/* Scope modal */
|
||||
.modal-scope {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.scope-description {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.scope-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scope-choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.scope-choice:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.scope-choice-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.scope-choice-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Day tabs (mobile only) */
|
||||
.day-tabs {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user