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}
/>
</>
}
/>