Éditer le registre unique du personnel

Le bouton menait au registre de paramétrage juridique — un autre document,
qui ne répond à aucune demande d'inspection. Le registre unique du
personnel n'existait pas.

Il se tient par établissement : celui-ci est demandé avant l'édition
plutôt que déduit, car en tirer un au hasard pour une entreprise qui
compte trente sites ne répondrait à rien.

Une ligne par contrat et non par personne : un salarié réembauché a deux
entrées et deux sorties, et les fondre effacerait l'interruption. Les
partis y figurent aussi — c'est l'historique que l'inspection vient
chercher.

Les mentions manquantes sont annoncées avant le téléchargement, et
comptées par salarié parce que la contravention l'est aussi. Découvrir un
registre incomplet en l'ouvrant, c'est le découvrir devant l'inspection.

L'édition est journalisée : le document rassemble l'identité, la
nationalité et le parcours de tout le personnel d'un site.

Une réserve, portée par le code et par le document : PlanFlow ne collecte
pas le sexe, mention pourtant exigée. La colonne reste vide et alimente le
décompte des dossiers incomplets, en attendant le champ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 16:46:15 +02:00
1 parent 8808fcdb95
commit 3bf2cbabff
12 files changed
+827 -6

No files matched your search

+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} />
</>
}
+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',
},
});
}