From 10a9c923e22dad6f11fc901b493c7973bd31cf99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 12:40:19 +0000 Subject: [PATCH] =?UTF-8?q?WP-09=20:=20tableau=20de=20bord=20RH,=20et=20di?= =?UTF-8?q?sparition=20des=20donn=C3=A9es=20de=20d=C3=A9monstration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- README.md | 32 +- src/app/(app)/conges/page.tsx | 5 +- src/app/(app)/page.tsx | 360 ++++++++++++++++------ src/components/shell/navigation.ts | 13 +- src/domain/hr/indicators.ts | 248 +++++++++++++++ src/lib/demo/README.md | 16 - src/lib/demo/apercu.ts | 103 ------- src/server/hr/queries.ts | 471 +++++++++++++++++++++++++++++ tests/e2e/acces.spec.ts | 31 ++ tests/e2e/apercu.spec.ts | 85 ++++++ tests/e2e/navigation.spec.ts | 9 +- tests/unit/hr-indicators.test.ts | 214 +++++++++++++ 12 files changed, 1360 insertions(+), 227 deletions(-) create mode 100644 src/domain/hr/indicators.ts delete mode 100644 src/lib/demo/README.md delete mode 100644 src/lib/demo/apercu.ts create mode 100644 src/server/hr/queries.ts create mode 100644 tests/e2e/apercu.spec.ts create mode 100644 tests/unit/hr-indicators.test.ts diff --git a/README.md b/README.md index f878b16..46eec8a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/(app)/conges/page.tsx b/src/app/(app)/conges/page.tsx index 87773d0..7ea91f6 100644 --- a/src/app/(app)/conges/page.tsx +++ b/src/app/(app)/conges/page.tsx @@ -150,7 +150,10 @@ export default async function CongesPage({ searchParams }: PageProps) { )} -
+

Compteurs

diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index eb44b98..e3597b8 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -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 = { - 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 = { + 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 ; + } + + 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 ( - - + + ← Mois précédent + + + Mois suivant → + } /> -
- {TILES.map((tile) => ( - + {location.name} + ))}
-
- - 4} - action={ - - } - /> -
    - {ENTRIES.map((entry) => ( -
  • - - {entry.initials} + {/* Chaque tuile est un lien : un indicateur qu'on ne peut pas ouvrir ne + se corrige pas, il se conteste. */} +
      + {board.tiles.map((tile) => ( +
    • + + + + {tile.label} - - - {entry.name} - - - {entry.job} - - - - {entry.date} - - {entry.state} -
    • - ))} -
    - + {tile.value} +
    + {tile.hint} + +
  • + ))} +
- - -
    - {TODOS.map((todo) => ( -
  • + + + + +
+ +
+
+

+ {LIST_LABELS[selected]} +

+ + {board.lists[selected].length} ligne + {board.lists[selected].length > 1 ? 's' : ''} + +
+ {LIST_KEYS.map((key) => ( + - - - - {todo.title} + {LIST_LABELS[key]} + + ))} +
+
+ + {board.lists[selected].length === 0 ? ( +

+ Rien à signaler sur cette période. +

+ ) : ( +
    + {board.lists[selected].map((row) => ( +
  • + + {row.name} + + {row.detail} + {row.date ? ( + + {row.date.split('-').reverse().join('/')} - - {todo.detail} - - - - {todo.when} - + ) : null}
  • ))}
- - + )} +
+ + ); +} + +function Indicator({ + label, + value, + hint, +}: { + label: string; + value: string; + hint: string; +}) { + return ( +
+

+ {label} +

+

{value}

+

{hint}

+
+ ); +} + +/** + * 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 ( + + +
    +
  • + + Mon planning + + Les semaines publiées de votre équipe + + +
  • +
  • + + + Mes absences et compteurs + + + Demander un congé, suivre son solde + + +
  • +
); } diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index 735fbb2..678add6 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -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' }, ], }, diff --git a/src/domain/hr/indicators.ts b/src/domain/hr/indicators.ts new file mode 100644 index 0000000..b2fded3 --- /dev/null +++ b/src/domain/hr/indicators.ts @@ -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; +} diff --git a/src/lib/demo/README.md b/src/lib/demo/README.md deleted file mode 100644 index ef7b902..0000000 --- a/src/lib/demo/README.md +++ /dev/null @@ -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. diff --git a/src/lib/demo/apercu.ts b/src/lib/demo/apercu.ts deleted file mode 100644 index 7f8880a..0000000 --- a/src/lib/demo/apercu.ts +++ /dev/null @@ -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', - }, -]; diff --git a/src/server/hr/queries.ts b/src/server/hr/queries.ts new file mode 100644 index 0000000..3b36c43 --- /dev/null +++ b/src/server/hr/queries.ts @@ -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; + 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 { + // `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 = { + 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; +} diff --git a/tests/e2e/acces.spec.ts b/tests/e2e/acces.spec.ts index ca1e00a..20606c9 100644 --- a/tests/e2e/acces.spec.ts +++ b/tests/e2e/acces.spec.ts @@ -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); +}); diff --git a/tests/e2e/apercu.spec.ts b/tests/e2e/apercu.spec.ts new file mode 100644 index 0000000..f76dc25 --- /dev/null +++ b/tests/e2e/apercu.spec.ts @@ -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}`)); +}); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index a4eb7fa..1b477ad 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -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 ». diff --git a/tests/unit/hr-indicators.test.ts b/tests/unit/hr-indicators.test.ts new file mode 100644 index 0000000..214cc8f --- /dev/null +++ b/tests/unit/hr-indicators.test.ts @@ -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); + }); +});