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>
311 lines
12 KiB
PHP
311 lines
12 KiB
PHP
<?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;
|
|
}
|
|
}
|