526 lines
23 KiB
PHP
526 lines
23 KiB
PHP
<?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;
|
||
|
||
/**
|
||
* Rendu de toutes les pages HTML de l'application.
|
||
*
|
||
* Chaque fichier .php de la racine appelle runPage('<nom>'), qui appelle la
|
||
* méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses
|
||
* à la fois : il est la route, la clé de page dans acces_role_page, et la valeur
|
||
* de $activePage qui surligne l'onglet courant.
|
||
*
|
||
* Ajouter une page suppose donc, ensemble :
|
||
* 1. un fichier mince à la racine ;
|
||
* 2. une méthode ici ;
|
||
* 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ;
|
||
* 4. un module JS ;
|
||
* 5. l'inscription de la clé dans $navigation / $navigationGroups
|
||
* (Views/layouts/header.php) ;
|
||
* 6. une migration SQL insérant la ligne dans acces_role_page — les droits
|
||
* sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée.
|
||
*
|
||
* Chaque méthode déclare les scripts et modules JS à charger ; footer.php les
|
||
* injecte et page-bootstrap.js appelle init() sur chaque module.
|
||
*/
|
||
final class PageController extends Controller
|
||
{
|
||
private Access $access;
|
||
|
||
public function __construct(private PDO $pdo)
|
||
{
|
||
$this->access = new Access($pdo);
|
||
}
|
||
|
||
/**
|
||
* Tableau de bord du Service Enfance (V7.9) — page d'accueil de ce rôle.
|
||
* Voir Access::homeUrl() pour le repli si la migration n'est pas jouée.
|
||
*/
|
||
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(),
|
||
],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Écran de saisie — cœur de l'application.
|
||
*
|
||
* Le lieu est VERROUILLÉ pour un responsable de structure
|
||
* (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est
|
||
* qu'un confort d'interface, le serveur retranche de toute façon.
|
||
*
|
||
* Les scripts de semaine type ne sont chargés que si la permission
|
||
* can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un
|
||
* utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs
|
||
* (éditeur de semaine type / saisie semaine par semaine, voir
|
||
* README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont
|
||
* les paramètres des liens profonds venus des autres écrans.
|
||
*/
|
||
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,
|
||
],
|
||
]);
|
||
}
|
||
|
||
/** File des plannings en attente de validation (Service Enfance). */
|
||
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'],
|
||
]);
|
||
}
|
||
|
||
/** Contrôle de couverture : plages où l'effectif passe sous le minimum du lieu. */
|
||
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,
|
||
],
|
||
]);
|
||
}
|
||
|
||
/** Planning d'un lieu sur une semaine, en lecture seule. */
|
||
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,
|
||
],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Planning d'un agent, mois par mois et tous lieux confondus.
|
||
*
|
||
* Un AGENT est verrouillé sur lui-même (`agentLocked`) et le titre bascule
|
||
* sur « Mon planning » : la page sert alors de vue personnelle.
|
||
*/
|
||
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,
|
||
],
|
||
]);
|
||
}
|
||
|
||
/** Vue annuelle (janvier -> décembre) et validation de l'année entière. */
|
||
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),
|
||
],
|
||
]);
|
||
}
|
||
|
||
/** PTA détaillé d'un agent : souhaits de lieux, contraintes, répartition. */
|
||
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),
|
||
],
|
||
]);
|
||
}
|
||
|
||
/** Composition des équipes sur les mercredis et les vacances. */
|
||
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.',
|
||
// Liste d'AFFECTATION : un agent dont l'accès a été coupé n'est plus
|
||
// proposé à la composition d'une équipe (V7.29).
|
||
'agents' => (new AgentModel($this->pdo))->allSelectable(),
|
||
'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),
|
||
],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Périodes de vacances scolaires : elles déterminent le contexte
|
||
* périscolaire / extrascolaire de chaque journée, et sont importables depuis
|
||
* l'API data.education.gouv.fr (SchoolHolidayApiService).
|
||
*/
|
||
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'],
|
||
]);
|
||
}
|
||
|
||
/** Référentiels : agents, lieux, postes, règles de couverture. */
|
||
public function administration(): void
|
||
{
|
||
$this->access->requirePage('administration');
|
||
$this->renderPage('administration', 'Administration', 'administration', [
|
||
'pageSubtitle' => 'Les horaires de couverture attendus de chaque lieu, et les jetons d\'accès des agents et des responsables.',
|
||
// Depuis la V7.14, cet écran ne porte plus les agents ni les lieux :
|
||
// ils ont leur propre page (management). Seule la liste des lieux
|
||
// reste nécessaire, pour son sélecteur.
|
||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||
// V7.27 : la distribution des jetons de terrain rejoint cet écran.
|
||
// Le contenu de la section vient entièrement de l'API — le HTML
|
||
// servi ne porte aucune information sur les comptes.
|
||
'scripts' => ['coverage.js', 'access-tokens.js'],
|
||
'modules' => ['coverage', 'accessTokens'],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Management — Agents (V7.14) : création, liste et fiche des agents.
|
||
*
|
||
* Ce référentiel partageait l'écran « Administration » avec celui des lieux
|
||
* et les horaires de couverture, trois objets sans rapport. Chacun a
|
||
* désormais son onglet, réunis sous le groupe « Management » de la barre de
|
||
* navigation.
|
||
*
|
||
* NOM DE MÉTHODE EN SNAKE_CASE, à rebours du reste du projet : le nom de la
|
||
* méthode EST la clé de page passée à runPage() et inscrite dans
|
||
* acces_role_page, où les clés sont en minuscules. Les garder identiques
|
||
* prime sur l'uniformité de style — voir l'en-tête de cette classe.
|
||
*
|
||
* La liste des lieux est nécessaire ici aussi : le formulaire de création
|
||
* demande le lieu principal de l'agent.
|
||
*/
|
||
public function manage_agents(): void
|
||
{
|
||
$this->access->requirePage('manage_agents');
|
||
$agentModel = new AgentModel($this->pdo);
|
||
$this->renderPage('manage_agents', 'Agents', 'manage_agents', [
|
||
'pageSubtitle' => 'Créer un agent, corriger sa fiche, changer son lieu principal de rattachement.',
|
||
'agents' => $agentModel->allActive(),
|
||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||
'posts' => $agentModel->allPosts(),
|
||
// V7.28 : `agentAccess` ajoute sur chaque ligne le bouton qui coupe
|
||
// ou rétablit l'accès de l'agent. Il n'ouvre aucun droit nouveau —
|
||
// il réutilise les endpoints délégués de la V7.27.
|
||
'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js', 'agent-access.js'],
|
||
'modules' => ['administration', 'addressAutocomplete', 'fieldFormat', 'agentAccess'],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Management — Lieux d'affectation (V7.14) : création et paramètres.
|
||
*
|
||
* Les horaires de couverture d'un lieu restent sur « Administration » : ils
|
||
* ne décrivent pas le lieu mais une exigence de présence.
|
||
*/
|
||
public function manage_structures(): void
|
||
{
|
||
$this->access->requirePage('manage_structures');
|
||
$this->renderPage('manage_structures', 'Lieux d\'affectation', 'manage_structures', [
|
||
'pageSubtitle' => 'Créer un lieu d\'affectation et définir son type : périscolaire ou extrascolaire.',
|
||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||
'scripts' => ['administration.js'],
|
||
'modules' => ['administration'],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||
*
|
||
* Page d'administration TECHNIQUE, ouverte au seul rôle DSI par
|
||
* acces_role_page. Elle ne charge aucun référentiel métier — la DSI n'a rien
|
||
* à connaître des agents ni des plannings ; les seules listes qu'elle
|
||
* manipule (identités sans compte) sont servies par l'API.
|
||
*/
|
||
public function dsi(): void
|
||
{
|
||
$this->access->requirePage('dsi');
|
||
$this->renderPage('dsi', 'Espace DSI', 'dsi', [
|
||
'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.',
|
||
'scripts' => ['dsi.js'],
|
||
'modules' => ['dsi'],
|
||
'pageConfig' => [
|
||
// Lien profond « journal de ce compte » : filtre d'emblée le
|
||
// journal sur le compte désigné.
|
||
'compteId' => $this->positiveInt($_GET['compte_id'] ?? null),
|
||
],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 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.',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Fabrique commune à toutes les pages : en-têtes de sécurité puis rendu.
|
||
*
|
||
* Pose les clés attendues par header.php et footer.php avec des valeurs par
|
||
* défaut, que $data écrase. Une page qui oublie 'scripts' ou 'motifs' rendra
|
||
* donc correctement au lieu de lever une notice sur variable indéfinie.
|
||
*
|
||
* Security::sendHtmlHeaders() DOIT être appelé avant tout rendu : il pose la
|
||
* CSP à nonce, et $cspNonce est ce que chaque <script> inline d'une vue doit
|
||
* porter pour être exécuté.
|
||
*
|
||
* @param string $view Vue de pages/, sans extension.
|
||
* @param string $activePage Clé de l'onglet à surligner.
|
||
* @param array<string, mixed> $data Surcharges spécifiques à la page.
|
||
*/
|
||
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));
|
||
}
|
||
|
||
/**
|
||
* Lieux proposés dans les sélecteurs, restreints au périmètre du rôle.
|
||
*
|
||
* Ce filtrage est du confort d'interface, pas une sécurité : les endpoints
|
||
* appliquent leurs propres gardes. Un lieu absent d'ici est simplement
|
||
* inaffichable dans la liste déroulante.
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
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] : [];
|
||
}
|
||
|
||
/**
|
||
* Variante pour l'éditeur de la fiche agent.
|
||
*
|
||
* Un créneau d'agent peut porter sur n'importe quel lieu ; le Service
|
||
* Enfance a donc besoin de la liste complète même s'il consulte un agent
|
||
* rattaché ailleurs.
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private function loadStructuresForAgentEditor(): array
|
||
{
|
||
if ($this->access->role() === Access::ROLE_SERVICE) {
|
||
return (new StructureModel($this->pdo))->allActive();
|
||
}
|
||
return $this->loadStructures();
|
||
}
|
||
|
||
/**
|
||
* Agents proposés dans les sélecteurs, selon le rôle : tous (Service),
|
||
* soi-même (Agent), ou ceux visibles depuis le lieu (Responsable — ce qui
|
||
* inclut les agents rattachés ailleurs mais planifiés sur place.)
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
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) : [];
|
||
}
|
||
|
||
/**
|
||
* Tous les motifs actifs, sans filtrage par agent : c'est l'écran qui
|
||
* masque « Formation » quand l'agent n'y a pas droit, et le serveur qui
|
||
* refuse (PlanningController::assertMotifsAllowedForAgent()).
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private function loadMotifs(): array
|
||
{
|
||
return (new MotifModel($this->pdo))->allActive();
|
||
}
|
||
|
||
/**
|
||
* Semaine courante au format « 2026-W08 ».
|
||
*
|
||
* Le format 'o-\WW' associe bien l'ANNÉE ISO ('o') au numéro de semaine ISO
|
||
* ('W'). Utiliser 'Y' donnerait un couple faux fin décembre / début janvier,
|
||
* quand la semaine ISO appartient à l'année civile voisine.
|
||
* Fuseau explicite : le conteneur PHP tourne en UTC.
|
||
*/
|
||
private function currentWeek(): string
|
||
{
|
||
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Assainissement des paramètres d'URL. Ces valeurs partent dans
|
||
// window.PTA_CONFIG et pilotent les filtres appliqués par
|
||
// page-bootstrap.js ; toute valeur non conforme est ramenée à null plutôt
|
||
// que corrigée, pour qu'un lien tronqué ouvre la page par défaut.
|
||
// ---------------------------------------------------------------------
|
||
|
||
/** Identifiant d'entité : strictement positif, ou null. */
|
||
private function positiveInt(mixed $value): ?int
|
||
{
|
||
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||
return $number === false ? null : (int) $number;
|
||
}
|
||
|
||
/** Semaine « AAAA-Www ». La validité du numéro est vérifiée côté API. */
|
||
private function weekValue(mixed $value): ?string
|
||
{
|
||
$value = trim((string) $value);
|
||
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
|
||
}
|
||
|
||
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
|
||
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;
|
||
}
|
||
|
||
/** Année bornée à [2000, 2100] : garde-fou contre les valeurs absurdes. */
|
||
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;
|
||
}
|
||
|
||
/** Mois « AAAA-MM », le motif interdisant les mois 00 et 13+. */
|
||
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;
|
||
}
|
||
}
|