Reprise dans le répertoire de production du travail mené dans le worktree ux-service-enfance : - tableau de bord Service Enfance (dashboard.php) et navigation regroupée ; - guide d'utilisation (faq.php) accessible depuis toute la navigation ; - éditeur de semaine type mis en avant sur l'écran de saisie ; - droit à l'enveloppe de formation par agent, en remplacement de la case « formation déjà prévue dans la répartition annuelle » ; - correctif de « [object Object] » sur les cartes de quota du PTA ; - motif de travail par défaut à la saisie, heures travaillées complètes dans les PDF. Le docker-compose.yml de production est conservé : celui du worktree décrit une pile de test (conteneurs et volume suffixés « test », ports 9080/9081), qui aurait fait redémarrer l'application sur un volume MariaDB vide. Les migrations database/migration_v7_9*.sql sont obligatoires ; le service db-migrate les applique au déploiement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
12 KiB
PHP
303 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 et combien
|
|
* n'ont encore aucune heure planifiée. Les cumuls tous agents confondus
|
|
* (consommation globale, reste à affecter) ne sont volontairement pas exposés :
|
|
* une somme d'agents aux quotités différentes ne décrit aucune situation réelle.
|
|
*/
|
|
private function quotaPayload(int $year): array
|
|
{
|
|
$tracking = $this->dashboardModel->quotaTracking($year);
|
|
$overCount = 0;
|
|
$emptyCount = 0;
|
|
|
|
foreach ($tracking as $agent) {
|
|
if ($agent['minutes_restantes'] < 0) {
|
|
$overCount++;
|
|
}
|
|
if ($agent['minutes_affectees'] === 0) {
|
|
$emptyCount++;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
'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;
|
|
}
|
|
}
|