WP-09 : tableau de bord RH, et disparition des données de démonstration

`src/lib/demo` n'existe plus. Aucun écran de PlanFlow ne lit désormais autre
chose que la base.

Un indicateur doit être explicable
Chaque tuile est un lien vers ses lignes sources : profils incomplets, fins de
période d'essai, titres de séjour, entrées, sorties, avenants, journal des
absences. Un chiffre qu'on ne peut pas ouvrir ne se corrige pas — il se
conteste. Et chaque ligne mène à la fiche du salarié.

Les manques sont **nommés**, pas comptés : « 6 profils incomplets » n'aide
personne à agir, « il manque l'IBAN de trois salariés » se règle en un message.
Le NIR et l'IBAN sont contrôlés par la présence de leur colonne chiffrée, jamais
déchiffrés — savoir qu'une valeur existe n'exige pas de la lire.

Des chiffres qui refusent de mentir
- La rotation moyenne entrées et sorties : compter seulement les départs
  sous-estime la rotation d'une équipe qui recrute autant qu'elle perd.
- Sur un effectif nul, elle rend `—` et non « 0 % ». Zéro pour cent de rotation
  sur un établissement vide est une affirmation fausse, pas une absence de
  mouvement.
- L'absentéisme se rapporte aux jours **théoriquement travaillés**, pas aux
  jours calendaires : rapporter à 30 jours ferait passer un problème réel pour
  du bruit.
- Un taux horaire absent vaut zéro dans le coût, jamais une estimation :
  afficher un coût inventé serait pire qu'un coût partiel.

Les échéances remontent avant de tomber
Périodes d'essai à 45 jours, titres de séjour à 90. Les échéances **dépassées**
sont conservées et placées en tête : une période d'essai qu'on a laissé filer
est plus urgente qu'une échéance à venir, et la masquer parce qu'elle est passée
est précisément ce qui la rend coûteuse.

Périmètre et confidentialité
Le filtrage par établissement s'applique **avant** l'agrégation : les mouvements
d'un autre établissement ne transparaissent pas, même fondus dans un total. Le
journal des absences ne porte jamais le motif médical — un tableau de bord n'en
a aucun besoin.

Un salarié, qui n'a pas accès à l'annuaire, reçoit un accueil adapté plutôt
qu'une erreur d'autorisation ou un tableau de bord vide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 12:40:19 +00:00
1 parent 19f082b7ef
commit 10a9c923e2
12 files changed
+1360 -227

No files matched your search

+30 -2
View File
@@ -12,9 +12,37 @@ La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative :
## État
**WP-00 — socle** et **implémentation du design** : six écrans sur données de démonstration.
Neuf lots livrés. **Aucun écran ne lit plus de données de démonstration** : le
répertoire `src/lib/demo` a disparu.
**WP-01 — tenancy, identité, autorisation** : en cours. Modèle de données, RLS, journal d'audit append-only, catalogue de 70 capacités, cinq rôles, sessions en base. Les écrans lisent encore `src/lib/demo`.
| Lot | Contenu |
|---|---|
| WP-00 | Socle : Next.js, PostgreSQL, CSP restrictive, CI, sauvegardes |
| WP-01 | Tenancy, identité, autorisation : RLS, audit append-only, 70 capacités, cinq rôles |
| WP-02 | Référentiels et registre de paramétrage juridique |
| WP-03 | Dossiers salariés, contrats et avenants, forfait jours |
| WP-04 | Planning : quatre vues, publication par équipe, impression |
| WP-05 | Moteur de règles de convention, effectif-daté |
| WP-06 | Absences, registre de compteurs, calendrier |
| WP-07 | Heures prévu/réalisé/payé, périodes de paie verrouillables |
| WP-08 | Export Silae, format relevé sur un export réel du dossier |
| WP-09 | Tableau de bord RH : indicateurs explicables |
Restent WP-10 (documents) et WP-11 (communication, optionnel), ainsi que deux
points nommés à l'intérieur des lots livrés : la régularisation automatique sur
la période suivante et l'information au retour d'arrêt.
### Ce qui attend une décision du client
Ces points sont des **signaux d'arrêt** au sens de `PLAN.md` : ils ne se
devinent pas.
- Les **codes d'absence Silae** (`AB-100`, `AB-200`, `AB-300`, `AB-630`) : leur
existence est connue, leur signification non. L'export refuse de tourner tant
que la correspondance n'est pas confirmée.
- Le **régime dominical** applicable aux magasins, qui détermine si la
majoration de 100 % et le repos compensateur sont les bonnes contreparties.
- Les **règles applicables aux mineurs**, sans source primaire au dossier.
## Démarrer
+4 -1
View File
@@ -150,7 +150,10 @@ export default async function CongesPage({ searchParams }: PageProps) {
)}
</section>
<section className="rounded-3 border border-line-1 bg-surface">
<section
id="compteurs"
className="rounded-3 border border-line-1 bg-surface"
>
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Compteurs
</h2>
+263 -97
View File
@@ -1,127 +1,293 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader } from '@/components/ui/Card';
import { ENTRIES, TILES, TODOS } from '@/lib/demo/apercu';
import { Badge, type Tone } from '@/components/ui/Badge';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { cx } from '@/lib/cx';
import { requireSession } from '@/server/context';
import { getDashboard, type ListKey } from '@/server/hr/queries';
export const metadata = { title: 'Aperçu RH · PlanFlow' };
const TILE_SWATCH: Record<string, string> = {
warn: 'bg-warn-soft border-warn',
info: 'bg-info-soft border-info',
accent: 'bg-accent-soft border-accent',
danger: 'bg-danger-soft border-danger',
interface PageProps {
searchParams: Promise<{
mois?: string;
etablissement?: string;
liste?: string;
}>;
}
const LIST_LABELS: Record<ListKey, string> = {
entrees: 'Entrées du mois',
sorties: 'Sorties du mois',
essais: 'Fins de période d’essai',
profils: 'Profils incomplets',
titres: 'Titres de séjour',
absences: 'Journal des absences',
contrats: 'Modifications de contrat',
};
export default function ApercuPage() {
const LIST_KEYS = Object.keys(LIST_LABELS) as ListKey[];
export default async function ApercuPage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
// Un salarié n'a pas accès à l'annuaire : lui servir une erreur
// d'autorisation sur la page d'accueil serait absurde. Il voit son propre
// point d'entrée.
if (!can(session.actor, 'members.view')) {
return <EmployeeHome />;
}
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const board = await getDashboard(month, params.etablissement);
const selected: ListKey = LIST_KEYS.includes(params.liste as ListKey)
? (params.liste as ListKey)
: 'profils';
const href = (
next: Partial<{ mois: string; etablissement: string; liste: string }>,
) => {
const query = new URLSearchParams({
mois: next.mois ?? board.monthParam,
liste: next.liste ?? selected,
});
const location = next.etablissement ?? board.location?.id;
if (location) query.set('etablissement', location);
return `/?${query.toString()}`;
};
return (
<PageBody>
<PageHeader
title="Aperçu RH"
subtitle="Vos tâches en cours sur Nantes Atlantis · lundi 10 août 2026"
subtitle={`${board.location?.name ?? 'Tous établissements'} · ${board.label}`}
actions={
<>
<Button>Exporter</Button>
<Button variant="primary">Nouvelle embauche</Button>
<Link
href={href({ mois: board.previousParam })}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Mois précédent
</Link>
<Link
href={href({ mois: board.nextParam })}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Mois suivant →
</Link>
</>
}
/>
<div className="grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(232px,1fr))]">
{TILES.map((tile) => (
<button
key={tile.label}
type="button"
className="flex cursor-pointer items-center gap-3 rounded-3 border border-line-1 bg-surface px-4 py-3.5 text-left transition-[border-color,box-shadow] duration-[var(--d-1)] ease-organic hover:border-line-3 hover:shadow-e1"
<div className="flex flex-wrap items-center gap-2">
{board.locations.map((location) => (
<Link
key={location.id}
href={href({ etablissement: location.id })}
aria-current={
location.id === board.location?.id ? 'page' : undefined
}
className={
location.id === board.location?.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
<span
aria-hidden
className={`size-8 flex-none rounded-2 border ${TILE_SWATCH[tile.tone] ?? ''}`}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-ink-2">
{tile.label}
</span>
<span className="tnum block text-xl leading-tight font-semibold">
{tile.value}
</span>
</span>
<span aria-hidden className="flex-none text-ink-3">
›
</span>
</button>
{location.name}
</Link>
))}
</div>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(380px,1fr))]">
<Card>
<CardHeader
title="Entrées à venir"
badge={<Badge tone="accent">4</Badge>}
action={
<Button size="sm" variant="ghost">
Voir tout
</Button>
}
/>
<ul>
{ENTRIES.map((entry) => (
<li
key={entry.name}
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0 hover:bg-surface-2"
>
<span
aria-hidden
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{entry.initials}
{/* Chaque tuile est un lien : un indicateur qu'on ne peut pas ouvrir ne
se corrige pas, il se conteste. */}
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{board.tiles.map((tile) => (
<li key={tile.key}>
<Link
href={href({ liste: tile.key })}
aria-current={tile.key === selected ? 'true' : undefined}
className={cx(
'flex h-full flex-col gap-1 rounded-3 border bg-surface p-4 transition-colors hover:bg-surface-2',
tile.key === selected ? 'border-accent' : 'border-line-1',
)}
>
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-ink-1">
{tile.label}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{entry.name}
</span>
<span className="block truncate text-micro text-ink-3">
{entry.job}
</span>
</span>
<span className="tnum flex-none text-xs text-ink-2">
{entry.date}
</span>
<Badge tone={entry.tone}>{entry.state}</Badge>
</li>
))}
</ul>
</Card>
<Badge tone={tile.tone as Tone}>{tile.value}</Badge>
</span>
<span className="text-micro text-ink-3">{tile.hint}</span>
</Link>
</li>
))}
</ul>
<Card>
<CardHeader title="À traiter cette semaine" />
<ul>
{TODOS.map((todo) => (
<li
key={todo.title}
className="flex items-start gap-3 border-b border-line-1 px-4 py-3 last:border-b-0 hover:bg-surface-2"
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Indicator
label="Effectif en fin de mois"
value={String(board.indicators.closingHeadcount)}
hint={`moyenne ${board.indicators.averageHeadcount}`}
/>
<Indicator
label="Rotation"
value={
board.indicators.turnoverRate === null
? '—'
: `${board.indicators.turnoverRate} %`
}
hint={
board.indicators.turnoverRate === null
? 'Effectif nul sur la période'
: 'Moyenne des entrées et sorties'
}
/>
<Indicator
label="Absentéisme"
value={
board.indicators.absenteeismRate === null
? '—'
: `${board.indicators.absenteeismRate} %`
}
hint={`${board.indicators.absenceDays} j dont ${board.indicators.sickDays} j d’arrêt`}
/>
<Indicator
label="Heures planifiées"
value={formatMinutes(board.indicators.plannedMinutes)}
hint={
board.canSeeCost
? board.indicators.labourCost === null
? 'Coût indisponible'
: `coût chargé ${board.indicators.labourCost.toLocaleString('fr-FR')} €`
: 'Coût masqué'
}
/>
</section>
<section className="rounded-3 border border-line-1 bg-surface">
<div className="flex flex-wrap items-center gap-2 border-b border-line-1 px-4 py-2">
<h2 className="text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
{LIST_LABELS[selected]}
</h2>
<span className="text-xs text-ink-3">
{board.lists[selected].length} ligne
{board.lists[selected].length > 1 ? 's' : ''}
</span>
<div className="ml-auto flex flex-wrap gap-1">
{LIST_KEYS.map((key) => (
<Link
key={key}
href={href({ liste: key })}
className={cx(
'rounded-2 px-2 py-0.5 text-micro',
key === selected
? 'bg-accent-soft text-accent-soft-ink'
: 'text-ink-3 hover:bg-surface-2',
)}
>
<span
aria-hidden
className="mt-1.5 size-2 flex-none rounded-full"
style={{ background: todo.dot }}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{todo.title}
{LIST_LABELS[key]}
</Link>
))}
</div>
</div>
{board.lists[selected].length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Rien à signaler sur cette période.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.lists[selected].map((row) => (
<li
key={row.id}
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
>
<Link
href={`/equipe/${row.membershipId}`}
className="text-sm font-medium text-ink-1 underline-offset-2 hover:underline"
>
{row.name}
</Link>
<span className="text-sm text-ink-2">{row.detail}</span>
{row.date ? (
<span className="tnum ml-auto text-xs text-ink-3">
{row.date.split('-').reverse().join('/')}
</span>
<span className="mt-0.5 block text-xs leading-[var(--lh-prose)] text-ink-2">
{todo.detail}
</span>
</span>
<span className="tnum flex-none whitespace-nowrap text-micro text-ink-3">
{todo.when}
</span>
) : null}
</li>
))}
</ul>
</Card>
</div>
)}
</section>
</PageBody>
);
}
function Indicator({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-3 border border-line-1 bg-surface p-4">
<p className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
{label}
</p>
<p className="tnum mt-1 text-2xl font-semibold text-ink-1">{value}</p>
<p className="mt-0.5 text-micro text-ink-3">{hint}</p>
</div>
);
}
/**
* Accueil d'un salarié.
*
* Il n'a ni annuaire ni indicateurs : lui servir une erreur d'autorisation sur
* la page d'accueil serait absurde, et lui montrer un tableau de bord vide le
* serait tout autant.
*/
function EmployeeHome() {
return (
<PageBody>
<PageHeader
title="Aperçu"
subtitle="Vos plannings, vos absences et vos compteurs."
/>
<ul className="grid gap-3 sm:grid-cols-2">
<li>
<Link
href="/planning/semaine"
className="flex h-full flex-col gap-1 rounded-3 border border-line-1 bg-surface p-4 hover:bg-surface-2"
>
<span className="text-sm font-medium text-ink-1">Mon planning</span>
<span className="text-micro text-ink-3">
Les semaines publiées de votre équipe
</span>
</Link>
</li>
<li>
<Link
href="/conges"
className="flex h-full flex-col gap-1 rounded-3 border border-line-1 bg-surface p-4 hover:bg-surface-2"
>
<span className="text-sm font-medium text-ink-1">
Mes absences et compteurs
</span>
<span className="text-micro text-ink-3">
Demander un congé, suivre son solde
</span>
</Link>
</li>
</ul>
</PageBody>
);
}
+8 -5
View File
@@ -29,9 +29,12 @@ export const NAVIGATION: NavSection[] = [
label: 'Aperçu',
items: [
{ id: 'taches', label: 'Tâches RH', href: '/' },
{ id: 'entrees', label: 'Entrées et sorties' },
{ id: 'profils', label: 'Profils incomplets', badge: '6' },
{ id: 'soldes-cp', label: 'Compteurs de congés payés' },
{ id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' },
{ id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' },
{ id: 'titres', label: 'Titres de séjour', href: '/?liste=titres' },
// Ancre plutôt que route : `/conges` appartient à la section Congés, et
// deux entrées allumées pour un même écran rendent la navigation muette.
{ id: 'soldes-cp', label: 'Compteurs de congés payés', href: '/conges#compteurs' },
],
},
{
@@ -42,7 +45,7 @@ export const NAVIGATION: NavSection[] = [
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' },
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
{ id: 'besoins', label: 'Besoins non couverts' },
{ id: 'modeles', label: "Modèles d'ouverture" },
],
},
@@ -59,7 +62,7 @@ export const NAVIGATION: NavSection[] = [
label: 'Congés',
items: [
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente', badge: '3' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente' },
{ id: 'politiques', label: 'Politiques de congés' },
],
},
+248
View File
@@ -0,0 +1,248 @@
/**
* Indicateurs RH — PLAN.md §WP-09.
*
* Une règle gouverne ce module : **un indicateur doit être explicable**. Chaque
* fonction rend donc non seulement un chiffre mais les identifiants qui le
* composent, pour que l'écran puisse mener aux lignes sources. Un taux de
* turnover que personne ne peut ouvrir ne se corrige pas — il se conteste.
*
* Deuxième règle, moins visible mais plus coûteuse quand elle manque : les
* échéances se calculent en **fenêtres**, pas en dates dépassées. Un titre de
* séjour qui expire dans trois semaines doit remonter avant l'expiration, pas
* après.
*/
export interface HeadcountInput {
membershipId: string;
/** Début du contrat, date civile. */
startDate: string;
/** Fin du contrat, `null` si en cours. */
endDate: string | null;
}
export interface HeadcountResult {
/** Effectif présent en fin de période. */
closing: number;
entries: string[];
exits: string[];
/** Effectif moyen : (début + fin) / 2. */
average: number;
}
/**
* Effectif, entrées et sorties sur une période.
*
* Un contrat compte comme présent dès lors qu'il **recouvre** la période : un
* salarié entré le 3 et sorti le 28 a été présent, même s'il n'était là ni au
* premier ni au dernier jour.
*/
export function headcount(
contracts: HeadcountInput[],
from: string,
to: string,
): HeadcountResult {
const present = (isoDate: string) =>
contracts.filter(
(contract) =>
contract.startDate <= isoDate &&
(contract.endDate === null || contract.endDate >= isoDate),
).length;
return {
closing: present(to),
entries: contracts
.filter((contract) => contract.startDate >= from && contract.startDate <= to)
.map((contract) => contract.membershipId),
exits: contracts
.filter(
(contract) =>
contract.endDate !== null &&
contract.endDate >= from &&
contract.endDate <= to,
)
.map((contract) => contract.membershipId),
average: (present(from) + present(to)) / 2,
};
}
/**
* Taux de rotation, en pourcentage.
*
* `(entrées + sorties) / 2 / effectif moyen`. La moyenne des deux mouvements
* est la définition usuelle en France ; compter seulement les départs
* sous-estime la rotation d'une équipe qui recrute autant qu'elle perd.
*
* Rend `null` plutôt que zéro sur un effectif nul : « 0 % de rotation » sur un
* établissement vide est une affirmation fausse, pas une absence de mouvement.
*/
export function turnoverRate(result: HeadcountResult): number | null {
if (result.average === 0) return null;
const movements = (result.entries.length + result.exits.length) / 2;
return Math.round((movements / result.average) * 1000) / 10;
}
export interface AbsenceInput {
membershipId: string;
/** Jours décomptés, tels que figés à la décision. */
days: number;
/** Vrai pour un arrêt relevant de la sécurité sociale. */
isSocialSecurity: boolean;
timeOffId: string;
}
export interface AbsenteeismResult {
/** Jours d'absence, toutes natures. */
totalDays: number;
/** Jours d'arrêt maladie et assimilés. */
sickDays: number;
/** Taux d'absentéisme en pourcentage des jours théoriques. */
rate: number | null;
sourceIds: string[];
}
/**
* Absentéisme sur une période.
*
* Le taux se rapporte aux **jours théoriquement travaillés**, pas aux jours
* calendaires : rapporter à 30 jours donnerait un taux artificiellement bas et
* ferait passer un problème réel pour du bruit.
*/
export function absenteeism(
absences: AbsenceInput[],
theoreticalWorkedDays: number,
): AbsenteeismResult {
const totalDays = absences.reduce((sum, entry) => sum + entry.days, 0);
const sickDays = absences
.filter((entry) => entry.isSocialSecurity)
.reduce((sum, entry) => sum + entry.days, 0);
return {
totalDays: round(totalDays),
sickDays: round(sickDays),
rate:
theoreticalWorkedDays > 0
? Math.round((totalDays / theoreticalWorkedDays) * 1000) / 10
: null,
sourceIds: absences.map((entry) => entry.timeOffId),
};
}
export interface DeadlineInput {
id: string;
membershipId: string;
name: string;
/** Date d'échéance, civile. */
dueDate: string;
label: string;
}
export interface Deadline extends DeadlineInput {
/** Jours restants ; négatif si l'échéance est passée. */
daysLeft: number;
severity: 'PASSED' | 'URGENT' | 'SOON';
}
const DAY_MS = 86_400_000;
/**
* Échéances à venir dans une fenêtre.
*
* Les échéances **dépassées** sont conservées et remontées en tête : une
* période d'essai qu'on a laissé filer est une information plus urgente qu'une
* échéance à venir, et la masquer parce qu'elle est passée est précisément ce
* qui la rend coûteuse.
*/
export function upcomingDeadlines(
entries: DeadlineInput[],
today: string,
windowDays: number,
urgentDays = 15,
): Deadline[] {
const now = new Date(`${today}T00:00:00Z`).getTime();
return entries
.map((entry) => {
const daysLeft = Math.round(
(new Date(`${entry.dueDate}T00:00:00Z`).getTime() - now) / DAY_MS,
);
return {
...entry,
daysLeft,
severity:
daysLeft < 0
? ('PASSED' as const)
: daysLeft <= urgentDays
? ('URGENT' as const)
: ('SOON' as const),
};
})
.filter((entry) => entry.daysLeft <= windowDays)
.sort((a, b) => a.daysLeft - b.daysLeft);
}
export interface ProfileCompleteness {
membershipId: string;
name: string;
/** Champs manquants, nommés pour l'écran. */
missing: string[];
}
export interface ProfileInput {
membershipId: string;
name: string;
birthDate: unknown;
address: unknown;
city: unknown;
phone: unknown;
socialSecurityNumber: unknown;
iban: unknown;
hasContract: boolean;
}
/**
* Champs manquants d'un dossier.
*
* La liste est **nommée**, pas comptée : « 6 profils incomplets » n'aide
* personne à agir, « il manque l'IBAN de trois salariés » se règle en un
* message. Le NIR et l'IBAN sont vérifiés par leur présence chiffrée, jamais
* déchiffrés pour ce contrôle — savoir qu'une valeur existe n'exige pas de la
* lire.
*/
export function profileGaps(profile: ProfileInput): ProfileCompleteness {
const missing: string[] = [];
if (!profile.birthDate) missing.push('date de naissance');
if (!profile.address) missing.push('adresse');
if (!profile.city) missing.push('ville');
if (!profile.phone) missing.push('téléphone');
if (!profile.socialSecurityNumber) missing.push('numéro de sécurité sociale');
if (!profile.iban) missing.push('IBAN');
if (!profile.hasContract) missing.push('contrat');
return {
membershipId: profile.membershipId,
name: profile.name,
missing,
};
}
/**
* Coût de main-d'œuvre — PLAN.md §7.5.
*
* Heures × taux horaire × (1 + taux de cotisations). Le taux patronal est un
* paramètre de l'établissement, jamais une constante : il varie d'un
* établissement à l'autre et d'une année sur l'autre.
*/
export function labourCost(
minutes: number,
hourlyRate: number,
employerContributionPercent: number,
): number {
const hours = minutes / 60;
const gross = hours * hourlyRate;
return Math.round(gross * (1 + employerContributionPercent / 100) * 100) / 100;
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
-16
View File
@@ -1,16 +0,0 @@
# Données de démonstration
**Entièrement fictives.** Aucune donnée réelle de salarié ne doit entrer ici :
ce répertoire est versionné, lisible par tous, et n'offre aucune des garanties
que `PLAN.md` §3.6 exige pour un dossier RH — chiffrement au repos,
journalisation des accès, durées de conservation.
Ce module existe parce que les écrans ont été construits avant WP-01 (tenancy,
identité, base de données). Il tient la forme des données pour que la mise en
page soit réelle plutôt que devinée.
**Remplacement.** Chaque écran importe depuis `@/lib/demo/...` et rien d'autre.
Au fil des lots, ces imports sont remplacés un à un par des requêtes serveur
scopées. Le jour où le répertoire disparaît, il ne reste rien à nettoyer
ailleurs : c'est la seule raison pour laquelle il est isolé plutôt que dispersé
dans les composants.
-103
View File
@@ -1,103 +0,0 @@
import type { Tone } from '@/components/ui/Badge';
export interface DemoTile {
label: string;
value: string;
tone: Tone;
}
export const TILES: DemoTile[] = [
{ label: 'Profils incomplets', value: '6', tone: 'warn' },
{ label: 'Fins de période d’essai', value: '2', tone: 'info' },
{ label: 'Documents à signer', value: '11', tone: 'accent' },
{ label: 'Titres de séjour à renouveler', value: '1', tone: 'danger' },
];
export interface DemoEntry {
initials: string;
name: string;
job: string;
date: string;
state: string;
tone: Tone;
}
export const ENTRIES: DemoEntry[] = [
{
initials: 'YB',
name: 'Yanis Bouchard',
job: 'Vendeur univers maison · CDI 35 h',
date: '17 août',
state: 'Dossier complet',
tone: 'ok',
},
{
initials: 'MR',
name: 'Maëlys Renard',
job: 'Hôtesse de caisse · CDD 24 h',
date: '24 août',
state: '2 pièces manquantes',
tone: 'warn',
},
{
initials: 'TN',
name: 'Théo Nguyen',
job: 'Employé réserve · CDI 35 h',
date: '1 sept.',
state: 'Contrat à signer',
tone: 'info',
},
{
initials: 'SL',
name: 'Sarah Lemoine',
job: 'Vendeuse décoration · CDI 30 h',
date: '7 sept.',
state: 'Dossier complet',
tone: 'ok',
},
];
export interface DemoTodo {
dot: string;
title: string;
detail: string;
when: string;
}
export const TODOS: DemoTodo[] = [
{
dot: 'var(--color-danger)',
title: '3 alertes de convention sur la semaine 33',
detail:
'Repos quotidien inférieur à 11 h pour Rémi Chartier (mardi) et amplitude dépassée pour Inès Bakhti (samedi).',
when: 'Aujourd’hui',
},
{
dot: 'var(--color-warn)',
title: 'Semaine 34 non publiée',
detail:
'La publication doit intervenir au moins 7 jours avant le premier créneau, soit avant le 10 août 18:00.',
when: 'J−0',
},
{
dot: 'var(--color-info)',
title: '3 demandes de congés en attente',
detail:
'Nour Zaidi (5 jours), Rémi Chartier (2 jours), Alix Perron (1 jour).',
when: '2 j',
},
{
dot: 'var(--color-ink-3)',
title: '11 bulletins de paie à diffuser',
detail:
'Période de juillet 2026, contrôlée et validée par le service paie le 5 août.',
when: '5 j',
},
{
dot: 'var(--color-ink-3)',
title: 'Compteurs de récupération à solder',
detail:
'4 salariés dépassent 12 h de récupération non prise depuis plus de deux mois.',
when: '12 j',
},
];
+471
View File
@@ -0,0 +1,471 @@
import { can, inScope } from '@/domain/access/authorize';
import {
absenteeism,
headcount,
labourCost,
profileGaps,
turnoverRate,
upcomingDeadlines,
type Deadline,
type ProfileCompleteness,
} from '@/domain/hr/indicators';
import { shiftMinutes } from '@/domain/counters/week';
import { monthDates, monthLabel, type Month } from '@/domain/planning/month';
import { zonedMidnight } from '@/domain/planning/week';
import { query } from '@/server/context';
/**
* Tableau de bord RH — WP-09.
*
* Trois exigences le gouvernent :
*
* 1. **Chaque indicateur est explicable.** Toute tuile porte l'identifiant de
* sa liste source ; un clic mène aux lignes. Un chiffre qu'on ne peut pas
* ouvrir ne se corrige pas, il se conteste.
* 2. **Les agrégats respectent le périmètre.** Un manager d'un établissement ne
* voit pas les mouvements d'un autre. Le filtrage est en base, pas à
* l'affichage.
* 3. **Les échéances remontent avant de tomber**, pas après.
*/
export type ListKey =
| 'entrees'
| 'sorties'
| 'essais'
| 'profils'
| 'titres'
| 'absences'
| 'contrats';
export interface Tile {
key: ListKey;
label: string;
value: string;
tone: 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'info';
hint: string;
}
export interface DashboardRow {
id: string;
membershipId: string;
name: string;
detail: string;
date: string | null;
tone: 'neutral' | 'warn' | 'danger' | 'info';
}
export interface Dashboard {
month: Month;
label: string;
monthParam: string;
previousParam: string;
nextParam: string;
location: { id: string; name: string } | null;
locations: Array<{ id: string; name: string }>;
tiles: Tile[];
indicators: {
closingHeadcount: number;
averageHeadcount: number;
turnoverRate: number | null;
absenceDays: number;
sickDays: number;
absenteeismRate: number | null;
plannedMinutes: number;
labourCost: number | null;
};
lists: Record<ListKey, DashboardRow[]>;
canSeeCost: boolean;
}
/** Fenêtres d'échéance, en jours. */
const TRIAL_WINDOW = 45;
const PERMIT_WINDOW = 90;
export async function getDashboard(
month: Month,
locationId?: string,
): Promise<Dashboard> {
// `members.view` plutôt qu'une capacité dédiée : le tableau de bord n'est
// qu'une lecture agrégée de l'annuaire et de ses mouvements. Créer une
// capacité de plus donnerait un droit à administrer sans rien protéger de
// nouveau.
return query('members.view', async (db, actor) => {
const allLocations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, employerContributionRate: true },
orderBy: { name: 'asc' },
});
// Le périmètre s'applique **avant** l'agrégation : un manager d'un
// établissement ne doit pas voir passer les mouvements d'un autre, même
// fondus dans un total.
const visible = allLocations.filter((location) =>
inScope(actor, { locationId: location.id }),
);
const location =
visible.find((candidate) => candidate.id === locationId) ??
visible[0] ??
null;
const dates = monthDates(month);
const from = dates[0] as string;
const to = dates[dates.length - 1] as string;
const locationFilter = location ? { locationId: location.id } : {};
const contracts = await db.userContract.findMany({
where: locationFilter,
include: {
membership: {
include: { profile: { select: { firstName: true, lastName: true } } },
},
},
});
const nameOf = (contract: (typeof contracts)[number]) =>
`${contract.membership.profile?.firstName ?? ''} ${contract.membership.profile?.lastName ?? contract.membership.employeeNumber}`.trim();
const counts = headcount(
contracts.map((contract) => ({
membershipId: contract.membershipId,
startDate: contract.startDate.toISOString().slice(0, 10),
endDate: contract.endDate?.toISOString().slice(0, 10) ?? null,
})),
from,
to,
);
const memberIds = [
...new Set(contracts.map((contract) => contract.membershipId)),
];
// --- Absences ---------------------------------------------------------
const timeOffs = await db.timeOff.findMany({
where: {
membershipId: { in: memberIds },
status: 'ACCEPTED',
startDate: { lte: new Date(`${to}T00:00:00Z`) },
endDate: { gte: new Date(`${from}T00:00:00Z`) },
},
include: { absenceType: true },
});
const absence = absenteeism(
timeOffs.map((entry) => ({
membershipId: entry.membershipId,
days: Number(entry.countedDays?.toString() ?? '0'),
isSocialSecurity: entry.absenceType.isSocialSecurity,
timeOffId: entry.id,
})),
// Base théorique : effectif moyen × jours ouvrables du mois.
counts.average * workingDaysIn(dates),
);
// --- Heures planifiées et coût ---------------------------------------
const canSeeCost = can(actor, 'planning.counters.view');
let plannedMinutes = 0;
let cost: number | null = null;
if (location) {
const timezone = 'Europe/Paris';
const shifts = await db.shift.findMany({
where: {
membershipId: { in: memberIds },
startAt: {
gte: zonedMidnight(from, timezone),
lt: zonedMidnight(
new Date(new Date(`${to}T00:00:00Z`).getTime() + 86_400_000)
.toISOString()
.slice(0, 10),
timezone,
),
},
},
select: {
membershipId: true,
startAt: true,
endAt: true,
breakMinutes: true,
},
});
plannedMinutes = shifts.reduce(
(sum, shift) =>
sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
0,
);
if (canSeeCost) {
const rateBy = new Map(
contracts.map((contract) => [
contract.membershipId,
Number(contract.hourlyRate?.toString() ?? '0'),
]),
);
const employerRate = Number(
allLocations
.find((entry) => entry.id === location.id)
?.employerContributionRate.toString() ?? '0',
);
// Un taux horaire absent vaut zéro et non une estimation : afficher un
// coût inventé serait pire que d'afficher un coût partiel.
const total = shifts.reduce((sum, shift) => {
const rate = rateBy.get(shift.membershipId ?? '') ?? 0;
if (rate === 0) return sum;
return (
sum +
labourCost(
shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
rate,
employerRate,
)
);
}, 0);
cost = Math.round(total * 100) / 100;
}
}
// --- Échéances --------------------------------------------------------
const today = new Date().toISOString().slice(0, 10);
const trials: Deadline[] = upcomingDeadlines(
contracts
.filter((contract) => contract.trialEndDate && contract.status === 'ACTIVE')
.map((contract) => ({
id: contract.id,
membershipId: contract.membershipId,
name: nameOf(contract),
dueDate: (contract.trialEndDate as Date).toISOString().slice(0, 10),
label: 'Fin de période d’essai',
})),
today,
TRIAL_WINDOW,
);
const permits = await db.workPermit.findMany({
where: { membershipId: { in: memberIds } },
include: {
membership: {
include: { profile: { select: { firstName: true, lastName: true } } },
},
},
});
const permitDeadlines: Deadline[] = upcomingDeadlines(
permits.map((permit) => ({
id: permit.id,
membershipId: permit.membershipId,
name: `${permit.membership.profile?.firstName ?? ''} ${permit.membership.profile?.lastName ?? permit.membership.employeeNumber}`.trim(),
dueDate: permit.expiresAt.toISOString().slice(0, 10),
label: permit.permitType,
})),
today,
PERMIT_WINDOW,
);
// --- Dossiers incomplets ---------------------------------------------
const memberships = await db.membership.findMany({
where: { id: { in: memberIds }, archivedAt: null },
include: { profile: true },
});
const gaps: ProfileCompleteness[] = memberships
.map((membership) =>
profileGaps({
membershipId: membership.id,
name: `${membership.profile?.firstName ?? ''} ${membership.profile?.lastName ?? membership.employeeNumber}`.trim(),
birthDate: membership.profile?.birthDate ?? null,
address: membership.profile?.addressLine1 ?? null,
city: membership.profile?.city ?? null,
phone: membership.profile?.phone ?? null,
// Présence seulement : savoir qu'une valeur existe n'exige pas de la
// déchiffrer.
socialSecurityNumber: membership.profile?.socialSecurityNumberEnc ?? null,
iban: membership.profile?.ibanEnc ?? null,
hasContract: contracts.some(
(contract) => contract.membershipId === membership.id,
),
}),
)
.filter((entry) => entry.missing.length > 0);
// --- Modifications de contrat ----------------------------------------
const amendments = await db.amendment.findMany({
where: {
userContractId: { in: contracts.map((contract) => contract.id) },
effectiveDate: {
gte: new Date(`${from}T00:00:00Z`),
lte: new Date(`${to}T00:00:00Z`),
},
},
orderBy: { effectiveDate: 'desc' },
});
const contractById = new Map(
contracts.map((contract) => [contract.id, contract]),
);
const lists: Record<ListKey, DashboardRow[]> = {
entrees: contracts
.filter((contract) => counts.entries.includes(contract.membershipId))
.map((contract) => ({
id: contract.id,
membershipId: contract.membershipId,
name: nameOf(contract),
detail: contract.contractType,
date: contract.startDate.toISOString().slice(0, 10),
tone: 'info' as const,
})),
sorties: contracts
.filter((contract) => counts.exits.includes(contract.membershipId))
.map((contract) => ({
id: contract.id,
membershipId: contract.membershipId,
name: nameOf(contract),
detail: contract.contractType,
date: contract.endDate?.toISOString().slice(0, 10) ?? null,
tone: 'neutral' as const,
})),
essais: trials.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name: entry.name,
detail:
entry.severity === 'PASSED'
? `Échéance dépassée de ${Math.abs(entry.daysLeft)} jours`
: `Dans ${entry.daysLeft} jours`,
date: entry.dueDate,
tone: entry.severity === 'PASSED' ? 'danger' : 'warn',
})),
titres: permitDeadlines.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name: entry.name,
detail:
entry.severity === 'PASSED'
? `${entry.label} expiré depuis ${Math.abs(entry.daysLeft)} jours`
: `${entry.label} — dans ${entry.daysLeft} jours`,
date: entry.dueDate,
tone: entry.severity === 'PASSED' ? 'danger' : 'warn',
})),
profils: gaps.map((entry) => ({
id: entry.membershipId,
membershipId: entry.membershipId,
name: entry.name,
detail: `Manque : ${entry.missing.join(', ')}`,
date: null,
tone: 'warn' as const,
})),
absences: timeOffs.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name:
memberships.find((member) => member.id === entry.membershipId)
?.profile?.firstName ?? 'Salarié',
// Le journal des absences ne porte jamais le motif médical : c'est une
// donnée de santé, et un tableau de bord n'en a aucun besoin.
detail: entry.absenceType.isSocialSecurity
? `Absence — ${entry.countedDays?.toString() ?? '0'} j`
: `${entry.absenceType.name} — ${entry.countedDays?.toString() ?? '0'} j`,
date: entry.startDate.toISOString().slice(0, 10),
tone: 'info' as const,
})),
contrats: amendments.map((amendment) => {
const contract = contractById.get(amendment.userContractId);
return {
id: amendment.id,
membershipId: contract?.membershipId ?? '',
name: contract ? nameOf(contract) : 'Salarié',
detail: amendment.reason ?? 'Avenant',
date: amendment.effectiveDate.toISOString().slice(0, 10),
tone: 'neutral' as const,
};
}),
};
const { formatMonthParam, nextMonth, previousMonth } = await import(
'@/domain/planning/month'
);
return {
month,
label: monthLabel(month),
monthParam: formatMonthParam(month),
previousParam: formatMonthParam(previousMonth(month)),
nextParam: formatMonthParam(nextMonth(month)),
location: location ? { id: location.id, name: location.name } : null,
locations: visible.map(({ id, name }) => ({ id, name })),
tiles: [
{
key: 'profils',
label: 'Profils incomplets',
value: String(lists.profils.length),
tone: lists.profils.length > 0 ? 'warn' : 'ok',
hint: 'Dossiers auxquels il manque une pièce',
},
{
key: 'essais',
label: 'Fins de période d’essai',
value: String(lists.essais.length),
tone: lists.essais.some((row) => row.tone === 'danger')
? 'danger'
: 'info',
hint: `Échéances dans les ${TRIAL_WINDOW} jours`,
},
{
key: 'titres',
label: 'Titres de séjour',
value: String(lists.titres.length),
tone: lists.titres.some((row) => row.tone === 'danger')
? 'danger'
: lists.titres.length > 0
? 'warn'
: 'ok',
hint: `Échéances dans les ${PERMIT_WINDOW} jours`,
},
{
key: 'entrees',
label: 'Entrées du mois',
value: String(lists.entrees.length),
tone: 'accent',
hint: 'Contrats démarrés sur la période',
},
{
key: 'sorties',
label: 'Sorties du mois',
value: String(lists.sorties.length),
tone: 'neutral',
hint: 'Contrats terminés sur la période',
},
{
key: 'contrats',
label: 'Avenants',
value: String(lists.contrats.length),
tone: 'neutral',
hint: 'Modifications de contrat prenant effet ce mois',
},
],
indicators: {
closingHeadcount: counts.closing,
averageHeadcount: counts.average,
turnoverRate: turnoverRate(counts),
absenceDays: absence.totalDays,
sickDays: absence.sickDays,
absenteeismRate: absence.rate,
plannedMinutes,
labourCost: cost,
},
lists,
canSeeCost,
};
}, locationId ? { locationId } : undefined);
}
/** Jours ouvrables d'un mois : base théorique de l'absentéisme. */
function workingDaysIn(dates: string[]): number {
return dates.filter((date) => {
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
return day !== 0;
}).length;
}
+31
View File
@@ -58,3 +58,34 @@ test('un manager voit le brouillon et peut le publier', async ({ page }) => {
await expect(page.getByText('Brouillon').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Publier' }).first()).toBeVisible();
});
test('un salarié voit un accueil adapté, pas un tableau de bord vide', async ({
page,
}) => {
await signIn(page, 'salarie@example.test');
await page.goto('/');
// Lui servir une erreur d'autorisation sur la page d'accueil serait absurde ;
// lui montrer des indicateurs vides le serait tout autant.
await expect(page.getByRole('heading', { name: 'Aperçu' })).toBeVisible();
await expect(page.getByText('Mon planning')).toBeVisible();
await expect(page.getByText('Effectif en fin de mois')).toHaveCount(0);
await expect(page.getByText('Rotation')).toHaveCount(0);
});
test('un manager ne voit que les établissements de son périmètre', async ({
page,
}) => {
await signIn(page, 'manager.nantes@example.test');
await page.goto('/');
// Le périmètre s'applique avant l'agrégation : les mouvements d'un autre
// établissement ne doivent pas transparaître, même fondus dans un total.
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Nantes Atlantis', exact: true }),
).toBeVisible();
await expect(
page.getByRole('link', { name: 'Rennes Alma', exact: true }),
).toHaveCount(0);
});
+85
View File
@@ -0,0 +1,85 @@
import { expect, test } from '@playwright/test';
/**
* Tableau de bord RH.
*
* Le critère qui compte : **un indicateur doit être explicable**. Un chiffre
* qu'on ne peut pas ouvrir ne se corrige pas, il se conteste.
*/
test('chaque tuile mène à ses lignes sources', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// Les tuiles sont des liens, pas des chiffres décoratifs. On vise celle du
// contenu, pas l'entrée homonyme de la barre latérale.
const tile = page
.locator('main')
.getByRole('link', { name: /Profils incomplets/ })
.first();
await tile.click();
await expect(page).toHaveURL(/liste=profils/);
await expect(
page.locator('main').getByText('Profils incomplets').first(),
).toBeVisible();
});
test('la liste nomme ce qui manque plutôt que de le compter', async ({
page,
}) => {
await page.goto('/?liste=profils');
// « 6 profils incomplets » n'aide personne à agir ; « il manque l'IBAN » se
// règle en un message.
await expect(page.getByText(/Manque :/).first()).toBeVisible();
});
test('les indicateurs agrégés sont affichés', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Effectif en fin de mois')).toBeVisible();
await expect(page.getByText('Rotation')).toBeVisible();
await expect(page.getByText('Absentéisme')).toBeVisible();
await expect(page.getByText('Heures planifiées')).toBeVisible();
});
test('chaque ligne mène à la fiche du salarié', async ({ page }) => {
await page.goto('/?liste=profils');
const first = page
.locator('section')
.filter({ hasText: 'Profils incomplets' })
.locator('li a')
.first();
if (await first.isVisible()) {
await expect(first).toHaveAttribute('href', /\/equipe\//);
}
});
test('la navigation entre listes conserve le mois et l’établissement', async ({
page,
}) => {
await page.goto('/');
await page
.locator('main')
.getByRole('link', { name: '← Mois précédent' })
.click();
// Attendre la navigation avant de lire l'URL : la lire tout de suite rendrait
// celle d'avant le clic.
await expect(page).toHaveURL(/mois=/);
const month = new URL(page.url()).searchParams.get('mois');
expect(month).toBeTruthy();
await page
.locator('main')
.getByRole('link', { name: 'Journal des absences' })
.first()
.click();
// Changer de liste ne doit pas ramener au mois courant : on perdrait le
// contexte à chaque clic.
await expect(page).toHaveURL(new RegExp(`mois=${month}`));
});
+6 -3
View File
@@ -51,10 +51,13 @@ test('le thème bascule et survit à un rechargement', async ({ page }) => {
test('une entrée de navigation non construite mène à un écran explicite', async ({
page,
}) => {
await page.goto('/');
await page.getByRole('link', { name: 'Profils incomplets' }).click();
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
await page.goto('/conges');
// « Politiques de congés » fait partie du périmètre visé mais n'est pas
// encore construit : l'entrée reste, et mène à un écran qui le dit.
await page.getByRole('link', { name: 'Politiques de congés' }).click();
await expect(
page.getByRole('heading', { name: 'Profils incomplets' }),
page.getByRole('heading', { name: 'Politiques de congés' }),
).toBeVisible();
// `exact` évite de heurter l'annonceur de route de Next, qui répète le titre
// du document — « Écran à venir · PlanFlow ».
+214
View File
@@ -0,0 +1,214 @@
import { describe, expect, it } from 'vitest';
import {
absenteeism,
headcount,
labourCost,
profileGaps,
turnoverRate,
upcomingDeadlines,
type HeadcountInput,
} from '@/domain/hr/indicators';
describe('effectif', () => {
const contracts: HeadcountInput[] = [
{ membershipId: 'a', startDate: '2024-01-01', endDate: null },
{ membershipId: 'b', startDate: '2024-01-01', endDate: '2026-08-15' },
{ membershipId: 'c', startDate: '2026-08-03', endDate: null },
{ membershipId: 'd', startDate: '2027-01-01', endDate: null },
];
it('compte les présents en fin de période', () => {
const result = headcount(contracts, '2026-08-01', '2026-08-31');
// a et c sont là au 31 ; b est parti le 15 ; d n'est pas encore arrivé.
expect(result.closing).toBe(2);
});
it('relève les entrées et les sorties de la période', () => {
const result = headcount(contracts, '2026-08-01', '2026-08-31');
expect(result.entries).toEqual(['c']);
expect(result.exits).toEqual(['b']);
});
it('compte un salarié entré et sorti dans la période', () => {
// Présent, même s'il n'était là ni au premier ni au dernier jour.
const result = headcount(
[{ membershipId: 'e', startDate: '2026-08-03', endDate: '2026-08-28' }],
'2026-08-01',
'2026-08-31',
);
expect(result.entries).toEqual(['e']);
expect(result.exits).toEqual(['e']);
expect(result.closing).toBe(0);
});
it('calcule l’effectif moyen sur les deux bornes', () => {
const result = headcount(contracts, '2026-08-01', '2026-08-31');
// 2 présents au 1er (a, b), 2 au 31 (a, c).
expect(result.average).toBe(2);
});
});
describe('rotation', () => {
it('moyenne entrées et sorties', () => {
// Compter seulement les départs sous-estime la rotation d'une équipe qui
// recrute autant qu'elle perd.
const result = headcount(
[
{ membershipId: 'a', startDate: '2024-01-01', endDate: null },
{ membershipId: 'b', startDate: '2024-01-01', endDate: null },
{ membershipId: 'c', startDate: '2024-01-01', endDate: null },
{ membershipId: 'd', startDate: '2024-01-01', endDate: '2026-08-20' },
{ membershipId: 'e', startDate: '2026-08-10', endDate: null },
],
'2026-08-01',
'2026-08-31',
);
// 1 entrée, 1 sortie, effectif moyen (4 + 4) / 2 = 4 → 25 %.
expect(turnoverRate(result)).toBe(25);
});
it('ne prétend pas 0 % sur un effectif nul', () => {
// « 0 % de rotation » sur un établissement vide est une affirmation
// fausse, pas une absence de mouvement.
const result = headcount([], '2026-08-01', '2026-08-31');
expect(turnoverRate(result)).toBeNull();
});
});
describe('absentéisme', () => {
const absences = [
{ membershipId: 'a', days: 5, isSocialSecurity: false, timeOffId: 't1' },
{ membershipId: 'b', days: 3, isSocialSecurity: true, timeOffId: 't2' },
{ membershipId: 'c', days: 1.5, isSocialSecurity: true, timeOffId: 't3' },
];
it('sépare les arrêts du reste', () => {
const result = absenteeism(absences, 200);
expect(result.totalDays).toBe(9.5);
expect(result.sickDays).toBe(4.5);
});
it('rapporte le taux aux jours théoriquement travaillés', () => {
// Rapporter à 30 jours calendaires donnerait un taux artificiellement bas
// et ferait passer un problème réel pour du bruit.
expect(absenteeism(absences, 200).rate).toBe(4.8);
expect(absenteeism(absences, 100).rate).toBe(9.5);
});
it('rend les identifiants sources', () => {
// Un indicateur qu'on ne peut pas ouvrir ne se corrige pas, il se conteste.
expect(absenteeism(absences, 200).sourceIds).toEqual(['t1', 't2', 't3']);
});
it('ne calcule pas de taux sans base', () => {
expect(absenteeism(absences, 0).rate).toBeNull();
});
});
describe('échéances', () => {
const entries = [
{
id: 'p1',
membershipId: 'a',
name: 'Awa Diallo',
dueDate: '2026-08-20',
label: 'Titre de séjour',
},
{
id: 'p2',
membershipId: 'b',
name: 'Yanis Trabelsi',
dueDate: '2026-07-30',
label: 'Période d’essai',
},
{
id: 'p3',
membershipId: 'c',
name: 'Léa Nguyen',
dueDate: '2026-11-30',
label: 'Titre de séjour',
},
];
it('remonte les échéances de la fenêtre', () => {
const result = upcomingDeadlines(entries, '2026-08-10', 60);
expect(result.map((entry) => entry.id)).toEqual(['p2', 'p1']);
});
it('place les échéances dépassées en tête', () => {
// Une période d'essai qu'on a laissé filer est plus urgente qu'une
// échéance à venir : la masquer est ce qui la rend coûteuse.
const result = upcomingDeadlines(entries, '2026-08-10', 60);
expect(result[0]?.severity).toBe('PASSED');
expect(result[0]?.daysLeft).toBe(-11);
});
it('distingue l’urgent du prochain', () => {
const result = upcomingDeadlines(entries, '2026-08-10', 200);
expect(result.find((entry) => entry.id === 'p1')?.severity).toBe('URGENT');
expect(result.find((entry) => entry.id === 'p3')?.severity).toBe('SOON');
});
it('exclut ce qui dépasse la fenêtre', () => {
const result = upcomingDeadlines(entries, '2026-08-10', 30);
expect(result.map((entry) => entry.id)).not.toContain('p3');
});
it('traite la borne exacte comme incluse', () => {
const result = upcomingDeadlines(entries, '2026-08-10', 10);
// 20 août − 10 août = 10 jours : exactement la fenêtre.
expect(result.map((entry) => entry.id)).toContain('p1');
});
});
describe('complétude des dossiers', () => {
const complete = {
membershipId: 'a',
name: 'Sofia Marchetti',
birthDate: new Date('1990-01-01'),
address: '1 rue de la Paix',
city: 'Nantes',
phone: '00 00 00 00 00',
socialSecurityNumber: Buffer.from('chiffré'),
iban: Buffer.from('chiffré'),
hasContract: true,
};
it('ne signale rien sur un dossier complet', () => {
expect(profileGaps(complete).missing).toEqual([]);
});
it('nomme ce qui manque plutôt que de le compter', () => {
// « 6 profils incomplets » n'aide personne à agir ; « il manque l'IBAN de
// trois salariés » se règle en un message.
const gaps = profileGaps({ ...complete, iban: null, phone: null });
expect(gaps.missing).toEqual(['téléphone', 'IBAN']);
});
it('signale un salarié sans contrat', () => {
expect(profileGaps({ ...complete, hasContract: false }).missing).toEqual([
'contrat',
]);
});
it('contrôle la présence du NIR sans le déchiffrer', () => {
// Savoir qu'une valeur existe n'exige pas de la lire.
const gaps = profileGaps({ ...complete, socialSecurityNumber: null });
expect(gaps.missing).toEqual(['numéro de sécurité sociale']);
});
});
describe('coût de main-d’œuvre', () => {
it('applique le taux de cotisations de l’établissement', () => {
// 10 h à 12 € avec 42 % de charges : 120 × 1,42.
expect(labourCost(600, 12, 42)).toBe(170.4);
});
it('suit un taux différent d’un établissement à l’autre', () => {
// Le taux patronal est un paramètre, jamais une constante.
expect(labourCost(600, 12, 0)).toBe(120);
expect(labourCost(600, 12, 50)).toBe(180);
});
});