feat: Permettre à l'enseignant de saisir les notes dans une grille inline
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

L'enseignant avait besoin d'un moyen rapide de saisir les notes après
une évaluation. La grille inline permet de compléter 30 élèves en moins
de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab),
la validation temps réel, l'auto-save debounced (500ms) et les
raccourcis /abs et /disp pour marquer absents/dispensés.

Les notes restent en brouillon jusqu'à publication explicite (avec
confirmation modale). Une fois publiées, les élèves les voient
immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy).
Le mode offline stocke les notes en IndexedDB et synchronise
automatiquement au retour de la connexion.

Chaque modification est auditée dans grade_events via un event
subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
This commit is contained in:
2026-03-29 09:55:45 +02:00
parent 98be1951bf
commit b70d5ec2ad
45 changed files with 3902 additions and 11 deletions

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\PublishGrades;
final readonly class PublishGradesCommand
{
public function __construct(
public string $tenantId,
public string $evaluationId,
public string $teacherId,
) {
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\PublishGrades;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class PublishGradesHandler
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private GradeRepository $gradeRepository,
private Clock $clock,
) {
}
public function __invoke(PublishGradesCommand $command): Evaluation
{
$tenantId = TenantId::fromString($command->tenantId);
$evaluationId = EvaluationId::fromString($command->evaluationId);
$teacherId = UserId::fromString($command->teacherId);
$now = $this->clock->now();
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
if ((string) $evaluation->teacherId !== (string) $teacherId) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId);
}
if (!$this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId)) {
throw AucuneNoteSaisieException::pourEvaluation($evaluationId);
}
$evaluation->publierNotes($now);
$this->evaluationRepository->save($evaluation);
return $evaluation;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveGrades;
final readonly class SaveGradesCommand
{
/**
* @param array<array{studentId: string, value: ?float, status: string}> $grades
*/
public function __construct(
public string $tenantId,
public string $evaluationId,
public string $teacherId,
public array $grades,
) {
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveGrades;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class SaveGradesHandler
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private GradeRepository $gradeRepository,
private Clock $clock,
) {
}
/** @return array<Grade> */
public function __invoke(SaveGradesCommand $command): array
{
$tenantId = TenantId::fromString($command->tenantId);
$evaluationId = EvaluationId::fromString($command->evaluationId);
$teacherId = UserId::fromString($command->teacherId);
$now = $this->clock->now();
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
if ((string) $evaluation->teacherId !== (string) $teacherId) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId);
}
$existingGrades = $this->gradeRepository->findByEvaluation($evaluationId, $tenantId);
$existingByStudent = [];
foreach ($existingGrades as $grade) {
$existingByStudent[(string) $grade->studentId] = $grade;
}
$savedGrades = [];
foreach ($command->grades as $gradeInput) {
$studentId = UserId::fromString($gradeInput['studentId']);
$status = GradeStatus::from($gradeInput['status']);
$value = $gradeInput['value'] !== null ? new GradeValue($gradeInput['value']) : null;
$existing = $existingByStudent[(string) $studentId] ?? null;
if ($existing !== null) {
$existing->modifier(
value: $value,
status: $status,
gradeScale: $evaluation->gradeScale,
modifiedBy: $teacherId,
now: $now,
);
$this->gradeRepository->save($existing);
$savedGrades[] = $existing;
} else {
$grade = Grade::saisir(
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: $status,
gradeScale: $evaluation->gradeScale,
createdBy: $teacherId,
now: $now,
);
$this->gradeRepository->save($grade);
$savedGrades[] = $grade;
}
}
return $savedGrades;
}
}