feat: Permettre à l'enseignant de saisir les notes dans une grille inline
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:
@@ -10,8 +10,10 @@ use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\EvaluationCreee;
|
||||
use App\Scolarite\Domain\Event\EvaluationModifiee;
|
||||
use App\Scolarite\Domain\Event\EvaluationSupprimee;
|
||||
use App\Scolarite\Domain\Event\NotesPubliees;
|
||||
use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
|
||||
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
|
||||
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
@@ -19,6 +21,7 @@ use DateTimeImmutable;
|
||||
final class Evaluation extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
public private(set) ?DateTimeImmutable $gradesPublishedAt = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) EvaluationId $id,
|
||||
@@ -113,6 +116,30 @@ final class Evaluation extends AggregateRoot
|
||||
));
|
||||
}
|
||||
|
||||
public function publierNotes(DateTimeImmutable $now): void
|
||||
{
|
||||
if ($this->status === EvaluationStatus::DELETED) {
|
||||
throw EvaluationDejaSupprimeeException::withId($this->id);
|
||||
}
|
||||
|
||||
if ($this->gradesPublishedAt !== null) {
|
||||
throw NotesDejaPublieesException::pourEvaluation($this->id);
|
||||
}
|
||||
|
||||
$this->gradesPublishedAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
$this->recordEvent(new NotesPubliees(
|
||||
evaluationId: $this->id,
|
||||
occurredOn: $now,
|
||||
));
|
||||
}
|
||||
|
||||
public function notesPubliees(): bool
|
||||
{
|
||||
return $this->gradesPublishedAt !== null;
|
||||
}
|
||||
|
||||
public function supprimer(DateTimeImmutable $now): void
|
||||
{
|
||||
if ($this->status === EvaluationStatus::DELETED) {
|
||||
@@ -145,6 +172,7 @@ final class Evaluation extends AggregateRoot
|
||||
EvaluationStatus $status,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
?DateTimeImmutable $gradesPublishedAt = null,
|
||||
): self {
|
||||
$evaluation = new self(
|
||||
id: $id,
|
||||
@@ -162,6 +190,7 @@ final class Evaluation extends AggregateRoot
|
||||
);
|
||||
|
||||
$evaluation->updatedAt = $updatedAt;
|
||||
$evaluation->gradesPublishedAt = $gradesPublishedAt;
|
||||
|
||||
return $evaluation;
|
||||
}
|
||||
|
||||
142
backend/src/Scolarite/Domain/Model/Grade/Grade.php
Normal file
142
backend/src/Scolarite/Domain/Model/Grade/Grade.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Grade;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\NoteModifiee;
|
||||
use App\Scolarite\Domain\Event\NoteSaisie;
|
||||
use App\Scolarite\Domain\Exception\NoteRequiseException;
|
||||
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class Grade extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
private function __construct(
|
||||
public private(set) GradeId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) EvaluationId $evaluationId,
|
||||
public private(set) UserId $studentId,
|
||||
public private(set) ?GradeValue $value,
|
||||
public private(set) GradeStatus $status,
|
||||
public private(set) UserId $createdBy,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
public static function saisir(
|
||||
TenantId $tenantId,
|
||||
EvaluationId $evaluationId,
|
||||
UserId $studentId,
|
||||
?GradeValue $value,
|
||||
GradeStatus $status,
|
||||
GradeScale $gradeScale,
|
||||
UserId $createdBy,
|
||||
DateTimeImmutable $now,
|
||||
): self {
|
||||
self::validerCoherence($value, $status, $gradeScale);
|
||||
|
||||
$grade = new self(
|
||||
id: GradeId::generate(),
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $evaluationId,
|
||||
studentId: $studentId,
|
||||
value: $value,
|
||||
status: $status,
|
||||
createdBy: $createdBy,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$grade->recordEvent(new NoteSaisie(
|
||||
gradeId: $grade->id,
|
||||
evaluationId: (string) $evaluationId,
|
||||
studentId: (string) $studentId,
|
||||
value: $value?->value,
|
||||
status: $status->value,
|
||||
createdBy: (string) $createdBy,
|
||||
occurredOn: $now,
|
||||
));
|
||||
|
||||
return $grade;
|
||||
}
|
||||
|
||||
public function modifier(
|
||||
?GradeValue $value,
|
||||
GradeStatus $status,
|
||||
GradeScale $gradeScale,
|
||||
UserId $modifiedBy,
|
||||
DateTimeImmutable $now,
|
||||
): void {
|
||||
self::validerCoherence($value, $status, $gradeScale);
|
||||
|
||||
$oldValue = $this->value?->value;
|
||||
$oldStatus = $this->status->value;
|
||||
|
||||
$this->value = $value;
|
||||
$this->status = $status;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
$this->recordEvent(new NoteModifiee(
|
||||
gradeId: $this->id,
|
||||
evaluationId: (string) $this->evaluationId,
|
||||
oldValue: $oldValue,
|
||||
newValue: $value?->value,
|
||||
oldStatus: $oldStatus,
|
||||
newStatus: $status->value,
|
||||
modifiedBy: (string) $modifiedBy,
|
||||
occurredOn: $now,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
GradeId $id,
|
||||
TenantId $tenantId,
|
||||
EvaluationId $evaluationId,
|
||||
UserId $studentId,
|
||||
?GradeValue $value,
|
||||
GradeStatus $status,
|
||||
UserId $createdBy,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
): self {
|
||||
$grade = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $evaluationId,
|
||||
studentId: $studentId,
|
||||
value: $value,
|
||||
status: $status,
|
||||
createdBy: $createdBy,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$grade->updatedAt = $updatedAt;
|
||||
|
||||
return $grade;
|
||||
}
|
||||
|
||||
private static function validerCoherence(
|
||||
?GradeValue $value,
|
||||
GradeStatus $status,
|
||||
GradeScale $gradeScale,
|
||||
): void {
|
||||
if ($status === GradeStatus::GRADED && $value === null) {
|
||||
throw NoteRequiseException::pourStatutNote();
|
||||
}
|
||||
|
||||
if ($value !== null && $value->value > $gradeScale->maxValue) {
|
||||
throw ValeurNoteInvalideException::depasseBareme($value->value, $gradeScale->maxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/src/Scolarite/Domain/Model/Grade/GradeId.php
Normal file
11
backend/src/Scolarite/Domain/Model/Grade/GradeId.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Grade;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class GradeId extends EntityId
|
||||
{
|
||||
}
|
||||
12
backend/src/Scolarite/Domain/Model/Grade/GradeStatus.php
Normal file
12
backend/src/Scolarite/Domain/Model/Grade/GradeStatus.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Grade;
|
||||
|
||||
enum GradeStatus: string
|
||||
{
|
||||
case GRADED = 'graded';
|
||||
case ABSENT = 'absent';
|
||||
case DISPENSED = 'dispensed';
|
||||
}
|
||||
25
backend/src/Scolarite/Domain/Model/Grade/GradeValue.php
Normal file
25
backend/src/Scolarite/Domain/Model/Grade/GradeValue.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\Grade;
|
||||
|
||||
use function abs;
|
||||
|
||||
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
|
||||
|
||||
final readonly class GradeValue
|
||||
{
|
||||
public function __construct(
|
||||
public float $value,
|
||||
) {
|
||||
if ($value < 0) {
|
||||
throw ValeurNoteInvalideException::valeurNegative($value);
|
||||
}
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return abs($this->value - $other->value) < 0.001;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user