Donner aux indicateurs RH leur profondeur temporelle
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>
This commit is contained in:
1 parent
1519b6bf9d
commit
5b60e7ddb6
7 files changed
+720
No files matched your search
@@ -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 (
|
||||||
|
<PageBody>
|
||||||
|
<PageHeader
|
||||||
|
title={title}
|
||||||
|
subtitle={`${series.location?.name ?? 'Tous établissements'} · ${subtitle}`}
|
||||||
|
actions={
|
||||||
|
<nav aria-label="Analyses" className="flex gap-1">
|
||||||
|
{ANALYSES.map((analysis) => (
|
||||||
|
<Link
|
||||||
|
key={analysis.key}
|
||||||
|
href={withLocation(analysis.href)}
|
||||||
|
aria-current={analysis.key === current ? 'page' : undefined}
|
||||||
|
className={
|
||||||
|
analysis.key === current
|
||||||
|
? 'flex h-8 items-center rounded-2 border border-line-3 bg-surface-2 px-3 text-sm font-medium text-ink-1'
|
||||||
|
: 'flex h-8 items-center rounded-2 px-3 text-sm text-ink-2 hover:bg-surface-2'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{analysis.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{series.locations.length > 1 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Établissement" />
|
||||||
|
<div className="flex flex-wrap gap-1 px-4 py-3">
|
||||||
|
{series.locations.map((location) => (
|
||||||
|
<Link
|
||||||
|
key={location.id}
|
||||||
|
href={`${ANALYSES.find((a) => 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}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||||
|
{footnote}
|
||||||
|
</p>
|
||||||
|
</PageBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)}`;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AnalysisShell
|
||||||
|
current="absences"
|
||||||
|
title="Absences"
|
||||||
|
subtitle={
|
||||||
|
last?.absenteeismRate === null || last === undefined
|
||||||
|
? 'Taux non calculable'
|
||||||
|
: `${last.absenteeismRate} % d’absentéisme ce mois`
|
||||||
|
}
|
||||||
|
series={series}
|
||||||
|
footnote="Le taux rapporte les jours d’absence aux jours théoriquement travaillés, pas aux jours calendaires : rapporter à trente donnerait un taux artificiellement bas et ferait passer un problème réel pour du bruit. La colonne « dont arrêts » agrège les absences relevant de la Sécurité sociale sans jamais nommer de salarié — un taux ne doit pas devenir un moyen d’identifier qui est malade."
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Taux d’absentéisme mensuel" />
|
||||||
|
<SeriesChart
|
||||||
|
tone="warn"
|
||||||
|
points={series.points.map((point) => ({
|
||||||
|
label: point.label,
|
||||||
|
short: shortMonth(point.label),
|
||||||
|
value: point.absenteeismRate,
|
||||||
|
}))}
|
||||||
|
format={(value) => `${value} %`}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Détail mensuel" />
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line-1 text-left">
|
||||||
|
{[
|
||||||
|
'Mois',
|
||||||
|
'Jours d’absence',
|
||||||
|
'dont arrêts',
|
||||||
|
'Jours théoriques',
|
||||||
|
'Taux',
|
||||||
|
].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) => (
|
||||||
|
<tr key={point.param}>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={`/?mois=${point.param}&liste=absences${series.location ? `&etablissement=${series.location.id}` : ''}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{point.label}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 tnum">{point.absenceDays}</td>
|
||||||
|
<td className="px-4 py-2 tnum">{point.sickDays}</td>
|
||||||
|
<td className="px-4 py-2 tnum">
|
||||||
|
{point.theoreticalWorkedDays}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 tnum">
|
||||||
|
{point.absenteeismRate === null
|
||||||
|
? '—'
|
||||||
|
: `${point.absenteeismRate} %`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</AnalysisShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AnalysisShell
|
||||||
|
current="effectifs"
|
||||||
|
title="Effectifs"
|
||||||
|
subtitle={`${last?.closingHeadcount ?? 0} salariés en fin de mois`}
|
||||||
|
series={series}
|
||||||
|
footnote="Le taux de rotation rapporte la moyenne des entrées et des sorties à l’effectif moyen : ne compter que les départs sous-estimerait une équipe qui recrute autant qu’elle perd. Sur un effectif nul il n’est pas calculé — « 0 % » y serait une affirmation fausse, pas une absence de mouvement."
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Effectif en fin de mois" />
|
||||||
|
<SeriesChart
|
||||||
|
points={series.points.map((point) => ({
|
||||||
|
label: point.label,
|
||||||
|
short: shortMonth(point.label),
|
||||||
|
value: point.closingHeadcount,
|
||||||
|
}))}
|
||||||
|
format={(value) => String(value)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Détail mensuel" />
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line-1 text-left">
|
||||||
|
{['Mois', 'Fin de mois', 'Moyen', 'Entrées', 'Sorties', 'Rotation'].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) => (
|
||||||
|
<tr key={point.param}>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{/* Chaque chiffre mène à ses lignes sources : un indicateur
|
||||||
|
qu'on ne peut pas ouvrir ne se corrige pas, il se
|
||||||
|
conteste. */}
|
||||||
|
<Link
|
||||||
|
href={`/?mois=${point.param}&liste=entrees${series.location ? `&etablissement=${series.location.id}` : ''}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{point.label}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 tnum">{point.closingHeadcount}</td>
|
||||||
|
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
|
||||||
|
<td className="px-4 py-2 tnum">{point.entries}</td>
|
||||||
|
<td className="px-4 py-2 tnum">{point.exits}</td>
|
||||||
|
<td className="px-4 py-2 tnum">
|
||||||
|
{point.turnoverRate === null
|
||||||
|
? '—'
|
||||||
|
: `${point.turnoverRate} %`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</AnalysisShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="flex items-end gap-1.5 overflow-x-auto px-4 py-4"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={point.label}
|
||||||
|
className="flex min-w-[3.25rem] flex-1 flex-col items-center gap-1"
|
||||||
|
>
|
||||||
|
<span className="tnum text-micro text-ink-2">
|
||||||
|
{point.value === null ? '—' : format(point.value)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'w-full rounded-t-2',
|
||||||
|
point.value === null ? 'bg-surface-3' : bar,
|
||||||
|
)}
|
||||||
|
style={{ height: `${height}px` }}
|
||||||
|
/>
|
||||||
|
<span className="text-micro text-ink-3">{point.short}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,21 @@ export const NAVIGATION: NavSection[] = [
|
|||||||
label: 'Compteurs de congés payés',
|
label: 'Compteurs de congés payés',
|
||||||
href: '/absences/calendrier#compteurs',
|
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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<AnalyticsSeries> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user