Files
pta/app/Controllers/PageController.php
PTA b68ac15de5 V7.9 — Tableau de bord et navigation regroupée pour le Service Enfance
Le Service Enfance ouvrait onze onglets à plat et atterrissait sur l'écran de
saisie, alors que son travail commence par le contrôle. Les chiffres qui
déclenchent une action vivaient sur trois écrans distincts.

- Nouvelle page dashboard.php, réservée au Service Enfance et devenue sa page
  d'atterrissage : quatre tuiles d'action, les brouillons les plus anciens avec
  ouverture directe sur la bonne semaine, les plages de couverture les plus
  déficitaires, et le suivi des quotas annuels par agent.
- Navigation regroupée par intention (Piloter / Planifier / Consulter /
  Paramétrer). Les rôles à quelques pages gardent la barre simple.
- Sous-titre propre à chaque page : le bandeau générique n'apprenait rien.
- Pastille « À valider » alimentée depuis toutes les pages, et non plus
  seulement depuis la page qu'elle annonce.

Aucune règle métier n'est modifiée : le tableau de bord relit ce que
PlanningModel, CoverageModel et QuotaModel calculent déjà, et son endpoint est
gardé par requireService(). Le droit d'accès à la page vient de la migration
V7.9 ; sans elle, l'onglet n'apparaît pas et homeUrl() se replie sur planning.

Vérifié sur la base réelle : les chiffres de quota du tableau de bord sont
identiques à ceux de api/annual_overview.php, les trois rôles rendent leurs
écrans sans erreur, le rendu JS a été exécuté dans un DOM avec la réponse réelle
de l'API, et la page ne produit aucun attribut style inline (CSP).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:48:50 +02:00

305 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
final class PageController extends Controller
{
private Access $access;
public function __construct(private PDO $pdo)
{
$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');
$forcedStructure = $this->access->editableStructureId();
$permissions = $this->access->permissions();
$scripts = ['planning-editor.js'];
$modules = ['planning'];
if (!empty($permissions['can_use_templates'])) {
$scripts[] = 'week-templates.js';
$modules[] = 'weekTemplates';
}
$this->renderPage('planning', 'Saisie du planning', 'planning', [
'pageSubtitle' => '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(),
'scripts' => $scripts,
'modules' => $modules,
'pageConfig' => [
'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null),
'agentId' => $this->positiveInt($_GET['agent_id'] ?? null),
'week' => $this->weekValue($_GET['week'] ?? null),
'focus' => (string) ($_GET['focus'] ?? ''),
'notice' => trim((string) ($_GET['notice'] ?? '')),
'structureLocked' => $forcedStructure !== null,
],
]);
}
public function pending(): void
{
$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'],
]);
}
public function coverage(): void
{
$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'],
'modules' => ['coverage'],
'pageConfig' => [
'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null),
'structureLocked' => $forcedStructure !== null,
],
]);
}
public function structures(): void
{
$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'],
'modules' => ['structureOverview'],
'pageConfig' => [
'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null),
'structureLocked' => $forcedStructure !== null,
],
]);
}
public function agents(): void
{
$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(),
'scripts' => ['agent-overview.js'],
'modules' => ['agentOverview'],
'pageConfig' => [
'agentId' => $forcedAgent ?: $this->positiveInt($_GET['agent_id'] ?? null),
'month' => $this->monthValue($_GET['month'] ?? null),
'agentLocked' => $forcedAgent !== null,
],
]);
}
public function annual(): void
{
$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'],
'pageConfig' => [
'agentId' => $this->positiveInt($_GET['agent_id'] ?? null),
'year' => $this->yearValue($_GET['year'] ?? null),
],
]);
}
public function pta(): void
{
$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'],
'modules' => ['pta'],
'pageConfig' => [
'agentId' => $this->positiveInt($_GET['agent_id'] ?? null),
'date' => $this->dateValue($_GET['date'] ?? null),
],
]);
}
public function teams(): void
{
$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'],
'modules' => ['teams'],
'pageConfig' => [
'dateDebut' => $this->dateValue($_GET['date_debut'] ?? null),
'dateFin' => $this->dateValue($_GET['date_fin'] ?? null),
],
]);
}
public function vacations(): void
{
$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'],
]);
}
public function administration(): void
{
$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(),
'scripts' => ['administration.js', 'coverage.js'],
'modules' => ['administration', 'coverage'],
]);
}
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' => [],
'motifs' => [],
'pageConfig' => [],
'accessProfile' => $profile,
'permissions' => $profile['permissions'],
'allowedPages' => $this->access->allowedPages(),
], $data));
}
private function loadStructures(): array
{
$model = new StructureModel($this->pdo);
if ($this->access->role() === Access::ROLE_SERVICE) {
return $model->allActive();
}
$structureId = $this->access->structureId();
if ($structureId === null) {
return [];
}
$structure = $model->findActive($structureId);
return $structure ? [$structure] : [];
}
private function loadStructuresForAgentEditor(): array
{
if ($this->access->role() === Access::ROLE_SERVICE) {
return (new StructureModel($this->pdo))->allActive();
}
return $this->loadStructures();
}
private function loadAgents(): array
{
$model = new AgentModel($this->pdo);
if ($this->access->role() === Access::ROLE_SERVICE) {
return $model->allActive();
}
if ($this->access->role() === Access::ROLE_AGENT) {
$agent = $this->access->agentId() ? $model->findActive((int) $this->access->agentId()) : null;
return $agent ? [$agent] : [];
}
$structureId = $this->access->structureId();
return $structureId ? $model->allVisibleForStructure($structureId) : [];
}
private function loadMotifs(): array
{
return (new MotifModel($this->pdo))->allActive();
}
private function currentWeek(): string
{
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
}
private function positiveInt(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $number === false ? null : (int) $number;
}
private function weekValue(mixed $value): ?string
{
$value = trim((string) $value);
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
}
private function dateValue(mixed $value): ?string
{
$value = trim((string) $value);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date && $date->format('Y-m-d') === $value ? $value : null;
}
private function yearValue(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $number === false ? null : (int) $number;
}
private function monthValue(mixed $value): ?string
{
$value = trim((string) $value);
return preg_match('/^\\d{4}-(0[1-9]|1[0-2])$/', $value) === 1 ? $value : null;
}
}