É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:
1 parent
8808fcdb95
commit
3bf2cbabff
12 files changed
+827
-6
No files matched your search
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user