Recueillir à l'embauche ce qu'exige le contrat

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 17:34:10 +02:00
1 parent e27d61dd4c
commit b608aa87cf
20 files changed
+791 -155

No files matched your search

@@ -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;
+49 -1
View File
@@ -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
+36 -28
View File
@@ -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à.
*
* `<dialog>` 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 `<div>` doit
* réimplémenter et rate presque toujours.
* `<dialog>` natif plutôt qu'un panneau maison : le piège de focus, la
* fermeture par Échap et le fond inerte viennent avec, et une `<div>` doit les
* réimplémenter sans jamais les tenir tout à fait.
*/
export function AddEmployeeDialog({
locations,
options,
}: {
locations: ContractLocation[];
options: HiringOptions;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
@@ -40,29 +45,32 @@ export function AddEmployeeDialog({
<dialog
ref={dialog}
aria-labelledby="ajout-collaborateur"
aria-labelledby="titre-embauche"
onClose={() => 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)]"
>
<header className="flex items-center gap-3 border-b border-line-1 px-5 py-4">
<h2 id="ajout-collaborateur" className="text-md font-semibold">
Ajouter un collaborateur
</h2>
<span className="flex-1" />
<Button variant="ghost" onClick={() => setOpen(false)}>
Fermer
</Button>
</header>
<div className="flex h-full flex-col">
<header className="flex items-center gap-3 border-b border-line-1 px-5 py-4">
<h2 id="titre-embauche" className="text-md font-semibold">
Nouvel employé
</h2>
<span className="flex-1" />
<Button variant="ghost" onClick={() => setOpen(false)}>
Fermer
</Button>
</header>
<div className="max-h-[70vh] overflow-y-auto p-5">
{open ? (
// Monté à l'ouverture : un formulaire démonté entre deux usages
// repart vide, sans traîner la saisie abandonnée la fois d'avant.
<AddEmployeeForm
locations={locations}
onSaved={() => setOpen(false)}
/>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-5">
{open ? (
// Monté à l'ouverture : un formulaire démonté entre deux usages
// repart vide, sans traîner la saisie abandonnée la fois d'avant.
<AddEmployeeForm
locations={locations}
options={options}
onSaved={() => setOpen(false)}
/>
) : null}
</div>
</div>
</dialog>
</>
+176 -80
View File
@@ -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 (
<section className="flex flex-col gap-3">
<h3 className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{title}
</h3>
{children}
</section>
);
}
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<ActionState, FormData>(
@@ -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<ActionState>(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({
<PersistentForm
action={formAction}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-4"
className="flex flex-col gap-6"
>
<div className="flex flex-wrap gap-3">
<Section title="Informations salarié">
<Field label="Prénom" name="firstName" required />
<Field label="Nom" name="lastName" required />
<Field label="Matricule" name="employeeNumber" required />
</div>
<Field
label="Nom de naissance"
name="birthName"
hint="Laissez vide s’il est identique au nom de famille."
/>
<Field label="Nom de famille" name="lastName" required />
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline gap-3">
<span className="text-sm font-medium">Matricule</span>
<span className="flex-1" />
<button
type="button"
onClick={() => setAutoNumber(!autoNumber)}
className="text-micro font-medium text-accent-soft-ink underline underline-offset-2"
>
{autoNumber
? 'Saisir un matricule'
: 'Passer à la génération automatique'}
</button>
</div>
{autoNumber ? (
<p className="text-micro text-ink-3">
Attribué à la suite du dernier, au moment de l’enregistrement.
</p>
) : (
<input
name="employeeNumber"
aria-label="Matricule"
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
/>
)}
</div>
<Field label="Date de naissance" name="birthDate" type="date" />
<div className="flex flex-wrap gap-3">
<Field
label="Adresse électronique"
name="email"
type="email"
hint="Facultative. Sans adresse, le salarié reste planifiable et déclarable ; il n’aura simplement pas d’accès à l’application."
hint="Permet de se connecter à PlanFlow et de recevoir ses plannings. Sans elle, le salarié reste planifiable et déclarable."
/>
<Field label="Téléphone mobile" name="phone" type="tel" />
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Envoyer les plannings par SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" />
</Section>
{locations.length > 0 ? (
<>
<Section title="Contrat">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
@@ -101,63 +167,36 @@ export function AddEmployeeForm({
</label>
{withContract ? (
<div className="flex flex-col gap-3 rounded-3 border border-line-1 bg-surface-2 p-4">
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Établissement</span>
<select
name="locationId"
className={selectClass}
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Équipe</span>
<select name="teamId" className={selectClass}>
<option value="">Aucune pour l’instant</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-3">
<Field
label="Début du contrat"
label="Date de début de contrat"
name="startDate"
type="date"
required={withContract}
required
defaultValue={today()}
/>
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Vide pour un contrat sans terme."
label="Heure de début de contrat"
name="startTime"
type="time"
required
defaultValue="09:00"
hint="Demandée par la DPAE."
/>
</div>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">
@@ -187,7 +226,7 @@ export function AddEmployeeForm({
/>
) : (
<Field
label="Durée hebdomadaire"
label="Temps de travail hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
@@ -214,28 +253,85 @@ export function AddEmployeeForm({
/>
</div>
) : null}
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Vide pour un contrat sans terme."
/>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">
Établissement par défaut
</span>
<select
name="locationId"
className={selectClass}
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Équipe</span>
<select name="teamId" className={selectClass}>
<option value="">Aucune pour l’instant</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">
Responsable hiérarchique
</span>
<select name="lineManagerId" className={selectClass}>
<option value="">Aucun</option>
{options.managers.map((manager) => (
<option key={manager.id} value={manager.id}>
{manager.name}
</option>
))}
</select>
</label>
{options.rttPolicies.length > 0 ? (
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Politique RTT</span>
<select name="rttPolicyId" className={selectClass}>
<option value="">Aucune</option>
{options.rttPolicies.map((policy) => (
<option key={policy.id} value={policy.id}>
{policy.name}
</option>
))}
</select>
</label>
) : null}
</div>
) : null}
</>
</Section>
) : null}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ajouter</SubmitButton>
<div className="sticky bottom-0 -mx-5 border-t border-line-1 bg-surface px-5 py-4">
<SubmitButton className="w-full">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
<p className="mt-2 text-center text-xs text-ok-soft-ink">
Salarié ajouté.
</p>
) : null}
{withContract ? null : (
<Button
type="button"
variant="ghost"
onClick={() => setWithContract(true)}
disabled={locations.length === 0}
>
Ajouter un contrat
</Button>
)}
</div>
</PersistentForm>
);
+129 -5
View File
@@ -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<readonly [string, string]>;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
<select
name={name}
defaultValue={defaultValue}
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus"
>
<option value="">Non renseigné</option>
{options.map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
</label>
);
}
function readableDate(value: string): string {
if (!value) return '';
const parsed = new Date(`${value}T00:00:00Z`);
@@ -89,8 +140,20 @@ export function PersonalInfoPanel({
<InfoGrid>
<InfoCard title="État civil">
<div className="flex flex-col gap-3 pt-1">
<Select
label="Genre"
name="gender"
defaultValue={profile.gender}
options={GENDERS}
/>
<Field label="Prénom" name="firstName" required defaultValue={profile.firstName} />
<Field
label="Nom de naissance"
name="birthName"
defaultValue={profile.birthName}
/>
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
<Field
label="Date de naissance"
name="birthDate"
@@ -98,12 +161,34 @@ export function PersonalInfoPanel({
defaultValue={profile.birthDate}
/>
<Field
label="Lieu de naissance"
label="Pays de naissance"
name="birthCountry"
defaultValue={profile.birthCountry}
/>
<Field
label="Département de naissance"
name="birthDepartment"
defaultValue={profile.birthDepartment}
/>
<Field
label="Commune de naissance"
name="birthPlace"
defaultValue={profile.birthPlace}
hint="Commune, et département pour la France."
/>
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
<Select
label="Situation familiale"
name="maritalStatus"
defaultValue={profile.maritalStatus}
options={MARITAL_STATUSES}
/>
<Field
label="Nombre de personnes à charge"
name="dependents"
type="number"
min="0"
max="99"
defaultValue={profile.dependents}
/>
</div>
</InfoCard>
@@ -116,7 +201,27 @@ export function PersonalInfoPanel({
defaultValue={profile.personalEmail}
/>
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
defaultChecked={profile.smsSchedules}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Notifications SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} />
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
<Field
label="Complément d’adresse"
name="addressLine2"
defaultValue={profile.addressLine2}
/>
<div className="flex flex-wrap gap-3">
<Field label="Code postal" name="postalCode" defaultValue={profile.postalCode} />
<Field label="Ville" name="city" defaultValue={profile.city} />
@@ -158,17 +263,36 @@ export function PersonalInfoPanel({
<>
<InfoGrid>
<InfoCard title="État civil">
<InfoRow label="Genre" value={genderLabel(profile.gender || null)} />
<InfoRow label="Prénom" value={profile.firstName} />
<InfoRow label="Nom de naissance" value={profile.birthName} />
<InfoRow label="Nom de famille" value={profile.lastName} />
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
<InfoRow label="Lieu de naissance" value={profile.birthPlace} />
<InfoRow label="Nationalité" value={profile.nationality} />
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
<InfoRow label="Pays de naissance" value={profile.birthCountry} />
<InfoRow label="Département de naissance" value={profile.birthDepartment} />
<InfoRow label="Commune de naissance" value={profile.birthPlace} />
<InfoRow
label="Situation familiale"
value={maritalStatusLabel(profile.maritalStatus || null)}
/>
<InfoRow
label="Nombre de personnes à charge"
value={profile.dependents}
tnum
/>
</InfoCard>
<InfoCard title="Coordonnées">
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
<InfoRow label="Téléphone mobile" value={profile.phone} tnum />
<InfoRow
label="Notifications SMS"
value={profile.smsSchedules ? 'Oui' : 'Non'}
/>
<InfoRow label="Téléphone fixe" value={profile.landline} tnum />
<InfoRow label="Adresse" value={profile.addressLine1} />
<InfoRow label="Complément d’adresse" value={profile.addressLine2} />
<InfoRow label="Code postal" value={profile.postalCode} tnum />
<InfoRow label="Ville" value={profile.city} />
<InfoRow label="Pays" value={profile.country} />
+12
View File
@@ -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),
+10 -1
View File
@@ -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 ? (
<RegisterDialog locations={registerLocations} />
) : null}
<AddEmployeeDialog locations={hiringLocations} />
<AddEmployeeDialog
locations={hiringLocations}
options={hiringOptions}
/>
</>
}
/>
+69
View File
@@ -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<string, string>(GENDERS);
const MARITAL_LABELS = new Map<string, string>(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')}`;
}
+84 -5
View File
@@ -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<ActionState> {
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',
+48 -3
View File
@@ -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 = <T extends readonly string[]>(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<ProfileActionState> {
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' };
+61
View File
@@ -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<HiringOptions> {
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. */
+17 -6
View File
@@ -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,
+8 -6
View File
@@ -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();
+4 -3
View File
@@ -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();
+8 -6
View File
@@ -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
+4 -3
View File
@@ -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) });
+5 -2
View File
@@ -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
+4 -3
View File
@@ -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();
+4 -3
View File
@@ -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();
+19
View File
@@ -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');
});
});