Le tableau de bord montrait un mois. Un effectif, un taux de rotation ou un absentéisme lus sur un seul mois ne disent rien : c'est la pente qui informe, et elle demande une série. Trois analyses sur douze mois glissants — effectifs, heures, absences — servies par une seule requête. C'est ce qui garantit que l'effectif moyen affiché en « effectifs » soit exactement le dénominateur du taux d'absentéisme affiché en « absences ». Trois calculs séparés finiraient par diverger d'un arrondi, et l'écart se plaide mal devant un CSE. Les mois se découpent en mémoire après une lecture par nature, pas en douze requêtes par indicateur : sur une année glissante, la seconde approche rend l'écran inutilisable. Le graphique est en HTML. Douze barres ne justifient pas 90 ko de dépendance, et la CSP interdit de toute façon toute origine tierce. La hauteur ne porte jamais l'information seule — chaque barre affiche sa valeur, et le tableau reste la source. Une valeur non calculable s'y dessine en gris plutôt qu'à zéro : « 0 % de rotation » sur un établissement vide est une affirmation fausse, pas une absence de mouvement. Le mois de chaque ligne mène à ses lignes sources. Un indicateur qu'on ne peut pas ouvrir ne se corrige pas, il se conteste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
3.7 KiB
TypeScript
100 lines
3.7 KiB
TypeScript
import {
|
||
AnalysisShell,
|
||
shortMonth,
|
||
} from '@/app/(app)/rh/analyses/AnalysisShell';
|
||
import { SeriesChart } from '@/components/hr/SeriesChart';
|
||
import { Badge } from '@/components/ui/Badge';
|
||
import { Card, CardHeader } from '@/components/ui/Card';
|
||
import { formatMinutes } from '@/domain/counters/week';
|
||
import { monthOf } from '@/domain/planning/month';
|
||
import { getAnalyticsSeries } from '@/server/hr/analytics';
|
||
|
||
export const metadata = { title: 'Analyse des heures · PlanFlow' };
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
interface PageProps {
|
||
searchParams: Promise<{ etablissement?: string }>;
|
||
}
|
||
|
||
export default async function HeuresPage({ searchParams }: PageProps) {
|
||
const { etablissement } = await searchParams;
|
||
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
|
||
const last = series.points[series.points.length - 1];
|
||
|
||
return (
|
||
<AnalysisShell
|
||
current="heures"
|
||
title="Heures"
|
||
subtitle={
|
||
last ? `${formatMinutes(last.plannedMinutes)} planifiées ce mois` : '—'
|
||
}
|
||
series={series}
|
||
footnote="Le prévu et le réalisé sont deux états distincts du temps, et l’écart entre les deux est ce qu’un contrôle demande à justifier. Quand aucune heure réelle n’a été saisie sur un créneau, le prévu fait foi : laisser un zéro creuserait un écart qui n’existe pas."
|
||
>
|
||
<Card>
|
||
<CardHeader title="Heures planifiées par mois" />
|
||
<SeriesChart
|
||
points={series.points.map((point) => ({
|
||
label: point.label,
|
||
short: shortMonth(point.label),
|
||
value: Math.round(point.plannedMinutes / 60),
|
||
}))}
|
||
format={(value) => `${value} h`}
|
||
/>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader
|
||
title="Prévu et réalisé"
|
||
badge={<Badge tone="neutral">12 mois</Badge>}
|
||
/>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-line-1 text-left">
|
||
{['Mois', 'Planifié', 'Réalisé', 'Écart', 'Effectif moyen'].map(
|
||
(heading) => (
|
||
<th
|
||
key={heading}
|
||
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||
>
|
||
{heading}
|
||
</th>
|
||
),
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-line-1">
|
||
{[...series.points].reverse().map((point) => {
|
||
const delta = point.actualMinutes - point.plannedMinutes;
|
||
return (
|
||
<tr key={point.param}>
|
||
<td className="px-4 py-2">{point.label}</td>
|
||
<td className="px-4 py-2 tnum">
|
||
{formatMinutes(point.plannedMinutes)}
|
||
</td>
|
||
<td className="px-4 py-2 tnum">
|
||
{formatMinutes(point.actualMinutes)}
|
||
</td>
|
||
<td className="px-4 py-2 tnum">
|
||
{delta === 0 ? (
|
||
'—'
|
||
) : (
|
||
<Badge tone={delta > 0 ? 'warn' : 'info'}>
|
||
{delta > 0 ? '+' : '−'}
|
||
{formatMinutes(Math.abs(delta))}
|
||
</Badge>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</AnalysisShell>
|
||
);
|
||
}
|