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:
1 parent
e27d61dd4c
commit
b608aa87cf
20 files changed
+771
-135
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;
|
||||
@@ -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
|
||||
/// 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
|
||||
|
||||
|
||||
@@ -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,13 +45,14 @@ 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)]"
|
||||
>
|
||||
<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="ajout-collaborateur" className="text-md font-semibold">
|
||||
Ajouter un collaborateur
|
||||
<h2 id="titre-embauche" className="text-md font-semibold">
|
||||
Nouvel employé
|
||||
</h2>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
@@ -54,16 +60,18 @@ export function AddEmployeeDialog({
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto p-5">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
<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>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Date de naissance" name="birthDate" type="date" />
|
||||
|
||||
<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,37 +167,26 @@ 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-col gap-3">
|
||||
<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>
|
||||
<Field
|
||||
label="Date de début de contrat"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
defaultValue={today()}
|
||||
/>
|
||||
<Field
|
||||
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-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">
|
||||
<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]) => (
|
||||
@@ -141,22 +196,6 @@ export function AddEmployeeForm({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Début du contrat"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required={withContract}
|
||||
/>
|
||||
<Field
|
||||
label="Fin du contrat"
|
||||
name="endDate"
|
||||
type="date"
|
||||
hint="Vide pour un contrat sans terme."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user