feat: Permettre la création et modification de l'emploi du temps des classes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

L'administration a besoin de construire et maintenir les emplois du temps
hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont
pas en conflit (même créneau, classes différentes) et que les affectations
enseignant-matière-classe sont respectées.

Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot),
la détection de conflits (classe, enseignant, salle) avec possibilité de
forcer, la validation des affectations côté serveur (AC2), l'intégration
calendrier pour les jours bloqués, une vue mobile-first avec onglets jour
par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
This commit is contained in:
2026-03-03 13:54:53 +01:00
parent 1db8a7a0b2
commit d103b34023
53 changed files with 6382 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
final readonly class CreateScheduleSlotCommand
{
public function __construct(
public string $tenantId,
public string $classId,
public string $subjectId,
public string $teacherId,
public int $dayOfWeek,
public string $startTime,
public string $endTime,
public ?string $room,
public bool $isRecurring = true,
public bool $forceConflicts = false,
) {
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
use App\Scolarite\Domain\Service\ScheduleConflict;
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateScheduleSlotHandler
{
public function __construct(
private ScheduleSlotRepository $repository,
private ScheduleConflictDetector $conflictDetector,
private EnseignantAffectationChecker $affectationChecker,
private Clock $clock,
) {
}
/**
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
*/
public function __invoke(CreateScheduleSlotCommand $command): array
{
$tenantId = TenantId::fromString($command->tenantId);
$classId = ClassId::fromString($command->classId);
$subjectId = SubjectId::fromString($command->subjectId);
$teacherId = UserId::fromString($command->teacherId);
$timeSlot = new TimeSlot($command->startTime, $command->endTime);
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
}
$slot = ScheduleSlot::creer(
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
dayOfWeek: DayOfWeek::from($command->dayOfWeek),
timeSlot: $timeSlot,
room: $command->room,
isRecurring: $command->isRecurring,
now: $this->clock->now(),
);
$conflicts = $this->conflictDetector->detectConflicts($slot, $tenantId);
if ($conflicts === [] || $command->forceConflicts) {
$this->repository->save($slot);
}
return ['slot' => $slot, 'conflicts' => $conflicts];
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
final readonly class DeleteScheduleSlotCommand
{
public function __construct(
public string $tenantId,
public string $slotId,
) {
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
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 Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class DeleteScheduleSlotHandler
{
public function __construct(
private ScheduleSlotRepository $repository,
private Clock $clock,
) {
}
/**
* @return ScheduleSlot Le slot supprimé (pour dispatch events)
*/
public function __invoke(DeleteScheduleSlotCommand $command): ScheduleSlot
{
$tenantId = TenantId::fromString($command->tenantId);
$slotId = ScheduleSlotId::fromString($command->slotId);
$slot = $this->repository->get($slotId, $tenantId);
$slot->supprimer($this->clock->now());
$this->repository->delete($slotId, $tenantId);
return $slot;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
final readonly class UpdateScheduleSlotCommand
{
public function __construct(
public string $tenantId,
public string $slotId,
public string $classId,
public string $subjectId,
public string $teacherId,
public int $dayOfWeek,
public string $startTime,
public string $endTime,
public ?string $room,
public bool $forceConflicts = false,
) {
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
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\ScheduleSlotRepository;
use App\Scolarite\Domain\Service\ScheduleConflict;
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateScheduleSlotHandler
{
public function __construct(
private ScheduleSlotRepository $repository,
private ScheduleConflictDetector $conflictDetector,
private EnseignantAffectationChecker $affectationChecker,
private Clock $clock,
) {
}
/**
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
*/
public function __invoke(UpdateScheduleSlotCommand $command): array
{
$tenantId = TenantId::fromString($command->tenantId);
$slotId = ScheduleSlotId::fromString($command->slotId);
$slot = $this->repository->get($slotId, $tenantId);
$classId = ClassId::fromString($command->classId);
$subjectId = SubjectId::fromString($command->subjectId);
$teacherId = UserId::fromString($command->teacherId);
$dayOfWeek = DayOfWeek::from($command->dayOfWeek);
$newTimeSlot = new TimeSlot($command->startTime, $command->endTime);
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
}
// Détecte les conflits avant de modifier le slot.
// On crée un slot temporaire avec les nouvelles valeurs pour la détection.
$preview = ScheduleSlot::creer(
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
dayOfWeek: $dayOfWeek,
timeSlot: $newTimeSlot,
room: $command->room,
isRecurring: $slot->isRecurring,
now: $this->clock->now(),
);
$conflicts = $this->conflictDetector->detectConflicts($preview, $tenantId, $slotId);
if ($conflicts === [] || $command->forceConflicts) {
$slot->modifier(
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
dayOfWeek: $dayOfWeek,
timeSlot: $newTimeSlot,
room: $command->room,
at: $this->clock->now(),
);
$this->repository->save($slot);
}
return ['slot' => $slot, 'conflicts' => $conflicts];
}
}