From b608aa87cf79aaaab418d266574691e6790cc077 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 10 Aug 2026 17:28:28 +0200 Subject: [PATCH] =?UTF-8?q?Recueillir=20=C3=A0=20l'embauche=20ce=20qu'exig?= =?UTF-8?q?e=20le=20contrat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Créer un salarié demandait cinq champs. Le contrat, la déclaration et le registre en réclament une vingtaine, et rien ne permettait de les saisir : le sexe, le nom de naissance, le pays et le département de naissance, la situation de famille, les personnes à charge, le téléphone fixe, le complément d'adresse et l'heure d'embauche n'existaient pas en base. La migration les ajoute, toutes facultatives : un dossier incomplet doit pouvoir exister — c'est au registre de signaler ce qui lui manque, pas à la base de refuser l'embauche. Seul l'envoi des plannings par SMS fait exception : c'est un consentement, donc faux par défaut, la charge de la preuve pesant sur l'employeur. Le pays et le département de naissance sont des colonnes à part et non une commune saisie librement : la déclaration sociale les demande séparément, et les rétro-extraire échouerait au premier « Bar-le-Duc (Meuse) ». Le formulaire devient un panneau latéral : une trentaine de champs posés au centre masquaient l'annuaire, et on embauche en regardant qui est déjà là. Le matricule y est proposé à la suite du dernier, attribué dans la transaction pour que deux embauches simultanées ne tombent pas sur le même rang. Le nom de naissance vaut le nom de famille quand il n'en diffère pas, plutôt que d'imposer une ressaisie à l'immense majorité des dossiers. Le responsable hiérarchique et la politique RTT se posent enfin à l'embauche : les deux modèles existaient sans qu'aucun écran ne les alimente. Le registre unique du personnel gagne sa colonne « sexe » et se tient désormais au nom de naissance — le nom d'usage peut changer sans que la personne change. La migration s'applique au redéploiement : l'entrypoint du conteneur passe `prisma migrate deploy` avant de démarrer le serveur. Elle n'ajoute que des colonnes nullables et deux types énumérés, sans réécriture de table. Écrite et validée contre le schéma, non exécutée ici : aucune base n'était ouverte. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 44 +++ prisma/schema.prisma | 50 +++- src/app/(app)/equipe/AddEmployeeDialog.tsx | 64 +++-- src/app/(app)/equipe/AddEmployeeForm.tsx | 256 ++++++++++++------ .../(app)/equipe/[id]/PersonalInfoPanel.tsx | 134 ++++++++- src/app/(app)/equipe/[id]/page.tsx | 12 + src/app/(app)/equipe/page.tsx | 11 +- src/domain/hr/civil-status.ts | 69 +++++ src/server/employees/actions.ts | 89 +++++- src/server/employees/profile-actions.ts | 51 +++- src/server/employees/queries.ts | 61 +++++ src/server/employees/rup.ts | 23 +- tests/e2e/conservation.spec.ts | 14 +- tests/e2e/documents.spec.ts | 7 +- tests/e2e/fiche.spec.ts | 14 +- tests/e2e/invitation.spec.ts | 7 +- tests/e2e/registre.spec.ts | 7 +- tests/e2e/roles.spec.ts | 7 +- tests/e2e/securite.spec.ts | 7 +- tests/unit/rup.test.ts | 19 ++ 20 files changed, 791 insertions(+), 155 deletions(-) create mode 100644 prisma/migrations/20260810180000_employee_civil_status/migration.sql create mode 100644 src/domain/hr/civil-status.ts diff --git a/prisma/migrations/20260810180000_employee_civil_status/migration.sql b/prisma/migrations/20260810180000_employee_civil_status/migration.sql new file mode 100644 index 0000000..39290d5 --- /dev/null +++ b/prisma/migrations/20260810180000_employee_civil_status/migration.sql @@ -0,0 +1,44 @@ +-- État civil complet du dossier salarié. +-- +-- Ces mentions manquaient au schéma alors que la loi les réclame ou que la +-- paie s'appuie dessus : +-- +-- - le **sexe** est exigé au registre unique du personnel (art. D1221-23) ; +-- sans lui, tout dossier y sort incomplet ; +-- - le **nom de naissance** ne se déduit pas du nom d'usage, et c'est lui que +-- portent la déclaration sociale et le registre ; +-- - le **pays** et le **département** de naissance sont demandés séparément +-- par la déclaration : les extraire d'une commune saisie librement échouerait +-- au premier « Bar-le-Duc (Meuse) » ; +-- - la **situation de famille** et les **personnes à charge** commandent des +-- droits que le salarié seul peut déclarer ; +-- - l'**heure d'embauche** est demandée par la DPAE, que la date seule ne +-- suffit pas à remplir. +-- +-- Toutes les colonnes sont facultatives : un dossier incomplet doit pouvoir +-- exister — c'est au registre de signaler ce qui lui manque, pas à la base de +-- refuser l'embauche. Seule exception, l'envoi des plannings par SMS, qui est +-- un consentement : explicitement faux par défaut, la charge de la preuve +-- pesant sur l'employeur. + +-- CreateEnum +CREATE TYPE "Gender" AS ENUM ('FEMALE', 'MALE', 'UNSPECIFIED'); + +-- Le PACS est distinct du concubinage et du mariage : les trois n'ouvrent pas +-- les mêmes droits, et les confondre fausserait la paie. +-- CreateEnum +CREATE TYPE "MaritalStatus" AS ENUM ('SINGLE', 'MARRIED', 'PACS', 'COHABITING', 'DIVORCED', 'WIDOWED'); + +-- AlterTable +ALTER TABLE "EmployeeProfile" ADD COLUMN "addressLine2" TEXT, +ADD COLUMN "birthCountry" TEXT, +ADD COLUMN "birthDepartment" TEXT, +ADD COLUMN "birthName" TEXT, +ADD COLUMN "dependents" INTEGER, +ADD COLUMN "gender" "Gender", +ADD COLUMN "landline" TEXT, +ADD COLUMN "maritalStatus" "MaritalStatus", +ADD COLUMN "smsSchedules" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "UserContract" ADD COLUMN "startTime" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41d64fb..970e028 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -481,16 +481,41 @@ model EmployeeProfile { firstName String lastName String + /// Nom de naissance, distinct du nom d'usage. La paie, la déclaration + /// sociale et le registre du personnel se tiennent au nom de naissance ; + /// l'un ne remplace pas l'autre. + birthName String? + + /// Sexe — mention exigée au registre unique du personnel (art. D1221-23). + /// Facultatif en base : un dossier incomplet doit pouvoir exister, et c'est + /// le registre qui signale ce qui lui manque. + gender Gender? + birthDate DateTime? @db.Date - birthPlace String? + /// Commune de naissance. Le pays et le département sont portés à part : + /// la déclaration sociale les demande séparément, et les recoller depuis une + /// chaîne libre échouerait au premier « Bar-le-Duc (Meuse) ». + birthPlace String? + birthCountry String? + birthDepartment String? nationality String? + /// Situation de famille et personnes à charge : la paie en dépend, et le + /// salarié seul peut les déclarer. + maritalStatus MaritalStatus? + dependents Int? addressLine1 String? + addressLine2 String? postalCode String? city String? country String? phone String? + landline String? personalEmail String? + /// Envoi des plannings par SMS. Un consentement, donc explicitement faux + /// par défaut : la charge de la preuve pèse sur l'employeur. + smsSchedules Boolean @default(false) + socialSecurityNumberEnc Bytes? ibanEnc Bytes? bicEnc Bytes? @@ -505,6 +530,26 @@ model EmployeeProfile { @@index([accountId]) } +/// Sexe au sens de l'état civil, tel que le registre du personnel le demande. +/// Trois valeurs et non deux : un dossier peut être ouvert avant que la +/// mention soit connue, et « non précisé » se distingue d'un champ jamais rempli. +enum Gender { + FEMALE + MALE + UNSPECIFIED +} + +/// Situation de famille. Le PACS est distinct du concubinage et du mariage : +/// les trois n'ouvrent pas les mêmes droits, et les confondre fausserait la paie. +enum MaritalStatus { + SINGLE + MARRIED + PACS + COHABITING + DIVORCED + WIDOWED +} + /// Titre de séjour et son échéance. Le tableau de bord RH surveille les /// expirations : un titre périmé interdit l'emploi. model WorkPermit { @@ -530,6 +575,9 @@ model UserContract { contractType ContractType startDate DateTime @db.Date + /// Heure de prise de poste le premier jour, au format « HH:MM ». + /// Une date seule ne suffit pas à la DPAE, qui demande l'heure d'embauche. + startTime String? endDate DateTime? @db.Date trialEndDate DateTime? @db.Date diff --git a/src/app/(app)/equipe/AddEmployeeDialog.tsx b/src/app/(app)/equipe/AddEmployeeDialog.tsx index 019f331..f88fad8 100644 --- a/src/app/(app)/equipe/AddEmployeeDialog.tsx +++ b/src/app/(app)/equipe/AddEmployeeDialog.tsx @@ -4,23 +4,28 @@ import { useEffect, useRef, useState } from 'react'; import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm'; import { Button } from '@/components/ui/Button'; -import type { ContractLocation } from '@/server/employees/queries'; +import type { + ContractLocation, + HiringOptions, +} from '@/server/employees/queries'; /** - * Ajout d'un collaborateur, en modale. + * Embauche, en panneau latéral. * - * Le formulaire d'embauche compte une quinzaine de champs : posé au bas de la - * liste, il occupait plus de place que l'effectif qu'on vient consulter. Il ne - * s'ouvre donc qu'à la demande. + * Une trentaine de champs : posés au bas de la liste, ils occupaient plus de + * place que l'effectif qu'on vient consulter. Le panneau glisse par la droite + * et laisse l'annuaire visible — on embauche en regardant qui est déjà là. * - * `` natif plutôt qu'un panneau maison : il apporte le piège de focus, - * la fermeture par Échap et le fond inerte, trois choses qu'une `
` doit - * réimplémenter et rate presque toujours. + * `` natif plutôt qu'un panneau maison : le piège de focus, la + * fermeture par Échap et le fond inerte viennent avec, et une `
` doit les + * réimplémenter sans jamais les tenir tout à fait. */ export function AddEmployeeDialog({ locations, + options, }: { locations: ContractLocation[]; + options: HiringOptions; }) { const dialog = useRef(null); const [open, setOpen] = useState(false); @@ -40,29 +45,32 @@ export function AddEmployeeDialog({ setOpen(false)} - className="m-auto w-[min(46rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]" + className="mt-0 mr-0 mb-0 ml-auto h-full max-h-none w-[min(30rem,100vw)] max-w-none border-l border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]" > -
-

- Ajouter un collaborateur -

- - -
+
+
+

+ Nouvel employé +

+ + +
-
- {open ? ( - // Monté à l'ouverture : un formulaire démonté entre deux usages - // repart vide, sans traîner la saisie abandonnée la fois d'avant. - setOpen(false)} - /> - ) : null} +
+ {open ? ( + // Monté à l'ouverture : un formulaire démonté entre deux usages + // repart vide, sans traîner la saisie abandonnée la fois d'avant. + setOpen(false)} + /> + ) : null} +
diff --git a/src/app/(app)/equipe/AddEmployeeForm.tsx b/src/app/(app)/equipe/AddEmployeeForm.tsx index e77b240..5d4b472 100644 --- a/src/app/(app)/equipe/AddEmployeeForm.tsx +++ b/src/app/(app)/equipe/AddEmployeeForm.tsx @@ -2,22 +2,25 @@ import { useActionState, useState } from 'react'; -import { Button } from '@/components/ui/Button'; import { Field, FormError, SubmitButton } from '@/components/ui/Form'; import { PersistentForm } from '@/components/ui/PersistentForm'; import { createEmployeeAction, type ActionState, } from '@/server/employees/actions'; -import type { ContractLocation } from '@/server/employees/queries'; +import type { + ContractLocation, + HiringOptions, +} from '@/server/employees/queries'; /** * Embauche. * - * Un salarié créé sans contrat ni équipe est un dossier que rien ne rattache : - * il n'apparaît sur aucune grille et ne se déclare pas. Le contrat est donc - * proposé d'emblée — et reste décochable, parce qu'un remplaçant se saisit - * parfois avant que son établissement soit tranché. + * Deux sections et un seul envoi : ce qui identifie la personne, puis ce qui + * l'emploie. Un salarié créé sans contrat ni équipe est un dossier que rien ne + * rattache — il n'apparaît sur aucune grille et ne se déclare pas — d'où un + * contrat proposé d'emblée, et décochable seulement pour les cas où + * l'établissement n'est pas encore tranché. */ const empty: ActionState = {}; @@ -37,13 +40,28 @@ const CONTRACT_TYPES = [ const selectClass = 'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus'; +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +const today = () => new Date().toISOString().slice(0, 10); + export function AddEmployeeForm({ locations, + options, onSaved, }: { /** Vide quand la capacité d'ouvrir un contrat manque : la section disparaît. */ locations: ContractLocation[]; - /** Appelé une fois l'ajout accepté — la modale s'y referme. */ + options: HiringOptions; + /** Appelé une fois l'ajout accepté — le panneau s'y referme. */ onSaved?: () => void; }) { const [state, formAction] = useActionState( @@ -53,10 +71,11 @@ export function AddEmployeeForm({ const [withContract, setWithContract] = useState(locations.length > 0); const [locationId, setLocationId] = useState(locations[0]?.id ?? ''); const [forfait, setForfait] = useState(false); + const [autoNumber, setAutoNumber] = useState(true); const [acknowledged, setAcknowledged] = useState(empty); // Acquitté une fois pour toutes : sans cela, un succès resté en mémoire - // refermerait la modale à sa réouverture. + // refermerait le panneau à sa réouverture. if (state !== acknowledged && state.ok) { setAcknowledged(state); onSaved?.(); @@ -69,26 +88,73 @@ export function AddEmployeeForm({ -
+
- - -
+ + + +
+
+ Matricule + + +
+ {autoNumber ? ( +

+ Attribué à la suite du dernier, au moment de l’enregistrement. +

+ ) : ( + + )} +
+ + -
-
+ + + + + {locations.length > 0 ? ( - <> +
) : null} {state.error} -
- Ajouter +
+ Enregistrer {state.ok ? ( - Salarié ajouté. +

+ Salarié ajouté. +

) : null} - {withContract ? null : ( - - )}
); diff --git a/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx index c676f4a..5d94764 100644 --- a/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx +++ b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx @@ -5,6 +5,12 @@ import { useActionState, useState } from 'react'; import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard'; import { Button } from '@/components/ui/Button'; import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { + GENDERS, + MARITAL_STATUSES, + genderLabel, + maritalStatusLabel, +} from '@/domain/hr/civil-status'; import { updateProfileAction, updateSensitiveAction, @@ -21,14 +27,23 @@ import { */ export interface ProfileFields { + gender: string; firstName: string; + birthName: string; lastName: string; birthDate: string; birthPlace: string; + birthCountry: string; + birthDepartment: string; nationality: string; + maritalStatus: string; + dependents: string; personalEmail: string; phone: string; + landline: string; + smsSchedules: boolean; addressLine1: string; + addressLine2: string; postalCode: string; city: string; country: string; @@ -46,6 +61,42 @@ const empty: ProfileActionState = {}; const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }); +/** + * Liste déroulante à valeur facultative. + * + * L'option vide est explicite : sans elle, ouvrir le formulaire poserait la + * première valeur de la liste sur un champ que personne n'a renseigné. + */ +function Select({ + label, + name, + defaultValue, + options, +}: { + label: string; + name: string; + defaultValue: string; + options: ReadonlyArray; +}) { + return ( + + ); +} + function readableDate(value: string): string { if (!value) return ''; const parsed = new Date(`${value}T00:00:00Z`); @@ -89,8 +140,20 @@ export function PersonalInfoPanel({
+ +
@@ -116,7 +201,27 @@ export function PersonalInfoPanel({ defaultValue={profile.personalEmail} /> + + +
@@ -158,17 +263,36 @@ export function PersonalInfoPanel({ <> + + - - + + + + + + + + + diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx index 43f72ca..262eea8 100644 --- a/src/app/(app)/equipe/[id]/page.tsx +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -33,16 +33,28 @@ export default async function PersonalTab({ } const profile: ProfileFields = { + gender: text(employee.profile.gender), firstName: text(employee.profile.firstName), + birthName: text(employee.profile.birthName), lastName: text(employee.profile.lastName), birthDate: employee.profile.birthDate ? employee.profile.birthDate.toISOString().slice(0, 10) : '', birthPlace: text(employee.profile.birthPlace), + birthCountry: text(employee.profile.birthCountry), + birthDepartment: text(employee.profile.birthDepartment), nationality: text(employee.profile.nationality), + maritalStatus: text(employee.profile.maritalStatus), + dependents: + employee.profile.dependents === null + ? '' + : String(employee.profile.dependents), personalEmail: text(employee.profile.personalEmail), phone: text(employee.profile.phone), + landline: text(employee.profile.landline), + smsSchedules: employee.profile.smsSchedules, addressLine1: text(employee.profile.addressLine1), + addressLine2: text(employee.profile.addressLine2), postalCode: text(employee.profile.postalCode), city: text(employee.profile.city), country: text(employee.profile.country), diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx index cc6515d..cbaf95d 100644 --- a/src/app/(app)/equipe/page.tsx +++ b/src/app/(app)/equipe/page.tsx @@ -11,6 +11,7 @@ import { EmptyState } from '@/components/ui/Card'; import { listContractLocations, listEmployees, + listHiringOptions, } from '@/server/employees/queries'; export const metadata = { title: 'Équipe · PlanFlow' }; @@ -56,6 +57,11 @@ export default async function EquipePage({ // Sans `members.register.export`, le bouton du registre ne paraît pas. const registerLocations = await listRegisterLocations().catch(() => []); + const hiringOptions = await listHiringOptions().catch(() => ({ + managers: [], + rttPolicies: [], + })); + const filtered = directory.rows.length !== directory.total; return ( @@ -72,7 +78,10 @@ export default async function EquipePage({ {registerLocations.length > 0 ? ( ) : null} - + } /> diff --git a/src/domain/hr/civil-status.ts b/src/domain/hr/civil-status.ts new file mode 100644 index 0000000..08123d4 --- /dev/null +++ b/src/domain/hr/civil-status.ts @@ -0,0 +1,69 @@ +/** + * Libellés d'état civil. + * + * Les codes vivent en base, les mots à l'écran. Les séparer évite qu'un + * changement de formulation devienne une migration, et qu'une valeur stockée + * dépende de la langue de l'interface. + */ + +export const GENDERS = [ + ['FEMALE', 'Femme'], + ['MALE', 'Homme'], + ['UNSPECIFIED', 'Non précisé'], +] as const; + +export const MARITAL_STATUSES = [ + ['SINGLE', 'Célibataire'], + ['MARRIED', 'Marié(e)'], + ['PACS', 'Pacsé(e)'], + ['COHABITING', 'Concubinage'], + ['DIVORCED', 'Divorcé(e)'], + ['WIDOWED', 'Veuf(ve)'], +] as const; + +export type GenderCode = (typeof GENDERS)[number][0]; +export type MaritalStatusCode = (typeof MARITAL_STATUSES)[number][0]; + +const GENDER_LABELS = new Map(GENDERS); +const MARITAL_LABELS = new Map(MARITAL_STATUSES); + +export function genderLabel(code: string | null): string | null { + return code ? (GENDER_LABELS.get(code) ?? code) : null; +} + +export function maritalStatusLabel(code: string | null): string | null { + return code ? (MARITAL_LABELS.get(code) ?? code) : null; +} + +/** + * Abrégé porté au registre du personnel. + * + * La colonne y est étroite et la mention se lit d'un coup d'œil ; « Non + * précisé » y devient un tiret, qui dit la même chose sans déborder. + */ +export function genderShort(code: string | null): string | null { + if (code === 'FEMALE') return 'F'; + if (code === 'MALE') return 'M'; + return null; +} + +/** + * Matricule proposé à l'embauche. + * + * Une lettre et un rang, et non un horodatage : un matricule se dicte au + * téléphone et se recopie à la main. Le rang suit le dernier attribué de la + * même forme, ce qui laisse coexister les matricules repris d'un autre outil. + */ +export function nextEmployeeNumber( + existing: string[], + prefix = 'E', +): string { + const pattern = new RegExp(`^${prefix}(\\d+)$`); + const highest = existing.reduce((max, value) => { + const match = pattern.exec(value.trim()); + if (!match) return max; + return Math.max(max, Number(match[1])); + }, 0); + + return `${prefix}${String(highest + 1).padStart(4, '0')}`; +} diff --git a/src/server/employees/actions.ts b/src/server/employees/actions.ts index 319f3ae..1b146a5 100644 --- a/src/server/employees/actions.ts +++ b/src/server/employees/actions.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { AuthorizationError, can } from '@/domain/access/authorize'; import { findOverlaps, validateContract } from '@/domain/contracts/rules'; +import { nextEmployeeNumber } from '@/domain/hr/civil-status'; import { recordAudit } from '@/server/audit'; import { mutate } from '@/server/context'; @@ -27,11 +28,17 @@ const CONTRACT_TYPES = [ const employeeInput = z.object({ firstName: z.string().trim().min(1, 'Prénom requis').max(80), - lastName: z.string().trim().min(1, 'Nom requis').max(80), - employeeNumber: z.string().trim().min(1, 'Matricule requis').max(40), + /** Le nom d'usage vaut nom de naissance quand il n'est pas distinct. */ + birthName: z.string().trim().max(80), + lastName: z.string().trim().min(1, 'Nom de famille requis').max(80), + /** Vide = proposé par l'application, à la suite du dernier attribué. */ + employeeNumber: z.string().trim().max(40), + birthDate: z.string().trim().max(10), /** Vide = salarié géré sans accès applicatif. */ email: z.string().trim().email('Adresse invalide').or(z.literal('')), phone: z.string().trim().max(30), + landline: z.string().trim().max(30), + smsSchedules: z.boolean(), }); /** @@ -46,12 +53,19 @@ const hiringContractInput = z.object({ teamId: z.string().trim(), contractType: z.enum(CONTRACT_TYPES), startDate: z.coerce.date(), + /** Heure de prise de poste : la DPAE la demande, la date seule n'y suffit pas. */ + startTime: z + .string() + .trim() + .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Heure de début invalide'), endDate: z.string().trim(), workTimeArrangement: z.enum(['HOURLY', 'FORFAIT_JOURS']), weeklyHours: z.coerce.number().min(0).max(60), forfaitDaysPerYear: z.coerce.number().min(0).max(400).optional(), forfaitAgreementRef: z.string().trim(), forfaitAgreedAt: z.string().trim(), + lineManagerId: z.string().trim(), + rttPolicyId: z.string().trim(), }); /** @@ -71,16 +85,29 @@ export async function createEmployeeAction( ): Promise { const parsed = employeeInput.safeParse({ firstName: formData.get('firstName'), + birthName: formData.get('birthName') ?? '', lastName: formData.get('lastName'), - employeeNumber: formData.get('employeeNumber'), + employeeNumber: formData.get('employeeNumber') ?? '', + birthDate: formData.get('birthDate') ?? '', email: formData.get('email') ?? '', phone: formData.get('phone') ?? '', + landline: formData.get('landline') ?? '', + smsSchedules: formData.get('smsSchedules') === 'on', }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; } + let birthDate: Date | null = null; + if (parsed.data.birthDate) { + const candidate = new Date(`${parsed.data.birthDate}T00:00:00Z`); + if (Number.isNaN(candidate.getTime()) || candidate > new Date()) { + return { error: 'Date de naissance invalide.' }; + } + birthDate = candidate; + } + // Le contrat n'est tenté que si le formulaire l'a ouvert : créer un salarié // sans contrat reste légitime — un remplaçant se saisit avant que son // établissement soit tranché. @@ -95,6 +122,9 @@ export async function createEmployeeAction( teamId: formData.get('teamId') ?? '', contractType: formData.get('contractType') ?? 'CDI', startDate: formData.get('startDate'), + startTime: formData.get('startTime') || '09:00', + lineManagerId: formData.get('lineManagerId') ?? '', + rttPolicyId: formData.get('rttPolicyId') ?? '', endDate: formData.get('endDate') ?? '', workTimeArrangement: formData.get('workTimeArrangement') ?? 'HOURLY', weeklyHours: formData.get('weeklyHours') || 35, @@ -142,18 +172,43 @@ export async function createEmployeeAction( const role = await db.role.findFirst({ where: { key: 'employee' } }); if (!role) throw new Error('Rôle « employee » introuvable.'); + // Le matricule est proposé quand il n'est pas donné. Attribué **dans la + // transaction**, à la suite du dernier : deux embauches simultanées ne + // peuvent pas tomber sur le même rang, la seconde échouant sur l'unicité + // plutôt que d'écraser la première. + let employeeNumber = parsed.data.employeeNumber; + if (!employeeNumber) { + const taken = await db.membership.findMany({ + select: { employeeNumber: true }, + }); + employeeNumber = nextEmployeeNumber( + taken.map((entry) => entry.employeeNumber), + ); + } + const existing = await db.membership.findFirst({ - where: { employeeNumber: parsed.data.employeeNumber }, + where: { employeeNumber }, }); if (existing) { throw new ValidationError('Ce matricule est déjà utilisé.'); } + if (contract?.lineManagerId) { + const manager = await db.membership.findUnique({ + where: { id: contract.lineManagerId }, + select: { id: true }, + }); + if (!manager) { + throw new ValidationError('Responsable hiérarchique introuvable.'); + } + } + const created = await db.membership.create({ data: { roleId: role.id, - employeeNumber: parsed.data.employeeNumber, + employeeNumber, status: parsed.data.email ? 'INVITED' : 'ACTIVE', + lineManagerId: contract?.lineManagerId || null, } as never, }); @@ -162,8 +217,15 @@ export async function createEmployeeAction( membershipId: created.id, firstName: parsed.data.firstName, lastName: parsed.data.lastName, + // Sans mention contraire, le nom de naissance est le nom d'usage : + // laisser le champ vide obligerait à le ressaisir pour l'immense + // majorité des dossiers, où les deux coïncident. + birthName: parsed.data.birthName || parsed.data.lastName, + birthDate: birthDate, personalEmail: parsed.data.email || null, phone: parsed.data.phone || null, + landline: parsed.data.landline || null, + smsSchedules: parsed.data.smsSchedules, } as never, }); @@ -181,6 +243,7 @@ export async function createEmployeeAction( locationId: location.id, contractType: contract.contractType, startDate: contract.startDate, + startTime: contract.startTime, endDate: contractEnd, workTimeArrangement: contract.workTimeArrangement, weeklyHours: contract.weeklyHours, @@ -208,6 +271,22 @@ export async function createEmployeeAction( }); } + if (contract.rttPolicyId) { + const policy = await db.rttPolicy.findUnique({ + where: { id: contract.rttPolicyId }, + select: { id: true, status: true }, + }); + if (!policy || policy.status !== 'ACTIVE') { + throw new ValidationError('Politique RTT introuvable ou archivée.'); + } + await db.rttPolicyAssignment.create({ + data: { + rttPolicyId: policy.id, + membershipId: created.id, + } as never, + }); + } + await recordAudit(db, { actorMembershipId: actor.membershipId, action: 'contract.create', diff --git a/src/server/employees/profile-actions.ts b/src/server/employees/profile-actions.ts index a3db89d..05632c1 100644 --- a/src/server/employees/profile-actions.ts +++ b/src/server/employees/profile-actions.ts @@ -33,13 +33,44 @@ const optionalText = (max: number) => .max(max) .transform((value) => (value === '' ? null : value)); +/** Une valeur vide vaut « non renseigné » ; toute autre doit être connue. */ +const optionalEnum = (values: T) => + z + .string() + .trim() + .refine( + (value) => value === '' || (values as readonly string[]).includes(value), + 'Valeur inconnue', + ) + .transform((value) => (value === '' ? null : value)); + const profileInput = z.object({ membershipId: z.string().min(1), + gender: optionalEnum(['FEMALE', 'MALE', 'UNSPECIFIED'] as const), firstName: z.string().trim().min(1, 'Prénom requis').max(80), + birthName: optionalText(80), lastName: z.string().trim().min(1, 'Nom requis').max(80), birthDate: optionalText(10), birthPlace: optionalText(120), + birthCountry: optionalText(80), + birthDepartment: optionalText(80), nationality: optionalText(80), + maritalStatus: optionalEnum([ + 'SINGLE', + 'MARRIED', + 'PACS', + 'COHABITING', + 'DIVORCED', + 'WIDOWED', + ] as const), + dependents: z + .string() + .trim() + .refine( + (value) => value === '' || /^\d{1,2}$/.test(value), + 'Nombre de personnes à charge invalide', + ) + .transform((value) => (value === '' ? null : Number(value))), personalEmail: z .string() .trim() @@ -50,7 +81,10 @@ const profileInput = z.object({ ) .transform((value) => (value === '' ? null : value)), phone: optionalText(30), + landline: optionalText(30), + smsSchedules: z.boolean(), addressLine1: optionalText(180), + addressLine2: optionalText(180), postalCode: optionalText(12), city: optionalText(120), country: optionalText(80), @@ -62,18 +96,26 @@ export async function updateProfileAction( _previous: ProfileActionState, formData: FormData, ): Promise { - const parsed = profileInput.safeParse( - Object.fromEntries( + const parsed = profileInput.safeParse({ + ...Object.fromEntries( [ 'membershipId', + 'gender', 'firstName', + 'birthName', 'lastName', 'birthDate', 'birthPlace', + 'birthCountry', + 'birthDepartment', 'nationality', + 'maritalStatus', + 'dependents', 'personalEmail', 'phone', + 'landline', 'addressLine1', + 'addressLine2', 'postalCode', 'city', 'country', @@ -81,7 +123,10 @@ export async function updateProfileAction( 'emergencyContactPhone', ].map((key) => [key, formData.get(key) ?? '']), ), - ); + // Une case décochée n'est pas envoyée : son absence vaut « non », et la + // lire comme une chaîne vide en ferait une valeur inconnue. + smsSchedules: formData.get('smsSchedules') === 'on', + }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts index aaed3b0..fd84d05 100644 --- a/src/server/employees/queries.ts +++ b/src/server/employees/queries.ts @@ -267,16 +267,25 @@ export async function listEmployees( * qu'il n'avait pas le droit de lire. */ export interface EmployeeProfileDetail { + gender: string | null; firstName: string; + birthName: string | null; lastName: string; birthDate: Date | null; birthPlace: string | null; + birthCountry: string | null; + birthDepartment: string | null; nationality: string | null; + maritalStatus: string | null; + dependents: number | null; addressLine1: string | null; + addressLine2: string | null; postalCode: string | null; city: string | null; country: string | null; phone: string | null; + landline: string | null; + smsSchedules: boolean; personalEmail: string | null; emergencyContactName: string | null; emergencyContactPhone: string | null; @@ -460,16 +469,25 @@ export const getEmployee = cache(async function getEmployee( }, profile: membership.profile ? { + gender: membership.profile.gender, firstName: membership.profile.firstName, + birthName: membership.profile.birthName, lastName: membership.profile.lastName, birthDate: membership.profile.birthDate, birthPlace: membership.profile.birthPlace, + birthCountry: membership.profile.birthCountry, + birthDepartment: membership.profile.birthDepartment, nationality: membership.profile.nationality, + maritalStatus: membership.profile.maritalStatus, + dependents: membership.profile.dependents, addressLine1: membership.profile.addressLine1, + addressLine2: membership.profile.addressLine2, postalCode: membership.profile.postalCode, city: membership.profile.city, country: membership.profile.country, phone: membership.profile.phone, + landline: membership.profile.landline, + smsSchedules: membership.profile.smsSchedules, personalEmail: membership.profile.personalEmail, emergencyContactName: membership.profile.emergencyContactName, emergencyContactPhone: membership.profile.emergencyContactPhone, @@ -561,6 +579,49 @@ export const listContractLocations = cache( }, ); +/** + * Ce que le panneau d'embauche propose en plus des établissements. + * + * Lu avec `members.view` : ce sont des noms déjà affichés par l'annuaire, et + * une politique RTT n'est pas une donnée sensible. + */ +export interface HiringOptions { + managers: Array<{ id: string; name: string }>; + rttPolicies: Array<{ id: string; name: string }>; +} + +export const listHiringOptions = cache( + async function listHiringOptions(): Promise { + return query('members.view', async (db) => { + const [managers, rttPolicies] = await Promise.all([ + db.membership.findMany({ + where: { archivedAt: null }, + select: { + id: true, + employeeNumber: true, + profile: { select: { firstName: true, lastName: true } }, + }, + }), + db.rttPolicy.findMany({ + where: { status: 'ACTIVE' }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }), + ]); + + return { + managers: managers + .map((manager) => ({ + id: manager.id, + name: `${manager.profile?.firstName ?? ''} ${manager.profile?.lastName ?? manager.employeeNumber}`.trim(), + })) + .sort((a, b) => a.name.localeCompare(b.name, 'fr')), + rttPolicies, + }; + }); + }, +); + /** Rattachement et périmètre — onglet « Planification et accès ». */ export interface MemberPlacement { /** Établissement porté par le contrat en cours. Il ne se change qu'en avenant. */ diff --git a/src/server/employees/rup.ts b/src/server/employees/rup.ts index 1551e99..3e7b62d 100644 --- a/src/server/employees/rup.ts +++ b/src/server/employees/rup.ts @@ -2,6 +2,7 @@ import 'server-only'; import { cache } from 'react'; +import { genderShort } from '@/domain/hr/civil-status'; import type { RupPerson } from '@/domain/legal/rup'; import { peopleWithGaps } from '@/domain/legal/rup'; import { query } from '@/server/context'; @@ -44,6 +45,8 @@ export const getRegisterData = cache(async function getRegisterData( select: { firstName: true, lastName: true, + birthName: true, + gender: true, nationality: true, birthDate: true, }, @@ -71,11 +74,14 @@ export const getRegisterData = cache(async function getRegisterData( // 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 ?? '', + // Le registre se tient au nom de naissance : c'est celui de l'état civil, + // et le nom d'usage peut changer sans que la personne change. + lastName: + contract.membership.profile?.birthName ?? + 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, + sex: genderShort(contract.membership.profile?.gender ?? null), nationality: contract.membership.profile?.nationality ?? null, birthDate: contract.membership.profile?.birthDate ?? null, jobTitle: contract.jobTitleId @@ -128,6 +134,8 @@ export const listRegisterLocations = cache( select: { firstName: true, lastName: true, + birthName: true, + gender: true, nationality: true, birthDate: true, }, @@ -138,9 +146,12 @@ export const listRegisterLocations = cache( }); const people: RupPerson[] = contracts.map((contract) => ({ - lastName: contract.membership.profile?.lastName ?? '', + lastName: + contract.membership.profile?.birthName ?? + contract.membership.profile?.lastName ?? + '', firstName: contract.membership.profile?.firstName ?? '', - sex: null, + sex: genderShort(contract.membership.profile?.gender ?? null), nationality: contract.membership.profile?.nationality ?? null, birthDate: contract.membership.profile?.birthDate ?? null, jobTitle: contract.jobTitleId ? 'x' : null, diff --git a/tests/e2e/conservation.spec.ts b/tests/e2e/conservation.spec.ts index 149412d..ceeb007 100644 --- a/tests/e2e/conservation.spec.ts +++ b/tests/e2e/conservation.spec.ts @@ -116,14 +116,15 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const create = page.locator('form').filter({ hasText: 'Ajouter' }); + const create = page.locator('form').filter({ hasText: 'Informations salarié' }); await create.getByLabel('Prénom').fill('Garde'); - await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`); + await create.getByLabel('Nom de famille').fill(`Garde${suffix}`); + await create.getByRole('button', { name: 'Saisir un matricule' }).click(); await create.getByLabel('Matricule').fill(`RET${suffix}`); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await create.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await create.getByRole('button', { name: 'Ajouter' }).click(); + await create.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click(); @@ -173,14 +174,15 @@ test('une pièce échue est effectivement effacée', async ({ page }) => { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const create = page.locator('form').filter({ hasText: 'Ajouter' }); + const create = page.locator('form').filter({ hasText: 'Informations salarié' }); await create.getByLabel('Prénom').fill('Purge'); - await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`); + await create.getByLabel('Nom de famille').fill(`Purge${suffix}`); + await create.getByRole('button', { name: 'Saisir un matricule' }).click(); await create.getByLabel('Matricule').fill(`PUR${suffix}`); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await create.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await create.getByRole('button', { name: 'Ajouter' }).click(); + await create.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click(); diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts index 70b091f..00ba6b8 100644 --- a/tests/e2e/documents.spec.ts +++ b/tests/e2e/documents.spec.ts @@ -30,14 +30,15 @@ async function createEmployee(page: Page, tag: string) { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const form = page.locator('form').filter({ hasText: 'Ajouter' }); + const form = page.locator('form').filter({ hasText: 'Informations salarié' }); await form.getByLabel('Prénom').fill('Inès'); - await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Nom de famille').fill(lastName); + await form.getByRole('button', { name: 'Saisir un matricule' }).click(); await form.getByLabel('Matricule').fill(`DOC${suffix}`); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await form.getByRole('button', { name: 'Ajouter' }).click(); + await form.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); await page.getByRole('link', { name: new RegExp(lastName) }).click(); diff --git a/tests/e2e/fiche.spec.ts b/tests/e2e/fiche.spec.ts index 0179b2b..f711fe6 100644 --- a/tests/e2e/fiche.spec.ts +++ b/tests/e2e/fiche.spec.ts @@ -17,14 +17,15 @@ async function createEmployee(page: Page) { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const form = page.locator('form').filter({ hasText: 'Ajouter' }); + const form = page.locator('form').filter({ hasText: 'Informations salarié' }); await form.getByLabel('Prénom').fill('Awa'); - await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Nom de famille').fill(lastName); + await form.getByRole('button', { name: 'Saisir un matricule' }).click(); await form.getByLabel('Matricule').fill(`FIC${suffix}`); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await form.getByRole('button', { name: 'Ajouter' }).click(); + await form.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); await page.getByRole('link', { name: new RegExp(lastName) }).click(); @@ -105,9 +106,10 @@ test('une embauche pose le contrat en même temps que le dossier', async ({ // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const form = page.locator('form').filter({ hasText: 'Ajouter' }); + const form = page.locator('form').filter({ hasText: 'Informations salarié' }); await form.getByLabel('Prénom').fill('Sofia'); - await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Nom de famille').fill(lastName); + await form.getByRole('button', { name: 'Saisir un matricule' }).click(); await form.getByLabel('Matricule').fill(`EMB${suffix}`); // Le contrat est proposé coché : un dossier créé sans lui n'apparaît sur @@ -116,7 +118,7 @@ test('une embauche pose le contrat en même temps que le dossier', async ({ form.getByLabel('Ouvrir un contrat maintenant'), ).toBeChecked(); await form.getByLabel('Début du contrat').fill('2026-01-05'); - await form.getByRole('button', { name: 'Ajouter' }).click(); + await form.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); // Le rattachement paraît dans l'annuaire : un dossier sans contrat y porte diff --git a/tests/e2e/invitation.spec.ts b/tests/e2e/invitation.spec.ts index 24a82bd..b32e777 100644 --- a/tests/e2e/invitation.spec.ts +++ b/tests/e2e/invitation.spec.ts @@ -24,15 +24,16 @@ async function createEmployee(page: Page, tag: string) { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const form = page.locator('form').filter({ hasText: 'Ajouter' }); + const form = page.locator('form').filter({ hasText: 'Informations salarié' }); await form.getByLabel('Prénom').fill(firstName); - await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Nom de famille').fill(lastName); + await form.getByRole('button', { name: 'Saisir un matricule' }).click(); await form.getByLabel('Matricule').fill(`E2E${suffix}`); await form.getByLabel('Adresse électronique').fill(email); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await form.getByRole('button', { name: 'Ajouter' }).click(); + await form.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); const row = page.getByRole('link', { name: new RegExp(lastName) }); diff --git a/tests/e2e/registre.spec.ts b/tests/e2e/registre.spec.ts index 62867ef..cc6bf28 100644 --- a/tests/e2e/registre.spec.ts +++ b/tests/e2e/registre.spec.ts @@ -58,14 +58,17 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const form = page.locator('form').filter({ hasText: 'Ajouter' }); + const form = page.locator('form').filter({ hasText: 'Informations salarié' }); await form.locator('input[name="firstName"]').fill('Sans'); await form.locator('input[name="lastName"]').fill(nom); + // Le matricule est proposé par l'application : ce test en impose un, parce + // qu'il vérifie ensuite qu'on retrouve ce salarié par ce numéro. + await form.getByRole('button', { name: 'Saisir un matricule' }).click(); await form.locator('input[name="employeeNumber"]').fill(matricule); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await page.getByRole('button', { name: 'Ajouter' }).click(); + await page.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); // Rechargement explicite : ce qui est vérifié ici est la persistance et la diff --git a/tests/e2e/roles.spec.ts b/tests/e2e/roles.spec.ts index b43e140..7cd8385 100644 --- a/tests/e2e/roles.spec.ts +++ b/tests/e2e/roles.spec.ts @@ -161,15 +161,16 @@ async function inviteWithRole(page: Page, roleLabel: string) { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const create = page.locator('form').filter({ hasText: 'Ajouter' }); + const create = page.locator('form').filter({ hasText: 'Informations salarié' }); await create.getByLabel('Prénom').fill('Alix'); - await create.getByLabel('Nom', { exact: true }).fill(lastName); + await create.getByLabel('Nom de famille').fill(lastName); + await create.getByRole('button', { name: 'Saisir un matricule' }).click(); await create.getByLabel('Matricule').fill(`ROL${suffix}`); await create.getByLabel('Adresse électronique').fill(email); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await create.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await create.getByRole('button', { name: 'Ajouter' }).click(); + await create.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); await page.getByRole('link', { name: new RegExp(lastName) }).click(); diff --git a/tests/e2e/securite.spec.ts b/tests/e2e/securite.spec.ts index 047900f..0ff884e 100644 --- a/tests/e2e/securite.spec.ts +++ b/tests/e2e/securite.spec.ts @@ -154,15 +154,16 @@ async function createAccessibleEmployee(page: Page) { // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre // en modale, à la demande. await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); - const form = page.locator('form').filter({ hasText: 'Ajouter' }); + const form = page.locator('form').filter({ hasText: 'Informations salarié' }); await form.getByLabel('Prénom').fill('Noé'); - await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Nom de famille').fill(lastName); + await form.getByRole('button', { name: 'Saisir un matricule' }).click(); await form.getByLabel('Matricule').fill(`MFA${suffix}`); await form.getByLabel('Adresse électronique').fill(email); // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut // pas, et un salarié sans contrat doit rester créable. await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); - await form.getByRole('button', { name: 'Ajouter' }).click(); + await form.getByRole('button', { name: 'Enregistrer' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); await page.getByRole('link', { name: new RegExp(lastName) }).click(); diff --git a/tests/unit/rup.test.ts b/tests/unit/rup.test.ts index 8db0768..ae9a9ec 100644 --- a/tests/unit/rup.test.ts +++ b/tests/unit/rup.test.ts @@ -117,3 +117,22 @@ describe('rendu du registre', () => { expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-'); }); }); + +describe('matricule proposé', () => { + it('part à E0001 sur un effectif vide', async () => { + const { nextEmployeeNumber } = await import('@/domain/hr/civil-status'); + expect(nextEmployeeNumber([])).toBe('E0001'); + }); + + it('suit le dernier rang attribué', async () => { + const { nextEmployeeNumber } = await import('@/domain/hr/civil-status'); + expect(nextEmployeeNumber(['E0001', 'E0007', 'E0003'])).toBe('E0008'); + }); + + it('ignore les matricules repris d’un autre outil', async () => { + const { nextEmployeeNumber } = await import('@/domain/hr/civil-status'); + // Un client qui migre apporte ses propres formes : les compter fausserait + // le rang, et refuser l'embauche pour cette raison serait absurde. + expect(nextEmployeeNumber(['SILAE-42', '00012', 'E0002'])).toBe('E0003'); + }); +});