Merge branch 'claude/mfa-lever-barriere-enrolement'

This commit is contained in:
Michael committed 2026-08-10 17:05:09 +02:00
commit e27d61dd4c
13 files changed
+827 -27

No files matched your search

+1
View File
@@ -34,6 +34,7 @@
"@prisma/client": "7.9.1",
"next": "16.3.0",
"nodemailer": "^9.0.5",
"pdf-lib": "^1.17.1",
"pg": "8.22.0",
"qrcode": "^1.5.4",
"react": "19.2.8",
+37
View File
@@ -23,6 +23,9 @@ importers:
nodemailer:
specifier: ^9.0.5
version: 9.0.5
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
pg:
specifier: 8.22.0
version: 8.22.0
@@ -769,6 +772,12 @@ packages:
'@oxc-project/types@0.143.0':
resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
'@pdf-lib/standard-fonts@1.0.0':
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@playwright/test@1.62.1':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
@@ -2689,6 +2698,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2707,6 +2719,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
@@ -3137,6 +3152,9 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3900,6 +3918,14 @@ snapshots:
'@oxc-project/types@0.143.0': {}
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
pako: 1.0.11
'@pdf-lib/upng@1.0.1':
dependencies:
pako: 1.0.11
'@playwright/test@1.62.1':
dependencies:
playwright: 1.62.1
@@ -5943,6 +5969,8 @@ snapshots:
p-try@2.2.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5955,6 +5983,13 @@ snapshots:
pathe@2.0.3: {}
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
perfect-debounce@2.1.0: {}
pg-cloudflare@1.4.0:
@@ -6432,6 +6467,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsx@4.20.6:
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/Button';
/**
* Édition du registre unique du personnel.
*
* L'établissement est demandé avant le téléchargement, et non déduit : le
* registre se tient **par établissement**, et en tirer un au hasard pour une
* entreprise qui en compte trente ne répondrait à aucune demande d'inspection.
*
* L'avertissement sur les mentions manquantes arrive avant le fichier, pas
* dedans : découvrir un registre incomplet en l'ouvrant, c'est le découvrir
* devant l'inspection.
*/
export interface RegisterLocation {
id: string;
name: string;
incomplete: number;
total: number;
}
export function RegisterDialog({
locations,
}: {
locations: RegisterLocation[];
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
const [locationId, setLocationId] = useState('');
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
const chosen = locations.find((location) => location.id === locationId);
return (
<>
<Button onClick={() => setOpen(true)}>
Registre unique du personnel
</Button>
<dialog
ref={dialog}
aria-labelledby="titre-rup"
onClose={() => setOpen(false)}
className="m-auto w-[min(38rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
>
<header className="flex items-center gap-3 px-5 pt-5">
<h2 id="titre-rup" className="text-lg font-semibold">
Téléchargez votre registre unique du personnel
</h2>
<span className="flex-1" />
<Button variant="ghost" onClick={() => setOpen(false)}>
Fermer
</Button>
</header>
<div className="flex flex-col gap-4 p-5">
<p className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
Tout employeur doit tenir un registre unique du personnel par
établissement. Aucune forme n’est imposée : en cas de contrôle par
l’inspection du travail, le format numérique peut être présenté.
</p>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Établissement pour lequel éditer le registre
</span>
<select
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
className="h-10 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
>
<option value="">Choisissez un établissement…</option>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
{chosen && chosen.incomplete > 0 ? (
<div
role="alert"
className="rounded-3 border border-warn bg-warn-soft p-4 text-sm text-warn-soft-ink"
>
<p className="font-semibold">
Des mentions manquent pour {chosen.incomplete} salarié
{chosen.incomplete > 1 ? 's' : ''} de cet établissement
</p>
<p className="mt-1 leading-[var(--lh-prose)]">
Le registre reste éditable, et les manques y figurent en clair.
Un registre incomplet expose à une contravention par salarié
concerné : complétez les dossiers avant de le présenter.
</p>
</div>
) : null}
{chosen && chosen.total === 0 ? (
<p className="text-sm text-ink-3">
Aucun contrat n’est rattaché à cet établissement : le registre
sera vide.
</p>
) : null}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-line-1 px-5 py-4">
<Button onClick={() => setOpen(false)}>Annuler</Button>
{/* Un lien, pas un bouton : le navigateur télécharge sans quitter la
page, et l'adresse reste rejouable. */}
<a
href={
locationId ? `/equipe/registre?etablissement=${locationId}` : '#'
}
aria-disabled={!locationId}
onClick={(event) => {
if (!locationId) event.preventDefault();
else setOpen(false);
}}
className={
locationId
? 'inline-flex h-8 items-center rounded-2 border border-accent bg-accent px-3.5 text-sm font-medium text-accent-ink hover:bg-accent-hover'
: 'pointer-events-none inline-flex h-8 items-center rounded-2 border border-line-2 bg-surface-3 px-3.5 text-sm font-medium text-ink-3'
}
>
Télécharger
</a>
</footer>
</dialog>
</>
);
}
+8 -6
View File
@@ -3,6 +3,8 @@ import { Suspense } from 'react';
import { AddEmployeeDialog } from '@/app/(app)/equipe/AddEmployeeDialog';
import { DirectoryFilters } from '@/app/(app)/equipe/DirectoryFilters';
import { RegisterDialog } from '@/app/(app)/equipe/RegisterDialog';
import { listRegisterLocations } from '@/server/employees/rup';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/Card';
@@ -51,6 +53,9 @@ export default async function EquipePage({
// formulaire s'en tient à l'identité.
const hiringLocations = await listContractLocations().catch(() => []);
// Sans `members.register.export`, le bouton du registre ne paraît pas.
const registerLocations = await listRegisterLocations().catch(() => []);
const filtered = directory.rows.length !== directory.total;
return (
@@ -64,12 +69,9 @@ export default async function EquipePage({
: {})}
actions={
<>
<Link
href="/reglages/registre"
className="inline-flex h-8 items-center rounded-2 border border-line-3 bg-surface px-3.5 text-sm font-medium text-ink-1 hover:bg-surface-2"
>
Registre unique du personnel
</Link>
{registerLocations.length > 0 ? (
<RegisterDialog locations={registerLocations} />
) : null}
<AddEmployeeDialog locations={hiringLocations} />
</>
}
-21
View File
@@ -28,27 +28,6 @@ export default async function RegistrePage() {
subtitle={`${register.approvedCount} paramètre${register.approvedCount > 1 ? 's' : ''} approuvé${register.approvedCount > 1 ? 's' : ''} · ${register.pendingCount} en attente`}
/>
<Card>
<div className="p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
<p>
Chaque paramètre appliqué par PlanFlow doit porter sa{' '}
<strong className="font-semibold text-ink-1">valeur</strong>, sa{' '}
<strong className="font-semibold text-ink-1">source</strong>, sa{' '}
<strong className="font-semibold text-ink-1">date d’effet</strong>,
la <strong className="font-semibold text-ink-1">population</strong>{' '}
concernée et un{' '}
<strong className="font-semibold text-ink-1">approbateur</strong>.
</p>
<p className="mt-2">
Recopier la configuration d’un autre logiciel ne suffit pas : sans
justification conservée, un paramètre n’est pas opposable en cas de
contrôle. Les valeurs de la convention IDCC 1517 déjà chargées dans
le moteur restent à recouper avec le texte consolidé et à faire
valider par le gestionnaire de paie.
</p>
</div>
</Card>
{register.missingDomains.length > 0 ? (
<Card className="border-warn">
<CardHeader
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse, type NextRequest } from 'next/server';
import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules';
import { recordAudit } from '@/server/audit';
import { query } from '@/server/context';
import { getRegisterData } from '@/server/employees/rup';
import { renderRegisterPdf } from '@/server/employees/rup-pdf';
/**
* Téléchargement du registre unique du personnel.
*
* Une route et non une action : le document est un fichier, pas un écran, et
* son édition doit pouvoir être rejouée par une simple adresse — c'est aussi
* ce qui permet de la présenter depuis un poste d'inspection.
*
* L'édition est journalisée. Le registre rassemble l'identité, la nationalité
* et le parcours de tout le personnel d'un établissement : savoir qui l'a
* extrait, et quand, fait partie de ce qu'on doit pouvoir répondre.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const locationId = request.nextUrl.searchParams.get('etablissement');
if (!locationId) {
return NextResponse.json(
{ error: 'Choisissez un établissement.' },
{ status: 400 },
);
}
const data = await getRegisterData(locationId);
if (!data) {
return NextResponse.json(
{ error: 'Établissement introuvable.' },
{ status: 404 },
);
}
await query('members.register.export', async (db, actor) =>
recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'members.register.export',
entityType: 'Location',
entityId: data.locationId,
after: { lines: data.people.length, incomplete: data.incomplete },
}),
);
const pdf = await renderRegisterPdf(data);
const stamp = new Date().toISOString().slice(0, 10).split('-').reverse().join('_');
const name = sanitiseFileName(`RUP - ${data.locationName} - ${stamp}.pdf`);
return new NextResponse(pdf as BodyInit, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
// Un registre se lit à l'instant où il est demandé : le mettre en cache
// ferait présenter un effectif périmé au contrôle suivant.
'Cache-Control': 'no-store',
},
});
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Registre unique du personnel — articles L1221-13 et D1221-23 du code du
* travail.
*
* Tenu par établissement et non par entreprise : c'est l'inspection d'un
* établissement donné qui le réclame, et un registre global ne répondrait pas
* à la demande.
*
* Les mentions sont fixées par le texte. Elles sont énumérées ici plutôt que
* dispersées dans le rendu, pour qu'une relecture juridique porte sur un seul
* endroit.
*/
export interface RupPerson {
lastName: string;
firstName: string;
/** Mention exigée. Non collectée par PlanFlow à ce jour. */
sex: string | null;
nationality: string | null;
birthDate: Date | null;
/** Emploi tenu, tel que porté par le contrat. */
jobTitle: string | null;
qualification: string | null;
contractLabel: string | null;
entryDate: Date | null;
exitDate: Date | null;
}
export interface RupColumn {
key: keyof RupPerson;
label: string;
/** Part de la largeur utile. La somme vaut 1. */
width: number;
}
/**
* L'ordre suit celui du texte réglementaire : identité, puis situation, puis
* dates. Un registre réordonné se relit mal en regard de l'article.
*/
export const RUP_COLUMNS: RupColumn[] = [
{ key: 'lastName', label: 'Nom', width: 0.13 },
{ key: 'firstName', label: 'Prénom', width: 0.11 },
{ key: 'sex', label: 'Sexe', width: 0.05 },
{ key: 'nationality', label: 'Nationalité', width: 0.1 },
{ key: 'birthDate', label: 'Date de naissance', width: 0.1 },
{ key: 'jobTitle', label: 'Emploi', width: 0.14 },
{ key: 'qualification', label: 'Qualification', width: 0.11 },
{ key: 'contractLabel', label: 'Contrat', width: 0.08 },
{ key: 'entryDate', label: 'Entrée', width: 0.09 },
{ key: 'exitDate', label: 'Sortie', width: 0.09 },
];
/**
* Mentions obligatoires manquantes pour une personne.
*
* La date de sortie est exclue : son absence signifie que le salarié est
* toujours présent, ce qui est une information et non un oubli.
*/
export function missingMentions(person: RupPerson): string[] {
return RUP_COLUMNS.filter((column) => {
if (column.key === 'exitDate') return false;
const value = person[column.key];
return value === null || value === '';
}).map((column) => column.label);
}
/**
* Une contravention par salarié concerné, et non une par registre : c'est ce
* qui justifie de compter les personnes plutôt que les champs.
*/
export function peopleWithGaps(people: RupPerson[]): number {
return people.filter((person) => missingMentions(person).length > 0).length;
}
+164
View File
@@ -0,0 +1,164 @@
import 'server-only';
import { PDFDocument, StandardFonts, rgb, type PDFFont } from 'pdf-lib';
import { RUP_COLUMNS, type RupPerson } from '@/domain/legal/rup';
import type { RupData } from '@/server/employees/rup';
/**
* Rendu du registre unique du personnel.
*
* Aucune forme n'est imposée par la loi : ce qui compte est que les mentions y
* soient, lisibles, et que le document puisse être présenté tel quel en cas de
* contrôle. D'où un tableau paysage sobre plutôt qu'une mise en page.
*
* Helvetica et l'encodage WinAnsi : les accents français y sont, et une police
* embarquée alourdirait le fichier sans rien apporter à un tableau.
*/
const A4_LANDSCAPE: [number, number] = [841.89, 595.28];
const MARGIN = 32;
const ROW_HEIGHT = 18;
const FONT_SIZE = 8;
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'short' });
function cell(person: RupPerson, key: keyof RupPerson): string {
const value = person[key];
if (value === null || value === '') return '—';
if (value instanceof Date) return dateFormat.format(value);
return String(value);
}
/** Coupe au lieu de déborder : une colonne qui empiète sur la suivante rend
* les deux illisibles. */
function fit(text: string, font: PDFFont, size: number, width: number): string {
if (font.widthOfTextAtSize(text, size) <= width) return text;
let cut = text;
while (cut.length > 1 && font.widthOfTextAtSize(`${cut}…`, size) > width) {
cut = cut.slice(0, -1);
}
return `${cut}…`;
}
export async function renderRegisterPdf(data: RupData): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
pdf.setTitle(`Registre unique du personnel — ${data.locationName}`);
pdf.setCreator('PlanFlow');
const regular = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
const usable = A4_LANDSCAPE[0] - MARGIN * 2;
const widths = RUP_COLUMNS.map((column) => column.width * usable);
const ink = rgb(0.11, 0.11, 0.11);
const faint = rgb(0.62, 0.62, 0.62);
const rule = rgb(0.85, 0.85, 0.85);
const printedOn = dateFormat.format(new Date());
let page = pdf.addPage(A4_LANDSCAPE);
let y = A4_LANDSCAPE[1] - MARGIN;
const header = () => {
page.drawText('Registre unique du personnel', {
x: MARGIN,
y: y - 12,
size: 14,
font: bold,
color: ink,
});
page.drawText(
[
data.locationName,
data.siret ? `SIRET ${data.siret}` : null,
`édité le ${printedOn}`,
]
.filter(Boolean)
.join(' · '),
{ x: MARGIN, y: y - 28, size: 9, font: regular, color: faint },
);
y -= 48;
let x = MARGIN;
RUP_COLUMNS.forEach((column, index) => {
page.drawText(column.label, {
x,
y: y - 10,
size: FONT_SIZE,
font: bold,
color: ink,
});
x += widths[index] as number;
});
y -= 16;
page.drawLine({
start: { x: MARGIN, y },
end: { x: MARGIN + usable, y },
thickness: 0.7,
color: rule,
});
y -= 4;
};
header();
for (const person of data.people) {
// Nouvelle page avant d'écrire, pas après : une ligne à moitié sous la
// marge est pire qu'une page de plus.
if (y - ROW_HEIGHT < MARGIN + 24) {
page = pdf.addPage(A4_LANDSCAPE);
y = A4_LANDSCAPE[1] - MARGIN;
header();
}
let x = MARGIN;
RUP_COLUMNS.forEach((column, index) => {
const width = widths[index] as number;
const value = cell(person, column.key);
page.drawText(fit(value, regular, FONT_SIZE, width - 6), {
x,
y: y - 12,
size: FONT_SIZE,
font: regular,
color: value === '—' ? faint : ink,
});
x += width;
});
y -= ROW_HEIGHT;
page.drawLine({
start: { x: MARGIN, y: y + 4 },
end: { x: MARGIN + usable, y: y + 4 },
thickness: 0.4,
color: rule,
});
}
if (data.people.length === 0) {
page.drawText('Aucun salarié rattaché à cet établissement.', {
x: MARGIN,
y: y - 12,
size: 9,
font: regular,
color: faint,
});
}
// Mention de pied : un registre incomplet reste un registre, mais celui qui
// le présente doit savoir ce qui lui manque avant l'inspection.
const pages = pdf.getPages();
pages.forEach((current, index) => {
current.drawText(
[
`Page ${index + 1} sur ${pages.length}`,
`${data.people.length} ligne${data.people.length > 1 ? 's' : ''}`,
data.incomplete > 0
? `${data.incomplete} dossier${data.incomplete > 1 ? 's' : ''} incomplet${data.incomplete > 1 ? 's' : ''}`
: 'Mentions complètes',
].join(' · '),
{ x: MARGIN, y: MARGIN - 12, size: 7, font: regular, color: faint },
);
});
return pdf.save();
}
+163
View File
@@ -0,0 +1,163 @@
import 'server-only';
import { cache } from 'react';
import type { RupPerson } from '@/domain/legal/rup';
import { peopleWithGaps } from '@/domain/legal/rup';
import { query } from '@/server/context';
import { contractLabel } from '@/server/employees/queries';
/**
* Registre unique du personnel — lecture.
*
* Le registre porte **tous** ceux qui ont été employés dans l'établissement,
* y compris les partis : c'est précisément l'historique que l'inspection vient
* chercher. Filtrer sur les présents en ferait un trombinoscope.
*/
export interface RupData {
locationId: string;
locationName: string;
siret: string | null;
people: RupPerson[];
/** Nombre de salariés à qui il manque au moins une mention obligatoire. */
incomplete: number;
}
export const getRegisterData = cache(async function getRegisterData(
locationId: string,
): Promise<RupData | null> {
return query('members.register.export', async (db) => {
const location = await db.location.findUnique({
where: { id: locationId },
select: { id: true, name: true, siret: true },
});
if (!location) return null;
const contracts = await db.userContract.findMany({
where: { locationId },
orderBy: { startDate: 'asc' },
include: {
membership: {
select: {
profile: {
select: {
firstName: true,
lastName: true,
nationality: true,
birthDate: true,
},
},
},
},
},
});
const jobTitleIds = [
...new Set(
contracts
.map((contract) => contract.jobTitleId)
.filter((id): id is string => Boolean(id)),
),
];
const jobTitles = await db.jobTitle.findMany({
where: { id: { in: jobTitleIds } },
select: { id: true, name: true },
});
const jobTitleNames = new Map(
jobTitles.map((entry) => [entry.id, entry.name]),
);
// Une ligne par contrat, pas par personne : un salarié réembauché a deux
// entrées et deux sorties, et les fondre effacerait l'interruption.
const people: RupPerson[] = contracts.map((contract) => ({
lastName: contract.membership.profile?.lastName ?? '',
firstName: contract.membership.profile?.firstName ?? '',
// PlanFlow ne collecte pas le sexe : la mention est exigée, la colonne
// reste donc vide et le décompte des dossiers incomplets le signale.
sex: null,
nationality: contract.membership.profile?.nationality ?? null,
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId
? (jobTitleNames.get(contract.jobTitleId) ?? null)
: null,
qualification: contract.classification,
contractLabel: contractLabel(contract.contractType),
entryDate: contract.startDate,
exitDate: contract.endDate,
}));
return {
locationId: location.id,
locationName: location.name,
siret: location.siret,
people,
incomplete: peopleWithGaps(people),
};
});
});
/** Établissements pour lesquels un registre peut être tiré. */
export const listRegisterLocations = cache(
async function listRegisterLocations(): Promise<
Array<{ id: string; name: string; incomplete: number; total: number }>
> {
return query('members.register.export', async (db) => {
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
// Le décompte des manques est calculé ici pour que l'écran puisse
// avertir **avant** le téléchargement : découvrir un registre incomplet
// en l'ouvrant, c'est le découvrir devant l'inspection.
return Promise.all(
locations.map(async (location) => {
const contracts = await db.userContract.findMany({
where: { locationId: location.id },
select: {
classification: true,
jobTitleId: true,
contractType: true,
startDate: true,
endDate: true,
membership: {
select: {
profile: {
select: {
firstName: true,
lastName: true,
nationality: true,
birthDate: true,
},
},
},
},
},
});
const people: RupPerson[] = contracts.map((contract) => ({
lastName: contract.membership.profile?.lastName ?? '',
firstName: contract.membership.profile?.firstName ?? '',
sex: null,
nationality: contract.membership.profile?.nationality ?? null,
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId ? 'x' : null,
qualification: contract.classification,
contractLabel: contractLabel(contract.contractType),
entryDate: contract.startDate,
exitDate: contract.endDate,
}));
return {
id: location.id,
name: location.name,
incomplete: peopleWithGaps(people),
total: people.length,
};
}),
);
});
},
);
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';
/**
* Registre unique du personnel — édition.
*
* Ce qui est éprouvé n'est pas la mise en page, mais qu'un fichier sorte et
* qu'il porte le bon établissement : un registre édité pour le mauvais site ne
* répond à aucune demande d'inspection.
*/
test('le registre s’édite par établissement', async ({ page }) => {
await page.goto('/equipe');
await page.getByRole('button', { name: 'Registre unique du personnel' }).click();
await expect(
page.getByRole('heading', {
name: 'Téléchargez votre registre unique du personnel',
}),
).toBeVisible();
// Sans établissement, rien à télécharger : le registre se tient par site.
const download = page.getByRole('link', { name: 'Télécharger' });
await expect(download).toHaveAttribute('aria-disabled', 'true');
const select = page.getByLabel('Établissement pour lequel éditer le registre');
const value = await select.locator('option').nth(1).getAttribute('value');
await select.selectOption(value!);
const started = page.waitForEvent('download');
await download.click();
const file = await started;
expect(file.suggestedFilename()).toMatch(/^RUP - .+\.pdf$/);
// Le contenu est bien un PDF, pas une page d'erreur renvoyée en 200.
const path = await file.path();
const { readFileSync } = await import('node:fs');
expect(readFileSync(path).subarray(0, 5).toString()).toBe('%PDF-');
});
test('l’adresse du registre refuse un établissement absent', async ({
page,
}) => {
const response = await page.goto('/equipe/registre');
expect(response?.status()).toBe(400);
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Doublure de `server-only`.
*
* Le paquet est fourni par Next au moment du bundling : il n'existe pas dans
* `node_modules`, et son seul rôle est de faire échouer la compilation d'un
* module serveur importé depuis un composant client. Hors de Next, il n'a rien
* à garantir — mais son absence empêcherait de tester les modules qui le
* déclarent.
*/
export {};
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import {
RUP_COLUMNS,
missingMentions,
peopleWithGaps,
type RupPerson,
} from '@/domain/legal/rup';
/**
* Registre unique du personnel — mentions obligatoires.
*
* La sanction se compte **par salarié concerné**, pas par registre : c'est ce
* que le décompte doit refléter, et c'est ce qui décide de l'avertissement
* affiché avant l'édition.
*/
const complete: RupPerson = {
lastName: 'Ferrand',
firstName: 'Camille',
sex: 'F',
nationality: 'France',
birthDate: new Date('1990-04-12T00:00:00Z'),
jobTitle: 'Hôte de caisse',
qualification: 'Niveau 3',
contractLabel: 'CDI',
entryDate: new Date('2024-09-01T00:00:00Z'),
exitDate: null,
};
describe('registre unique du personnel', () => {
it('énumère les mentions fixées par le texte', () => {
expect(RUP_COLUMNS.map((column) => column.key)).toEqual([
'lastName',
'firstName',
'sex',
'nationality',
'birthDate',
'jobTitle',
'qualification',
'contractLabel',
'entryDate',
'exitDate',
]);
});
it('répartit la largeur sur la totalité de la page', () => {
const total = RUP_COLUMNS.reduce((sum, column) => sum + column.width, 0);
expect(total).toBeCloseTo(1, 5);
});
it('ne réclame rien quand toutes les mentions sont là', () => {
expect(missingMentions(complete)).toEqual([]);
});
it('tient une date de sortie absente pour une présence, pas pour un oubli', () => {
// Un salarié en poste n'a pas de date de sortie : la réclamer ferait
// apparaître tout l'effectif courant comme incomplet.
expect(missingMentions({ ...complete, exitDate: null })).toEqual([]);
});
it('nomme les mentions manquantes', () => {
expect(
missingMentions({ ...complete, nationality: null, qualification: '' }),
).toEqual(['Nationalité', 'Qualification']);
});
it('compte les personnes concernées, pas les champs vides', () => {
const bancal: RupPerson = {
...complete,
sex: null,
nationality: null,
birthDate: null,
};
// Trois manques sur une seule personne : une contravention, pas trois.
expect(peopleWithGaps([complete, bancal])).toBe(1);
});
});
describe('rendu du registre', () => {
it('produit un PDF paginé qui porte les manques en pied', async () => {
const { renderRegisterPdf } = await import('@/server/employees/rup-pdf');
// Assez de lignes pour dépasser une page : c'est la rupture que le rendu
// rate le plus volontiers.
const people: RupPerson[] = Array.from({ length: 60 }, (_, index) => ({
...complete,
lastName: `Nom manifestement trop long pour la colonne ${index}`,
sex: index % 2 === 0 ? null : 'F',
}));
const pdf = await renderRegisterPdf({
locationId: 'loc',
locationName: 'La Foir’Fouille',
siret: '12345678901234',
people,
incomplete: 30,
});
expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-');
expect(pdf.length).toBeGreaterThan(1000);
});
it('édite un registre vide plutôt que de refuser', async () => {
const { renderRegisterPdf } = await import('@/server/employees/rup-pdf');
// Un établissement qui vient d'ouvrir n'a personne : le registre existe
// quand même, et son absence serait plus embarrassante que sa vacuité.
const pdf = await renderRegisterPdf({
locationId: 'loc',
locationName: 'Nouvel établissement',
siret: null,
people: [],
incomplete: 0,
});
expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-');
});
});
+5
View File
@@ -8,6 +8,11 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
// Fourni par Next au bundling, absent de `node_modules` : sans doublure,
// aucun module serveur qui le déclare ne serait testable.
'server-only': fileURLToPath(
new URL('./tests/support/server-only.ts', import.meta.url),
),
},
},
test: {