Files
planflow/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx
T
MichaelandClaude Opus 5 b608aa87cf 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>
2026-08-10 17:34:10 +02:00

397 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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,
type ProfileActionState,
} from '@/server/employees/profile-actions';
/**
* Dossier personnel : consultation, puis saisie.
*
* Un seul écran et deux modes, plutôt qu'une page de lecture et une page de
* formulaire. Le dossier se corrige champ par champ, souvent au téléphone avec
* la personne concernée : la faire changer de page pour changer un chiffre
* ferait perdre le reste de vue.
*/
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;
emergencyContactName: string;
emergencyContactPhone: string;
}
export interface SensitiveFields {
socialSecurityNumber: string;
iban: string;
bic: string;
}
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`);
return Number.isNaN(parsed.getTime()) ? value : dateFormat.format(parsed);
}
export function PersonalInfoPanel({
membershipId,
profile,
sensitive,
canEdit,
}: {
membershipId: string;
profile: ProfileFields;
/** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
sensitive: SensitiveFields | null;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
const [state, save] = useActionState(updateProfileAction, empty);
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
// L'enregistrement referme le mode saisie, mais seulement après la réponse :
// refermer à l'envoi ferait disparaître le message d'erreur avec le
// formulaire, et avec lui ce que la personne venait de taper.
//
// La réponse est **acquittée** une fois pour toutes : sans cela, un succès
// resté en mémoire refermerait aussitôt le formulaire à la prochaine
// ouverture, et le dossier ne serait plus jamais modifiable.
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
return (
<div className="flex flex-col gap-5">
{editing ? (
<form action={save} className="flex flex-col gap-5">
<input type="hidden" name="membershipId" value={membershipId} />
<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"
type="date"
defaultValue={profile.birthDate}
/>
<Field
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}
/>
<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>
<InfoCard title="Coordonnées">
<div className="flex flex-col gap-3 pt-1">
<Field
label="Adresse électronique personnelle"
name="personalEmail"
type="email"
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} />
</div>
<Field label="Pays" name="country" defaultValue={profile.country} />
</div>
</InfoCard>
<InfoCard title="Contact d’urgence">
<div className="flex flex-col gap-3 pt-1">
<Field
label="Nom"
name="emergencyContactName"
defaultValue={profile.emergencyContactName}
/>
<Field
label="Téléphone"
name="emergencyContactPhone"
type="tel"
defaultValue={profile.emergencyContactPhone}
/>
</div>
</InfoCard>
</InfoGrid>
<FormError>{state.error}</FormError>
{/* Barre collante : le dossier est long, et le bouton d'enregistrement
ne doit pas se trouver hors de l'écran au moment où l'on finit de
corriger un champ du haut. */}
<div className="sticky bottom-4 flex flex-wrap items-center justify-center gap-3 rounded-3 border border-line-1 bg-surface p-3 shadow-[var(--shadow-1,0_1px_2px_rgba(0,0,0,0.06))]">
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
<SubmitButton>Enregistrer les modifications</SubmitButton>
</div>
</form>
) : (
<>
<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="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} />
</InfoCard>
<InfoCard title="Contact d’urgence">
<InfoRow label="Nom" value={profile.emergencyContactName} />
<InfoRow label="Téléphone" value={profile.emergencyContactPhone} tnum />
</InfoCard>
</InfoGrid>
{canEdit ? (
<div className="flex justify-center">
<Button variant="primary" onClick={() => setEditing(true)}>
Modifier les informations personnelles
</Button>
</div>
) : null}
</>
)}
{sensitive ? (
<SensitivePanel
membershipId={membershipId}
sensitive={sensitive}
canEdit={canEdit}
/>
) : null}
</div>
);
}
/**
* NIR, IBAN et BIC.
*
* Séparés du reste, et pas seulement à l'écran : ils sont chiffrés au repos,
* leur lecture demande sa propre capacité, et leur écriture passe par une autre
* action. Les mêler au formulaire commun ferait dépendre leur sort d'un droit
* qui ne les concerne pas.
*/
function SensitivePanel({
membershipId,
sensitive,
canEdit,
}: {
membershipId: string;
sensitive: SensitiveFields;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
const [state, save] = useActionState(updateSensitiveAction, empty);
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
return (
<InfoCard title="Données protégées">
{editing ? (
<form action={save} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="membershipId" value={membershipId} />
<Field
label="Numéro de sécurité sociale"
name="socialSecurityNumber"
inputMode="numeric"
defaultValue={sensitive.socialSecurityNumber}
hint="Quinze chiffres. Conservé chiffré ; jamais écrit au journal."
/>
<Field label="IBAN" name="iban" defaultValue={sensitive.iban} />
<Field label="BIC" name="bic" defaultValue={sensitive.bic} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
) : (
<>
<InfoRow
label="Numéro de sécurité sociale"
value={sensitive.socialSecurityNumber}
tnum
/>
<InfoRow label="IBAN" value={sensitive.iban} tnum />
<InfoRow label="BIC" value={sensitive.bic} tnum />
{canEdit ? (
<div className="pt-3">
<Button onClick={() => setEditing(true)}>Modifier</Button>
</div>
) : null}
</>
)}
</InfoCard>
);
}