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) => ( + + ))} + + + + {[...series.points].reverse().map((point) => ( + + + + + + + + ))} + +
+ {heading} +
+ + {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) => ( + + ), + )} + + + + {[...series.points].reverse().map((point) => ( + + + + + + + + + ))} + +
+ {heading} +
+ {/* 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) => ( + + ), + )} + + + + {[...series.points].reverse().map((point) => { + const delta = point.actualMinutes - point.plannedMinutes; + return ( + + + + + + + + ); + })} + +
+ {heading} +
{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); +}