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

@@ -9,6 +9,7 @@ use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\PdfResponse;
use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
@@ -360,7 +361,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
@@ -404,13 +407,13 @@ final class AgentController
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}
@@ -435,7 +438,7 @@ final class AgentController
'id_poste' => (int) $postId,
'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''),
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0,
'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]),
]);
@@ -460,7 +463,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
$rawStructure = $payload['structure_id'] ?? null;
@@ -501,7 +506,7 @@ final class AgentController
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
'structure_id' => $structureId !== null ? (int) $structureId : null,
]);

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\JsonResponse;
use App\Models\CoverageModel;
use App\Models\DashboardModel;
use App\Models\PlanningModel;
use App\Models\StructureModel;
use App\Models\VacationModel;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
/**
* Tableau de bord du Service Enfance.
*
* Le tableau de bord n'ouvre aucun droit et n'applique aucune règle nouvelle :
* il rassemble sur un seul écran les compteurs déjà calculés par les écrans
* « À valider », « Couverture » et « Vue annuelle », et les transforme en
* liens directs vers l'action correspondante.
*/
final class DashboardController
{
private const APERCU_MAX = 5;
private const QUOTA_APERCU_MAX = 8;
private Access $access;
private DashboardModel $dashboardModel;
private PlanningModel $planningModel;
private CoverageModel $coverageModel;
private StructureModel $structureModel;
private VacationModel $vacationModel;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
$this->dashboardModel = new DashboardModel($pdo);
$this->planningModel = new PlanningModel($pdo);
$this->coverageModel = new CoverageModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->vacationModel = new VacationModel($pdo);
}
public function summary(): void
{
$this->access->requireService();
// Portée « badges » : uniquement le compteur de la pastille de
// navigation, appelé depuis toutes les pages. Il doit rester léger.
if (trim((string) ($_GET['scope'] ?? '')) === 'badges') {
JsonResponse::send(200, [
'validation' => ['count' => $this->dashboardModel->pendingCount()],
]);
}
$week = $this->resolveWeek($_GET['week'] ?? null);
// L'année du quota suit l'année ISO de la semaine affichée : une
// semaine à cheval sur deux années civiles reste rattachée à l'année
// qui contient son jeudi, comme partout ailleurs dans PTA.
$year = $this->resolveYear($_GET['year'] ?? null, (int) $week['iso_year']);
JsonResponse::send(200, [
'semaine' => $this->weekPayload($week),
'validation' => $this->validationPayload(),
'couverture' => $this->coveragePayload((int) $week['iso_year'], (int) $week['iso_week']),
'quota' => $this->quotaPayload($year),
'perimetre' => $this->dashboardModel->scope(),
]);
}
/**
* File de validation : le volume total, le volume décompté et les
* brouillons les plus anciens, qui sont ceux à traiter en premier.
*/
private function validationPayload(): array
{
$plannings = $this->planningModel->pendingValidation();
$totalMinutes = 0;
$countedMinutes = 0;
$structures = [];
$agents = [];
foreach ($plannings as $planning) {
$totalMinutes += (int) $planning['total_minutes'];
$countedMinutes += (int) $planning['minutes_decomptees'];
$structures[(int) $planning['id_structure']] = true;
$agents[(int) $planning['id_agent']] = true;
}
// pendingValidation() trie déjà par semaine croissante : les premiers
// éléments sont les plus anciens, donc les plus urgents.
$apercu = array_slice($plannings, 0, self::APERCU_MAX);
return [
'count' => count($plannings),
'total_minutes' => $totalMinutes,
'minutes_decomptees' => $countedMinutes,
'structure_count' => count($structures),
'agent_count' => count($agents),
'semaine_la_plus_ancienne' => $plannings[0]['date_debut'] ?? null,
'apercu' => array_map(static function (array $planning): array {
return [
'id_planning' => (int) $planning['id_planning'],
'id_agent' => (int) $planning['id_agent'],
'id_structure' => (int) $planning['id_structure'],
'agent_nom' => (string) $planning['agent_nom'],
'agent_prenom' => (string) $planning['agent_prenom'],
'structure_nom' => (string) $planning['structure_nom'],
'annee' => (int) $planning['annee'],
'numero_semaine' => (int) $planning['numero_semaine'],
'date_debut' => (string) $planning['date_debut'],
'date_fin_ouvrable' => (string) ($planning['date_fin_ouvrable'] ?? $planning['date_fin']),
'nombre_creneaux' => (int) $planning['nombre_creneaux'],
'total_minutes' => (int) $planning['total_minutes'],
'minutes_decomptees' => (int) $planning['minutes_decomptees'],
];
}, $apercu),
];
}
/**
* Couverture de la semaine affichée, tous lieux actifs confondus.
* Reprend exactement le calcul de l'écran « Couverture ».
*/
private function coveragePayload(int $year, int $weekNumber): array
{
$gaps = [];
$unconfigured = [];
$affectedStructures = [];
$gapMinutes = 0;
$validationWarningCount = 0;
foreach ($this->structureModel->allActive() as $structure) {
$structureId = (int) $structure['id_structure'];
$report = $this->coverageModel->reportForStructure($structureId, $year, $weekNumber);
if (!$report['configured']) {
$unconfigured[] = [
'id_structure' => $structureId,
'nom' => (string) $structure['nom'],
'code' => (string) $structure['code'],
];
continue;
}
$validationWarningCount += (int) $report['summary']['validation_warning_count'];
foreach ($report['gaps'] as $gap) {
$gaps[] = [
'id_structure' => $structureId,
'structure_nom' => (string) $structure['nom'],
'date' => (string) $gap['date'],
'jour_libelle' => (string) $gap['jour_libelle'],
'heure_debut' => (string) $gap['heure_debut'],
'heure_fin' => (string) $gap['heure_fin'],
'minimum_agents' => (int) $gap['minimum_agents'],
'agents_planifies' => (int) $gap['agents_planifies'],
'agents_manquants' => (int) $gap['agents_manquants'],
];
$affectedStructures[$structureId] = true;
}
$gapMinutes += (int) $report['summary']['gap_minutes'];
}
// Les manques les plus importants d'abord : c'est là que le service
// doit agir en priorité.
usort($gaps, static function (array $a, array $b): int {
return [$b['agents_manquants'], $a['date'], $a['heure_debut']]
<=> [$a['agents_manquants'], $b['date'], $b['heure_debut']];
});
return [
'gap_count' => count($gaps),
'gap_minutes' => $gapMinutes,
'structure_count' => count($affectedStructures),
'validation_warning_count' => $validationWarningCount,
'unconfigured_count' => count($unconfigured),
'unconfigured' => array_slice($unconfigured, 0, self::APERCU_MAX),
'apercu' => array_slice($gaps, 0, self::APERCU_MAX),
];
}
/**
* Suivi des quotas : combien d'agents dépassent leur quota annuel et combien
* n'ont encore aucune heure planifiée. Les cumuls tous agents confondus
* (consommation globale, reste à affecter) ne sont volontairement pas exposés :
* une somme d'agents aux quotités différentes ne décrit aucune situation réelle.
*/
private function quotaPayload(int $year): array
{
$tracking = $this->dashboardModel->quotaTracking($year);
$overCount = 0;
$emptyCount = 0;
foreach ($tracking as $agent) {
if ($agent['minutes_restantes'] < 0) {
$overCount++;
}
if ($agent['minutes_affectees'] === 0) {
$emptyCount++;
}
}
// Tri décroissant sur le taux : les dépassements remontent en tête,
// les agents non planifiés restent visibles en bas de liste.
usort($tracking, static function (array $a, array $b): int {
return $b['taux_consommation'] <=> $a['taux_consommation'];
});
return [
'annee' => $year,
'agent_count' => count($tracking),
'depassement_count' => $overCount,
'sans_heure_count' => $emptyCount,
'apercu' => array_slice($tracking, 0, self::QUOTA_APERCU_MAX),
];
}
/**
* Contexte de la semaine affichée : dates, et caractère scolaire ou
* extrascolaire déduit des périodes de vacances enregistrées.
*/
private function weekPayload(array $week): array
{
/** @var DateTimeImmutable $start */
$start = $week['start'];
$end = $start->modify('+4 days');
$days = $this->vacationModel->typesForIsoWeek((int) $week['iso_year'], (int) $week['iso_week']);
$vacationLabels = [];
$vacationDays = 0;
foreach ($days as $day) {
if ($day['type_affectation'] === 'EXTRASCOLAIRE') {
$vacationDays++;
if ($day['vacances'] !== null) {
$vacationLabels[$day['vacances']['libelle']] = true;
}
}
}
if ($vacationDays === 0) {
$contexte = 'PERISCOLAIRE';
} elseif ($vacationDays === count($days)) {
$contexte = 'EXTRASCOLAIRE';
} else {
$contexte = 'MIXTE';
}
return [
'valeur' => $week['value'],
'annee' => (int) $week['iso_year'],
'numero' => (int) $week['iso_week'],
'date_debut' => $start->format('Y-m-d'),
'date_fin' => $end->format('Y-m-d'),
'contexte' => $contexte,
'jours_vacances' => $vacationDays,
'vacances_libelles' => array_keys($vacationLabels),
];
}
/**
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
*/
private function resolveWeek(mixed $value): array
{
$raw = trim((string) $value);
if (preg_match('/^(\d{4})-W(\d{2})$/', $raw, $matches) === 1) {
$isoYear = (int) $matches[1];
$isoWeek = (int) $matches[2];
if ($isoWeek >= 1 && $isoWeek <= 53) {
$start = (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))
->setISODate($isoYear, $isoWeek, 1)
->setTime(0, 0);
return [
'value' => $raw,
'iso_year' => $isoYear,
'iso_week' => $isoWeek,
'start' => $start,
];
}
}
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
return [
'value' => $now->format('o-\WW'),
'iso_year' => (int) $now->format('o'),
'iso_week' => (int) $now->format('W'),
'start' => $now->setISODate((int) $now->format('o'), (int) $now->format('W'), 1)->setTime(0, 0),
];
}
private function resolveYear(mixed $value, int $fallback): int
{
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $year === false ? $fallback : (int) $year;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\StructureModel;
@@ -23,6 +24,20 @@ final class PageController extends Controller
$this->access = new Access($pdo);
}
public function dashboard(): void
{
$this->access->requirePage('dashboard');
$this->renderPage('dashboard', 'Tableau de bord', 'dashboard', [
'pageSubtitle' => 'Ce qui attend le Service Enfance : plannings à valider, couverture des lieux et suivi des quotas annuels.',
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['dashboard.js'],
'modules' => ['dashboard'],
'pageConfig' => [
'week' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
],
]);
}
public function planning(): void
{
$this->access->requirePage('planning');
@@ -32,9 +47,14 @@ final class PageController extends Controller
$modules = ['planning'];
if (!empty($permissions['can_use_templates'])) {
$scripts[] = 'week-templates.js';
$scripts[] = 'week-template-editor.js';
$modules[] = 'weekTemplates';
$modules[] = 'weekTemplateEditor';
}
$this->renderPage('planning', 'Saisie du planning', 'planning', [
'pageSubtitle' => !empty($permissions['can_use_templates'])
? 'Composer une semaine type et la dupliquer sur une période, ou saisir une semaine en particulier.'
: 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
'structures' => $this->loadStructures(),
'motifs' => $this->loadMotifs(),
'currentWeek' => $this->currentWeek(),
@@ -55,6 +75,7 @@ final class PageController extends Controller
{
$this->access->requirePage('pending');
$this->renderPage('pending', 'Plannings à valider', 'pending', [
'pageSubtitle' => 'La file des brouillons prêts à être contrôlés. Ouvrir un planning charge l’écran de saisie sur la bonne semaine.',
'scripts' => ['pending-validation.js'],
'modules' => ['pendingValidation'],
]);
@@ -65,6 +86,7 @@ final class PageController extends Controller
$this->access->requirePage('coverage');
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
$this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [
'pageSubtitle' => 'Les plages où le nombre d’agents planifiés passe sous le minimum attendu du lieu.',
'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['coverage.js'],
@@ -81,6 +103,7 @@ final class PageController extends Controller
$this->access->requirePage('structures');
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
$this->renderPage('structures', 'Planning par lieu', 'structures', [
'pageSubtitle' => 'Le planning d’un lieu sur une semaine : les agents rattachés et ceux qui y sont réellement planifiés. Lecture seule.',
'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['structure-overview.js'],
@@ -97,6 +120,9 @@ final class PageController extends Controller
$this->access->requirePage('agents');
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
$this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [
'pageSubtitle' => $forcedAgent
? 'Vos horaires mois par mois, tous lieux d’affectation confondus.'
: 'Le planning d’un agent mois par mois, tous lieux d’affectation confondus, avec le suivi de son quota.',
'agents' => $this->loadAgents(),
'structures' => $this->loadStructuresForAgentEditor(),
'motifs' => $this->loadMotifs(),
@@ -114,6 +140,7 @@ final class PageController extends Controller
{
$this->access->requirePage('annual');
$this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [
'pageSubtitle' => 'L’annualisation d’un agent de janvier à décembre, et la validation de toute l’année en une fois.',
'agents' => (new AgentModel($this->pdo))->allActive(),
'scripts' => ['annual-overview.js'],
'modules' => ['annualOverview'],
@@ -128,6 +155,7 @@ final class PageController extends Controller
{
$this->access->requirePage('pta');
$this->renderPage('pta', 'PTA annuel par agent', 'pta', [
'pageSubtitle' => 'Le PTA détaillé d’un agent : souhaits de lieux, contraintes et répartition des heures.',
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['pta.js'],
@@ -143,6 +171,7 @@ final class PageController extends Controller
{
$this->access->requirePage('teams');
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.',
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['teams.js'],
@@ -158,6 +187,7 @@ final class PageController extends Controller
{
$this->access->requirePage('vacations');
$this->renderPage('vacations', 'Vacances scolaires', 'vacations', [
'pageSubtitle' => 'Les périodes de vacances qui déterminent le contexte périscolaire ou extrascolaire de chaque journée.',
'scripts' => ['vacation-calendar.js'],
'modules' => ['vacationCalendar'],
]);
@@ -167,6 +197,7 @@ final class PageController extends Controller
{
$this->access->requirePage('administration');
$this->renderPage('administration', 'Administration', 'administration', [
'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.',
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => (new AgentModel($this->pdo))->allPosts(),
@@ -175,12 +206,32 @@ final class PageController extends Controller
]);
}
/**
* Guide d'utilisation. Page purement statique : aucun script, aucun appel
* API, aucune donnée métier. Elle suit malgré tout la règle des droits en
* base — tant que la migration n'est pas jouée, la page est refusée et le
* bouton d'accès n'apparaît pas dans la barre de navigation.
*/
public function faq(): void
{
$this->access->requirePage('faq');
$this->renderPage('faq', 'Guide d’utilisation', 'faq', [
'pageSubtitle' => 'Le parcours complet, du paramétrage des lieux à la validation des plannings, et les réponses aux questions les plus fréquentes.',
]);
}
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
{
Security::sendHtmlHeaders();
$profile = $this->access->profile();
$this->render('pages/' . $view, array_merge([
'pageTitle' => $title,
// Chaque page explique en une phrase ce qu'on y fait : le bandeau
// générique ne disait rien et obligeait à ouvrir l'écran pour
// comprendre à quoi il servait.
'pageSubtitle' => 'Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.',
'activePage' => $activePage,
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'scripts' => [],
'modules' => [],

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);

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\StructureModel;
use PDO;
@@ -21,8 +22,13 @@ final class RoleController extends Controller
{
$access = new Access($this->pdo);
// Le changement de rôle modifie l'état de la session : il est protégé
// par le jeton CSRF au même titre que les écritures de l'API.
if (($_GET['action'] ?? '') === 'reset') {
$access->clear();
$provided = (string) ($_GET['csrf_token'] ?? '');
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
$access->clear();
}
header('Location: role.php', true, 302);
exit;
}
@@ -47,8 +53,10 @@ final class RoleController extends Controller
}
}
Security::sendHtmlHeaders();
$this->render('pages/role', [
'pageTitle' => 'Choisir votre accès',
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),

View File

@@ -8,8 +8,11 @@ use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Models\WeekTemplateModel;
use DateTimeImmutable;
use DomainException;
@@ -55,6 +58,120 @@ final class WeekTemplateController
JsonResponse::send(200, ['templates' => $templates]);
}
/**
* Contenu détaillé d'une semaine type, pour l'ouvrir dans l'éditeur.
*/
public function show(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT);
if (!$agentId || !$templateId) {
JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']);
}
$template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId);
if ($template === null) {
JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']);
}
$entries = array_map(static function (array $entry): array {
return [
'jour_semaine' => (int) $entry['jour_semaine'],
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
// L'éditeur manipule des heures au format HH:MM.
'heure_debut' => substr((string) $entry['heure_debut'], 0, 5),
'heure_fin' => substr((string) $entry['heure_fin'], 0, 5),
'structure_nom' => (string) $entry['structure_nom'],
'structure_active' => (int) $entry['structure_actif'] === 1,
'motif_libelle' => (string) $entry['motif_libelle'],
'motif_actif' => (int) $entry['motif_actif'] === 1,
'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1,
];
}, $this->templateModel->entries((int) $templateId));
JsonResponse::send(200, [
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'updated_at' => (string) $template['date_modification'],
],
'entries' => $entries,
]);
}
/**
* Enregistre une semaine type composée dans l'éditeur.
*/
public function storeEntries(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$name = (string) ($payload['name'] ?? '');
$entries = $payload['entries'] ?? null;
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || !is_array($entries)) {
JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
// Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un
// agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable.
$allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent);
$allowedStructureIds = array_map(
static fn (array $structure): int => (int) $structure['id_structure'],
(new StructureModel($this->pdo))->allActive()
);
try {
$template = $this->templateModel->saveFromEntries(
(int) $agentId,
$name,
$entries,
$allowedMotifIds,
$allowedStructureIds,
$replace
);
} catch (DomainException $e) {
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
JsonResponse::send($status, [
'error' => $e->getMessage(),
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’enregistrer la semaine type.',
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'success' => true,
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'entry_count' => (int) $template['nombre_creneaux'],
],
'message' => sprintf(
'La semaine type « %s » a été enregistrée avec %d plage%s.',
$template['nom'],
$template['nombre_creneaux'],
$template['nombre_creneaux'] > 1 ? 's' : ''
),
]);
}
public function store(): void
{
$this->access->requireService();