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>
This commit is contained in:
PTA
2026-08-10 16:48:50 +02:00
parent 66cc8c8ade
commit b68ac15de5
17 changed files with 1571 additions and 24 deletions

View File

@@ -0,0 +1,310 @@
<?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, combien
* n'ont encore aucune heure planifiée, et le volume restant à affecter.
*/
private function quotaPayload(int $year): array
{
$tracking = $this->dashboardModel->quotaTracking($year);
$overCount = 0;
$emptyCount = 0;
$remainingMinutes = 0;
$targetMinutes = 0;
$allocatedMinutes = 0;
foreach ($tracking as $agent) {
$targetMinutes += $agent['quota_cible_minutes'];
$allocatedMinutes += $agent['minutes_affectees'];
if ($agent['minutes_restantes'] < 0) {
$overCount++;
}
if ($agent['minutes_affectees'] === 0) {
$emptyCount++;
}
$remainingMinutes += max(0, $agent['minutes_restantes']);
}
// 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,
'minutes_restantes' => $remainingMinutes,
'minutes_cible' => $targetMinutes,
'minutes_affectees' => $allocatedMinutes,
'taux_global' => $targetMinutes > 0 ? round(($allocatedMinutes / $targetMinutes) * 100, 1) : 0.0,
'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

@@ -24,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');
@@ -36,6 +50,7 @@ final class PageController extends Controller
$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(),
@@ -56,6 +71,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'],
]);
@@ -66,6 +82,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'],
@@ -82,6 +99,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'],
@@ -98,6 +116,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(),
@@ -115,6 +136,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'],
@@ -129,6 +151,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'],
@@ -144,6 +167,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'],
@@ -159,6 +183,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'],
]);
@@ -168,6 +193,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(),
@@ -182,6 +208,10 @@ final class PageController extends Controller
$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(),

View File

@@ -205,12 +205,21 @@ final class Access
return in_array($page, $this->allowedPages(), true);
}
/**
* Page d'atterrissage du rôle.
*
* Le Service Enfance arrive sur son tableau de bord plutôt que sur l'écran
* de saisie : son travail commence par le contrôle, pas par la saisie. Le
* repli sur « planning » est indispensable, car la page « dashboard » n'est
* autorisée que si la migration V7.9 a été jouée — sans ce repli,
* requirePage() renverrait indéfiniment vers une page interdite.
*/
public function homeUrl(): string
{
return match ($this->role()) {
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => 'planning.php',
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
default => 'role.php',
};
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Models;
/**
* Agrégats de pilotage du tableau de bord.
*
* Ce modèle ne porte AUCUNE règle métier nouvelle : il ne fait que compter et
* additionner ce que les autres écrans affichent déjà en détail. Le calcul du
* quota reprend exactement la logique de QuotaModel (année civile, motifs
* décomptés uniquement, quota de l'année la plus proche à défaut de l'année
* exacte) afin qu'un agent n'affiche jamais deux valeurs différentes selon
* l'écran consulté.
*/
final class DashboardModel extends BaseModel
{
/**
* Suivi du quota annuel de tous les agents actifs, en deux requêtes.
*
* @return array<int, array<string, mixed>>
*/
public function quotaTracking(int $year): array
{
$profiles = $this->quotaProfiles($year);
$rows = $this->allocatedMinutes($year);
$tracking = [];
foreach ($rows as $row) {
$agentId = (int) $row['id_agent'];
$profile = $profiles[$agentId] ?? [
'quotite_travail' => 100.0,
'quota_cible_minutes' => QuotaModel::REFERENCE_MINUTES,
'annee' => $year,
];
$target = (int) $profile['quota_cible_minutes'];
$validated = (int) $row['minutes_valides'];
$draft = (int) $row['minutes_brouillon'];
$allocated = $validated + $draft;
$tracking[] = [
'id_agent' => $agentId,
'matricule' => (string) ($row['matricule'] ?? ''),
'nom' => (string) $row['nom'],
'prenom' => (string) $row['prenom'],
'structure_nom' => $row['structure_nom'] !== null ? (string) $row['structure_nom'] : null,
'poste_libelle' => $row['poste_libelle'] !== null ? (string) $row['poste_libelle'] : null,
'quotite_travail' => (float) $profile['quotite_travail'],
'quota_cible_minutes' => $target,
'quota_source_annee' => (int) $profile['annee'],
'quota_estime' => !isset($profiles[$agentId]),
'minutes_valides' => $validated,
'minutes_brouillon' => $draft,
'minutes_affectees' => $allocated,
'minutes_restantes' => $target - $allocated,
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 1) : 0.0,
];
}
return $tracking;
}
/**
* Minutes décomptées de l'année civile, par agent actif.
*
* @return array<int, array<string, mixed>>
*/
private function allocatedMinutes(int $year): array
{
$stmt = $this->pdo->prepare(
"SELECT a.id_agent, a.matricule, a.nom, a.prenom,
s.nom AS structure_nom,
po.libelle AS poste_libelle,
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' AND m.id_motif IS NOT NULL THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' AND m.id_motif IS NOT NULL THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_brouillon
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN planning p ON p.id_agent = a.id_agent
LEFT JOIN creneau_horaire c
ON c.id_planning = p.id_planning
AND c.date_jour BETWEEN :date_debut AND :date_fin
LEFT JOIN motif_planning m
ON m.id_motif = c.id_motif
AND m.compte_dans_quota = TRUE
WHERE a.actif = TRUE
GROUP BY a.id_agent, a.matricule, a.nom, a.prenom, s.nom, po.libelle
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'date_debut' => sprintf('%04d-01-01', $year),
'date_fin' => sprintf('%04d-12-31', $year),
]);
return $stmt->fetchAll();
}
/**
* Quota retenu pour chaque agent : année exacte si elle existe, sinon
* l'année passée la plus proche, sinon l'année future la plus proche.
* Même ordre de préférence que QuotaModel::quotaProfile().
*
* @return array<int, array<string, mixed>>
*/
private function quotaProfiles(int $year): array
{
$stmt = $this->pdo->prepare(
'SELECT id_agent, annee, quotite_travail, quota_cible_minutes
FROM (
SELECT q.id_agent, q.annee, q.quotite_travail, q.quota_cible_minutes,
ROW_NUMBER() OVER (
PARTITION BY q.id_agent
ORDER BY CASE
WHEN q.annee = :year_exact THEN 0
WHEN q.annee < :year_past THEN 1
ELSE 2
END,
CASE WHEN q.annee <= :year_latest THEN q.annee END DESC,
CASE WHEN q.annee > :year_future THEN q.annee END ASC
) AS rang
FROM quota_agent_annuel q
) AS classement
WHERE rang = 1'
);
$stmt->execute([
'year_exact' => $year,
'year_past' => $year,
'year_latest' => $year,
'year_future' => $year,
]);
$profiles = [];
foreach ($stmt->fetchAll() as $row) {
$profiles[(int) $row['id_agent']] = [
'annee' => (int) $row['annee'],
'quotite_travail' => (float) $row['quotite_travail'],
'quota_cible_minutes' => (int) $row['quota_cible_minutes'],
];
}
return $profiles;
}
/**
* Nombre de brouillons contenant au moins une affectation.
* Requête volontairement légère : elle alimente la pastille de navigation
* rechargée sur chaque page.
*/
public function pendingCount(): int
{
$stmt = $this->pdo->query(
"SELECT COUNT(*)
FROM (
SELECT p.id_planning
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.statut = 'BROUILLON'
GROUP BY p.id_planning
) AS brouillons_servis"
);
return (int) $stmt->fetchColumn();
}
/**
* Nombre d'agents actifs et de lieux actifs, pour situer les compteurs.
*
* @return array{agents: int, structures: int}
*/
public function scope(): array
{
return [
'agents' => (int) $this->pdo->query('SELECT COUNT(*) FROM agent WHERE actif = TRUE')->fetchColumn(),
'structures' => (int) $this->pdo->query('SELECT COUNT(*) FROM structure WHERE actif = TRUE')->fetchColumn(),
];
}
}

View File

@@ -28,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/core.js?v=7.8"></script>
<script src="assets/js/core.js?v=7.9"></script>
<?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.8"></script>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.9"></script>
<?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.8"></script>
<script src="assets/js/page-bootstrap.js?v=7.9"></script>
</body>
</html>

View File

@@ -1,22 +1,65 @@
<?php
/** @var string $pageTitle */
/** @var string $pageSubtitle */
/** @var string $activePage */
/** @var array $allowedPages */
/** @var array $accessProfile */
/** @var string $csrfToken */
$isAgent = ($accessProfile['role'] ?? '') === 'AGENT';
$navigation = [
'dashboard' => ['dashboard.php', 'Tableau de bord'],
'planning' => ['planning.php', 'Saisie'],
'pending' => ['pending.php', 'À valider'],
'coverage' => ['coverage.php', 'Couverture'],
'structures' => ['structures.php', 'Par lieu'],
'agents' => ['agents.php', ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Par agent'],
'agents' => ['agents.php', $isAgent ? 'Mon planning' : 'Par agent'],
'annual' => ['annual.php', 'Vue annuelle'],
'pta' => ['pta.php', 'PTA détaillé'],
'teams' => ['teams.php', 'Équipes'],
'vacations' => ['vacations.php', 'Vacances'],
'administration' => ['administration.php', 'Administration'],
];
/**
* Les onze onglets du Service Enfance étaient alignés à plat, sans hiérarchie :
* rien ne distinguait une tâche quotidienne d'un référentiel modifié deux fois
* par an. Ils sont désormais regroupés par intention. Les rôles qui n'ont que
* quelques pages gardent une barre simple, sans titres de groupe.
*/
$navigationGroups = [
'Piloter' => ['dashboard', 'pending', 'coverage'],
'Planifier' => ['planning', 'teams'],
'Consulter' => ['agents', 'structures', 'annual', 'pta'],
'Paramétrer' => ['vacations', 'administration'],
];
$visibleGroups = [];
$grouped = [];
foreach ($navigationGroups as $groupLabel => $keys) {
$visibleKeys = array_values(array_filter(
$keys,
static fn (string $key): bool => in_array($key, $allowedPages, true) && isset($navigation[$key])
));
if ($visibleKeys !== []) {
$visibleGroups[$groupLabel] = $visibleKeys;
$grouped = array_merge($grouped, $visibleKeys);
}
}
// Filet de sécurité : une page autorisée en base mais oubliée dans les groupes
// ci-dessus reste accessible plutôt que de disparaître de la navigation.
$ungrouped = array_values(array_filter(
$allowedPages,
static fn (string $key): bool => isset($navigation[$key]) && !in_array($key, $grouped, true)
));
if ($ungrouped !== []) {
$visibleGroups['Autres'] = $ungrouped;
}
$visiblePageCount = count($grouped) + count($ungrouped);
$showGroupLabels = $visiblePageCount > 4;
?>
<!doctype html>
<html lang="fr">
@@ -24,15 +67,15 @@ $navigation = [
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.8">
<link rel="stylesheet" href="assets/style.css?v=7.9">
</head>
<body>
<main class="app-shell">
<header class="page-header">
<div>
<div class="page-header-identity">
<p class="eyebrow">PTA</p>
<h1><?= htmlspecialchars($pageTitle) ?></h1>
<p class="subtitle">Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.</p>
<p class="subtitle"><?= htmlspecialchars($pageSubtitle ?? '') ?></p>
</div>
<div class="header-actions">
<?php if ($activePage === 'planning'): ?>
@@ -41,7 +84,7 @@ $navigation = [
<div class="access-profile-badge">
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
<?php if (!empty($accessProfile['structure_name']) && ($accessProfile['role'] ?? '') === 'AGENT'): ?>
<?php if (!empty($accessProfile['structure_name']) && $isAgent): ?>
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
<?php endif; ?>
</div>
@@ -53,12 +96,21 @@ $navigation = [
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
<?php endif; ?>
<nav class="view-tabs" aria-label="Navigation principale">
<?php foreach ($navigation as $key => [$href, $label]): ?>
<?php if (!in_array($key, $allowedPages, true)) continue; ?>
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
<?= htmlspecialchars($label) ?>
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
</a>
<nav class="view-tabs<?= $showGroupLabels ? ' view-tabs-grouped' : '' ?>" aria-label="Navigation principale">
<?php foreach ($visibleGroups as $groupLabel => $keys): ?>
<div class="view-tab-group" role="group"<?= $showGroupLabels ? ' aria-label="' . htmlspecialchars($groupLabel, ENT_QUOTES) . '"' : '' ?>>
<?php if ($showGroupLabels): ?>
<span class="view-tab-group-label"><?= htmlspecialchars($groupLabel) ?></span>
<?php endif; ?>
<div class="view-tab-group-items">
<?php foreach ($keys as $key): ?>
<?php [$href, $label] = $navigation[$key]; ?>
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
<?= htmlspecialchars($label) ?>
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/dashboard.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -12,7 +12,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choisir votre accès — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.8">
<link rel="stylesheet" href="assets/style.css?v=7.9">
</head>
<body class="role-page-body">
<main class="role-page-shell">

View File

@@ -0,0 +1,164 @@
<?php
/** @var string $currentWeek */
?>
<section id="dashboard-view" class="app-view page-content dashboard-page">
<section class="card dashboard-context-card">
<div class="section-heading">
<div>
<h2>Semaine de travail</h2>
<p class="section-subtitle" id="dashboard-week-label">Chargement de la semaine en cours…</p>
</div>
<div class="dashboard-context-actions">
<label class="field dashboard-week-field">
<span>Semaine analysée</span>
<input id="dashboard-week" type="week" value="<?= htmlspecialchars($currentWeek) ?>">
</label>
<button id="dashboard-refresh" class="button button-secondary" type="button">Actualiser</button>
</div>
</div>
<p class="dashboard-context-note" id="dashboard-week-context" hidden></p>
</section>
<div id="dashboard-message" class="message" role="status" aria-live="polite"></div>
<div id="dashboard-loading" class="empty-state" hidden>Consolidation des indicateurs…</div>
<div class="dashboard-tiles" aria-live="polite">
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-pending" href="pending.php">
<span class="dashboard-tile-label">Plannings à valider</span>
<strong class="dashboard-tile-value" id="dashboard-pending-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-pending-detail">Brouillons contenant des affectations</small>
<span class="dashboard-tile-cta">Ouvrir la file de validation</span>
</a>
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-coverage" href="coverage.php">
<span class="dashboard-tile-label">Manques de couverture</span>
<strong class="dashboard-tile-value" id="dashboard-coverage-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-coverage-detail">Sur la semaine analysée</small>
<span class="dashboard-tile-cta">Voir le détail des plages</span>
</a>
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-quota" href="annual.php">
<span class="dashboard-tile-label">Quotas dépassés</span>
<strong class="dashboard-tile-value" id="dashboard-quota-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-quota-detail">Agents au-delà de leur quota annuel</small>
<span class="dashboard-tile-cta">Ouvrir la vue annuelle</span>
</a>
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-setup" href="administration.php">
<span class="dashboard-tile-label">Lieux à paramétrer</span>
<strong class="dashboard-tile-value" id="dashboard-setup-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-setup-detail">Sans horaires de couverture attendus</small>
<span class="dashboard-tile-cta">Ouvrir l’administration</span>
</a>
</div>
<div class="dashboard-columns">
<section class="card dashboard-list-card">
<div class="section-heading">
<div>
<h2>À valider en priorité</h2>
<p class="section-subtitle">Les brouillons dont la semaine est la plus ancienne.</p>
</div>
<a class="button button-secondary" href="pending.php">Tout voir</a>
</div>
<div id="dashboard-pending-empty" class="empty-state" hidden>Aucun planning n’attend de validation.</div>
<ul class="dashboard-list" id="dashboard-pending-list"></ul>
</section>
<section class="card dashboard-list-card">
<div class="section-heading">
<div>
<h2>Couverture à renforcer</h2>
<p class="section-subtitle">Les plages les plus déficitaires de la semaine analysée.</p>
</div>
<a class="button button-secondary" id="dashboard-coverage-link" href="coverage.php">Tout voir</a>
</div>
<div id="dashboard-coverage-empty" class="empty-state" hidden>Aucun manque de couverture sur cette semaine.</div>
<ul class="dashboard-list" id="dashboard-coverage-list"></ul>
<div id="dashboard-coverage-unconfigured" class="dashboard-inline-note" hidden></div>
</section>
</div>
<section class="card dashboard-quota-card">
<div class="section-heading">
<div>
<h2>Suivi des quotas annuels</h2>
<p class="section-subtitle" id="dashboard-quota-subtitle">Heures affectées, validées et brouillon comprises, sur l’année civile.</p>
</div>
<a class="button button-secondary" href="annual.php">Vue annuelle détaillée</a>
</div>
<div class="dashboard-quota-summary">
<article class="summary-card">
<span>Agents suivis</span>
<strong id="dashboard-quota-agents">—</strong>
<small>actifs</small>
</article>
<article class="summary-card">
<span>Consommation globale</span>
<strong id="dashboard-quota-rate">—</strong>
<small id="dashboard-quota-rate-detail">du quota cumulé</small>
</article>
<article class="summary-card">
<span>Reste à affecter</span>
<strong id="dashboard-quota-remaining">—</strong>
<small>hors agents déjà au-delà</small>
</article>
<article class="summary-card">
<span>Sans aucune heure</span>
<strong id="dashboard-quota-empty">—</strong>
<small>agent(s) non planifié(s)</small>
</article>
</div>
<div id="dashboard-quota-empty-state" class="empty-state" hidden>Aucun agent actif à suivre.</div>
<div class="dashboard-quota-table-wrap">
<table class="dashboard-quota-table">
<thead>
<tr>
<th>Agent</th>
<th>Lieu de rattachement</th>
<th>Quota annuel</th>
<th>Affecté</th>
<th>Consommation</th>
<th></th>
</tr>
</thead>
<tbody id="dashboard-quota-body"></tbody>
</table>
</div>
</section>
<section class="card dashboard-shortcuts-card">
<div class="section-heading">
<div>
<h2>Actions fréquentes</h2>
<p class="section-subtitle">Les écrans les plus utilisés, accessibles en un clic.</p>
</div>
</div>
<div class="dashboard-shortcuts">
<a class="dashboard-shortcut" href="planning.php">
<strong>Saisir un planning</strong>
<small>Un agent, une semaine, un lieu.</small>
</a>
<a class="dashboard-shortcut" href="structures.php">
<strong>Consulter un lieu</strong>
<small>Qui travaille où cette semaine.</small>
</a>
<a class="dashboard-shortcut" href="teams.php">
<strong>Composer une équipe</strong>
<small>Mercredis et vacances scolaires.</small>
</a>
<a class="dashboard-shortcut" href="vacations.php">
<strong>Mettre à jour les vacances</strong>
<small>Périodes scolaires de référence.</small>
</a>
<a class="dashboard-shortcut" href="pta.php">
<strong>Ouvrir un PTA détaillé</strong>
<small>Souhaits, contraintes et répartition.</small>
</a>
<a class="dashboard-shortcut" href="administration.php">
<strong>Gérer les référentiels</strong>
<small>Agents, lieux, postes, couverture.</small>
</a>
</div>
</section>
</section>