Merge branch 'claude/mfa-lever-barriere-enrolement'
This commit is contained in:
commit
e27d61dd4c
13 files changed
+827
-27
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+37
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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-');
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user