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:
23
CLAUDE.md
23
CLAUDE.md
@@ -49,8 +49,8 @@ appliquée manuellement en SQL — il n'y a pas d'outil de migration.
|
||||
|
||||
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
|
||||
|
||||
**Pages HTML** — `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`, `coverage.php`,
|
||||
`structures.php`, `pending.php`, `vacations.php`, `administration.php` :
|
||||
**Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`,
|
||||
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` :
|
||||
|
||||
```php
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
@@ -74,8 +74,11 @@ octets binaires via `PdfResponse::inline()`).
|
||||
|
||||
Pour ajouter un endpoint : créer le fichier mince dans `api/`, ajouter la méthode au contrôleur.
|
||||
Pour ajouter une page : fichier racine + méthode dans `PageController` + vue `app/Views/pages/` +
|
||||
partiel `app/Views/partials/` + module JS + **inscription de la clé dans `Access::allowedPages()`
|
||||
et dans le tableau `$navigation` de `app/Views/layouts/header.php`**.
|
||||
partiel `app/Views/partials/` + module JS + **inscription de la clé dans le tableau `$navigation` de
|
||||
`app/Views/layouts/header.php`, dans un groupe de `$navigationGroups`, et dans `acces_role_page` par
|
||||
une migration SQL** (les pages autorisées sont lues en base, pas écrites en PHP — voir plus bas).
|
||||
Une page absente des groupes reste affichée dans un groupe « Autres » : le filet évite qu'un oubli
|
||||
la rende inatteignable. Chaque page passe aussi un `pageSubtitle` décrivant l'action de l'écran.
|
||||
|
||||
### Chaîne d'un appel
|
||||
|
||||
@@ -108,6 +111,11 @@ SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une p
|
||||
la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait
|
||||
échouer l'application si la migration manque — c'est volontairement fail-closed.
|
||||
|
||||
`dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit
|
||||
d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas
|
||||
jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.php` — ce repli est
|
||||
indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite.
|
||||
|
||||
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
|
||||
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
|
||||
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
|
||||
@@ -145,9 +153,10 @@ PTA.modules.agentOverview = { init, load };
|
||||
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se
|
||||
rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
|
||||
|
||||
Les assets sont versionnés à la main : `assets/style.css?v=7.7` et `assets/js/*.js?v=7.7` dans
|
||||
`layouts/header.php` / `layouts/footer.php`. **Bumper ce numéro après toute modification de JS/CSS**,
|
||||
sinon les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
|
||||
Les assets sont versionnés à la main : `assets/style.css?v=7.9` et `assets/js/*.js?v=7.9` dans
|
||||
`layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre
|
||||
balise `<head>`. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon
|
||||
les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
|
||||
|
||||
### Modèle métier
|
||||
|
||||
|
||||
113
README_V7_9.md
Normal file
113
README_V7_9.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# PTA V7.9 — Parcours et lisibilité du Service Enfance
|
||||
|
||||
## Le problème traité
|
||||
|
||||
Le rôle **Service Enfance** ouvre onze écrans. Jusqu'ici :
|
||||
|
||||
- il atterrissait sur **Saisie**, alors que son travail commence par le contrôle et non par la saisie ;
|
||||
- les onze onglets étaient alignés à plat, sans hiérarchie : rien ne distinguait une tâche
|
||||
quotidienne (« À valider ») d'un référentiel modifié deux fois par an (« Administration ») ;
|
||||
- chaque bandeau de page affichait le **même** sous-titre générique, il fallait donc ouvrir un écran
|
||||
pour savoir à quoi il servait ;
|
||||
- les chiffres qui déclenchent une action (brouillons en attente, manques de couverture, quotas)
|
||||
vivaient sur trois écrans distincts : personne ne voyait l'état général sans faire le tour ;
|
||||
- la pastille « À valider » de la barre de navigation n'était alimentée que **sur la page
|
||||
« À valider » elle-même**, donc invisible là où elle aurait servi.
|
||||
|
||||
## Ce qui change
|
||||
|
||||
### 1. Un tableau de bord comme page d'accueil
|
||||
|
||||
Nouvelle page `dashboard.php`, **réservée au Service Enfance**, qui devient sa page d'atterrissage.
|
||||
|
||||
- **Quatre tuiles d'action**, vertes quand il n'y a rien à faire, rouges dès qu'un compteur remonte :
|
||||
plannings à valider, manques de couverture de la semaine, quotas dépassés, lieux sans horaires de
|
||||
couverture. Chaque tuile est un lien vers l'écran de traitement.
|
||||
- **« À valider en priorité »** : les cinq brouillons dont la semaine est la plus ancienne, avec un
|
||||
bouton **Contrôler** qui ouvre directement l'écran de saisie sur le bon agent, le bon lieu, la
|
||||
bonne semaine, curseur sur la validation.
|
||||
- **« Couverture à renforcer »** : les cinq plages les plus déficitaires, triées par nombre d'agents
|
||||
manquants, avec un lien vers l'écran de couverture déjà filtré.
|
||||
- **« Suivi des quotas annuels »** : consommation globale, reste à affecter, agents sans aucune heure,
|
||||
et le détail par agent avec une jauge et un lien vers sa vue annuelle.
|
||||
- **« Actions fréquentes »** : six raccourcis vers les écrans les plus utilisés.
|
||||
|
||||
Un sélecteur de semaine en haut de page rejoue l'ensemble des indicateurs de couverture, et indique
|
||||
si la semaine est scolaire, de vacances, ou à cheval sur les deux.
|
||||
|
||||
### 2. Navigation regroupée par intention
|
||||
|
||||
Les onglets sont désormais rangés en quatre groupes : **Piloter** (tableau de bord, à valider,
|
||||
couverture), **Planifier** (saisie, équipes), **Consulter** (par agent, par lieu, vue annuelle, PTA
|
||||
détaillé), **Paramétrer** (vacances, administration).
|
||||
|
||||
Les rôles qui n'ont que quelques pages — Agent (2), Responsable de structure (4) — gardent la barre
|
||||
simple d'avant, sans titres de groupe. Une page autorisée en base mais absente des groupes reste
|
||||
affichée dans un groupe « Autres » plutôt que de disparaître.
|
||||
|
||||
### 3. Chaque page dit ce qu'on y fait
|
||||
|
||||
Le sous-titre du bandeau est propre à chaque écran et décrit l'action, pas le produit.
|
||||
|
||||
### 4. La pastille « À valider » suit partout
|
||||
|
||||
Elle est alimentée depuis n'importe quel écran par un compteur léger
|
||||
(`api/dashboard_summary.php?scope=badges`). L'échec de cet appel est silencieux : une commodité ne
|
||||
doit jamais empêcher une page de fonctionner.
|
||||
|
||||
## Ce qui ne change pas
|
||||
|
||||
Aucune règle métier, aucun calcul, aucun droit n'est modifié.
|
||||
|
||||
Le tableau de bord ne fait que **relire** ce que les autres écrans calculent déjà : la file de
|
||||
validation vient de `PlanningModel::pendingValidation()`, la couverture de
|
||||
`CoverageModel::reportForStructure()`, et le 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) — un agent ne peut donc pas afficher deux valeurs différentes selon l'écran consulté.
|
||||
|
||||
L'endpoint `api/dashboard_summary.php` est gardé par `requireService()` : 403 pour les autres rôles,
|
||||
401 sans session.
|
||||
|
||||
## Migration
|
||||
|
||||
```sql
|
||||
SOURCE database/migration_v7_9_tableau_de_bord.sql;
|
||||
```
|
||||
|
||||
Elle insère une seule ligne dans `acces_role_page`. **Elle doit être jouée en même temps que le
|
||||
déploiement du code**, dans un sens comme dans l'autre :
|
||||
|
||||
- code déployé, migration non jouée → l'onglet n'apparaît pas, le Service Enfance atterrit comme
|
||||
avant sur l'écran de saisie, et `dashboard.php` renvoie vers l'accueil. L'application reste
|
||||
entièrement fonctionnelle, simplement sans tableau de bord ;
|
||||
- migration jouée, code non déployé → l'onglet apparaît et pointe vers un fichier inexistant.
|
||||
|
||||
Retour arrière :
|
||||
|
||||
```sql
|
||||
DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard';
|
||||
```
|
||||
|
||||
## Fichiers
|
||||
|
||||
| Ajouté | Rôle |
|
||||
| --- | --- |
|
||||
| `dashboard.php` | point d'entrée de la page |
|
||||
| `api/dashboard_summary.php` | endpoint JSON |
|
||||
| `app/Controllers/DashboardController.php` | agrégation et gardes d'accès |
|
||||
| `app/Models/DashboardModel.php` | suivi des quotas et compteurs |
|
||||
| `app/Views/pages/dashboard.php`, `app/Views/partials/dashboard.php` | vue |
|
||||
| `assets/js/dashboard.js` | module `PTA.modules.dashboard` |
|
||||
| `database/migration_v7_9_tableau_de_bord.sql` | droit d'accès à la page |
|
||||
|
||||
| Modifié | Raison |
|
||||
| --- | --- |
|
||||
| `app/Core/Access.php` | `homeUrl()` vise le tableau de bord, avec repli si la migration manque |
|
||||
| `app/Controllers/PageController.php` | méthode `dashboard()` et sous-titre propre à chaque page |
|
||||
| `app/Views/layouts/header.php` | navigation regroupée, sous-titre contextuel |
|
||||
| `app/Views/layouts/footer.php`, `app/Views/pages/role.php` | versions d'assets `?v=7.9` |
|
||||
| `assets/js/page-bootstrap.js` | pastille « À valider » sur toutes les pages |
|
||||
| `assets/style.css` | styles de la navigation groupée et du tableau de bord |
|
||||
|
||||
Les jauges de quota utilisent l'élément natif `<progress>` : la CSP interdit l'attribut `style`, une
|
||||
largeur ne peut donc pas être calculée en JavaScript sur une simple `div`.
|
||||
12
api/dashboard_summary.php
Normal file
12
api/dashboard_summary.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
use App\Controllers\DashboardController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DashboardController($pdo))->summary();
|
||||
310
app/Controllers/DashboardController.php
Normal file
310
app/Controllers/DashboardController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
183
app/Models/DashboardModel.php
Normal file
183
app/Models/DashboardModel.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
4
app/Views/pages/dashboard.php
Normal file
4
app/Views/pages/dashboard.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/dashboard.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
@@ -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">
|
||||
|
||||
164
app/Views/partials/dashboard.php
Normal file
164
app/Views/partials/dashboard.php
Normal 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>
|
||||
329
assets/js/dashboard.js
Normal file
329
assets/js/dashboard.js
Normal file
@@ -0,0 +1,329 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const PTA = window.PTA;
|
||||
if (!PTA) throw new Error('PTA core doit être chargé avant dashboard.js');
|
||||
|
||||
const { utils } = PTA;
|
||||
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils;
|
||||
|
||||
const elements = {
|
||||
week: document.querySelector('#dashboard-week'),
|
||||
weekLabel: document.querySelector('#dashboard-week-label'),
|
||||
weekContext: document.querySelector('#dashboard-week-context'),
|
||||
refresh: document.querySelector('#dashboard-refresh'),
|
||||
message: document.querySelector('#dashboard-message'),
|
||||
loading: document.querySelector('#dashboard-loading'),
|
||||
|
||||
pendingCount: document.querySelector('#dashboard-pending-count'),
|
||||
pendingDetail: document.querySelector('#dashboard-pending-detail'),
|
||||
pendingTile: document.querySelector('#dashboard-tile-pending'),
|
||||
pendingList: document.querySelector('#dashboard-pending-list'),
|
||||
pendingEmpty: document.querySelector('#dashboard-pending-empty'),
|
||||
|
||||
coverageCount: document.querySelector('#dashboard-coverage-count'),
|
||||
coverageDetail: document.querySelector('#dashboard-coverage-detail'),
|
||||
coverageTile: document.querySelector('#dashboard-tile-coverage'),
|
||||
coverageList: document.querySelector('#dashboard-coverage-list'),
|
||||
coverageEmpty: document.querySelector('#dashboard-coverage-empty'),
|
||||
coverageLink: document.querySelector('#dashboard-coverage-link'),
|
||||
coverageUnconfigured: document.querySelector('#dashboard-coverage-unconfigured'),
|
||||
|
||||
quotaCount: document.querySelector('#dashboard-quota-count'),
|
||||
quotaDetail: document.querySelector('#dashboard-quota-detail'),
|
||||
quotaTile: document.querySelector('#dashboard-tile-quota'),
|
||||
quotaSubtitle: document.querySelector('#dashboard-quota-subtitle'),
|
||||
quotaAgents: document.querySelector('#dashboard-quota-agents'),
|
||||
quotaRate: document.querySelector('#dashboard-quota-rate'),
|
||||
quotaRateDetail: document.querySelector('#dashboard-quota-rate-detail'),
|
||||
quotaRemaining: document.querySelector('#dashboard-quota-remaining'),
|
||||
quotaEmptyAgents: document.querySelector('#dashboard-quota-empty'),
|
||||
quotaBody: document.querySelector('#dashboard-quota-body'),
|
||||
quotaEmptyState: document.querySelector('#dashboard-quota-empty-state'),
|
||||
|
||||
setupCount: document.querySelector('#dashboard-setup-count'),
|
||||
setupDetail: document.querySelector('#dashboard-setup-detail'),
|
||||
setupTile: document.querySelector('#dashboard-tile-setup'),
|
||||
|
||||
navBadge: document.querySelector('#pending-validation-tab-badge'),
|
||||
};
|
||||
|
||||
let pendingItems = [];
|
||||
let loading = false;
|
||||
|
||||
function setMessage(text = '', type = 'info') {
|
||||
if (!elements.message) return;
|
||||
elements.message.className = text ? `message message-${type}` : 'message';
|
||||
elements.message.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Une tuile est neutre quand il n'y a rien à faire, alertante dès qu'un
|
||||
* compteur remonte. La couleur ne dit rien de plus que le chiffre : elle
|
||||
* évite juste d'avoir à lire les quatre tuiles pour savoir où agir.
|
||||
*/
|
||||
function setTileTone(tile, count) {
|
||||
if (!tile) return;
|
||||
tile.classList.toggle('is-alert', Number(count) > 0);
|
||||
tile.classList.toggle('is-clear', Number(count) === 0);
|
||||
}
|
||||
|
||||
function plural(count, singular, pluralForm) {
|
||||
return Number(count) > 1 ? pluralForm : singular;
|
||||
}
|
||||
|
||||
function weekParam() {
|
||||
return elements.week?.value || '';
|
||||
}
|
||||
|
||||
function weekValueOf(item) {
|
||||
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function setNavBadge(count) {
|
||||
if (!elements.navBadge) return;
|
||||
elements.navBadge.textContent = String(count);
|
||||
elements.navBadge.hidden = Number(count) === 0;
|
||||
elements.navBadge.setAttribute(
|
||||
'aria-label',
|
||||
`${count} planning${Number(count) > 1 ? 's' : ''} en attente de validation`
|
||||
);
|
||||
}
|
||||
|
||||
function renderWeek(week) {
|
||||
if (!week) return;
|
||||
const contexteLabel = {
|
||||
PERISCOLAIRE: 'Semaine scolaire — contexte périscolaire',
|
||||
EXTRASCOLAIRE: 'Semaine de vacances — contexte extrascolaire',
|
||||
MIXTE: 'Semaine à cheval entre période scolaire et vacances',
|
||||
}[week.contexte] || '';
|
||||
|
||||
if (elements.weekLabel) {
|
||||
elements.weekLabel.textContent =
|
||||
`Semaine ${week.numero} · du ${formatDate(week.date_debut)} au ${formatDate(week.date_fin)}`;
|
||||
}
|
||||
if (elements.weekContext) {
|
||||
const vacances = Array.isArray(week.vacances_libelles) ? week.vacances_libelles : [];
|
||||
const suffix = vacances.length ? ` (${vacances.join(', ')})` : '';
|
||||
elements.weekContext.textContent = contexteLabel ? `${contexteLabel}${suffix}` : '';
|
||||
elements.weekContext.hidden = !contexteLabel;
|
||||
}
|
||||
if (elements.coverageLink) {
|
||||
elements.coverageLink.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
|
||||
}
|
||||
if (elements.coverageTile) {
|
||||
elements.coverageTile.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPending(validation) {
|
||||
const count = Number(validation.count || 0);
|
||||
pendingItems = Array.isArray(validation.apercu) ? validation.apercu : [];
|
||||
|
||||
setNavBadge(count);
|
||||
setTileTone(elements.pendingTile, count);
|
||||
if (elements.pendingCount) elements.pendingCount.textContent = String(count);
|
||||
if (elements.pendingDetail) {
|
||||
elements.pendingDetail.textContent = count === 0
|
||||
? 'Aucun brouillon en attente'
|
||||
: `${formatDuration(Number(validation.minutes_decomptees || 0))} décomptées · `
|
||||
+ `${validation.agent_count} ${plural(validation.agent_count, 'agent', 'agents')} · `
|
||||
+ `${validation.structure_count} ${plural(validation.structure_count, 'lieu', 'lieux')}`;
|
||||
}
|
||||
|
||||
if (elements.pendingEmpty) elements.pendingEmpty.hidden = count !== 0;
|
||||
if (!elements.pendingList) return;
|
||||
|
||||
elements.pendingList.innerHTML = pendingItems.map((item) => `
|
||||
<li class="dashboard-list-item">
|
||||
<div class="dashboard-list-main">
|
||||
<strong>${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)}</strong>
|
||||
<small>${escapeHtml(item.structure_nom)} · semaine ${Number(item.numero_semaine)} du ${formatDate(item.date_debut)}</small>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span>${Number(item.nombre_creneaux)} ${plural(item.nombre_creneaux, 'créneau', 'créneaux')}</span>
|
||||
<strong>${formatDuration(Number(item.total_minutes || 0))}</strong>
|
||||
</div>
|
||||
<button class="button button-success dashboard-list-action" type="button" data-planning-id="${Number(item.id_planning)}">
|
||||
Contrôler
|
||||
</button>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderCoverage(coverage, week) {
|
||||
const gapCount = Number(coverage.gap_count || 0);
|
||||
const unconfigured = Number(coverage.unconfigured_count || 0);
|
||||
|
||||
setTileTone(elements.coverageTile, gapCount);
|
||||
setTileTone(elements.setupTile, unconfigured);
|
||||
|
||||
if (elements.coverageCount) elements.coverageCount.textContent = String(gapCount);
|
||||
if (elements.coverageDetail) {
|
||||
elements.coverageDetail.textContent = gapCount === 0
|
||||
? 'Tous les minimums sont atteints'
|
||||
: `${formatDuration(Number(coverage.gap_minutes || 0))} non couvertes · `
|
||||
+ `${coverage.structure_count} ${plural(coverage.structure_count, 'lieu concerné', 'lieux concernés')}`;
|
||||
}
|
||||
if (elements.setupCount) elements.setupCount.textContent = String(unconfigured);
|
||||
if (elements.setupDetail) {
|
||||
elements.setupDetail.textContent = unconfigured === 0
|
||||
? 'Tous les lieux ont des horaires attendus'
|
||||
: 'Aucun minimum d’agents n’y est contrôlé';
|
||||
}
|
||||
|
||||
const items = Array.isArray(coverage.apercu) ? coverage.apercu : [];
|
||||
if (elements.coverageEmpty) elements.coverageEmpty.hidden = items.length !== 0;
|
||||
if (elements.coverageList) {
|
||||
const weekValue = week?.valeur || '';
|
||||
elements.coverageList.innerHTML = items.map((gap) => {
|
||||
const href = `coverage.php?week=${encodeURIComponent(weekValue)}&structure_id=${Number(gap.id_structure)}`;
|
||||
return `
|
||||
<li class="dashboard-list-item">
|
||||
<div class="dashboard-list-main">
|
||||
<strong>${escapeHtml(gap.structure_nom)}</strong>
|
||||
<small>${escapeHtml(gap.jour_libelle)} ${formatDate(gap.date)} · ${escapeHtml(gap.heure_debut)} → ${escapeHtml(gap.heure_fin)}</small>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span>${Number(gap.agents_planifies)} / ${Number(gap.minimum_agents)} agents</span>
|
||||
<strong class="dashboard-gap-count">−${Number(gap.agents_manquants)}</strong>
|
||||
</div>
|
||||
<a class="button button-secondary dashboard-list-action" href="${href}">Analyser</a>
|
||||
</li>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (elements.coverageUnconfigured) {
|
||||
const list = Array.isArray(coverage.unconfigured) ? coverage.unconfigured : [];
|
||||
if (list.length === 0) {
|
||||
elements.coverageUnconfigured.hidden = true;
|
||||
elements.coverageUnconfigured.textContent = '';
|
||||
} else {
|
||||
const names = list.map((structure) => escapeHtml(structure.nom)).join(', ');
|
||||
const reste = unconfigured - list.length;
|
||||
elements.coverageUnconfigured.innerHTML =
|
||||
`<strong>Non contrôlés :</strong> ${names}${reste > 0 ? ` et ${reste} autre${reste > 1 ? 's' : ''}` : ''}. `
|
||||
+ 'Définissez leurs horaires de couverture dans l’administration.';
|
||||
elements.coverageUnconfigured.hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuota(quota) {
|
||||
const overCount = Number(quota.depassement_count || 0);
|
||||
setTileTone(elements.quotaTile, overCount);
|
||||
|
||||
if (elements.quotaCount) elements.quotaCount.textContent = String(overCount);
|
||||
if (elements.quotaDetail) {
|
||||
elements.quotaDetail.textContent = overCount === 0
|
||||
? `Aucun dépassement sur ${quota.annee}`
|
||||
: `Sur ${quota.agent_count} ${plural(quota.agent_count, 'agent suivi', 'agents suivis')} en ${quota.annee}`;
|
||||
}
|
||||
if (elements.quotaTile) {
|
||||
elements.quotaTile.href = `annual.php?year=${Number(quota.annee)}`;
|
||||
}
|
||||
if (elements.quotaSubtitle) {
|
||||
elements.quotaSubtitle.textContent =
|
||||
`Année civile ${quota.annee} · heures validées et brouillon comprises, motifs décomptés uniquement.`;
|
||||
}
|
||||
if (elements.quotaAgents) elements.quotaAgents.textContent = String(quota.agent_count || 0);
|
||||
if (elements.quotaRate) elements.quotaRate.textContent = `${Number(quota.taux_global || 0)} %`;
|
||||
if (elements.quotaRateDetail) {
|
||||
elements.quotaRateDetail.textContent =
|
||||
`${formatDuration(Number(quota.minutes_affectees || 0))} sur ${formatDuration(Number(quota.minutes_cible || 0))}`;
|
||||
}
|
||||
if (elements.quotaRemaining) elements.quotaRemaining.textContent = formatDuration(Number(quota.minutes_restantes || 0));
|
||||
if (elements.quotaEmptyAgents) elements.quotaEmptyAgents.textContent = String(quota.sans_heure_count || 0);
|
||||
|
||||
const items = Array.isArray(quota.apercu) ? quota.apercu : [];
|
||||
if (elements.quotaEmptyState) elements.quotaEmptyState.hidden = items.length !== 0;
|
||||
if (!elements.quotaBody) return;
|
||||
|
||||
elements.quotaBody.innerHTML = items.map((agent) => {
|
||||
const over = Number(agent.minutes_restantes) < 0;
|
||||
const rate = Number(agent.taux_consommation || 0);
|
||||
const href = `annual.php?agent_id=${Number(agent.id_agent)}&year=${Number(quota.annee)}`;
|
||||
return `
|
||||
<tr class="${over ? 'is-over-quota' : ''}">
|
||||
<td>
|
||||
<strong>${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong>
|
||||
<small>${escapeHtml(agent.poste_libelle || 'Poste non renseigné')}</small>
|
||||
</td>
|
||||
<td>${escapeHtml(agent.structure_nom || 'Non rattaché')}</td>
|
||||
<td>
|
||||
<strong>${formatDuration(Number(agent.quota_cible_minutes || 0))}</strong>
|
||||
<small>quotité ${Number(agent.quotite_travail)} %</small>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${formatDuration(Number(agent.minutes_affectees || 0))}</strong>
|
||||
<small>${formatDuration(Number(agent.minutes_brouillon || 0))} en brouillon</small>
|
||||
</td>
|
||||
<td class="dashboard-quota-gauge-cell">
|
||||
<progress class="dashboard-quota-gauge${over ? ' is-over' : ''}" max="100" value="${Math.min(100, Math.max(0, rate))}" aria-hidden="true"></progress>
|
||||
<span>${rate} %</span>
|
||||
</td>
|
||||
<td class="dashboard-quota-action-cell">
|
||||
<a class="button button-secondary" href="${href}">Ouvrir</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openPendingPlanning(item) {
|
||||
if (!item) return;
|
||||
const params = new URLSearchParams({
|
||||
structure_id: String(item.id_structure),
|
||||
agent_id: String(item.id_agent),
|
||||
week: weekValueOf(item),
|
||||
focus: 'validation',
|
||||
notice: `Planning de ${item.agent_prenom} ${item.agent_nom} ouvert depuis le tableau de bord.`,
|
||||
});
|
||||
window.location.href = `planning.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function load(options = {}) {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
if (!options.silent) setMessage('');
|
||||
if (elements.loading) elements.loading.hidden = false;
|
||||
if (elements.refresh) elements.refresh.disabled = true;
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
const week = weekParam();
|
||||
if (week) query.set('week', week);
|
||||
|
||||
const response = await fetch(`api/dashboard_summary.php?${query.toString()}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await readJsonResponse(response);
|
||||
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le tableau de bord.'));
|
||||
|
||||
renderWeek(data.semaine);
|
||||
renderPending(data.validation || {});
|
||||
renderCoverage(data.couverture || {}, data.semaine);
|
||||
renderQuota(data.quota || {});
|
||||
} catch (error) {
|
||||
setMessage(error.message, 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
if (elements.loading) elements.loading.hidden = true;
|
||||
if (elements.refresh) elements.refresh.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
elements.refresh?.addEventListener('click', () => load());
|
||||
elements.week?.addEventListener('change', () => load());
|
||||
elements.pendingList?.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest('[data-planning-id]');
|
||||
if (!trigger) return;
|
||||
const id = Number(trigger.dataset.planningId);
|
||||
openPendingPlanning(pendingItems.find((item) => Number(item.id_planning) === id));
|
||||
});
|
||||
|
||||
load({ silent: true });
|
||||
}
|
||||
|
||||
PTA.modules.dashboard = { init, load, refresh: load };
|
||||
})();
|
||||
@@ -73,8 +73,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastille « À valider » de la barre de navigation.
|
||||
*
|
||||
* Elle n'était alimentée que sur la page « À valider » elle-même, donc
|
||||
* invisible là où elle sert : depuis n'importe quel écran, le service voit
|
||||
* maintenant si des brouillons attendent. La requête est volontairement
|
||||
* réduite à un compteur, et son échec est silencieux — une commodité ne
|
||||
* doit jamais empêcher une page de fonctionner.
|
||||
*/
|
||||
async function refreshNavigationCounters() {
|
||||
const badge = document.querySelector('#pending-validation-tab-badge');
|
||||
if (!badge) return;
|
||||
// Les écrans qui chargent déjà ce compteur s'en chargent eux-mêmes.
|
||||
if (PTA.modules.pendingValidation || PTA.modules.dashboard) return;
|
||||
if (!PTA.config?.permissions?.can_view_pending) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('api/dashboard_summary.php?scope=badges', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await PTA.utils.readJsonResponse(response);
|
||||
const count = Number(data?.validation?.count || 0);
|
||||
badge.textContent = String(count);
|
||||
badge.hidden = count === 0;
|
||||
badge.setAttribute('aria-label', `${count} planning${count > 1 ? 's' : ''} en attente de validation`);
|
||||
} catch (error) {
|
||||
badge.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
initialisePlanning();
|
||||
initialiseStructureOverview();
|
||||
initialiseAgentOverview();
|
||||
initialiseCoverage();
|
||||
refreshNavigationCounters();
|
||||
})();
|
||||
|
||||
267
assets/style.css
267
assets/style.css
@@ -2553,3 +2553,270 @@ textarea:disabled {
|
||||
@media print {
|
||||
.annual-validation-card { display: none !important; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PTA V7.9 - Navigation regroupée et tableau de bord
|
||||
============================================================ */
|
||||
|
||||
/* --- Barre de navigation par intention --------------------- */
|
||||
.view-tabs-grouped {
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0.45rem 0.35rem 0.4rem;
|
||||
}
|
||||
|
||||
.view-tab-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.6rem;
|
||||
}
|
||||
|
||||
.view-tabs-grouped .view-tab-group + .view-tab-group {
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.view-tab-group-label {
|
||||
padding-left: 0.35rem;
|
||||
color: #7b8794;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-tab-group-items {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.view-tabs-grouped .view-tab {
|
||||
padding: 0.55rem 0.8rem;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* Sans titres de groupe, la barre retrouve son alignement d'origine. */
|
||||
.view-tabs:not(.view-tabs-grouped) .view-tab-group,
|
||||
.view-tabs:not(.view-tabs-grouped) .view-tab-group-items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.page-header-identity { min-width: 0; }
|
||||
.page-header-identity .subtitle { max-width: 68ch; }
|
||||
|
||||
/* --- Tuiles d'action --------------------------------------- */
|
||||
.dashboard-context-card .section-heading { align-items: flex-end; }
|
||||
.dashboard-context-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.dashboard-week-field { min-width: 190px; }
|
||||
.dashboard-context-note {
|
||||
margin: 14px 0 0;
|
||||
padding: 10px 13px;
|
||||
border-radius: 10px;
|
||||
border-left: 5px solid #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1e3a5f;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dashboard-tile {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e4e8eb;
|
||||
border-left: 6px solid #cbd5e1;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 24px rgba(23, 32, 42, 0.05);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.dashboard-tile:hover,
|
||||
.dashboard-tile:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px rgba(23, 32, 42, 0.11);
|
||||
}
|
||||
.dashboard-tile-label {
|
||||
color: #5f6b7a;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dashboard-tile-value { font-size: 2.1rem; line-height: 1.1; }
|
||||
.dashboard-tile-detail { color: #66737d; font-size: 0.84rem; }
|
||||
.dashboard-tile-cta {
|
||||
margin-top: 6px;
|
||||
color: #1f4c73;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.dashboard-tile-cta::after { content: ' →'; }
|
||||
|
||||
.dashboard-tile.is-alert { border-left-color: #c72f3f; }
|
||||
.dashboard-tile.is-alert .dashboard-tile-value { color: #a3212f; }
|
||||
.dashboard-tile.is-clear { border-left-color: #2f855a; }
|
||||
.dashboard-tile.is-clear .dashboard-tile-value { color: #256d3b; }
|
||||
|
||||
/* --- Listes « à traiter » ---------------------------------- */
|
||||
.dashboard-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.dashboard-columns > .card { margin-bottom: 0; }
|
||||
.dashboard-list-card { display: flex; flex-direction: column; }
|
||||
|
||||
.dashboard-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.dashboard-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #e6ebee;
|
||||
border-radius: 11px;
|
||||
background: #f9fbfc;
|
||||
}
|
||||
.dashboard-list-main { flex: 1 1 auto; min-width: 0; }
|
||||
.dashboard-list-main strong { display: block; }
|
||||
.dashboard-list-main small { color: #66737d; font-size: 0.82rem; }
|
||||
.dashboard-list-meta {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dashboard-list-meta span { color: #66737d; font-size: 0.78rem; }
|
||||
.dashboard-list-action {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.86rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dashboard-gap-count { color: #a3212f; }
|
||||
|
||||
.dashboard-inline-note {
|
||||
margin-top: 14px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 10px;
|
||||
border-left: 5px solid #d97706;
|
||||
background: #fffbeb;
|
||||
color: #7d5c00;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* --- Suivi des quotas -------------------------------------- */
|
||||
.dashboard-quota-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.dashboard-quota-table-wrap { overflow-x: auto; }
|
||||
.dashboard-quota-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.dashboard-quota-table th,
|
||||
.dashboard-quota-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eceff1;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dashboard-quota-table th {
|
||||
color: #5f6b7a;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dashboard-quota-table td small { display: block; color: #66737d; font-size: 0.78rem; }
|
||||
.dashboard-quota-table tr.is-over-quota { background: #fef4f4; }
|
||||
.dashboard-quota-action-cell { text-align: right; }
|
||||
.dashboard-quota-action-cell .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 7px 13px;
|
||||
font-size: 0.84rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-quota-gauge-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Jauge native : la CSP interdit l'attribut style, la largeur ne peut donc pas
|
||||
être calculée en JavaScript sur une simple div. */
|
||||
.dashboard-quota-gauge {
|
||||
width: 110px;
|
||||
height: 9px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #e4e9ec;
|
||||
appearance: none;
|
||||
}
|
||||
.dashboard-quota-gauge::-webkit-progress-bar { background: #e4e9ec; border-radius: 999px; }
|
||||
.dashboard-quota-gauge::-webkit-progress-value { background: #2f6f9f; border-radius: 999px; }
|
||||
.dashboard-quota-gauge::-moz-progress-bar { background: #2f6f9f; border-radius: 999px; }
|
||||
.dashboard-quota-gauge.is-over::-webkit-progress-value { background: #c72f3f; }
|
||||
.dashboard-quota-gauge.is-over::-moz-progress-bar { background: #c72f3f; }
|
||||
|
||||
/* --- Raccourcis -------------------------------------------- */
|
||||
.dashboard-shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.dashboard-shortcut {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e4e8eb;
|
||||
border-radius: 12px;
|
||||
background: #f9fbfc;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dashboard-shortcut:hover,
|
||||
.dashboard-shortcut:focus-visible { border-color: #b9c6cf; background: #fff; }
|
||||
.dashboard-shortcut small { color: #66737d; font-size: 0.82rem; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.view-tabs-grouped { flex-direction: column; align-items: flex-start; gap: 0.6rem; }
|
||||
.view-tabs-grouped .view-tab-group + .view-tab-group { border-left: 0; }
|
||||
.view-tab-group { width: 100%; padding: 0; }
|
||||
.view-tab-group-items { flex-wrap: wrap; }
|
||||
.dashboard-context-card .section-heading { align-items: stretch; }
|
||||
.dashboard-context-actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.dashboard-list-item { flex-wrap: wrap; }
|
||||
.dashboard-list-meta { text-align: left; }
|
||||
.dashboard-list-action { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
7
dashboard.php
Normal file
7
dashboard.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
|
||||
runPage('dashboard');
|
||||
26
database/migration_v7_9_tableau_de_bord.sql
Normal file
26
database/migration_v7_9_tableau_de_bord.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- ============================================================================
|
||||
-- PTA V7.9 — Tableau de bord du Service Enfance
|
||||
-- ============================================================================
|
||||
-- À exécuter UNE SEULE FOIS sur une base PTA V7.8 existante :
|
||||
-- SOURCE database/migration_v7_9_tableau_de_bord.sql;
|
||||
--
|
||||
-- La page « dashboard » suit la règle posée en V7.8 : une page absente de
|
||||
-- acces_role_page est refusée. Tant que cette migration n'est pas jouée,
|
||||
-- l'onglet n'apparaît pas et le Service Enfance atterrit comme avant sur
|
||||
-- l'écran de saisie — l'application reste fonctionnelle, sans tableau de bord.
|
||||
--
|
||||
-- Aucune table, aucune vue, aucune donnée métier n'est modifiée : le tableau de
|
||||
-- bord ne fait que relire ce que les autres écrans calculent déjà.
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
|
||||
('SERVICE_ENFANCE', 'dashboard', 5)
|
||||
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
|
||||
|
||||
-- ============================================================================
|
||||
-- Vérification après import :
|
||||
-- SELECT * FROM v_acces_role_page WHERE role = 'SERVICE_ENFANCE' ORDER BY ordre_affichage;
|
||||
--
|
||||
-- Retour arrière :
|
||||
-- DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard';
|
||||
-- ============================================================================
|
||||
Reference in New Issue
Block a user