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

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\MotifCategories;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\MotifModel;
@@ -98,7 +99,8 @@ final class PlanningController
if (!is_array($rawEntries)) {
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
@@ -107,7 +109,10 @@ final class PlanningController
[$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds());
// Les motifs autorisés dépendent de l'agent : « Formation » n'est pas
// affectable à un agent sans droit à l'enveloppe de formation.
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
$this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap(
$entries,
@@ -169,7 +174,12 @@ final class PlanningController
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
}
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) {
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
if ($entryAgent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
$this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent);
if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), true)) {
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
}
@@ -550,6 +560,48 @@ final class PlanningController
return [$year, $week];
}
/**
* Refuse les affectations portant un motif fermé à l'agent, avec un message
* qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais
* sans dire pourquoi. L'interface masque déjà ces motifs — reste les
* plannings saisis avant le retrait du droit, et les appels directs.
*
* @param array<int, mixed> $rawEntries
*/
private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void
{
if (AgentModel::hasTrainingRight($agent)) {
return;
}
$restricted = [];
foreach ($this->motifModel->allActive() as $motif) {
if (MotifCategories::requiresTrainingRight((string) $motif['code'])) {
$restricted[(int) $motif['id_motif']] = (string) $motif['libelle'];
}
}
if ($restricted === []) {
return;
}
foreach ($rawEntries as $entry) {
if (!is_array($entry)) {
continue;
}
$motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT);
if (isset($restricted[$motifId])) {
JsonResponse::send(422, [
'error' => sprintf(
'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.',
$restricted[$motifId],
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? '')
),
]);
}
}
}
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);