diff --git a/src/app/(app)/rh/analyses/AnalysisShell.tsx b/src/app/(app)/rh/analyses/AnalysisShell.tsx
new file mode 100644
index 0000000..cbcd6da
--- /dev/null
+++ b/src/app/(app)/rh/analyses/AnalysisShell.tsx
@@ -0,0 +1,105 @@
+import Link from 'next/link';
+
+import { PageBody, PageHeader } from '@/components/shell/PageHeader';
+import { Card, CardHeader } from '@/components/ui/Card';
+import type { AnalyticsSeries } from '@/server/hr/analytics';
+
+/**
+ * Cadre commun aux trois analyses.
+ *
+ * Même en-tête, même sélecteur d'établissement, mêmes onglets : trois écrans
+ * qui répondent à trois questions sur le même jeu de données n'ont pas de
+ * raison de se présenter différemment.
+ */
+
+export const ANALYSES = [
+ { key: 'effectifs', href: '/rh/analyses/effectifs', label: 'Effectifs' },
+ { key: 'heures', href: '/rh/analyses/heures', label: 'Heures' },
+ { key: 'absences', href: '/rh/analyses/absences', label: 'Absences' },
+] as const;
+
+export type AnalysisKey = (typeof ANALYSES)[number]['key'];
+
+export function AnalysisShell({
+ current,
+ title,
+ subtitle,
+ series,
+ children,
+ footnote,
+}: {
+ current: AnalysisKey;
+ title: string;
+ subtitle: string;
+ series: AnalyticsSeries;
+ children: React.ReactNode;
+ footnote: string;
+}) {
+ const withLocation = (href: string) =>
+ series.location ? `${href}?etablissement=${series.location.id}` : href;
+
+ return (
+
+
+ {ANALYSES.map((analysis) => (
+
+ {analysis.label}
+
+ ))}
+
+ }
+ />
+
+ {series.locations.length > 1 ? (
+
+
+
+ {series.locations.map((location) => (
+ a.key === current)?.href}?etablissement=${location.id}`}
+ aria-current={
+ location.id === series.location?.id ? 'page' : undefined
+ }
+ className={
+ location.id === series.location?.id
+ ? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
+ : 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
+ }
+ >
+ {location.name}
+
+ ))}
+
+
+ ) : null}
+
+ {children}
+
+
+ {footnote}
+
+
+ );
+}
+
+/** Mois abrégé, pour l'axe du graphique. « septembre 2026 » → « sept. 26 ». */
+export function shortMonth(label: string): string {
+ const [name, year] = label.split(' ');
+ if (!name || !year) return label;
+ const abbreviated = name.length > 4 ? `${name.slice(0, 4)}.` : name;
+ return `${abbreviated} ${year.slice(2)}`;
+}
diff --git a/src/app/(app)/rh/analyses/absences/page.tsx b/src/app/(app)/rh/analyses/absences/page.tsx
new file mode 100644
index 0000000..8620e94
--- /dev/null
+++ b/src/app/(app)/rh/analyses/absences/page.tsx
@@ -0,0 +1,100 @@
+import Link from 'next/link';
+
+import {
+ AnalysisShell,
+ shortMonth,
+} from '@/app/(app)/rh/analyses/AnalysisShell';
+import { SeriesChart } from '@/components/hr/SeriesChart';
+import { Card, CardHeader } from '@/components/ui/Card';
+import { monthOf } from '@/domain/planning/month';
+import { getAnalyticsSeries } from '@/server/hr/analytics';
+
+export const metadata = { title: 'Analyse des absences · PlanFlow' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+ searchParams: Promise<{ etablissement?: string }>;
+}
+
+export default async function AbsencesAnalysePage({ searchParams }: PageProps) {
+ const { etablissement } = await searchParams;
+ const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
+ const last = series.points[series.points.length - 1];
+
+ return (
+
+
+
+ ({
+ label: point.label,
+ short: shortMonth(point.label),
+ value: point.absenteeismRate,
+ }))}
+ format={(value) => `${value} %`}
+ />
+
+
+
+
+
+
+
+
+ {[
+ 'Mois',
+ 'Jours d’absence',
+ 'dont arrêts',
+ 'Jours théoriques',
+ 'Taux',
+ ].map((heading) => (
+
+ {heading}
+
+ ))}
+
+
+
+ {[...series.points].reverse().map((point) => (
+
+
+
+ {point.label}
+
+
+ {point.absenceDays}
+ {point.sickDays}
+
+ {point.theoreticalWorkedDays}
+
+
+ {point.absenteeismRate === null
+ ? '—'
+ : `${point.absenteeismRate} %`}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/rh/analyses/effectifs/page.tsx b/src/app/(app)/rh/analyses/effectifs/page.tsx
new file mode 100644
index 0000000..9ea45dc
--- /dev/null
+++ b/src/app/(app)/rh/analyses/effectifs/page.tsx
@@ -0,0 +1,93 @@
+import Link from 'next/link';
+
+import {
+ AnalysisShell,
+ shortMonth,
+} from '@/app/(app)/rh/analyses/AnalysisShell';
+import { SeriesChart } from '@/components/hr/SeriesChart';
+import { Card, CardHeader } from '@/components/ui/Card';
+import { monthOf } from '@/domain/planning/month';
+import { getAnalyticsSeries } from '@/server/hr/analytics';
+
+export const metadata = { title: 'Analyse des effectifs · PlanFlow' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+ searchParams: Promise<{ etablissement?: string }>;
+}
+
+export default async function EffectifsPage({ searchParams }: PageProps) {
+ const { etablissement } = await searchParams;
+ const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
+ const last = series.points[series.points.length - 1];
+
+ return (
+
+
+
+ ({
+ label: point.label,
+ short: shortMonth(point.label),
+ value: point.closingHeadcount,
+ }))}
+ format={(value) => String(value)}
+ />
+
+
+
+
+
+
+
+
+ {['Mois', 'Fin de mois', 'Moyen', 'Entrées', 'Sorties', 'Rotation'].map(
+ (heading) => (
+
+ {heading}
+
+ ),
+ )}
+
+
+
+ {[...series.points].reverse().map((point) => (
+
+
+ {/* Chaque chiffre mène à ses lignes sources : un indicateur
+ qu'on ne peut pas ouvrir ne se corrige pas, il se
+ conteste. */}
+
+ {point.label}
+
+
+ {point.closingHeadcount}
+ {point.averageHeadcount}
+ {point.entries}
+ {point.exits}
+
+ {point.turnoverRate === null
+ ? '—'
+ : `${point.turnoverRate} %`}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/rh/analyses/heures/page.tsx b/src/app/(app)/rh/analyses/heures/page.tsx
new file mode 100644
index 0000000..23b37d6
--- /dev/null
+++ b/src/app/(app)/rh/analyses/heures/page.tsx
@@ -0,0 +1,99 @@
+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 (
+
+
+
+ ({
+ label: point.label,
+ short: shortMonth(point.label),
+ value: Math.round(point.plannedMinutes / 60),
+ }))}
+ format={(value) => `${value} h`}
+ />
+
+
+
+ 12 mois}
+ />
+
+
+
+
+ {['Mois', 'Planifié', 'Réalisé', 'Écart', 'Effectif moyen'].map(
+ (heading) => (
+
+ {heading}
+
+ ),
+ )}
+
+
+
+ {[...series.points].reverse().map((point) => {
+ const delta = point.actualMinutes - point.plannedMinutes;
+ return (
+
+ {point.label}
+
+ {formatMinutes(point.plannedMinutes)}
+
+
+ {formatMinutes(point.actualMinutes)}
+
+
+ {delta === 0 ? (
+ '—'
+ ) : (
+ 0 ? 'warn' : 'info'}>
+ {delta > 0 ? '+' : '−'}
+ {formatMinutes(Math.abs(delta))}
+
+ )}
+
+ {point.averageHeadcount}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/components/hr/SeriesChart.tsx b/src/components/hr/SeriesChart.tsx
new file mode 100644
index 0000000..369549e
--- /dev/null
+++ b/src/components/hr/SeriesChart.tsx
@@ -0,0 +1,76 @@
+import { cx } from '@/lib/cx';
+
+/**
+ * Histogramme mensuel, en HTML.
+ *
+ * Pas de bibliothèque de graphiques : douze barres et une échelle ne justifient
+ * pas 90 ko de dépendance, et la CSP interdit de toute façon toute origine
+ * tierce. Le tableau reste la source — le graphique n'est qu'une lecture rapide
+ * posée devant lui.
+ *
+ * **La hauteur ne porte jamais l'information seule** : chaque barre affiche sa
+ * valeur, et l'ensemble est doublé d'un tableau lisible au lecteur d'écran.
+ */
+
+export interface SeriesPoint {
+ label: string;
+ value: number | null;
+ /** Libellé affiché sous la barre — le mois abrégé. */
+ short: string;
+}
+
+export function SeriesChart({
+ points,
+ format,
+ tone = 'accent',
+}: {
+ points: SeriesPoint[];
+ format: (value: number) => string;
+ tone?: 'accent' | 'warn' | 'info';
+}) {
+ const values = points
+ .map((point) => point.value)
+ .filter((value): value is number => value !== null);
+ const max = values.length > 0 ? Math.max(...values) : 0;
+
+ const bar = {
+ accent: 'bg-accent',
+ warn: 'bg-warn',
+ info: 'bg-info',
+ }[tone];
+
+ return (
+
+ {points.map((point) => {
+ // Une valeur nulle n'est pas zéro : un taux non calculable sur un
+ // effectif vide ne doit pas se dessiner comme une absence de mouvement.
+ const height =
+ point.value === null || max === 0
+ ? 0
+ : Math.max(2, Math.round((point.value / max) * 120));
+
+ return (
+
+
+ {point.value === null ? '—' : format(point.value)}
+
+
+
{point.short}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts
index 0fadb59..c4a1e00 100644
--- a/src/components/shell/navigation.ts
+++ b/src/components/shell/navigation.ts
@@ -40,6 +40,21 @@ export const NAVIGATION: NavSection[] = [
label: 'Compteurs de congés payés',
href: '/absences/calendrier#compteurs',
},
+ {
+ id: 'analyse-effectifs',
+ label: 'Analyse des effectifs',
+ href: '/rh/analyses/effectifs',
+ },
+ {
+ id: 'analyse-heures',
+ label: 'Analyse des heures',
+ href: '/rh/analyses/heures',
+ },
+ {
+ id: 'analyse-absences',
+ label: 'Analyse des absences',
+ href: '/rh/analyses/absences',
+ },
],
},
{
diff --git a/src/server/hr/analytics.ts b/src/server/hr/analytics.ts
new file mode 100644
index 0000000..a3864d4
--- /dev/null
+++ b/src/server/hr/analytics.ts
@@ -0,0 +1,232 @@
+import { inScope } from '@/domain/access/authorize';
+import { shiftMinutes } from '@/domain/counters/week';
+import {
+ absenteeism,
+ headcount,
+ turnoverRate,
+ type AbsenceInput,
+ type HeadcountInput,
+} from '@/domain/hr/indicators';
+import {
+ formatMonthParam,
+ monthDates,
+ monthLabel,
+ previousMonth,
+ type Month,
+} from '@/domain/planning/month';
+import { query } from '@/server/context';
+
+/**
+ * Analyses RH — PLAN.md §9, `/rh/analyses/{effectifs,heures,absences}`.
+ *
+ * Une série mensuelle, pas trois écrans qui recalculent chacun leur version.
+ * Les trois analyses lisent le même tableau : c'est ce qui garantit qu'un
+ * effectif moyen affiché en « effectifs » soit le même que le dénominateur du
+ * taux d'absentéisme affiché en « absences ». Deux calculs séparés finiraient
+ * par diverger d'un arrondi, et l'écart se plaide mal devant un CSE.
+ *
+ * Les indicateurs eux-mêmes viennent du domaine, testés aux bornes. Ce module
+ * ne fait que rassembler les données et boucler sur les mois.
+ */
+
+export interface MonthPoint {
+ month: Month;
+ label: string;
+ param: string;
+ closingHeadcount: number;
+ averageHeadcount: number;
+ entries: number;
+ exits: number;
+ turnoverRate: number | null;
+ absenceDays: number;
+ sickDays: number;
+ absenteeismRate: number | null;
+ /** Minutes planifiées, pauses déduites. */
+ plannedMinutes: number;
+ /** Minutes réellement travaillées quand elles ont été saisies. */
+ actualMinutes: number;
+ theoreticalWorkedDays: number;
+}
+
+export interface AnalyticsSeries {
+ location: { id: string; name: string } | null;
+ locations: Array<{ id: string; name: string }>;
+ /** Du plus ancien au plus récent : un graphique se lit dans ce sens. */
+ points: MonthPoint[];
+}
+
+/** Jours ouvrables d'un mois — base théorique de l'absentéisme (dimanche exclu). */
+function workingDaysIn(dates: string[]): number {
+ return dates.filter(
+ (date) => new Date(`${date}T00:00:00Z`).getUTCDay() !== 0,
+ ).length;
+}
+
+function monthsEndingWith(last: Month, count: number): Month[] {
+ const months: Month[] = [last];
+ for (let index = 1; index < count; index += 1) {
+ months.unshift(previousMonth(months[0] as Month));
+ }
+ return months;
+}
+
+export async function getAnalyticsSeries(
+ lastMonth: Month,
+ monthCount = 12,
+ locationId?: string,
+): Promise {
+ return query('members.view', async (db, actor) => {
+ const allLocations = await db.location.findMany({
+ where: { archivedAt: null },
+ select: { id: true, name: true },
+ orderBy: { name: 'asc' },
+ });
+
+ // Le périmètre s'applique avant l'agrégation : un total consolidé ne doit
+ // pas laisser deviner l'effectif d'un établissement qu'on n'a pas le droit
+ // de lire.
+ const visible = allLocations.filter((location) =>
+ inScope(actor, { locationId: location.id }),
+ );
+ const location =
+ visible.find((candidate) => candidate.id === locationId) ??
+ visible[0] ??
+ null;
+
+ const months = monthsEndingWith(lastMonth, monthCount);
+ const firstDate = monthDates(months[0] as Month)[0] as string;
+ const lastDates = monthDates(months[months.length - 1] as Month);
+ const lastDate = lastDates[lastDates.length - 1] as string;
+
+ const locationFilter = location ? { locationId: location.id } : {};
+
+ // Une seule lecture par nature, puis un découpage en mémoire. Douze
+ // requêtes par indicateur donneraient le même résultat en douze fois plus
+ // de temps, et rendraient l'écran inutilisable sur une année glissante.
+ const contracts = await db.userContract.findMany({
+ where: locationFilter,
+ select: {
+ membershipId: true,
+ startDate: true,
+ endDate: true,
+ },
+ });
+
+ const contractInputs: HeadcountInput[] = contracts.map((contract) => ({
+ membershipId: contract.membershipId,
+ startDate: contract.startDate.toISOString().slice(0, 10),
+ endDate: contract.endDate?.toISOString().slice(0, 10) ?? null,
+ }));
+
+ const timeOffs = await db.timeOff.findMany({
+ where: {
+ status: 'ACCEPTED',
+ startDate: { lte: new Date(`${lastDate}T00:00:00Z`) },
+ endDate: { gte: new Date(`${firstDate}T00:00:00Z`) },
+ },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ countedDays: true,
+ absenceType: { select: { isSocialSecurity: true } },
+ },
+ });
+
+ const shifts = await db.shift.findMany({
+ where: {
+ localDate: {
+ gte: new Date(`${firstDate}T00:00:00Z`),
+ lte: new Date(`${lastDate}T00:00:00Z`),
+ },
+ },
+ select: {
+ localDate: true,
+ startAt: true,
+ endAt: true,
+ breakMinutes: true,
+ actualStartAt: true,
+ actualEndAt: true,
+ actualBreakMinutes: true,
+ },
+ });
+
+ const points = months.map((month) => {
+ const dates = monthDates(month);
+ const from = dates[0] as string;
+ const to = dates[dates.length - 1] as string;
+
+ const counts = headcount(contractInputs, from, to);
+
+ // Une absence à cheval sur deux mois est rattachée au mois de son début :
+ // `countedDays` a été figé à la décision et ne se redécoupe pas sans
+ // refaire le décompte des fériés, ce qui produirait un autre chiffre que
+ // celui écrit au registre des compteurs.
+ const monthAbsences: AbsenceInput[] = timeOffs
+ .filter((entry) => {
+ const start = entry.startDate.toISOString().slice(0, 10);
+ return start >= from && start <= to;
+ })
+ .map((entry) => ({
+ membershipId: entry.id,
+ timeOffId: entry.id,
+ days: Number(entry.countedDays?.toString() ?? '0'),
+ isSocialSecurity: entry.absenceType.isSocialSecurity,
+ }));
+
+ const theoreticalWorkedDays =
+ workingDaysIn(dates) * Math.round(counts.average);
+ const absence = absenteeism(monthAbsences, theoreticalWorkedDays);
+
+ const monthShifts = shifts.filter((shift) => {
+ const day = shift.localDate.toISOString().slice(0, 10);
+ return day >= from && day <= to;
+ });
+
+ const plannedMinutes = monthShifts.reduce(
+ (sum, shift) =>
+ sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
+ 0,
+ );
+
+ const actualMinutes = monthShifts.reduce((sum, shift) => {
+ if (!shift.actualStartAt || !shift.actualEndAt) {
+ // Sans heures réelles saisies, le prévu fait foi (PLAN.md §7.3) :
+ // laisser un zéro creuserait un écart qui n'existe pas.
+ return sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
+ }
+ return (
+ sum +
+ shiftMinutes(
+ shift.actualStartAt,
+ shift.actualEndAt,
+ shift.actualBreakMinutes ?? shift.breakMinutes,
+ )
+ );
+ }, 0);
+
+ return {
+ month,
+ label: monthLabel(month),
+ param: formatMonthParam(month),
+ closingHeadcount: counts.closing,
+ averageHeadcount: counts.average,
+ entries: counts.entries.length,
+ exits: counts.exits.length,
+ turnoverRate: turnoverRate(counts),
+ absenceDays: absence.totalDays,
+ sickDays: absence.sickDays,
+ absenteeismRate: absence.rate,
+ plannedMinutes,
+ actualMinutes,
+ theoreticalWorkedDays,
+ };
+ });
+
+ return {
+ location: location ? { id: location.id, name: location.name } : null,
+ locations: visible,
+ points,
+ };
+ }, locationId ? { locationId } : undefined);
+}