V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
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>
This commit is contained in:
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user