feat: Gestion des matières scolaires
Les établissements ont besoin de définir leur référentiel de matières pour pouvoir ensuite les associer aux enseignants et aux classes. Cette fonctionnalité permet aux administrateurs de créer, modifier et archiver les matières avec leurs propriétés (nom, code court, couleur). L'architecture suit le pattern DDD avec des Value Objects utilisant les property hooks PHP 8.5 pour garantir l'immutabilité et la validation. L'isolation multi-tenant est assurée par vérification dans les handlers.
This commit is contained in:
@@ -130,6 +130,10 @@ services:
|
|||||||
App\Administration\Domain\Repository\ClassRepository:
|
App\Administration\Domain\Repository\ClassRepository:
|
||||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassRepository
|
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassRepository
|
||||||
|
|
||||||
|
# Subject Repository (Story 2.2 - Gestion des matières)
|
||||||
|
App\Administration\Domain\Repository\SubjectRepository:
|
||||||
|
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSubjectRepository
|
||||||
|
|
||||||
# GeoLocation Service (null implementation - no geolocation)
|
# GeoLocation Service (null implementation - no geolocation)
|
||||||
App\Administration\Application\Port\GeoLocationService:
|
App\Administration\Application\Port\GeoLocationService:
|
||||||
alias: App\Administration\Infrastructure\Service\NullGeoLocationService
|
alias: App\Administration\Infrastructure\Service\NullGeoLocationService
|
||||||
|
|||||||
57
backend/migrations/Version20260205100001.php
Normal file
57
backend/migrations/Version20260205100001.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration pour créer la table subjects (matières).
|
||||||
|
*
|
||||||
|
* @see Story 2.2 - Création et Gestion des Matières
|
||||||
|
* @see FR74 - Structurer l'offre pédagogique
|
||||||
|
*/
|
||||||
|
final class Version20260205100001 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create subjects table for school subjects management';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE subjects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
school_id UUID NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
code VARCHAR(10) NOT NULL,
|
||||||
|
color VARCHAR(7),
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Index pour la recherche par tenant et school
|
||||||
|
$this->addSql('CREATE INDEX idx_subjects_tenant_id ON subjects(tenant_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_subjects_school_id ON subjects(school_id)');
|
||||||
|
|
||||||
|
// Index unique partiel pour l'unicité du code par tenant/school (excluant les matières archivées)
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE UNIQUE INDEX idx_subjects_unique_code_active
|
||||||
|
ON subjects (tenant_id, school_id, code)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS subjects');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\ArchiveSubject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command pour archiver une matière.
|
||||||
|
*/
|
||||||
|
final readonly class ArchiveSubjectCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $subjectId,
|
||||||
|
public string $tenantId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\ArchiveSubject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Repository\SubjectRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour archiver une matière.
|
||||||
|
*
|
||||||
|
* Note: La vérification des notes associées doit être faite par le Processor API
|
||||||
|
* avant d'appeler ce handler (via HasGradesForSubjectQuery).
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class ArchiveSubjectHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SubjectRepository $subjectRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ArchiveSubjectCommand $command): Subject
|
||||||
|
{
|
||||||
|
$subjectId = SubjectId::fromString($command->subjectId);
|
||||||
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
|
|
||||||
|
$subject = $this->subjectRepository->get($subjectId);
|
||||||
|
|
||||||
|
// Vérifier que la matière appartient au tenant (défense en profondeur)
|
||||||
|
if (!$subject->tenantId->equals($tenantId)) {
|
||||||
|
throw SubjectNotFoundException::withId($subjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject->archiver($this->clock->now());
|
||||||
|
|
||||||
|
$this->subjectRepository->save($subject);
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\CreateSubject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command pour créer une nouvelle matière.
|
||||||
|
*/
|
||||||
|
final readonly class CreateSubjectCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $tenantId,
|
||||||
|
public string $schoolId,
|
||||||
|
public string $name,
|
||||||
|
public string $code,
|
||||||
|
public ?string $color,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\CreateSubject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Domain\Repository\SubjectRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function assert;
|
||||||
|
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour créer une nouvelle matière.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class CreateSubjectHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SubjectRepository $subjectRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(CreateSubjectCommand $command): Subject
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
|
$schoolId = SchoolId::fromString($command->schoolId);
|
||||||
|
|
||||||
|
// Value Objects validate input; assert non-empty for PHPStan
|
||||||
|
assert($command->code !== '');
|
||||||
|
assert($command->name !== '');
|
||||||
|
$code = new SubjectCode($command->code);
|
||||||
|
|
||||||
|
// Vérifier l'unicité du code dans le tenant et l'école
|
||||||
|
$existingSubject = $this->subjectRepository->findByCode($code, $tenantId, $schoolId);
|
||||||
|
if ($existingSubject !== null) {
|
||||||
|
throw SubjectDejaExistanteException::avecCode($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
$color = null;
|
||||||
|
if ($command->color !== null) {
|
||||||
|
assert($command->color !== '');
|
||||||
|
$color = new SubjectColor($command->color);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = Subject::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
name: new SubjectName($command->name),
|
||||||
|
code: $code,
|
||||||
|
color: $color,
|
||||||
|
createdAt: $this->clock->now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->subjectRepository->save($subject);
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\UpdateSubject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command pour modifier une matière existante.
|
||||||
|
*/
|
||||||
|
final readonly class UpdateSubjectCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $subjectId,
|
||||||
|
public string $tenantId,
|
||||||
|
public string $schoolId,
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $code = null,
|
||||||
|
public ?string $color = null,
|
||||||
|
public ?string $description = null,
|
||||||
|
public bool $clearColor = false,
|
||||||
|
public bool $clearDescription = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\UpdateSubject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Domain\Repository\SubjectRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function assert;
|
||||||
|
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour modifier une matière existante.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class UpdateSubjectHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SubjectRepository $subjectRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(UpdateSubjectCommand $command): Subject
|
||||||
|
{
|
||||||
|
$subjectId = SubjectId::fromString($command->subjectId);
|
||||||
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
|
$schoolId = SchoolId::fromString($command->schoolId);
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
|
$subject = $this->subjectRepository->get($subjectId);
|
||||||
|
|
||||||
|
// Vérifier que la matière appartient au tenant (défense en profondeur)
|
||||||
|
if (!$subject->tenantId->equals($tenantId)) {
|
||||||
|
throw SubjectNotFoundException::withId($subjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour du nom
|
||||||
|
if ($command->name !== null) {
|
||||||
|
assert($command->name !== '');
|
||||||
|
$subject->renommer(new SubjectName($command->name), $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour du code
|
||||||
|
if ($command->code !== null) {
|
||||||
|
assert($command->code !== '');
|
||||||
|
$newCode = new SubjectCode($command->code);
|
||||||
|
|
||||||
|
// Vérifier l'unicité du code (sauf si c'est le même)
|
||||||
|
if (!$subject->code->equals($newCode)) {
|
||||||
|
$existingSubject = $this->subjectRepository->findByCode($newCode, $tenantId, $schoolId);
|
||||||
|
if ($existingSubject !== null && !$existingSubject->id->equals($subjectId)) {
|
||||||
|
throw SubjectDejaExistanteException::avecCode($newCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject->changerCode($newCode, $now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour de la couleur
|
||||||
|
if ($command->clearColor) {
|
||||||
|
$subject->changerCouleur(null, $now);
|
||||||
|
} elseif ($command->color !== null) {
|
||||||
|
assert($command->color !== '');
|
||||||
|
$subject->changerCouleur(new SubjectColor($command->color), $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour de la description
|
||||||
|
if ($command->clearDescription) {
|
||||||
|
$subject->decrire(null, $now);
|
||||||
|
} elseif ($command->description !== null) {
|
||||||
|
$subject->decrire($command->description, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->subjectRepository->save($subject);
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\GetSubjects;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Repository\SubjectRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour récupérer les matières actives d'un tenant.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
|
final readonly class GetSubjectsHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SubjectRepository $subjectRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return SubjectDto[]
|
||||||
|
*/
|
||||||
|
public function __invoke(GetSubjectsQuery $query): array
|
||||||
|
{
|
||||||
|
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
|
||||||
|
TenantId::fromString($query->tenantId),
|
||||||
|
SchoolId::fromString($query->schoolId),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Récupérer les comptages d'enseignants et de classes
|
||||||
|
// quand les modules Affectations seront implémentés (T7)
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn ($subject) => SubjectDto::fromDomain(
|
||||||
|
$subject,
|
||||||
|
teacherCount: 0, // Placeholder - T7
|
||||||
|
classCount: 0, // Placeholder - T7
|
||||||
|
),
|
||||||
|
$subjects,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\GetSubjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query pour récupérer les matières actives d'un tenant et d'une école.
|
||||||
|
*/
|
||||||
|
final readonly class GetSubjectsQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $tenantId,
|
||||||
|
public string $schoolId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\GetSubjects;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour représenter une matière dans les réponses de query.
|
||||||
|
*/
|
||||||
|
final readonly class SubjectDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $name,
|
||||||
|
public string $code,
|
||||||
|
public ?string $color,
|
||||||
|
public ?string $description,
|
||||||
|
public string $status,
|
||||||
|
public DateTimeImmutable $createdAt,
|
||||||
|
public DateTimeImmutable $updatedAt,
|
||||||
|
public int $teacherCount = 0,
|
||||||
|
public int $classCount = 0,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromDomain(
|
||||||
|
Subject $subject,
|
||||||
|
int $teacherCount = 0,
|
||||||
|
int $classCount = 0,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
id: (string) $subject->id,
|
||||||
|
name: (string) $subject->name,
|
||||||
|
code: (string) $subject->code,
|
||||||
|
color: $subject->color !== null ? (string) $subject->color : null,
|
||||||
|
description: $subject->description,
|
||||||
|
status: $subject->status->value,
|
||||||
|
createdAt: $subject->createdAt,
|
||||||
|
updatedAt: $subject->updatedAt,
|
||||||
|
teacherCount: $teacherCount,
|
||||||
|
classCount: $classCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/Administration/Domain/Event/MatiereCreee.php
Normal file
41
backend/src/Administration/Domain/Event/MatiereCreee.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lors de la création d'une matière.
|
||||||
|
*/
|
||||||
|
final readonly class MatiereCreee implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public SubjectId $subjectId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public SubjectName $name,
|
||||||
|
public SubjectCode $code,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->subjectId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/src/Administration/Domain/Event/MatiereModifiee.php
Normal file
40
backend/src/Administration/Domain/Event/MatiereModifiee.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lors de la modification d'une matière.
|
||||||
|
*/
|
||||||
|
final readonly class MatiereModifiee implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public SubjectId $subjectId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public SubjectName $ancienNom,
|
||||||
|
public SubjectName $nouveauNom,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->subjectId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/Administration/Domain/Event/MatiereSupprimee.php
Normal file
37
backend/src/Administration/Domain/Event/MatiereSupprimee.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lors de la suppression/archivage d'une matière.
|
||||||
|
*/
|
||||||
|
final readonly class MatiereSupprimee implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public SubjectId $subjectId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->subjectId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SubjectCodeInvalideException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function pourFormat(string $value, int $min, int $max): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le code de matière "%s" doit contenir entre %d et %d caractères alphanumériques majuscules (ex: "MATH", "FR", "EPS").',
|
||||||
|
$value,
|
||||||
|
$min,
|
||||||
|
$max,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SubjectColorInvalideException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function pourFormat(string $value): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'La couleur "%s" doit être au format hexadécimal #RRGGBB (ex: "#3B82F6").',
|
||||||
|
$value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SubjectDejaExistanteException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function avecCode(SubjectCode $code): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Une matière avec le code "%s" existe déjà dans cet établissement.',
|
||||||
|
(string) $code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SubjectNameInvalideException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function pourLongueur(string $value, int $min, int $max): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le nom de matière "%s" doit contenir entre %d et %d caractères.',
|
||||||
|
$value,
|
||||||
|
$min,
|
||||||
|
$max,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SubjectNonSupprimableException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function avecNotes(SubjectId $id): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'La matière "%s" ne peut pas être supprimée car des notes y sont associées.',
|
||||||
|
(string) $id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SubjectNotFoundException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function withId(SubjectId $id): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Matière "%s" non trouvée.', (string) $id));
|
||||||
|
}
|
||||||
|
}
|
||||||
217
backend/src/Administration/Domain/Model/Subject/Subject.php
Normal file
217
backend/src/Administration/Domain/Model/Subject/Subject.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\MatiereCreee;
|
||||||
|
use App\Administration\Domain\Event\MatiereModifiee;
|
||||||
|
use App\Administration\Domain\Event\MatiereSupprimee;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate Root représentant une matière enseignée.
|
||||||
|
*
|
||||||
|
* Une matière appartient à un établissement (tenant) et une école.
|
||||||
|
* Elle a un nom, un code court unique et une couleur optionnelle pour l'affichage.
|
||||||
|
*
|
||||||
|
* @see FR74: Structurer l'offre pédagogique
|
||||||
|
*/
|
||||||
|
final class Subject extends AggregateRoot
|
||||||
|
{
|
||||||
|
public private(set) ?string $description = null;
|
||||||
|
public private(set) DateTimeImmutable $updatedAt;
|
||||||
|
public private(set) ?DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) SubjectId $id,
|
||||||
|
public private(set) TenantId $tenantId,
|
||||||
|
public private(set) SchoolId $schoolId,
|
||||||
|
public private(set) SubjectName $name,
|
||||||
|
public private(set) SubjectCode $code,
|
||||||
|
public private(set) ?SubjectColor $color,
|
||||||
|
public private(set) SubjectStatus $status,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
) {
|
||||||
|
$this->updatedAt = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une nouvelle matière.
|
||||||
|
*/
|
||||||
|
public static function creer(
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
SubjectName $name,
|
||||||
|
SubjectCode $code,
|
||||||
|
?SubjectColor $color,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
$subject = new self(
|
||||||
|
id: SubjectId::generate(),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
name: $name,
|
||||||
|
code: $code,
|
||||||
|
color: $color,
|
||||||
|
status: SubjectStatus::ACTIVE,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject->recordEvent(new MatiereCreee(
|
||||||
|
subjectId: $subject->id,
|
||||||
|
tenantId: $subject->tenantId,
|
||||||
|
name: $subject->name,
|
||||||
|
code: $subject->code,
|
||||||
|
occurredOn: $createdAt,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renomme la matière.
|
||||||
|
*/
|
||||||
|
public function renommer(SubjectName $nouveauNom, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->name->equals($nouveauNom)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ancienNom = $this->name;
|
||||||
|
$this->name = $nouveauNom;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new MatiereModifiee(
|
||||||
|
subjectId: $this->id,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
ancienNom: $ancienNom,
|
||||||
|
nouveauNom: $nouveauNom,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change le code de la matière.
|
||||||
|
*
|
||||||
|
* Note: L'unicité du code doit être vérifiée par l'Application Layer
|
||||||
|
* avant d'appeler cette méthode.
|
||||||
|
*/
|
||||||
|
public function changerCode(SubjectCode $nouveauCode, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->code->equals($nouveauCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->code = $nouveauCode;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change la couleur de la matière.
|
||||||
|
*/
|
||||||
|
public function changerCouleur(?SubjectColor $couleur, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
// Compare colors, considering null cases
|
||||||
|
$sameColor = ($this->color === null && $couleur === null)
|
||||||
|
|| ($this->color !== null && $couleur !== null && $this->color->equals($couleur));
|
||||||
|
|
||||||
|
if ($sameColor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->color = $couleur;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute ou modifie la description de la matière.
|
||||||
|
*/
|
||||||
|
public function decrire(?string $description, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->description === $description) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->description = $description;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive la matière (soft delete).
|
||||||
|
*
|
||||||
|
* Note: La vérification des notes associées doit être faite par l'Application Layer
|
||||||
|
* via une Query avant d'appeler cette méthode.
|
||||||
|
*/
|
||||||
|
public function archiver(DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->status === SubjectStatus::ARCHIVED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = SubjectStatus::ARCHIVED;
|
||||||
|
$this->deletedAt = $at;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new MatiereSupprimee(
|
||||||
|
subjectId: $this->id,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la matière est active.
|
||||||
|
*/
|
||||||
|
public function estActive(): bool
|
||||||
|
{
|
||||||
|
return $this->status === SubjectStatus::ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la matière peut être utilisée.
|
||||||
|
*/
|
||||||
|
public function peutEtreUtilisee(): bool
|
||||||
|
{
|
||||||
|
return $this->status->peutEtreUtilisee();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstitue un Subject depuis le stockage.
|
||||||
|
*
|
||||||
|
* @internal Pour usage Infrastructure uniquement
|
||||||
|
*/
|
||||||
|
public static function reconstitute(
|
||||||
|
SubjectId $id,
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
SubjectName $name,
|
||||||
|
SubjectCode $code,
|
||||||
|
?SubjectColor $color,
|
||||||
|
SubjectStatus $status,
|
||||||
|
?string $description,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
DateTimeImmutable $updatedAt,
|
||||||
|
?DateTimeImmutable $deletedAt,
|
||||||
|
): self {
|
||||||
|
$subject = new self(
|
||||||
|
id: $id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
name: $name,
|
||||||
|
code: $code,
|
||||||
|
color: $color,
|
||||||
|
status: $status,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject->description = $description;
|
||||||
|
$subject->updatedAt = $updatedAt;
|
||||||
|
$subject->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectCodeInvalideException;
|
||||||
|
|
||||||
|
use function assert;
|
||||||
|
use function preg_match;
|
||||||
|
use function strtoupper;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant le code court d'une matière.
|
||||||
|
*
|
||||||
|
* Contraintes :
|
||||||
|
* - Entre 2 et 10 caractères
|
||||||
|
* - Lettres majuscules et chiffres uniquement
|
||||||
|
* - Exemple: "MATH", "FR", "HG", "EPS"
|
||||||
|
*/
|
||||||
|
final class SubjectCode
|
||||||
|
{
|
||||||
|
private const int MIN_LENGTH = 2;
|
||||||
|
private const int MAX_LENGTH = 10;
|
||||||
|
private const string PATTERN = '/^[A-Z0-9]{2,10}$/';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
/** @var non-empty-string */
|
||||||
|
public private(set) string $value {
|
||||||
|
set(string $value) {
|
||||||
|
$normalized = strtoupper(trim($value));
|
||||||
|
|
||||||
|
if (preg_match(self::PATTERN, $normalized) !== 1) {
|
||||||
|
throw SubjectCodeInvalideException::pourFormat($value, self::MIN_LENGTH, self::MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After validation, $normalized is guaranteed to be non-empty (MIN_LENGTH >= 2)
|
||||||
|
assert($normalized !== '');
|
||||||
|
$this->value = $normalized;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectColorInvalideException;
|
||||||
|
|
||||||
|
use function assert;
|
||||||
|
use function preg_match;
|
||||||
|
use function strtoupper;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant la couleur d'une matière (format hexadécimal).
|
||||||
|
*
|
||||||
|
* Contraintes :
|
||||||
|
* - Format #RRGGBB
|
||||||
|
* - Exemple: "#3B82F6" (bleu), "#EF4444" (rouge)
|
||||||
|
*/
|
||||||
|
final class SubjectColor
|
||||||
|
{
|
||||||
|
private const string PATTERN = '/^#[0-9A-F]{6}$/';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
/** @var non-empty-string */
|
||||||
|
public private(set) string $value {
|
||||||
|
set(string $value) {
|
||||||
|
$normalized = strtoupper(trim($value));
|
||||||
|
|
||||||
|
if (preg_match(self::PATTERN, $normalized) !== 1) {
|
||||||
|
throw SubjectColorInvalideException::pourFormat($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After validation, $normalized is guaranteed to be non-empty (#RRGGBB = 7 chars)
|
||||||
|
assert($normalized !== '');
|
||||||
|
$this->value = $normalized;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class SubjectId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectNameInvalideException;
|
||||||
|
|
||||||
|
use function assert;
|
||||||
|
use function mb_strlen;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant le nom d'une matière.
|
||||||
|
*
|
||||||
|
* Contraintes :
|
||||||
|
* - Entre 2 et 100 caractères
|
||||||
|
* - Non vide après trim
|
||||||
|
*/
|
||||||
|
final class SubjectName
|
||||||
|
{
|
||||||
|
private const int MIN_LENGTH = 2;
|
||||||
|
private const int MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
/** @var non-empty-string */
|
||||||
|
public private(set) string $value {
|
||||||
|
set(string $value) {
|
||||||
|
$trimmed = trim($value);
|
||||||
|
$length = mb_strlen($trimmed);
|
||||||
|
|
||||||
|
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
|
||||||
|
throw SubjectNameInvalideException::pourLongueur($value, self::MIN_LENGTH, self::MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After validation, $trimmed is guaranteed to be non-empty (MIN_LENGTH >= 2)
|
||||||
|
assert($trimmed !== '');
|
||||||
|
$this->value = $trimmed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return non-empty-string
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statut d'une matière.
|
||||||
|
*/
|
||||||
|
enum SubjectStatus: string
|
||||||
|
{
|
||||||
|
case ACTIVE = 'active';
|
||||||
|
case ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la matière peut être utilisée.
|
||||||
|
*/
|
||||||
|
public function peutEtreUtilisee(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ACTIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
interface SubjectRepository
|
||||||
|
{
|
||||||
|
public function save(Subject $subject): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \App\Administration\Domain\Exception\SubjectNotFoundException
|
||||||
|
*/
|
||||||
|
public function get(SubjectId $id): Subject;
|
||||||
|
|
||||||
|
public function findById(SubjectId $id): ?Subject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche une matière par code dans un tenant et une école.
|
||||||
|
*/
|
||||||
|
public function findByCode(
|
||||||
|
SubjectCode $code,
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
): ?Subject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne toutes les matières actives d'un tenant et une école.
|
||||||
|
*
|
||||||
|
* @return Subject[]
|
||||||
|
*/
|
||||||
|
public function findActiveByTenantAndSchool(
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une matière du repository.
|
||||||
|
*/
|
||||||
|
public function delete(SubjectId $id): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Application\Command\CreateSubject\CreateSubjectCommand;
|
||||||
|
use App\Administration\Application\Command\CreateSubject\CreateSubjectHandler;
|
||||||
|
use App\Administration\Domain\Exception\SubjectCodeInvalideException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectColorInvalideException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNameInvalideException;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||||
|
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||||
|
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||||
|
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\ConflictHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor API Platform pour créer une matière.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<SubjectResource, SubjectResource>
|
||||||
|
*/
|
||||||
|
final readonly class CreateSubjectProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CreateSubjectHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
private SchoolIdResolver $schoolIdResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SubjectResource $data
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SubjectResource
|
||||||
|
{
|
||||||
|
if (!$this->authorizationChecker->isGranted(SubjectVoter::CREATE)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une matière.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||||
|
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new CreateSubjectCommand(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
name: $data->name ?? '',
|
||||||
|
code: $data->code ?? '',
|
||||||
|
color: $data->color,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject = ($this->handler)($command);
|
||||||
|
|
||||||
|
// Dispatch domain events from the created aggregate
|
||||||
|
foreach ($subject->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the created resource
|
||||||
|
$resource = new SubjectResource();
|
||||||
|
$resource->id = (string) $subject->id;
|
||||||
|
$resource->name = (string) $subject->name;
|
||||||
|
$resource->code = (string) $subject->code;
|
||||||
|
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||||
|
$resource->description = $subject->description;
|
||||||
|
$resource->status = $subject->status->value;
|
||||||
|
$resource->createdAt = $subject->createdAt;
|
||||||
|
$resource->updatedAt = $subject->updatedAt;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
} catch (SubjectDejaExistanteException $e) {
|
||||||
|
throw new ConflictHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectCommand;
|
||||||
|
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectHandler;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||||
|
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor API Platform pour supprimer (archiver) une matière.
|
||||||
|
*
|
||||||
|
* Note: Cette implémentation fait un soft delete (archivage).
|
||||||
|
* La vérification des notes associées (T6) sera ajoutée ultérieurement
|
||||||
|
* quand le module Notes sera implémenté.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<SubjectResource, null>
|
||||||
|
*/
|
||||||
|
final readonly class DeleteSubjectProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ArchiveSubjectHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SubjectResource $data
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||||
|
{
|
||||||
|
if (!$this->authorizationChecker->isGranted(SubjectVoter::DELETE)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette matière.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var string|null $subjectId */
|
||||||
|
$subjectId = $uriVariables['id'] ?? null;
|
||||||
|
if ($subjectId === null) {
|
||||||
|
throw new NotFoundHttpException('Matière non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Vérifier si des notes sont associées (T6)
|
||||||
|
// et retourner un warning si c'est le cas (via query param ?confirm=true)
|
||||||
|
|
||||||
|
$command = new ArchiveSubjectCommand(
|
||||||
|
subjectId: $subjectId,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject = ($this->handler)($command);
|
||||||
|
|
||||||
|
// Dispatch domain events from the archived aggregate
|
||||||
|
foreach ($subject->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||||
|
throw new NotFoundHttpException('Matière non trouvée.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Application\Command\UpdateSubject\UpdateSubjectCommand;
|
||||||
|
use App\Administration\Application\Command\UpdateSubject\UpdateSubjectHandler;
|
||||||
|
use App\Administration\Domain\Exception\SubjectCodeInvalideException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectColorInvalideException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNameInvalideException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||||
|
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||||
|
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor API Platform pour modifier une matière.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<SubjectResource, SubjectResource>
|
||||||
|
*/
|
||||||
|
final readonly class UpdateSubjectProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UpdateSubjectHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
private SchoolIdResolver $schoolIdResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param SubjectResource $data
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SubjectResource
|
||||||
|
{
|
||||||
|
if (!$this->authorizationChecker->isGranted(SubjectVoter::EDIT)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier cette matière.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var string|null $subjectId */
|
||||||
|
$subjectId = $uriVariables['id'] ?? null;
|
||||||
|
if ($subjectId === null) {
|
||||||
|
throw new NotFoundHttpException('Matière non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||||
|
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: $subjectId,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
name: $data->name,
|
||||||
|
code: $data->code,
|
||||||
|
color: $data->color,
|
||||||
|
description: $data->description,
|
||||||
|
clearColor: $data->clearColor ?? false,
|
||||||
|
clearDescription: $data->clearDescription ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject = ($this->handler)($command);
|
||||||
|
|
||||||
|
// Dispatch domain events from the updated aggregate
|
||||||
|
foreach ($subject->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the updated resource
|
||||||
|
$resource = new SubjectResource();
|
||||||
|
$resource->id = (string) $subject->id;
|
||||||
|
$resource->name = (string) $subject->name;
|
||||||
|
$resource->code = (string) $subject->code;
|
||||||
|
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||||
|
$resource->description = $subject->description;
|
||||||
|
$resource->status = $subject->status->value;
|
||||||
|
$resource->createdAt = $subject->createdAt;
|
||||||
|
$resource->updatedAt = $subject->updatedAt;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||||
|
throw new NotFoundHttpException('Matière non trouvée.');
|
||||||
|
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
} catch (SubjectDejaExistanteException $e) {
|
||||||
|
throw new ConflictHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
|
||||||
|
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||||
|
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||||
|
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State Provider pour récupérer la liste des matières.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<SubjectResource>
|
||||||
|
*/
|
||||||
|
final readonly class SubjectCollectionProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GetSubjectsHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
private SchoolIdResolver $schoolIdResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return SubjectResource[]
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||||
|
{
|
||||||
|
// Vérifier les permissions de lecture (sans sujet spécifique)
|
||||||
|
if (!$this->authorizationChecker->isGranted(SubjectVoter::VIEW)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les matières.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||||
|
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||||
|
|
||||||
|
$query = new GetSubjectsQuery(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subjectDtos = ($this->handler)($query);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static function ($dto) {
|
||||||
|
$resource = new SubjectResource();
|
||||||
|
$resource->id = $dto->id;
|
||||||
|
$resource->name = $dto->name;
|
||||||
|
$resource->code = $dto->code;
|
||||||
|
$resource->color = $dto->color;
|
||||||
|
$resource->description = $dto->description;
|
||||||
|
$resource->status = $dto->status;
|
||||||
|
$resource->createdAt = $dto->createdAt;
|
||||||
|
$resource->updatedAt = $dto->updatedAt;
|
||||||
|
$resource->teacherCount = $dto->teacherCount;
|
||||||
|
$resource->classCount = $dto->classCount;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
},
|
||||||
|
$subjectDtos,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Repository\SubjectRepository;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||||
|
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State Provider pour récupérer une matière par son ID.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<SubjectResource>
|
||||||
|
*/
|
||||||
|
final readonly class SubjectItemProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SubjectRepository $subjectRepository,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SubjectResource
|
||||||
|
{
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var string|null $subjectId */
|
||||||
|
$subjectId = $uriVariables['id'] ?? null;
|
||||||
|
if ($subjectId === null) {
|
||||||
|
throw new NotFoundHttpException('Matière non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$subject = $this->subjectRepository->get(SubjectId::fromString($subjectId));
|
||||||
|
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||||
|
throw new NotFoundHttpException('Matière non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que la matière appartient au tenant actuel
|
||||||
|
if ((string) $subject->tenantId !== (string) $this->tenantContext->getCurrentTenantId()) {
|
||||||
|
throw new NotFoundHttpException('Matière non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les permissions de lecture
|
||||||
|
if (!$this->authorizationChecker->isGranted(SubjectVoter::VIEW, $subject)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette matière.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource = new SubjectResource();
|
||||||
|
$resource->id = (string) $subject->id;
|
||||||
|
$resource->name = (string) $subject->name;
|
||||||
|
$resource->code = (string) $subject->code;
|
||||||
|
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||||
|
$resource->description = $subject->description;
|
||||||
|
$resource->status = $subject->status->value;
|
||||||
|
$resource->createdAt = $subject->createdAt;
|
||||||
|
$resource->updatedAt = $subject->updatedAt;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\CreateSubjectProcessor;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\DeleteSubjectProcessor;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\UpdateSubjectProcessor;
|
||||||
|
use App\Administration\Infrastructure\Api\Provider\SubjectCollectionProvider;
|
||||||
|
use App\Administration\Infrastructure\Api\Provider\SubjectItemProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Resource pour la gestion des matières.
|
||||||
|
*
|
||||||
|
* @see Story 2.2 - Création et Gestion des Matières
|
||||||
|
* @see FR74 - Structurer l'offre pédagogique
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'Subject',
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/subjects',
|
||||||
|
provider: SubjectCollectionProvider::class,
|
||||||
|
name: 'get_subjects',
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/subjects/{id}',
|
||||||
|
provider: SubjectItemProvider::class,
|
||||||
|
name: 'get_subject',
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/subjects',
|
||||||
|
processor: CreateSubjectProcessor::class,
|
||||||
|
validationContext: ['groups' => ['Default', 'create']],
|
||||||
|
name: 'create_subject',
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/subjects/{id}',
|
||||||
|
provider: SubjectItemProvider::class,
|
||||||
|
processor: UpdateSubjectProcessor::class,
|
||||||
|
validationContext: ['groups' => ['Default', 'update']],
|
||||||
|
name: 'update_subject',
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
uriTemplate: '/subjects/{id}',
|
||||||
|
provider: SubjectItemProvider::class,
|
||||||
|
processor: DeleteSubjectProcessor::class,
|
||||||
|
name: 'delete_subject',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class SubjectResource
|
||||||
|
{
|
||||||
|
#[ApiProperty(identifier: true)]
|
||||||
|
public ?string $id = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Le nom de la matière est requis.', groups: ['create'])]
|
||||||
|
#[Assert\Length(
|
||||||
|
min: 2,
|
||||||
|
max: 100,
|
||||||
|
minMessage: 'Le nom de la matière doit contenir au moins {{ limit }} caractères.',
|
||||||
|
maxMessage: 'Le nom de la matière ne peut pas dépasser {{ limit }} caractères.',
|
||||||
|
)]
|
||||||
|
public ?string $name = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Le code de la matière est requis.', groups: ['create'])]
|
||||||
|
#[Assert\Regex(
|
||||||
|
pattern: '/^[A-Za-z0-9]{2,10}$/',
|
||||||
|
message: 'Le code doit contenir entre 2 et 10 caractères alphanumériques.',
|
||||||
|
)]
|
||||||
|
public ?string $code = null;
|
||||||
|
|
||||||
|
#[Assert\Regex(
|
||||||
|
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||||
|
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||||
|
)]
|
||||||
|
public ?string $color = null;
|
||||||
|
|
||||||
|
public ?string $description = null;
|
||||||
|
|
||||||
|
public ?string $status = null;
|
||||||
|
|
||||||
|
public ?DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
public ?DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistiques : nombre d'enseignants associés à cette matière.
|
||||||
|
* Disponible uniquement dans GetCollection.
|
||||||
|
*/
|
||||||
|
#[ApiProperty(readable: true, writable: false)]
|
||||||
|
public ?int $teacherCount = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistiques : nombre de classes associées à cette matière.
|
||||||
|
* Disponible uniquement dans GetCollection.
|
||||||
|
*/
|
||||||
|
#[ApiProperty(readable: true, writable: false)]
|
||||||
|
public ?int $classCount = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permet de supprimer explicitement la couleur lors d'un PATCH.
|
||||||
|
* Si true, la couleur sera mise à null même si color n'est pas fourni.
|
||||||
|
*/
|
||||||
|
#[ApiProperty(readable: false)]
|
||||||
|
public ?bool $clearColor = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permet de supprimer explicitement la description lors d'un PATCH.
|
||||||
|
* Si true, la description sera mise à null même si description n'est pas fourni.
|
||||||
|
*/
|
||||||
|
#[ApiProperty(readable: false)]
|
||||||
|
public ?bool $clearDescription = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si la matière a des notes associées (pour avertissement avant suppression).
|
||||||
|
*/
|
||||||
|
#[ApiProperty(readable: true, writable: false)]
|
||||||
|
public ?bool $hasGrades = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectStatus;
|
||||||
|
use App\Administration\Domain\Repository\SubjectRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final readonly class DoctrineSubjectRepository implements SubjectRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(Subject $subject): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'id' => (string) $subject->id,
|
||||||
|
'tenant_id' => (string) $subject->tenantId,
|
||||||
|
'school_id' => (string) $subject->schoolId,
|
||||||
|
'name' => (string) $subject->name,
|
||||||
|
'code' => (string) $subject->code,
|
||||||
|
'color' => $subject->color !== null ? (string) $subject->color : null,
|
||||||
|
'status' => $subject->status->value,
|
||||||
|
'description' => $subject->description,
|
||||||
|
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
|
||||||
|
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
|
||||||
|
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||||
|
];
|
||||||
|
|
||||||
|
$exists = $this->findById($subject->id) !== null;
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->connection->update('subjects', $data, ['id' => (string) $subject->id]);
|
||||||
|
} else {
|
||||||
|
$this->connection->insert('subjects', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function get(SubjectId $id): Subject
|
||||||
|
{
|
||||||
|
$subject = $this->findById($id);
|
||||||
|
|
||||||
|
if ($subject === null) {
|
||||||
|
throw SubjectNotFoundException::withId($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findById(SubjectId $id): ?Subject
|
||||||
|
{
|
||||||
|
$row = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM subjects WHERE id = :id',
|
||||||
|
['id' => (string) $id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hydrate($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByCode(
|
||||||
|
SubjectCode $code,
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
): ?Subject {
|
||||||
|
$row = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM subjects
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND school_id = :school_id
|
||||||
|
AND code = :code
|
||||||
|
AND deleted_at IS NULL',
|
||||||
|
[
|
||||||
|
'tenant_id' => (string) $tenantId,
|
||||||
|
'school_id' => (string) $schoolId,
|
||||||
|
'code' => (string) $code,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hydrate($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findActiveByTenantAndSchool(
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
): array {
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT * FROM subjects
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND school_id = :school_id
|
||||||
|
AND status = :status
|
||||||
|
ORDER BY name ASC',
|
||||||
|
[
|
||||||
|
'tenant_id' => (string) $tenantId,
|
||||||
|
'school_id' => (string) $schoolId,
|
||||||
|
'status' => SubjectStatus::ACTIVE->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function delete(SubjectId $id): void
|
||||||
|
{
|
||||||
|
$this->connection->delete('subjects', ['id' => (string) $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function hydrate(array $row): Subject
|
||||||
|
{
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $tenantId */
|
||||||
|
$tenantId = $row['tenant_id'];
|
||||||
|
/** @var string $schoolId */
|
||||||
|
$schoolId = $row['school_id'];
|
||||||
|
/** @var non-empty-string $name */
|
||||||
|
$name = $row['name'];
|
||||||
|
/** @var non-empty-string $code */
|
||||||
|
$code = $row['code'];
|
||||||
|
/** @var non-empty-string|null $color */
|
||||||
|
$color = $row['color'];
|
||||||
|
/** @var string $status */
|
||||||
|
$status = $row['status'];
|
||||||
|
/** @var string|null $description */
|
||||||
|
$description = $row['description'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
/** @var string $updatedAt */
|
||||||
|
$updatedAt = $row['updated_at'];
|
||||||
|
/** @var string|null $deletedAt */
|
||||||
|
$deletedAt = $row['deleted_at'];
|
||||||
|
|
||||||
|
return Subject::reconstitute(
|
||||||
|
id: SubjectId::fromString($id),
|
||||||
|
tenantId: TenantId::fromString($tenantId),
|
||||||
|
schoolId: SchoolId::fromString($schoolId),
|
||||||
|
name: new SubjectName($name),
|
||||||
|
code: new SubjectCode($code),
|
||||||
|
color: $color !== null ? new SubjectColor($color) : null,
|
||||||
|
status: SubjectStatus::from($status),
|
||||||
|
description: $description,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
updatedAt: new DateTimeImmutable($updatedAt),
|
||||||
|
deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectStatus;
|
||||||
|
use App\Administration\Domain\Repository\SubjectRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function mb_strtolower;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final class InMemorySubjectRepository implements SubjectRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, Subject> Indexed by ID */
|
||||||
|
private array $byId = [];
|
||||||
|
|
||||||
|
/** @var array<string, Subject> Indexed by tenant:school:code */
|
||||||
|
private array $byTenantSchoolCode = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> Maps subject ID to its current code key */
|
||||||
|
private array $codeKeyById = [];
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(Subject $subject): void
|
||||||
|
{
|
||||||
|
$subjectIdStr = (string) $subject->id;
|
||||||
|
|
||||||
|
// If subject already exists, remove the old code key (handles code changes)
|
||||||
|
if (isset($this->codeKeyById[$subjectIdStr])) {
|
||||||
|
$oldKey = $this->codeKeyById[$subjectIdStr];
|
||||||
|
unset($this->byTenantSchoolCode[$oldKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newKey = $this->codeKey($subject->code, $subject->tenantId, $subject->schoolId);
|
||||||
|
|
||||||
|
$this->byId[$subjectIdStr] = $subject;
|
||||||
|
$this->byTenantSchoolCode[$newKey] = $subject;
|
||||||
|
$this->codeKeyById[$subjectIdStr] = $newKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function get(SubjectId $id): Subject
|
||||||
|
{
|
||||||
|
$subject = $this->findById($id);
|
||||||
|
|
||||||
|
if ($subject === null) {
|
||||||
|
throw SubjectNotFoundException::withId($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findById(SubjectId $id): ?Subject
|
||||||
|
{
|
||||||
|
return $this->byId[(string) $id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByCode(
|
||||||
|
SubjectCode $code,
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
): ?Subject {
|
||||||
|
$subject = $this->byTenantSchoolCode[$this->codeKey($code, $tenantId, $schoolId)] ?? null;
|
||||||
|
|
||||||
|
// Filtrer les matières archivées (comme Doctrine avec deleted_at IS NULL)
|
||||||
|
if ($subject !== null && $subject->deletedAt !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findActiveByTenantAndSchool(
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
): array {
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($this->byId as $subject) {
|
||||||
|
if ($subject->tenantId->equals($tenantId)
|
||||||
|
&& $subject->schoolId->equals($schoolId)
|
||||||
|
&& $subject->status === SubjectStatus::ACTIVE
|
||||||
|
) {
|
||||||
|
$result[] = $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function delete(SubjectId $id): void
|
||||||
|
{
|
||||||
|
$subjectIdStr = (string) $id;
|
||||||
|
|
||||||
|
if (isset($this->byId[$subjectIdStr])) {
|
||||||
|
if (isset($this->codeKeyById[$subjectIdStr])) {
|
||||||
|
unset($this->byTenantSchoolCode[$this->codeKeyById[$subjectIdStr]]);
|
||||||
|
unset($this->codeKeyById[$subjectIdStr]);
|
||||||
|
}
|
||||||
|
unset($this->byId[$subjectIdStr]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function codeKey(SubjectCode $code, TenantId $tenantId, SchoolId $schoolId): string
|
||||||
|
{
|
||||||
|
return $tenantId . ':' . $schoolId . ':' . mb_strtolower((string) $code, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\School;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service temporaire pour résoudre le schoolId à partir du tenant.
|
||||||
|
*
|
||||||
|
* TODO: Ce service sera remplacé quand le module Schools sera implémenté.
|
||||||
|
* Actuellement, il génère un UUID déterministe basé sur le tenantId,
|
||||||
|
* ce qui suppose qu'un tenant = une école.
|
||||||
|
*
|
||||||
|
* Quand le module multi-écoles sera implémenté :
|
||||||
|
* 1. Ce service lira le schoolId depuis le contexte utilisateur
|
||||||
|
* 2. Les données existantes devront être migrées vers les vraies écoles
|
||||||
|
*/
|
||||||
|
final readonly class SchoolIdResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Résout le schoolId pour le tenant courant.
|
||||||
|
*
|
||||||
|
* @param string $tenantId L'identifiant du tenant
|
||||||
|
*
|
||||||
|
* @return string L'identifiant de l'école (UUID)
|
||||||
|
*/
|
||||||
|
public function resolveForTenant(string $tenantId): string
|
||||||
|
{
|
||||||
|
// Génère un UUID déterministe basé sur le tenantId
|
||||||
|
// Cela garantit que le même tenantId donne toujours le même schoolId
|
||||||
|
return Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voter pour les autorisations sur les matières.
|
||||||
|
*
|
||||||
|
* Règles d'accès :
|
||||||
|
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
|
||||||
|
* - ENSEIGNANT : lecture seule (via affectations)
|
||||||
|
* - VIE_SCOLAIRE, SECRETARIAT : lecture seule
|
||||||
|
* - ELEVE et PARENT : pas d'accès direct
|
||||||
|
*
|
||||||
|
* @extends Voter<string, Subject|SubjectResource>
|
||||||
|
*/
|
||||||
|
final class SubjectVoter extends Voter
|
||||||
|
{
|
||||||
|
public const string VIEW = 'SUBJECT_VIEW';
|
||||||
|
public const string CREATE = 'SUBJECT_CREATE';
|
||||||
|
public const string EDIT = 'SUBJECT_EDIT';
|
||||||
|
public const string DELETE = 'SUBJECT_DELETE';
|
||||||
|
|
||||||
|
private const array SUPPORTED_ATTRIBUTES = [
|
||||||
|
self::VIEW,
|
||||||
|
self::CREATE,
|
||||||
|
self::EDIT,
|
||||||
|
self::DELETE,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
protected function supports(string $attribute, mixed $subject): bool
|
||||||
|
{
|
||||||
|
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE, EDIT, DELETE, and VIEW (for collections) don't require a subject
|
||||||
|
// since authorization is role-based, not object-based
|
||||||
|
if ($subject === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subject instanceof Subject || $subject instanceof SubjectResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||||
|
{
|
||||||
|
$user = $token->getUser();
|
||||||
|
|
||||||
|
if (!$user instanceof UserInterface) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le rôle depuis les rôles Symfony
|
||||||
|
$roles = $user->getRoles();
|
||||||
|
|
||||||
|
return match ($attribute) {
|
||||||
|
self::VIEW => $this->canView($roles),
|
||||||
|
self::CREATE => $this->canCreate($roles),
|
||||||
|
self::EDIT => $this->canEdit($roles),
|
||||||
|
self::DELETE => $this->canDelete($roles),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function canView(array $roles): bool
|
||||||
|
{
|
||||||
|
// Personnel de l'établissement uniquement
|
||||||
|
return $this->hasAnyRole($roles, [
|
||||||
|
Role::SUPER_ADMIN->value,
|
||||||
|
Role::ADMIN->value,
|
||||||
|
Role::PROF->value,
|
||||||
|
Role::VIE_SCOLAIRE->value,
|
||||||
|
Role::SECRETARIAT->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function canCreate(array $roles): bool
|
||||||
|
{
|
||||||
|
// Seuls ADMIN et SUPER_ADMIN peuvent créer des matières
|
||||||
|
return $this->hasAnyRole($roles, [
|
||||||
|
Role::SUPER_ADMIN->value,
|
||||||
|
Role::ADMIN->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function canEdit(array $roles): bool
|
||||||
|
{
|
||||||
|
// Seuls ADMIN et SUPER_ADMIN peuvent modifier des matières
|
||||||
|
return $this->hasAnyRole($roles, [
|
||||||
|
Role::SUPER_ADMIN->value,
|
||||||
|
Role::ADMIN->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function canDelete(array $roles): bool
|
||||||
|
{
|
||||||
|
// Seuls ADMIN et SUPER_ADMIN peuvent supprimer des matières
|
||||||
|
return $this->hasAnyRole($roles, [
|
||||||
|
Role::SUPER_ADMIN->value,
|
||||||
|
Role::ADMIN->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $userRoles
|
||||||
|
* @param string[] $allowedRoles
|
||||||
|
*/
|
||||||
|
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||||
|
{
|
||||||
|
foreach ($userRoles as $role) {
|
||||||
|
if (in_array($role, $allowedRoles, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command\ArchiveSubject;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectCommand;
|
||||||
|
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectHandler;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectStatus;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ArchiveSubjectHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemorySubjectRepository $subjectRepository;
|
||||||
|
private Clock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->subjectRepository = new InMemorySubjectRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itArchivesSubjectSuccessfully(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new ArchiveSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
$archivedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame(SubjectStatus::ARCHIVED, $archivedSubject->status);
|
||||||
|
self::assertFalse($archivedSubject->estActive());
|
||||||
|
self::assertNotNull($archivedSubject->deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPersistsArchivedSubjectInRepository(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new ArchiveSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
// Retrieve from repository
|
||||||
|
$retrievedSubject = $this->subjectRepository->get($subject->id);
|
||||||
|
|
||||||
|
self::assertSame(SubjectStatus::ARCHIVED, $retrievedSubject->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenSubjectNotFound(): void
|
||||||
|
{
|
||||||
|
$handler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$this->expectException(SubjectNotFoundException::class);
|
||||||
|
|
||||||
|
$command = new ArchiveSubjectCommand(
|
||||||
|
subjectId: '550e8400-e29b-41d4-a716-446655440099',
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
);
|
||||||
|
$handler($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenSubjectBelongsToDifferentTenant(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$this->expectException(SubjectNotFoundException::class);
|
||||||
|
|
||||||
|
// Try to archive with a different tenant ID
|
||||||
|
$command = new ArchiveSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::OTHER_TENANT_ID,
|
||||||
|
);
|
||||||
|
$handler($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itIsIdempotentWhenArchivingAlreadyArchivedSubject(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new ArchiveSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Archive twice
|
||||||
|
$handler($command);
|
||||||
|
$archivedAgain = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame(SubjectStatus::ARCHIVED, $archivedAgain->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAndSaveSubject(): Subject
|
||||||
|
{
|
||||||
|
$subject = Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Mathématiques'),
|
||||||
|
code: new SubjectCode('MATH'),
|
||||||
|
color: new SubjectColor('#3B82F6'),
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->subjectRepository->save($subject);
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command\CreateSubject;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\CreateSubject\CreateSubjectCommand;
|
||||||
|
use App\Administration\Application\Command\CreateSubject\CreateSubjectHandler;
|
||||||
|
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectStatus;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CreateSubjectHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemorySubjectRepository $subjectRepository;
|
||||||
|
private Clock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->subjectRepository = new InMemorySubjectRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-01-31 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesSubjectSuccessfully(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
$command = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject = $handler($command);
|
||||||
|
|
||||||
|
self::assertNotEmpty((string) $subject->id);
|
||||||
|
self::assertSame('Mathématiques', (string) $subject->name);
|
||||||
|
self::assertSame('MATH', (string) $subject->code);
|
||||||
|
self::assertNotNull($subject->color);
|
||||||
|
self::assertSame('#3B82F6', (string) $subject->color);
|
||||||
|
self::assertSame(SubjectStatus::ACTIVE, $subject->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesSubjectWithNullColor(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
$command = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Arts plastiques',
|
||||||
|
code: 'ART',
|
||||||
|
color: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject = $handler($command);
|
||||||
|
|
||||||
|
self::assertNotEmpty((string) $subject->id);
|
||||||
|
self::assertSame('Arts plastiques', (string) $subject->name);
|
||||||
|
self::assertSame('ART', (string) $subject->code);
|
||||||
|
self::assertNull($subject->color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPersistsSubjectInRepository(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
$command = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
|
||||||
|
$createdSubject = $handler($command);
|
||||||
|
|
||||||
|
$subject = $this->subjectRepository->get(
|
||||||
|
SubjectId::fromString((string) $createdSubject->id),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Mathématiques', (string) $subject->name);
|
||||||
|
self::assertSame('MATH', (string) $subject->code);
|
||||||
|
self::assertSame(SubjectStatus::ACTIVE, $subject->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenSubjectCodeAlreadyExists(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
$command = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
|
||||||
|
// First creation should succeed
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
// Second creation with same code should throw
|
||||||
|
$this->expectException(SubjectDejaExistanteException::class);
|
||||||
|
$this->expectExceptionMessage('Une matière avec le code "MATH" existe déjà dans cet établissement.');
|
||||||
|
|
||||||
|
$commandDuplicate = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Maths avancées', // Different name, same code
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#EF4444',
|
||||||
|
);
|
||||||
|
$handler($commandDuplicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsSameCodeInDifferentTenant(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
// Create in tenant 1
|
||||||
|
$command1 = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
$subject1 = $handler($command1);
|
||||||
|
|
||||||
|
// Create same code in tenant 2 should succeed
|
||||||
|
$command2 = new CreateSubjectCommand(
|
||||||
|
tenantId: '550e8400-e29b-41d4-a716-446655440099',
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
$subject2 = $handler($command2);
|
||||||
|
|
||||||
|
self::assertFalse($subject1->id->equals($subject2->id));
|
||||||
|
self::assertSame('MATH', (string) $subject1->code);
|
||||||
|
self::assertSame('MATH', (string) $subject2->code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsSameCodeInDifferentSchool(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
// Create in school 1
|
||||||
|
$command1 = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
$subject1 = $handler($command1);
|
||||||
|
|
||||||
|
// Create same code in school 2 should succeed
|
||||||
|
$command2 = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: '550e8400-e29b-41d4-a716-446655440099',
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
$subject2 = $handler($command2);
|
||||||
|
|
||||||
|
self::assertFalse($subject1->id->equals($subject2->id));
|
||||||
|
self::assertSame('MATH', (string) $subject1->code);
|
||||||
|
self::assertSame('MATH', (string) $subject2->code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itNormalizesCodeToUppercase(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
$command = new CreateSubjectCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques',
|
||||||
|
code: 'math',
|
||||||
|
color: '#3B82F6',
|
||||||
|
);
|
||||||
|
|
||||||
|
$subject = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('MATH', (string) $subject->code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command\UpdateSubject;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\UpdateSubject\UpdateSubjectCommand;
|
||||||
|
use App\Administration\Application\Command\UpdateSubject\UpdateSubjectHandler;
|
||||||
|
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class UpdateSubjectHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemorySubjectRepository $subjectRepository;
|
||||||
|
private Clock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->subjectRepository = new InMemorySubjectRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesSubjectName(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Mathématiques avancées',
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('Mathématiques avancées', (string) $updatedSubject->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesSubjectCode(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
code: 'MATHS',
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('MATHS', (string) $updatedSubject->code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesSubjectColor(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
color: '#EF4444',
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertNotNull($updatedSubject->color);
|
||||||
|
self::assertSame('#EF4444', (string) $updatedSubject->color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itClearsSubjectColor(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
clearColor: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertNull($updatedSubject->color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesSubjectDescription(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
description: 'Cours de mathématiques pour tous les niveaux',
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('Cours de mathématiques pour tous les niveaux', $updatedSubject->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itClearsSubjectDescription(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
// First add a description
|
||||||
|
$subject->decrire('Une description', new DateTimeImmutable());
|
||||||
|
$this->subjectRepository->save($subject);
|
||||||
|
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
clearDescription: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertNull($updatedSubject->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenChangingToExistingCode(): void
|
||||||
|
{
|
||||||
|
// Create first subject with code MATH
|
||||||
|
$subject1 = $this->createAndSaveSubject();
|
||||||
|
|
||||||
|
// Create second subject with code FR
|
||||||
|
$subject2 = Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Français'),
|
||||||
|
code: new SubjectCode('FR'),
|
||||||
|
color: new SubjectColor('#EF4444'),
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->subjectRepository->save($subject2);
|
||||||
|
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
// Try to change subject2's code to MATH (which already exists)
|
||||||
|
$this->expectException(SubjectDejaExistanteException::class);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject2->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
code: 'MATH',
|
||||||
|
);
|
||||||
|
$handler($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsKeepingSameCode(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
// Update name but keep same code
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Maths avancées',
|
||||||
|
code: 'MATH', // Same code
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('Maths avancées', (string) $updatedSubject->name);
|
||||||
|
self::assertSame('MATH', (string) $updatedSubject->code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesMultipleFieldsAtOnce(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Maths avancées',
|
||||||
|
color: '#10B981',
|
||||||
|
description: 'Niveau supérieur',
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedSubject = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('Maths avancées', (string) $updatedSubject->name);
|
||||||
|
self::assertNotNull($updatedSubject->color);
|
||||||
|
self::assertSame('#10B981', (string) $updatedSubject->color);
|
||||||
|
self::assertSame('Niveau supérieur', $updatedSubject->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenSubjectBelongsToDifferentTenant(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createAndSaveSubject();
|
||||||
|
$handler = new UpdateSubjectHandler($this->subjectRepository, $this->clock);
|
||||||
|
|
||||||
|
$this->expectException(SubjectNotFoundException::class);
|
||||||
|
|
||||||
|
// Try to update with a different tenant ID (tenant isolation violation)
|
||||||
|
$command = new UpdateSubjectCommand(
|
||||||
|
subjectId: (string) $subject->id,
|
||||||
|
tenantId: self::OTHER_TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
name: 'Tentative de modification',
|
||||||
|
);
|
||||||
|
$handler($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAndSaveSubject(): Subject
|
||||||
|
{
|
||||||
|
$subject = Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Mathématiques'),
|
||||||
|
code: new SubjectCode('MATH'),
|
||||||
|
color: new SubjectColor('#3B82F6'),
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->subjectRepository->save($subject);
|
||||||
|
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectCodeInvalideException;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SubjectCodeTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function constructWithValidCode(): void
|
||||||
|
{
|
||||||
|
$code = new SubjectCode('MATH');
|
||||||
|
|
||||||
|
self::assertSame('MATH', $code->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructNormalizesToUppercase(): void
|
||||||
|
{
|
||||||
|
$code = new SubjectCode('math');
|
||||||
|
|
||||||
|
self::assertSame('MATH', $code->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructTrimsWhitespace(): void
|
||||||
|
{
|
||||||
|
$code = new SubjectCode(' MATH ');
|
||||||
|
|
||||||
|
self::assertSame('MATH', $code->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructWithMinimumLength(): void
|
||||||
|
{
|
||||||
|
$code = new SubjectCode('FR');
|
||||||
|
|
||||||
|
self::assertSame('FR', $code->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructWithMaximumLength(): void
|
||||||
|
{
|
||||||
|
$code = new SubjectCode('HISTGEO123');
|
||||||
|
|
||||||
|
self::assertSame('HISTGEO123', $code->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructWithDigits(): void
|
||||||
|
{
|
||||||
|
$code = new SubjectCode('EPS1');
|
||||||
|
|
||||||
|
self::assertSame('EPS1', $code->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('invalidCodesProvider')]
|
||||||
|
public function constructThrowsExceptionForInvalidCode(string $invalidCode): void
|
||||||
|
{
|
||||||
|
$this->expectException(SubjectCodeInvalideException::class);
|
||||||
|
|
||||||
|
new SubjectCode($invalidCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function invalidCodesProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'empty string' => [''];
|
||||||
|
yield 'single character' => ['M'];
|
||||||
|
yield 'whitespace only' => [' '];
|
||||||
|
yield 'too long' => ['MATHEMATICS']; // 11 chars
|
||||||
|
yield 'with special characters' => ['MATH-1'];
|
||||||
|
yield 'with spaces' => ['MA TH'];
|
||||||
|
yield 'lowercase with special' => ['math!'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsTrueForSameValue(): void
|
||||||
|
{
|
||||||
|
$code1 = new SubjectCode('MATH');
|
||||||
|
$code2 = new SubjectCode('MATH');
|
||||||
|
|
||||||
|
self::assertTrue($code1->equals($code2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsTrueForDifferentCase(): void
|
||||||
|
{
|
||||||
|
$code1 = new SubjectCode('MATH');
|
||||||
|
$code2 = new SubjectCode('math');
|
||||||
|
|
||||||
|
self::assertTrue($code1->equals($code2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsFalseForDifferentValue(): void
|
||||||
|
{
|
||||||
|
$code1 = new SubjectCode('MATH');
|
||||||
|
$code2 = new SubjectCode('FR');
|
||||||
|
|
||||||
|
self::assertFalse($code1->equals($code2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function toStringReturnsValue(): void
|
||||||
|
{
|
||||||
|
$code = new SubjectCode('MATH');
|
||||||
|
|
||||||
|
self::assertSame('MATH', (string) $code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectColorInvalideException;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SubjectColorTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function constructWithValidColor(): void
|
||||||
|
{
|
||||||
|
$color = new SubjectColor('#3B82F6');
|
||||||
|
|
||||||
|
self::assertSame('#3B82F6', $color->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructNormalizesToUppercase(): void
|
||||||
|
{
|
||||||
|
$color = new SubjectColor('#3b82f6');
|
||||||
|
|
||||||
|
self::assertSame('#3B82F6', $color->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructTrimsWhitespace(): void
|
||||||
|
{
|
||||||
|
$color = new SubjectColor(' #3B82F6 ');
|
||||||
|
|
||||||
|
self::assertSame('#3B82F6', $color->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('validColorsProvider')]
|
||||||
|
public function constructWithValidColors(string $input, string $expected): void
|
||||||
|
{
|
||||||
|
$color = new SubjectColor($input);
|
||||||
|
|
||||||
|
self::assertSame($expected, $color->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string, string}>
|
||||||
|
*/
|
||||||
|
public static function validColorsProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'blue' => ['#3B82F6', '#3B82F6'];
|
||||||
|
yield 'red' => ['#EF4444', '#EF4444'];
|
||||||
|
yield 'green' => ['#10B981', '#10B981'];
|
||||||
|
yield 'orange' => ['#F59E0B', '#F59E0B'];
|
||||||
|
yield 'black' => ['#000000', '#000000'];
|
||||||
|
yield 'white' => ['#FFFFFF', '#FFFFFF'];
|
||||||
|
yield 'lowercase' => ['#aabbcc', '#AABBCC'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('invalidColorsProvider')]
|
||||||
|
public function constructThrowsExceptionForInvalidColor(string $invalidColor): void
|
||||||
|
{
|
||||||
|
$this->expectException(SubjectColorInvalideException::class);
|
||||||
|
|
||||||
|
new SubjectColor($invalidColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function invalidColorsProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'empty string' => [''];
|
||||||
|
yield 'no hash' => ['3B82F6'];
|
||||||
|
yield 'short format' => ['#FFF'];
|
||||||
|
yield 'too short' => ['#3B82F'];
|
||||||
|
yield 'too long' => ['#3B82F6F'];
|
||||||
|
yield 'invalid characters' => ['#GGGGGG'];
|
||||||
|
yield 'rgb format' => ['rgb(59,130,246)'];
|
||||||
|
yield 'named color' => ['blue'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsTrueForSameValue(): void
|
||||||
|
{
|
||||||
|
$color1 = new SubjectColor('#3B82F6');
|
||||||
|
$color2 = new SubjectColor('#3B82F6');
|
||||||
|
|
||||||
|
self::assertTrue($color1->equals($color2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsTrueForDifferentCase(): void
|
||||||
|
{
|
||||||
|
$color1 = new SubjectColor('#3B82F6');
|
||||||
|
$color2 = new SubjectColor('#3b82f6');
|
||||||
|
|
||||||
|
self::assertTrue($color1->equals($color2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsFalseForDifferentValue(): void
|
||||||
|
{
|
||||||
|
$color1 = new SubjectColor('#3B82F6');
|
||||||
|
$color2 = new SubjectColor('#EF4444');
|
||||||
|
|
||||||
|
self::assertFalse($color1->equals($color2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function toStringReturnsValue(): void
|
||||||
|
{
|
||||||
|
$color = new SubjectColor('#3B82F6');
|
||||||
|
|
||||||
|
self::assertSame('#3B82F6', (string) $color);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectNameInvalideException;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SubjectNameTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function constructWithValidName(): void
|
||||||
|
{
|
||||||
|
$name = new SubjectName('Mathématiques');
|
||||||
|
|
||||||
|
self::assertSame('Mathématiques', $name->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructTrimsWhitespace(): void
|
||||||
|
{
|
||||||
|
$name = new SubjectName(' Mathématiques ');
|
||||||
|
|
||||||
|
self::assertSame('Mathématiques', $name->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructWithMinimumLength(): void
|
||||||
|
{
|
||||||
|
$name = new SubjectName('FR');
|
||||||
|
|
||||||
|
self::assertSame('FR', $name->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructWithMaximumLength(): void
|
||||||
|
{
|
||||||
|
$longName = str_repeat('M', 100);
|
||||||
|
$name = new SubjectName($longName);
|
||||||
|
|
||||||
|
self::assertSame($longName, $name->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('invalidNamesProvider')]
|
||||||
|
public function constructThrowsExceptionForInvalidName(string $invalidName): void
|
||||||
|
{
|
||||||
|
$this->expectException(SubjectNameInvalideException::class);
|
||||||
|
|
||||||
|
new SubjectName($invalidName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function invalidNamesProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'empty string' => [''];
|
||||||
|
yield 'single character' => ['M'];
|
||||||
|
yield 'whitespace only' => [' '];
|
||||||
|
yield 'too long' => [str_repeat('M', 101)];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsTrueForSameValue(): void
|
||||||
|
{
|
||||||
|
$name1 = new SubjectName('Mathématiques');
|
||||||
|
$name2 = new SubjectName('Mathématiques');
|
||||||
|
|
||||||
|
self::assertTrue($name1->equals($name2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsFalseForDifferentValue(): void
|
||||||
|
{
|
||||||
|
$name1 = new SubjectName('Mathématiques');
|
||||||
|
$name2 = new SubjectName('Français');
|
||||||
|
|
||||||
|
self::assertFalse($name1->equals($name2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function toStringReturnsValue(): void
|
||||||
|
{
|
||||||
|
$name = new SubjectName('Mathématiques');
|
||||||
|
|
||||||
|
self::assertSame('Mathématiques', (string) $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\Subject;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\MatiereCreee;
|
||||||
|
use App\Administration\Domain\Event\MatiereModifiee;
|
||||||
|
use App\Administration\Domain\Event\MatiereSupprimee;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectStatus;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SubjectTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerCreatesSubjectWithActiveStatus(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
|
||||||
|
self::assertSame(SubjectStatus::ACTIVE, $subject->status);
|
||||||
|
self::assertTrue($subject->estActive());
|
||||||
|
self::assertTrue($subject->peutEtreUtilisee());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerRecordsMatiereCreeeEvent(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
|
||||||
|
$events = $subject->pullDomainEvents();
|
||||||
|
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(MatiereCreee::class, $events[0]);
|
||||||
|
self::assertSame($subject->id, $events[0]->subjectId);
|
||||||
|
self::assertSame($subject->tenantId, $events[0]->tenantId);
|
||||||
|
self::assertSame($subject->name, $events[0]->name);
|
||||||
|
self::assertSame($subject->code, $events[0]->code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerSetsAllProperties(): void
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
|
||||||
|
$name = new SubjectName('Mathématiques');
|
||||||
|
$code = new SubjectCode('MATH');
|
||||||
|
$color = new SubjectColor('#3B82F6');
|
||||||
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||||
|
|
||||||
|
$subject = Subject::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
name: $name,
|
||||||
|
code: $code,
|
||||||
|
color: $color,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($subject->tenantId->equals($tenantId));
|
||||||
|
self::assertTrue($subject->schoolId->equals($schoolId));
|
||||||
|
self::assertTrue($subject->name->equals($name));
|
||||||
|
self::assertTrue($subject->code->equals($code));
|
||||||
|
self::assertNotNull($subject->color);
|
||||||
|
self::assertTrue($subject->color->equals($color));
|
||||||
|
self::assertEquals($createdAt, $subject->createdAt);
|
||||||
|
self::assertEquals($createdAt, $subject->updatedAt);
|
||||||
|
self::assertNull($subject->deletedAt);
|
||||||
|
self::assertNull($subject->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerWithNullColor(): void
|
||||||
|
{
|
||||||
|
$subject = Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Arts plastiques'),
|
||||||
|
code: new SubjectCode('ART'),
|
||||||
|
color: null,
|
||||||
|
createdAt: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($subject->color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function renommerChangesNameAndRecordsEvent(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$subject->pullDomainEvents();
|
||||||
|
$ancienNom = $subject->name;
|
||||||
|
$nouveauNom = new SubjectName('Mathématiques avancées');
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$subject->renommer($nouveauNom, $at);
|
||||||
|
|
||||||
|
self::assertTrue($subject->name->equals($nouveauNom));
|
||||||
|
self::assertEquals($at, $subject->updatedAt);
|
||||||
|
|
||||||
|
$events = $subject->pullDomainEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
|
||||||
|
self::assertTrue($events[0]->ancienNom->equals($ancienNom));
|
||||||
|
self::assertTrue($events[0]->nouveauNom->equals($nouveauNom));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function renommerWithSameNameDoesNothing(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$subject->pullDomainEvents();
|
||||||
|
$originalUpdatedAt = $subject->updatedAt;
|
||||||
|
|
||||||
|
$subject->renommer(new SubjectName('Mathématiques'), new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalUpdatedAt, $subject->updatedAt);
|
||||||
|
self::assertEmpty($subject->pullDomainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerCodeUpdatesCode(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
$nouveauCode = new SubjectCode('MATHS');
|
||||||
|
|
||||||
|
$subject->changerCode($nouveauCode, $at);
|
||||||
|
|
||||||
|
self::assertTrue($subject->code->equals($nouveauCode));
|
||||||
|
self::assertEquals($at, $subject->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerCodeWithSameCodeDoesNothing(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$originalUpdatedAt = $subject->updatedAt;
|
||||||
|
|
||||||
|
$subject->changerCode(new SubjectCode('MATH'), new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalUpdatedAt, $subject->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerCouleurUpdatesColor(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
$nouvelleCouleur = new SubjectColor('#EF4444');
|
||||||
|
|
||||||
|
$subject->changerCouleur($nouvelleCouleur, $at);
|
||||||
|
|
||||||
|
self::assertNotNull($subject->color);
|
||||||
|
self::assertTrue($subject->color->equals($nouvelleCouleur));
|
||||||
|
self::assertEquals($at, $subject->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerCouleurToNullRemovesColor(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$subject->changerCouleur(null, $at);
|
||||||
|
|
||||||
|
self::assertNull($subject->color);
|
||||||
|
self::assertEquals($at, $subject->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerCouleurWithSameColorDoesNothing(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$originalUpdatedAt = $subject->updatedAt;
|
||||||
|
|
||||||
|
$subject->changerCouleur(new SubjectColor('#3B82F6'), new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalUpdatedAt, $subject->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function decrireUpdatesDescription(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$subject->decrire('Cours de mathématiques généralistes', $at);
|
||||||
|
|
||||||
|
self::assertSame('Cours de mathématiques généralistes', $subject->description);
|
||||||
|
self::assertEquals($at, $subject->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function archiverChangesStatusAndRecordsEvent(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$subject->pullDomainEvents();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$subject->archiver($at);
|
||||||
|
|
||||||
|
self::assertSame(SubjectStatus::ARCHIVED, $subject->status);
|
||||||
|
self::assertFalse($subject->estActive());
|
||||||
|
self::assertFalse($subject->peutEtreUtilisee());
|
||||||
|
self::assertEquals($at, $subject->deletedAt);
|
||||||
|
self::assertEquals($at, $subject->updatedAt);
|
||||||
|
|
||||||
|
$events = $subject->pullDomainEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(MatiereSupprimee::class, $events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function archiverAlreadyArchivedSubjectDoesNothing(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$subject->archiver(new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
$subject->pullDomainEvents();
|
||||||
|
$originalDeletedAt = $subject->deletedAt;
|
||||||
|
|
||||||
|
$subject->archiver(new DateTimeImmutable('2026-02-02 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalDeletedAt, $subject->deletedAt);
|
||||||
|
self::assertEmpty($subject->pullDomainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function reconstituteRestoresAllProperties(): void
|
||||||
|
{
|
||||||
|
$id = SubjectId::generate();
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
|
||||||
|
$name = new SubjectName('Mathématiques');
|
||||||
|
$code = new SubjectCode('MATH');
|
||||||
|
$color = new SubjectColor('#3B82F6');
|
||||||
|
$status = SubjectStatus::ARCHIVED;
|
||||||
|
$description = 'Test description';
|
||||||
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||||
|
$updatedAt = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
$deletedAt = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$subject = Subject::reconstitute(
|
||||||
|
id: $id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
name: $name,
|
||||||
|
code: $code,
|
||||||
|
color: $color,
|
||||||
|
status: $status,
|
||||||
|
description: $description,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
updatedAt: $updatedAt,
|
||||||
|
deletedAt: $deletedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($subject->id->equals($id));
|
||||||
|
self::assertTrue($subject->tenantId->equals($tenantId));
|
||||||
|
self::assertTrue($subject->schoolId->equals($schoolId));
|
||||||
|
self::assertTrue($subject->name->equals($name));
|
||||||
|
self::assertTrue($subject->code->equals($code));
|
||||||
|
self::assertNotNull($subject->color);
|
||||||
|
self::assertTrue($subject->color->equals($color));
|
||||||
|
self::assertSame($status, $subject->status);
|
||||||
|
self::assertSame($description, $subject->description);
|
||||||
|
self::assertEquals($createdAt, $subject->createdAt);
|
||||||
|
self::assertEquals($updatedAt, $subject->updatedAt);
|
||||||
|
self::assertEquals($deletedAt, $subject->deletedAt);
|
||||||
|
self::assertEmpty($subject->pullDomainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSubject(): Subject
|
||||||
|
{
|
||||||
|
return Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Mathématiques'),
|
||||||
|
code: new SubjectCode('MATH'),
|
||||||
|
color: new SubjectColor('#3B82F6'),
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\Subject\Subject;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class InMemorySubjectRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private InMemorySubjectRepository $repository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->repository = new InMemorySubjectRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function saveAndFindById(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
|
||||||
|
$this->repository->save($subject);
|
||||||
|
$found = $this->repository->findById($subject->id);
|
||||||
|
|
||||||
|
self::assertNotNull($found);
|
||||||
|
self::assertTrue($subject->id->equals($found->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getReturnsSubject(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$this->repository->save($subject);
|
||||||
|
|
||||||
|
$found = $this->repository->get($subject->id);
|
||||||
|
|
||||||
|
self::assertTrue($subject->id->equals($found->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getThrowsExceptionWhenNotFound(): void
|
||||||
|
{
|
||||||
|
$this->expectException(SubjectNotFoundException::class);
|
||||||
|
|
||||||
|
$this->repository->get(SubjectId::fromString('550e8400-e29b-41d4-a716-446655440099'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByIdReturnsNullWhenNotFound(): void
|
||||||
|
{
|
||||||
|
$found = $this->repository->findById(
|
||||||
|
SubjectId::fromString('550e8400-e29b-41d4-a716-446655440099'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByCode(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$this->repository->save($subject);
|
||||||
|
|
||||||
|
$found = $this->repository->findByCode(
|
||||||
|
$subject->code,
|
||||||
|
$subject->tenantId,
|
||||||
|
$subject->schoolId,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotNull($found);
|
||||||
|
self::assertTrue($subject->id->equals($found->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByCodeReturnsNullWhenNotFound(): void
|
||||||
|
{
|
||||||
|
$found = $this->repository->findByCode(
|
||||||
|
new SubjectCode('NOTEXIST'),
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByCodeIsTenantScoped(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$this->repository->save($subject);
|
||||||
|
|
||||||
|
// Same code, different tenant
|
||||||
|
$found = $this->repository->findByCode(
|
||||||
|
$subject->code,
|
||||||
|
TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
|
||||||
|
$subject->schoolId,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByCodeIsSchoolScoped(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$this->repository->save($subject);
|
||||||
|
|
||||||
|
// Same code, different school
|
||||||
|
$found = $this->repository->findByCode(
|
||||||
|
$subject->code,
|
||||||
|
$subject->tenantId,
|
||||||
|
SchoolId::fromString('550e8400-e29b-41d4-a716-446655440099'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findActiveByTenantAndSchool(): void
|
||||||
|
{
|
||||||
|
// Create 2 active subjects
|
||||||
|
$subject1 = $this->createSubject();
|
||||||
|
$subject2 = Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Français'),
|
||||||
|
code: new SubjectCode('FR'),
|
||||||
|
color: new SubjectColor('#EF4444'),
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create 1 archived subject
|
||||||
|
$subject3 = Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Latin'),
|
||||||
|
code: new SubjectCode('LAT'),
|
||||||
|
color: null,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
$subject3->archiver(new DateTimeImmutable());
|
||||||
|
|
||||||
|
$this->repository->save($subject1);
|
||||||
|
$this->repository->save($subject2);
|
||||||
|
$this->repository->save($subject3);
|
||||||
|
|
||||||
|
$activeSubjects = $this->repository->findActiveByTenantAndSchool(
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(2, $activeSubjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$this->repository->save($subject);
|
||||||
|
|
||||||
|
$this->repository->delete($subject->id);
|
||||||
|
|
||||||
|
self::assertNull($this->repository->findById($subject->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function saveUpdatesCodeIndex(): void
|
||||||
|
{
|
||||||
|
$subject = $this->createSubject();
|
||||||
|
$this->repository->save($subject);
|
||||||
|
|
||||||
|
// Change code
|
||||||
|
$subject->changerCode(new SubjectCode('MATHS'), new DateTimeImmutable());
|
||||||
|
$this->repository->save($subject);
|
||||||
|
|
||||||
|
// Old code should not find anything
|
||||||
|
$foundOld = $this->repository->findByCode(
|
||||||
|
new SubjectCode('MATH'),
|
||||||
|
$subject->tenantId,
|
||||||
|
$subject->schoolId,
|
||||||
|
);
|
||||||
|
self::assertNull($foundOld);
|
||||||
|
|
||||||
|
// New code should find it
|
||||||
|
$foundNew = $this->repository->findByCode(
|
||||||
|
new SubjectCode('MATHS'),
|
||||||
|
$subject->tenantId,
|
||||||
|
$subject->schoolId,
|
||||||
|
);
|
||||||
|
self::assertNotNull($foundNew);
|
||||||
|
self::assertTrue($subject->id->equals($foundNew->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSubject(): Subject
|
||||||
|
{
|
||||||
|
return Subject::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
name: new SubjectName('Mathématiques'),
|
||||||
|
code: new SubjectCode('MATH'),
|
||||||
|
color: new SubjectColor('#3B82F6'),
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
653
frontend/e2e/subjects.spec.ts
Normal file
653
frontend/e2e/subjects.spec.ts
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
// Test credentials
|
||||||
|
const ADMIN_EMAIL = 'e2e-subjects-admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = 'SubjectsTest123';
|
||||||
|
|
||||||
|
// Force serial execution to ensure Empty State runs first
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Subjects Management (Story 2.2)', () => {
|
||||||
|
// Create admin user and clean up subjects before running tests
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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' }
|
||||||
|
);
|
||||||
|
console.log('Subjects E2E test admin user created');
|
||||||
|
|
||||||
|
// Clean up all subjects for this tenant to ensure Empty State test works
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
console.log('Subjects cleaned up for E2E tests');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to login as admin
|
||||||
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||||
|
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to open "Nouvelle matière" dialog with proper wait
|
||||||
|
async function openNewSubjectDialog(page: import('@playwright/test').Page) {
|
||||||
|
const button = page.getByRole('button', { name: /nouvelle matière/i });
|
||||||
|
await button.waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
// Wait for any pending network requests to finish before clicking
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Click the button
|
||||||
|
await button.click();
|
||||||
|
|
||||||
|
// Wait for dialog to appear - retry click if needed (webkit timing issue)
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
try {
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
} catch {
|
||||||
|
// Retry once - webkit sometimes needs a second click
|
||||||
|
await button.click();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EMPTY STATE - Must run FIRST before any subject is created
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Empty State', () => {
|
||||||
|
test('shows empty state message when no subjects exist', async ({ page }) => {
|
||||||
|
// Clean up subjects right before this specific test to avoid race conditions with parallel browsers
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await expect(page.getByRole('heading', { name: /gestion des matières/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Should show empty state
|
||||||
|
await expect(page.locator('.empty-state')).toBeVisible();
|
||||||
|
await expect(page.getByText(/aucune matière/i)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /créer une matière/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// List Display
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('List Display', () => {
|
||||||
|
test('displays all created subjects in the list', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create multiple subjects
|
||||||
|
const subjects = [
|
||||||
|
{ name: `Math-${Date.now()}`, code: 'MATH1' },
|
||||||
|
{ name: `Français-${Date.now()}`, code: 'FR1' },
|
||||||
|
{ name: `Histoire-${Date.now()}`, code: 'HIST1' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const subject of subjects) {
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
await page.locator('#subject-name').fill(subject.name);
|
||||||
|
await page.locator('#subject-code').fill(subject.code);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ALL subjects appear in the list
|
||||||
|
for (const subject of subjects) {
|
||||||
|
await expect(page.getByText(subject.name)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the number of subject cards matches (at least the ones we created)
|
||||||
|
const subjectCards = page.locator('.subject-card');
|
||||||
|
const count = await subjectCards.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(subjects.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays subject details correctly (code, color)', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create a subject with all details
|
||||||
|
const subjectName = `Details-${Date.now()}`;
|
||||||
|
const subjectCode = `DET${Date.now() % 10000}`;
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
await page.locator('#subject-name').fill(subjectName);
|
||||||
|
await page.locator('#subject-code').fill(subjectCode);
|
||||||
|
|
||||||
|
// Select blue color
|
||||||
|
await page.locator('.color-swatch').first().click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Find the subject card
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
|
||||||
|
await expect(subjectCard).toBeVisible();
|
||||||
|
|
||||||
|
// Verify code is displayed (uppercase)
|
||||||
|
await expect(subjectCard.getByText(subjectCode.toUpperCase())).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC1: Subject Creation
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC1: Subject Creation', () => {
|
||||||
|
test('can create a new subject with all fields', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
// Navigate to subjects page
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
await expect(page.getByRole('heading', { name: /gestion des matières/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Click "Nouvelle matière" button
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
await expect(page.getByRole('heading', { name: /nouvelle matière/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
const uniqueName = `Test-E2E-${Date.now()}`;
|
||||||
|
const uniqueCode = `TE${Date.now() % 100000}`;
|
||||||
|
await page.locator('#subject-name').fill(uniqueName);
|
||||||
|
await page.locator('#subject-code').fill(uniqueCode);
|
||||||
|
|
||||||
|
// Select a color (blue - Mathématiques)
|
||||||
|
await page.locator('.color-swatch').first().click();
|
||||||
|
|
||||||
|
// Note: Description is only available on the edit page, not in the create modal
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
|
||||||
|
// Modal should close and subject should appear in list
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create a subject with only required fields (name, code)', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// Fill only the required fields
|
||||||
|
const uniqueName = `Minimal-${Date.now()}`;
|
||||||
|
const uniqueCode = `MIN${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(uniqueName);
|
||||||
|
await page.locator('#subject-code').fill(uniqueCode);
|
||||||
|
|
||||||
|
// Submit button should be enabled
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer la matière/i });
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Subject should be created
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit button is disabled when required fields are empty', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// Don't fill anything
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer la matière/i });
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Fill name but not code
|
||||||
|
await page.locator('#subject-name').fill('Test');
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Fill code - button should enable
|
||||||
|
await page.locator('#subject-code').fill('TST');
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can cancel subject creation', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await page.locator('#subject-name').fill('Should-Not-Be-Created');
|
||||||
|
await page.locator('#subject-code').fill('NOPE');
|
||||||
|
|
||||||
|
// Click cancel
|
||||||
|
await page.getByRole('button', { name: /annuler/i }).click();
|
||||||
|
|
||||||
|
// Modal should close
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Subject should not appear in list
|
||||||
|
await expect(page.getByText('Should-Not-Be-Created')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('code is automatically converted to uppercase', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
const uniqueName = `Upper-${Date.now()}`;
|
||||||
|
await page.locator('#subject-name').fill(uniqueName);
|
||||||
|
await page.locator('#subject-code').fill('lowercase');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify code is displayed in uppercase
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: uniqueName });
|
||||||
|
await expect(subjectCard.getByText('LOWERCASE')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows error for duplicate code', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create first subject
|
||||||
|
const uniqueCode = `DUP${Date.now() % 10000}`;
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
await page.locator('#subject-name').fill(`First-${Date.now()}`);
|
||||||
|
await page.locator('#subject-code').fill(uniqueCode);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Try to create second subject with same code
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
await page.locator('#subject-name').fill(`Second-${Date.now()}`);
|
||||||
|
await page.locator('#subject-code').fill(uniqueCode);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
|
||||||
|
// Should show error
|
||||||
|
await expect(page.getByText(/existe déjà|already exists/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC2: Subject Modification
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC2: Subject Modification', () => {
|
||||||
|
test('can modify an existing subject', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// First create a subject to modify
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
const originalName = `ToModify-${Date.now()}`;
|
||||||
|
const originalCode = `MOD${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(originalName);
|
||||||
|
await page.locator('#subject-code').fill(originalCode);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Find the subject card and click modify
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: originalName });
|
||||||
|
await subjectCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Should navigate to edit page
|
||||||
|
await expect(page).toHaveURL(/\/admin\/subjects\/[\w-]+/);
|
||||||
|
|
||||||
|
// Modify the name
|
||||||
|
const newName = `Modified-${Date.now()}`;
|
||||||
|
await page.locator('#subject-name').fill(newName);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
|
|
||||||
|
// Should show success message
|
||||||
|
await expect(page.getByText(/mise à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Go back to list and verify
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
await expect(page.getByText(newName)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can cancel modification', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create a subject
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
const originalName = `NoChange-${Date.now()}`;
|
||||||
|
const originalCode = `NCH${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(originalName);
|
||||||
|
await page.locator('#subject-code').fill(originalCode);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click modify
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: originalName });
|
||||||
|
await subjectCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Modify but cancel
|
||||||
|
await page.locator('#subject-name').fill('Should-Not-Change');
|
||||||
|
await page.getByRole('button', { name: /annuler/i }).click();
|
||||||
|
|
||||||
|
// Should go back to list
|
||||||
|
await expect(page).toHaveURL(/\/admin\/subjects$/);
|
||||||
|
|
||||||
|
// Original name should still be there
|
||||||
|
await expect(page.getByText(originalName)).toBeVisible();
|
||||||
|
await expect(page.getByText('Should-Not-Change')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can change subject color', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create a subject with blue color
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
const subjectName = `ColorChange-${Date.now()}`;
|
||||||
|
const subjectCode = `CLR${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(subjectName);
|
||||||
|
await page.locator('#subject-code').fill(subjectCode);
|
||||||
|
await page.locator('.color-swatch').first().click(); // Blue
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click modify
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
|
||||||
|
await subjectCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Change to red color (second swatch)
|
||||||
|
await page.locator('.color-swatch').nth(1).click();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
|
await expect(page.getByText(/mise à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can remove subject color', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create a subject with a color
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
const subjectName = `RemoveColor-${Date.now()}`;
|
||||||
|
const subjectCode = `RMC${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(subjectName);
|
||||||
|
await page.locator('#subject-code').fill(subjectCode);
|
||||||
|
await page.locator('.color-swatch').first().click();
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click modify
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
|
||||||
|
await subjectCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Click "no color" button
|
||||||
|
await page.locator('.color-none').click();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
|
await expect(page.getByText(/mise à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC3: Deletion with warning for subjects with grades
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC3: Deletion with warning for grades', () => {
|
||||||
|
// SKIP REASON: The Grades module is not yet implemented.
|
||||||
|
// HasGradesForSubjectHandler currently returns false (stub), so all subjects
|
||||||
|
// appear without grades and can be deleted without warning. This test will
|
||||||
|
// be enabled once the Grades module allows recording grades for subjects.
|
||||||
|
//
|
||||||
|
// When enabled, this test should:
|
||||||
|
// 1. Create a subject
|
||||||
|
// 2. Add at least one grade to it
|
||||||
|
// 3. Attempt to delete the subject
|
||||||
|
// 4. Verify the warning message about grades
|
||||||
|
// 5. Require explicit confirmation
|
||||||
|
test.skip('shows warning when trying to delete subject with grades', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
// Implementation pending Grades module
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC4: Subject deletion (soft delete)
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC4: Subject deletion (soft delete)', () => {
|
||||||
|
test('can delete a subject without grades', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create a subject to delete
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
const subjectName = `ToDelete-${Date.now()}`;
|
||||||
|
const subjectCode = `DEL${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(subjectName);
|
||||||
|
await page.locator('#subject-code').fill(subjectCode);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(subjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click delete button
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
|
||||||
|
await subjectCard.getByRole('button', { name: /supprimer/i }).click();
|
||||||
|
|
||||||
|
// Confirmation modal should appear
|
||||||
|
const deleteModal = page.getByRole('alertdialog');
|
||||||
|
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(deleteModal.getByText(subjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm deletion
|
||||||
|
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
|
||||||
|
|
||||||
|
// Modal should close and subject should no longer appear in list
|
||||||
|
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(subjectName)).not.toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can cancel deletion', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create a subject
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
const subjectName = `NoDelete-${Date.now()}`;
|
||||||
|
const subjectCode = `NDL${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(subjectName);
|
||||||
|
await page.locator('#subject-code').fill(subjectCode);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Find and click delete
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
|
||||||
|
await subjectCard.getByRole('button', { name: /supprimer/i }).click();
|
||||||
|
|
||||||
|
// Confirmation modal should appear
|
||||||
|
const deleteModal = page.getByRole('alertdialog');
|
||||||
|
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Cancel deletion
|
||||||
|
await deleteModal.getByRole('button', { name: /annuler/i }).click();
|
||||||
|
|
||||||
|
// Modal should close and subject should still be there
|
||||||
|
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(subjectName)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Navigation
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Navigation', () => {
|
||||||
|
test('can access subjects page directly', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
// Navigate directly to subjects page
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/admin\/subjects/);
|
||||||
|
await expect(page.getByRole('heading', { name: /gestion des matières/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('back button navigation works on edit page', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
// Create a subject
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
const subjectName = `BackNav-${Date.now()}`;
|
||||||
|
const subjectCode = `BCK${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(subjectName);
|
||||||
|
await page.locator('#subject-code').fill(subjectCode);
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Go to edit page
|
||||||
|
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
|
||||||
|
await subjectCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Click back button to go back
|
||||||
|
await page.getByRole('button', { name: /retour aux matières/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/admin\/subjects$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Validation', () => {
|
||||||
|
test('shows validation for subject name length', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// Try a name that's too short (1 char)
|
||||||
|
await page.locator('#subject-name').fill('A');
|
||||||
|
await page.locator('#subject-code').fill('TEST');
|
||||||
|
|
||||||
|
// The HTML5 minlength validation should prevent submission
|
||||||
|
const nameInput = page.locator('#subject-name');
|
||||||
|
const isInvalid = await nameInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(isInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation for subject code format', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// Try a code that's too short (1 char)
|
||||||
|
await page.locator('#subject-name').fill('Test Subject');
|
||||||
|
await page.locator('#subject-code').fill('A');
|
||||||
|
|
||||||
|
// The HTML5 minlength validation should prevent submission
|
||||||
|
const codeInput = page.locator('#subject-code');
|
||||||
|
const isInvalid = await codeInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(isInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('code rejects special characters (backend validation)', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// Try a code with special characters
|
||||||
|
await page.locator('#subject-name').fill('Test Subject');
|
||||||
|
await page.locator('#subject-code').fill('TEST-123');
|
||||||
|
|
||||||
|
// Submit - backend should reject it
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
|
||||||
|
// Should show error from backend
|
||||||
|
await expect(page.getByText(/alphanumériques|alphanumeric/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Color Picker
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Color Picker', () => {
|
||||||
|
test('can select predefined colors', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// Verify all color swatches are visible
|
||||||
|
const colorSwatches = page.locator('.color-swatch:not(.color-none)');
|
||||||
|
const count = await colorSwatches.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(7); // At least 7 predefined colors
|
||||||
|
|
||||||
|
// Click each color and verify it gets selected
|
||||||
|
for (let i = 0; i < Math.min(count, 3); i++) {
|
||||||
|
await colorSwatches.nth(i).click();
|
||||||
|
await expect(colorSwatches.nth(i)).toHaveClass(/selected/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can use custom color picker', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||||
|
|
||||||
|
await openNewSubjectDialog(page);
|
||||||
|
|
||||||
|
// The color input should be visible
|
||||||
|
const colorInput = page.locator('input[type="color"]');
|
||||||
|
await expect(colorInput).toBeVisible();
|
||||||
|
|
||||||
|
// Set a custom color
|
||||||
|
await colorInput.fill('#FF5733');
|
||||||
|
|
||||||
|
// Fill required fields and submit
|
||||||
|
const uniqueName = `CustomColor-${Date.now()}`;
|
||||||
|
const uniqueCode = `CC${Date.now() % 10000}`;
|
||||||
|
await page.locator('#subject-name').fill(uniqueName);
|
||||||
|
await page.locator('#subject-code').fill(uniqueCode);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Subject should be created
|
||||||
|
await expect(page.getByText(uniqueName)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,11 @@
|
|||||||
<span class="action-label">Configurer les classes</span>
|
<span class="action-label">Configurer les classes</span>
|
||||||
<span class="action-hint">Créer et gérer</span>
|
<span class="action-hint">Créer et gérer</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="action-card" href="/admin/subjects">
|
||||||
|
<span class="action-icon">📚</span>
|
||||||
|
<span class="action-label">Gérer les matières</span>
|
||||||
|
<span class="action-hint">Créer et gérer</span>
|
||||||
|
</a>
|
||||||
<div class="action-card disabled" aria-disabled="true">
|
<div class="action-card disabled" aria-disabled="true">
|
||||||
<span class="action-icon">📅</span>
|
<span class="action-icon">📅</span>
|
||||||
<span class="action-label">Calendrier scolaire</span>
|
<span class="action-label">Calendrier scolaire</span>
|
||||||
|
|||||||
212
frontend/src/routes/admin/+layout.svelte
Normal file
212
frontend/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { logout } from '$lib/auth/auth.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
let isLoggingOut = $state(false);
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
isLoggingOut = true;
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} finally {
|
||||||
|
isLoggingOut = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSettings() {
|
||||||
|
goto('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which admin section is active
|
||||||
|
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
||||||
|
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="admin-layout">
|
||||||
|
<header class="admin-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<button class="logo-button" onclick={goHome}>
|
||||||
|
<span class="logo-text">Classeo</span>
|
||||||
|
</button>
|
||||||
|
<nav class="header-nav">
|
||||||
|
<a href="/dashboard" class="nav-link">Tableau de bord</a>
|
||||||
|
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
|
||||||
|
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
|
||||||
|
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||||
|
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
{#if isLoggingOut}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Déconnexion...
|
||||||
|
{:else}
|
||||||
|
Déconnexion
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
<div class="main-content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface-primary, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
background: var(--surface-elevated, #fff);
|
||||||
|
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-primary, #0ea5e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--text-primary, #1f2937);
|
||||||
|
background: var(--surface-primary, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--accent-primary, #0ea5e9);
|
||||||
|
background: var(--accent-primary-light, #e0f2fe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
color: var(--text-primary, #1f2937);
|
||||||
|
background: var(--surface-primary, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-subtle, #e2e8f0);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover:not(:disabled) {
|
||||||
|
color: var(--color-alert, #ef4444);
|
||||||
|
border-color: var(--color-alert, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border-subtle, #e2e8f0);
|
||||||
|
border-top-color: var(--text-secondary, #64748b);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
842
frontend/src/routes/admin/subjects/+page.svelte
Normal file
842
frontend/src/routes/admin/subjects/+page.svelte
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Subject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
teacherCount: number | null;
|
||||||
|
classCount: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couleurs prédéfinies (suggestions UI)
|
||||||
|
const SUGGESTED_COLORS = [
|
||||||
|
{ label: 'Bleu (Mathématiques)', value: '#3B82F6' },
|
||||||
|
{ label: 'Rouge (Français)', value: '#EF4444' },
|
||||||
|
{ label: 'Orange (Histoire-Géo)', value: '#F59E0B' },
|
||||||
|
{ label: 'Vert (Sciences)', value: '#10B981' },
|
||||||
|
{ label: 'Indigo (Anglais)', value: '#6366F1' },
|
||||||
|
{ label: 'Rose (EPS)', value: '#EC4899' },
|
||||||
|
{ label: 'Violet (Arts)', value: '#8B5CF6' },
|
||||||
|
{ label: 'Gris (Autre)', value: '#6B7280' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// State
|
||||||
|
let subjects = $state<Subject[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let showCreateModal = $state(false);
|
||||||
|
let showDeleteModal = $state(false);
|
||||||
|
let subjectToDelete = $state<Subject | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let newSubjectName = $state('');
|
||||||
|
let newSubjectCode = $state('');
|
||||||
|
let newSubjectColor = $state<string | null>(null);
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let isDeleting = $state(false);
|
||||||
|
|
||||||
|
// Load subjects on mount
|
||||||
|
$effect(() => {
|
||||||
|
loadSubjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSubjects() {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/subjects`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur lors du chargement des matières');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// API Platform peut retourner hydra:member, member, ou un tableau direct
|
||||||
|
subjects = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
subjects = [];
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateSubject() {
|
||||||
|
if (!newSubjectName.trim() || !newSubjectCode.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSubmitting = true;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/subjects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newSubjectName.trim(),
|
||||||
|
code: newSubjectCode.trim().toUpperCase(),
|
||||||
|
color: newSubjectColor
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Erreur lors de la création (${response.status})`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData['hydra:description']) {
|
||||||
|
errorMessage = errorData['hydra:description'];
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.detail) {
|
||||||
|
errorMessage = errorData.detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parsing failed, keep default message
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload subjects and close modal
|
||||||
|
await loadSubjects();
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la création';
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(subject: Subject) {
|
||||||
|
subjectToDelete = subject;
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
showDeleteModal = false;
|
||||||
|
subjectToDelete = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmDelete() {
|
||||||
|
if (!subjectToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isDeleting = true;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Erreur lors de la suppression (${response.status})`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData['hydra:description']) {
|
||||||
|
errorMessage = errorData['hydra:description'];
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.detail) {
|
||||||
|
errorMessage = errorData.detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parsing failed, keep default message
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDeleteModal();
|
||||||
|
await loadSubjects();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la suppression';
|
||||||
|
} finally {
|
||||||
|
isDeleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
showCreateModal = true;
|
||||||
|
newSubjectName = '';
|
||||||
|
newSubjectCode = '';
|
||||||
|
newSubjectColor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showCreateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToEdit(subjectId: string) {
|
||||||
|
goto(`/admin/subjects/${subjectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorStyle(color: string | null): string {
|
||||||
|
if (!color) return '';
|
||||||
|
return `background-color: ${color}; color: ${getContrastColor(color)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContrastColor(hexColor: string): string {
|
||||||
|
// Simple luminance check for contrast
|
||||||
|
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
return luminance > 0.5 ? '#000000' : '#FFFFFF';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Gestion des matières - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="subjects-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>Gestion des matières</h1>
|
||||||
|
<p class="subtitle">Créez et gérez les matières enseignées dans votre établissement</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick={openCreateModal}>
|
||||||
|
<span class="btn-icon">+</span>
|
||||||
|
Nouvelle matière
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
{error}
|
||||||
|
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement des matières...</p>
|
||||||
|
</div>
|
||||||
|
{:else if subjects.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="empty-icon">📚</span>
|
||||||
|
<h2>Aucune matière</h2>
|
||||||
|
<p>Commencez par créer votre première matière</p>
|
||||||
|
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="subjects-grid">
|
||||||
|
{#each subjects as subject (subject.id)}
|
||||||
|
<div class="subject-card">
|
||||||
|
<div class="subject-header">
|
||||||
|
{#if subject.color}
|
||||||
|
<span class="subject-color-badge" style={getColorStyle(subject.color)}>
|
||||||
|
{subject.code}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="subject-code">{subject.code}</span>
|
||||||
|
{/if}
|
||||||
|
<h3 class="subject-name">{subject.name}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subject-stats">
|
||||||
|
<span class="stat-item" title="Enseignants affectés">
|
||||||
|
<span class="stat-icon">👨🏫</span>
|
||||||
|
{subject.teacherCount ?? 0}
|
||||||
|
</span>
|
||||||
|
<span class="stat-item" title="Classes associées">
|
||||||
|
<span class="stat-icon">🏫</span>
|
||||||
|
{subject.classCount ?? 0}
|
||||||
|
</span>
|
||||||
|
<span class="stat-item status-{subject.status}">
|
||||||
|
{subject.status === 'active' ? 'Active' : 'Archivée'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subject-actions">
|
||||||
|
<button class="btn-secondary btn-sm" onclick={() => navigateToEdit(subject.id)}>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(subject)}>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
{#if showCreateModal}
|
||||||
|
<div class="modal-overlay" onclick={closeModal} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="modal-title">Nouvelle matière</h2>
|
||||||
|
<button class="modal-close" onclick={closeModal} aria-label="Fermer">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="modal-body"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateSubject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject-name">Nom de la matière *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="subject-name"
|
||||||
|
bind:value={newSubjectName}
|
||||||
|
placeholder="ex: Mathématiques"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject-code">Code court *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="subject-code"
|
||||||
|
bind:value={newSubjectCode}
|
||||||
|
placeholder="ex: MATH"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="10"
|
||||||
|
/>
|
||||||
|
<small class="form-hint">2 à 10 caractères (lettres et chiffres uniquement)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject-color">Couleur</label>
|
||||||
|
<div class="color-picker-group">
|
||||||
|
<div class="color-swatches">
|
||||||
|
{#each SUGGESTED_COLORS as colorOption}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-swatch"
|
||||||
|
class:selected={newSubjectColor === colorOption.value}
|
||||||
|
style="background-color: {colorOption.value}"
|
||||||
|
onclick={() => (newSubjectColor = colorOption.value)}
|
||||||
|
title={colorOption.label}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-swatch color-none"
|
||||||
|
class:selected={newSubjectColor === null}
|
||||||
|
onclick={() => (newSubjectColor = null)}
|
||||||
|
title="Aucune couleur"
|
||||||
|
>
|
||||||
|
∅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="subject-color"
|
||||||
|
value={newSubjectColor ?? '#6B7280'}
|
||||||
|
onchange={(e) => (newSubjectColor = e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={closeModal} disabled={isSubmitting}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
disabled={isSubmitting || !newSubjectName.trim() || !newSubjectCode.trim()}
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
Création...
|
||||||
|
{:else}
|
||||||
|
Créer la matière
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
{#if showDeleteModal && subjectToDelete}
|
||||||
|
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal modal-confirm"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-modal-title"
|
||||||
|
aria-describedby="delete-modal-description"
|
||||||
|
>
|
||||||
|
<header class="modal-header modal-header-danger">
|
||||||
|
<h2 id="delete-modal-title">Supprimer la matière</h2>
|
||||||
|
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="delete-modal-description">
|
||||||
|
Êtes-vous sûr de vouloir supprimer la matière <strong>{subjectToDelete.name}</strong> ({subjectToDelete.code})
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
<p class="delete-warning">Cette action est irréversible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
onclick={closeDeleteModal}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-danger" onclick={handleConfirmDelete} disabled={isDeleting}>
|
||||||
|
{#if isDeleting}
|
||||||
|
Suppression...
|
||||||
|
{:else}
|
||||||
|
Supprimer
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.subjects-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 2px dashed #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subjects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-code {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-color-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-archived {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type='text'],
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch.selected {
|
||||||
|
border-color: #1f2937;
|
||||||
|
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-none {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
width: 3rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete confirmation modal */
|
||||||
|
.modal-confirm {
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-confirm .modal-actions {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-bottom-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-danger h2 {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
515
frontend/src/routes/admin/subjects/[id]/+page.svelte
Normal file
515
frontend/src/routes/admin/subjects/[id]/+page.svelte
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Subject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couleurs prédéfinies
|
||||||
|
const SUGGESTED_COLORS = [
|
||||||
|
{ label: 'Bleu (Mathématiques)', value: '#3B82F6' },
|
||||||
|
{ label: 'Rouge (Français)', value: '#EF4444' },
|
||||||
|
{ label: 'Orange (Histoire-Géo)', value: '#F59E0B' },
|
||||||
|
{ label: 'Vert (Sciences)', value: '#10B981' },
|
||||||
|
{ label: 'Indigo (Anglais)', value: '#6366F1' },
|
||||||
|
{ label: 'Rose (EPS)', value: '#EC4899' },
|
||||||
|
{ label: 'Violet (Arts)', value: '#8B5CF6' },
|
||||||
|
{ label: 'Gris (Autre)', value: '#6B7280' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// State
|
||||||
|
let subject = $state<Subject | null>(null);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let successMessage = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let name = $state('');
|
||||||
|
let code = $state('');
|
||||||
|
let color = $state<string | null>(null);
|
||||||
|
let description = $state('');
|
||||||
|
|
||||||
|
// Load subject on mount
|
||||||
|
$effect(() => {
|
||||||
|
const subjectId = page.params.id;
|
||||||
|
if (subjectId) {
|
||||||
|
loadSubject(subjectId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSubject(subjectId: string) {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('Matière non trouvée');
|
||||||
|
}
|
||||||
|
throw new Error('Erreur lors du chargement de la matière');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
subject = data;
|
||||||
|
|
||||||
|
// Initialize form state
|
||||||
|
name = data.name ?? '';
|
||||||
|
code = data.code ?? '';
|
||||||
|
color = data.color;
|
||||||
|
description = data.description ?? '';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!subject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSaving = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/subjects/${subject.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/merge-patch+json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
code: code.trim().toUpperCase(),
|
||||||
|
color: color,
|
||||||
|
description: description.trim() || null,
|
||||||
|
clearColor: color === null && subject.color !== null,
|
||||||
|
clearDescription: !description.trim() && subject.description !== null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Erreur lors de la sauvegarde (${response.status})`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData['hydra:description']) {
|
||||||
|
errorMessage = errorData['hydra:description'];
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.detail) {
|
||||||
|
errorMessage = errorData.detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parsing failed, keep default message
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSubject = await response.json();
|
||||||
|
subject = updatedSubject;
|
||||||
|
successMessage = 'Matière mise à jour avec succès';
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
window.setTimeout(() => {
|
||||||
|
successMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde';
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
goto('/admin/subjects');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{subject?.name ?? 'Chargement...'} - Gestion des matières - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="edit-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<button class="btn-back" onclick={goBack}>
|
||||||
|
← Retour aux matières
|
||||||
|
</button>
|
||||||
|
<h1>{subject?.name ?? 'Chargement...'}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
{error}
|
||||||
|
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if successMessage}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span class="alert-icon">✓</span>
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement de la matière...</p>
|
||||||
|
</div>
|
||||||
|
{:else if subject}
|
||||||
|
<form
|
||||||
|
class="edit-form"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="form-card">
|
||||||
|
<h2>Informations générales</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject-name">Nom de la matière *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="subject-name"
|
||||||
|
bind:value={name}
|
||||||
|
placeholder="ex: Mathématiques"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject-code">Code court *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="subject-code"
|
||||||
|
bind:value={code}
|
||||||
|
placeholder="ex: MATH"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="10"
|
||||||
|
/>
|
||||||
|
<small class="form-hint">2 à 10 caractères (lettres et chiffres uniquement)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject-color">Couleur</label>
|
||||||
|
<div class="color-picker-group">
|
||||||
|
<div class="color-swatches">
|
||||||
|
{#each SUGGESTED_COLORS as colorOption}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-swatch"
|
||||||
|
class:selected={color === colorOption.value}
|
||||||
|
style="background-color: {colorOption.value}"
|
||||||
|
onclick={() => (color = colorOption.value)}
|
||||||
|
title={colorOption.label}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-swatch color-none"
|
||||||
|
class:selected={color === null}
|
||||||
|
onclick={() => (color = null)}
|
||||||
|
title="Aucune couleur"
|
||||||
|
>
|
||||||
|
∅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="subject-color"
|
||||||
|
value={color ?? '#6B7280'}
|
||||||
|
onchange={(e) => (color = e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject-description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="subject-description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="Description optionnelle de la matière..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={goBack}>Annuler</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
disabled={isSaving || !name.trim() || !code.trim()}
|
||||||
|
>
|
||||||
|
{#if isSaving}
|
||||||
|
Enregistrement...
|
||||||
|
{:else}
|
||||||
|
Enregistrer les modifications
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Matière non trouvée</p>
|
||||||
|
<button class="btn-primary" onclick={goBack}>Retour à la liste</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.edit-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 2px dashed #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type='text'],
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch.selected {
|
||||||
|
border-color: #1f2937;
|
||||||
|
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-none {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
width: 3rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user