diff --git a/package.json b/package.json index a92abe3..137e95f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445f77b..c236671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/app/(app)/equipe/RegisterDialog.tsx b/src/app/(app)/equipe/RegisterDialog.tsx new file mode 100644 index 0000000..7c82614 --- /dev/null +++ b/src/app/(app)/equipe/RegisterDialog.tsx @@ -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(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 ( + <> + + + 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)]" + > +
+

+ Téléchargez votre registre unique du personnel +

+ + +
+ +
+

+ 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é. +

+ + + + {chosen && chosen.incomplete > 0 ? ( +
+

+ Des mentions manquent pour {chosen.incomplete} salarié + {chosen.incomplete > 1 ? 's' : ''} de cet établissement +

+

+ 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. +

+
+ ) : null} + + {chosen && chosen.total === 0 ? ( +

+ Aucun contrat n’est rattaché à cet établissement : le registre + sera vide. +

+ ) : null} +
+ + +
+ + ); +} diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx index 27502ac..cc6515d 100644 --- a/src/app/(app)/equipe/page.tsx +++ b/src/app/(app)/equipe/page.tsx @@ -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={ <> - - Registre unique du personnel - + {registerLocations.length > 0 ? ( + + ) : null} } diff --git a/src/app/equipe/registre/route.ts b/src/app/equipe/registre/route.ts new file mode 100644 index 0000000..9468592 --- /dev/null +++ b/src/app/equipe/registre/route.ts @@ -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 { + 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', + }, + }); +} diff --git a/src/domain/legal/rup.ts b/src/domain/legal/rup.ts new file mode 100644 index 0000000..a378002 --- /dev/null +++ b/src/domain/legal/rup.ts @@ -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; +} diff --git a/src/server/employees/rup-pdf.ts b/src/server/employees/rup-pdf.ts new file mode 100644 index 0000000..999c65b --- /dev/null +++ b/src/server/employees/rup-pdf.ts @@ -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 { + 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(); +} diff --git a/src/server/employees/rup.ts b/src/server/employees/rup.ts new file mode 100644 index 0000000..1551e99 --- /dev/null +++ b/src/server/employees/rup.ts @@ -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 { + 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, + }; + }), + ); + }); + }, +); diff --git a/tests/e2e/rup.spec.ts b/tests/e2e/rup.spec.ts new file mode 100644 index 0000000..da78961 --- /dev/null +++ b/tests/e2e/rup.spec.ts @@ -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); +}); diff --git a/tests/support/server-only.ts b/tests/support/server-only.ts new file mode 100644 index 0000000..4cfd8ad --- /dev/null +++ b/tests/support/server-only.ts @@ -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 {}; diff --git a/tests/unit/rup.test.ts b/tests/unit/rup.test.ts new file mode 100644 index 0000000..8db0768 --- /dev/null +++ b/tests/unit/rup.test.ts @@ -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-'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5bd4606..3d15573 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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: {