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:
@@ -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,
|
||||
]);
|
||||
|
||||
302
app/Controllers/DashboardController.php
Normal file
302
app/Controllers/DashboardController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user