Files
pta/app/Controllers/PageController.php
PTA 4201894e28 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>
2026-08-17 16:53:04 +02:00

323 lines
13 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';
$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(),
'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'],
]);
}
/**
* 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' => [],
'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;
}
}