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