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.

Migration écrite et validée contre le schéma, non appliquée : aucune base
n'était ouverte ici. À passer avec `pnpm db:migrate`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 17:28:28 +02:00
1 parent e27d61dd4c
commit 06198ddbce
20 files changed
+791 -155

No files matched your search

+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,