V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation

Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PTA
2026-08-17 16:53:04 +02:00
parent 57bd8da5c8
commit 4201894e28
69 changed files with 5228 additions and 246 deletions

View File

@@ -62,16 +62,175 @@ final class WeekTemplateModel extends BaseModel
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
{
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
if ($name === '' || strlen($name) > 240) {
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
}
// La colonne `nom` fait 120 caractères : la borne annoncée à
// l'utilisateur et celle réellement appliquée doivent coïncider.
$name = $this->normalizeName($name);
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
if (!$sourceEntries) {
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
}
return $this->persistTemplate($agentId, $name, $sourceEntries, $replaceExisting);
}
/**
* Enregistre une semaine type composée directement dans l'éditeur, sans
* partir d'une semaine réellement planifiée.
*
* Les plages arrivent du navigateur : elles sont intégralement revalidées
* ici, y compris les droits de motif, car l'écran n'est qu'une commodité —
* le serveur reste seul juge de ce qui entre en base.
*
* @param array<int, mixed> $rawEntries
* @param int[] $allowedMotifIds motifs affectables à cet agent
* @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur
*/
public function saveFromEntries(
int $agentId,
string $name,
array $rawEntries,
array $allowedMotifIds,
array $allowedStructureIds,
bool $replaceExisting = false
): array {
$name = $this->normalizeName($name);
$entries = $this->normalizeEditorEntries($rawEntries, $allowedMotifIds, $allowedStructureIds);
if ($entries === []) {
throw new DomainException('Une semaine type doit contenir au moins une plage horaire.');
}
return $this->persistTemplate($agentId, $name, $entries, $replaceExisting);
}
private function normalizeName(string $name): string
{
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
if ($name === '' || mb_strlen($name) > 120) {
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
}
return $name;
}
/**
* @param array<int, mixed> $rawEntries
* @param int[] $allowedMotifIds
* @param int[] $allowedStructureIds
* @return array<int, array<string, mixed>>
*/
private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array
{
$jours = [1 => 'lundi', 2 => 'mardi', 3 => 'mercredi', 4 => 'jeudi', 5 => 'vendredi'];
$entries = [];
foreach ($rawEntries as $rawEntry) {
if (!is_array($rawEntry)) {
throw new DomainException('Une plage de la semaine type est illisible.');
}
$weekday = filter_var($rawEntry['jour_semaine'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 5],
]);
if ($weekday === false) {
throw new DomainException('Une semaine type ne peut contenir que des jours du lundi au vendredi.');
}
$start = $this->normalizeQuarterHour($rawEntry['heure_debut'] ?? null, $jours[$weekday]);
$end = $this->normalizeQuarterHour($rawEntry['heure_fin'] ?? null, $jours[$weekday]);
if ($end <= $start) {
throw new DomainException(sprintf(
'Le %s, la fin (%s) doit être postérieure au début (%s).',
$jours[$weekday],
substr($end, 0, 5),
substr($start, 0, 5)
));
}
$structureId = filter_var($rawEntry['id_structure'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($structureId === false || !in_array((int) $structureId, $allowedStructureIds, true)) {
throw new DomainException(sprintf('Le lieu d’affectation choisi le %s n’est pas disponible.', $jours[$weekday]));
}
$motifId = filter_var($rawEntry['id_motif'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($motifId === false || !in_array((int) $motifId, $allowedMotifIds, true)) {
throw new DomainException(sprintf(
'Le motif choisi le %s n’est pas affectable à cet agent.',
$jours[$weekday]
));
}
$entries[] = [
'jour_semaine' => (int) $weekday,
'id_structure' => (int) $structureId,
'id_motif' => (int) $motifId,
'heure_debut' => $start,
'heure_fin' => $end,
];
}
$this->assertEditorEntriesNoOverlap($entries, $jours);
usort($entries, static function (array $a, array $b): int {
return [$a['jour_semaine'], $a['heure_debut']] <=> [$b['jour_semaine'], $b['heure_debut']];
});
return $entries;
}
private function normalizeQuarterHour(mixed $value, string $dayLabel): string
{
$time = trim((string) $value);
if (preg_match('/^([01]\d|2[0-3]):(00|15|30|45)(:00)?$/', $time) !== 1) {
throw new DomainException(sprintf(
'Le %s, les horaires doivent être renseignés par tranches de 15 minutes.',
$dayLabel
));
}
return substr($time, 0, 5) . ':00';
}
/**
* Deux plages du même jour ne peuvent pas se chevaucher, même sur deux
* lieux différents : un agent n'est jamais à deux endroits à la fois.
*
* @param array<int, array<string, mixed>> $entries
* @param array<int, string> $jours
*/
private function assertEditorEntriesNoOverlap(array $entries, array $jours): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$first = $entries[$i];
$second = $entries[$j];
if ($first['jour_semaine'] === $second['jour_semaine']
&& $first['heure_debut'] < $second['heure_fin']
&& $first['heure_fin'] > $second['heure_debut']) {
throw new DomainException(sprintf(
'Le %s, deux plages se chevauchent : %s-%s et %s-%s.',
$jours[$first['jour_semaine']] ?? 'jour',
substr($first['heure_debut'], 0, 5),
substr($first['heure_fin'], 0, 5),
substr($second['heure_debut'], 0, 5),
substr($second['heure_fin'], 0, 5)
));
}
}
}
}
/**
* Écriture du modèle et de ses plages, quelle qu'en soit l'origine :
* une semaine réellement planifiée ou l'éditeur de semaine type.
*
* @param array<int, array<string, mixed>> $rows
*/
private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array
{
try {
$this->pdo->beginTransaction();
@@ -106,7 +265,7 @@ final class WeekTemplateModel extends BaseModel
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
);
foreach ($sourceEntries as $entry) {
foreach ($rows as $entry) {
$insertEntry->execute([
'template_id' => $templateId,
'weekday' => (int) $entry['jour_semaine'],
@@ -121,7 +280,7 @@ final class WeekTemplateModel extends BaseModel
return [
'id_modele' => $templateId,
'nom' => $name,
'nombre_creneaux' => count($sourceEntries),
'nombre_creneaux' => count($rows),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {