Fermer les listes du dossier, et déclarer les titres de séjour
CI / verify (pull_request) Canceled after 0s

Quatre champs se saisissaient en texte libre là où la valeur appartient à
une liste : nationalité, pays de naissance, pays de l'adresse, département.
« Frnace » y entrait aussi bien que « France », et le registre imprimait la
faute.

Les pays sont stockés par leur code ISO 3166-1 et nommés à l'affichage par
`Intl.DisplayNames` : un nom dépend de la langue et des versions d'ICU, un
code ne bouge pas, et deux cent cinquante traductions n'ont pas à être
entretenues à la main. Les valeurs héritées — « France » en toutes lettres
— sont reconnues à la lecture puis normalisées au premier enregistrement,
plutôt que blanchies par un menu qui ne les retrouve pas.

Les départements portent la Corse et l'outre-mer : « 2A » n'est pas un
nombre, et le département de naissance sert à la déclaration sociale, qui
attend le code.

Les téléphones gagnent leur indicatif, choisi à part et recollé en un seul
champ. L'enregistrer séparément obligerait chaque lecture — annuaire,
export, SMS — à le recomposer, et un numéro à moitié lu ne joint personne.
La découpe prend l'indicatif le plus long : « +352 » commence par « +3 ».

Le titre de séjour se déclare enfin. `WorkPermit` existait depuis le début
sans qu'aucun écran ne l'alimente. L'échéance est exigée dès qu'un titre est
posé — employer quelqu'un dont l'autorisation a expiré est un délit, et sans
date rien ne peut le signaler à temps. Décocher retire le titre plutôt que
de le laisser alerter pour quelqu'un qui n'en relève plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 17:48:51 +02:00
1 parent b608aa87cf
commit 481af181fa
9 files changed
+766 -24

No files matched your search

+3 -2
View File
@@ -3,6 +3,7 @@
import { useActionState, useState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PhoneField } from '@/components/ui/PhoneField';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createEmployeeAction,
@@ -134,7 +135,7 @@ export function AddEmployeeForm({
type="email"
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" />
<PhoneField label="Téléphone mobile" name="phone" />
<label className="flex items-start gap-2 text-sm">
<input
@@ -150,7 +151,7 @@ export function AddEmployeeForm({
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" />
<PhoneField label="Téléphone fixe" name="landline" />
</Section>
{locations.length > 0 ? (
+215 -12
View File
@@ -5,12 +5,22 @@ 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 { PhoneField } from '@/components/ui/PhoneField';
import {
GENDERS,
MARITAL_STATUSES,
genderLabel,
maritalStatusLabel,
} from '@/domain/hr/civil-status';
import {
countryLabel,
countryOptions,
departmentLabel,
departmentOptions,
} from '@/domain/hr/geo';
import {
updateWorkPermitAction,
} from '@/server/employees/profile-actions';
import {
updateProfileAction,
updateSensitiveAction,
@@ -72,11 +82,13 @@ function Select({
name,
defaultValue,
options,
hint,
}: {
label: string;
name: string;
defaultValue: string;
options: ReadonlyArray<readonly [string, string]>;
hint?: string;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5">
@@ -93,6 +105,7 @@ function Select({
</option>
))}
</select>
{hint ? <span className="text-micro text-ink-3">{hint}</span> : null}
</label>
);
}
@@ -107,12 +120,14 @@ export function PersonalInfoPanel({
membershipId,
profile,
sensitive,
permit,
canEdit,
}: {
membershipId: string;
profile: ProfileFields;
/** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
sensitive: SensitiveFields | null;
permit: PermitFields;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
@@ -153,22 +168,30 @@ export function PersonalInfoPanel({
defaultValue={profile.birthName}
/>
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
<Select
label="Nationalité"
name="nationality"
defaultValue={profile.nationality}
options={countryOptions()}
/>
<Field
label="Date de naissance"
name="birthDate"
type="date"
defaultValue={profile.birthDate}
/>
<Field
<Select
label="Pays de naissance"
name="birthCountry"
defaultValue={profile.birthCountry}
options={countryOptions()}
/>
<Field
<Select
label="Département de naissance"
name="birthDepartment"
defaultValue={profile.birthDepartment}
options={departmentOptions()}
hint="Pour une naissance à l’étranger, laissez vide."
/>
<Field
label="Commune de naissance"
@@ -200,7 +223,11 @@ export function PersonalInfoPanel({
type="email"
defaultValue={profile.personalEmail}
/>
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
<PhoneField
label="Téléphone mobile"
name="phone"
defaultValue={profile.phone}
/>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
@@ -215,7 +242,11 @@ export function PersonalInfoPanel({
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} />
<PhoneField
label="Téléphone fixe"
name="landline"
defaultValue={profile.landline}
/>
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
<Field
label="Complément d’adresse"
@@ -226,7 +257,12 @@ export function PersonalInfoPanel({
<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} />
<Select
label="Pays"
name="country"
defaultValue={profile.country}
options={countryOptions()}
/>
</div>
</InfoCard>
@@ -237,10 +273,9 @@ export function PersonalInfoPanel({
name="emergencyContactName"
defaultValue={profile.emergencyContactName}
/>
<Field
<PhoneField
label="Téléphone"
name="emergencyContactPhone"
type="tel"
defaultValue={profile.emergencyContactPhone}
/>
</div>
@@ -267,10 +302,19 @@ export function PersonalInfoPanel({
<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="Nationalité"
value={countryLabel(profile.nationality || null)}
/>
<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="Pays de naissance"
value={countryLabel(profile.birthCountry || null)}
/>
<InfoRow
label="Département de naissance"
value={departmentLabel(profile.birthDepartment || null)}
/>
<InfoRow label="Commune de naissance" value={profile.birthPlace} />
<InfoRow
label="Situation familiale"
@@ -295,7 +339,7 @@ export function PersonalInfoPanel({
<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} />
<InfoRow label="Pays" value={countryLabel(profile.country || null)} />
</InfoCard>
<InfoCard title="Contact d’urgence">
@@ -314,6 +358,12 @@ export function PersonalInfoPanel({
</>
)}
<WorkPermitPanel
membershipId={membershipId}
permit={permit}
canEdit={canEdit}
/>
{sensitive ? (
<SensitivePanel
membershipId={membershipId}
@@ -394,3 +444,156 @@ function SensitivePanel({
</InfoCard>
);
}
export interface PermitFields {
foreignWorker: boolean;
permitType: string;
reference: string;
issuedAt: string;
expiresAt: string;
}
/**
* Autorisation de travail.
*
* Une seule case commande la section : la plupart des dossiers n'en relèvent
* pas, et poser quatre champs de titre de séjour devant tout le monde ferait
* d'une exception la règle de saisie.
*
* L'échéance est exigée dès qu'un titre est déclaré. Employer quelqu'un dont le
* titre a expiré est un délit ; sans date, rien ne peut le signaler à temps.
*/
function WorkPermitPanel({
membershipId,
permit,
canEdit,
}: {
membershipId: string;
permit: PermitFields;
canEdit: boolean;
}) {
const [state, save] = useActionState(updateWorkPermitAction, empty);
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
const [editing, setEditing] = useState(false);
const [foreign, setForeign] = useState(permit.foreignWorker);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
const expiry = permit.expiresAt
? new Date(`${permit.expiresAt}T00:00:00Z`)
: null;
const expired = expiry !== null && expiry < new Date();
return (
<InfoCard title="Autorisation de travail">
{editing ? (
<form action={save} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="membershipId" value={membershipId} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="foreignWorker"
checked={foreign}
onChange={(event) => setForeign(event.target.checked)}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">
Travailleur étranger avec autorisation de travail
</span>
<span className="block text-micro text-ink-3">
Décocher retire le titre : il cesserait sinon d’alerter à son
échéance pour quelqu’un qui n’en relève plus.
</span>
</span>
</label>
{foreign ? (
<>
<Field
label="Nature du titre"
name="permitType"
required
defaultValue={permit.permitType}
hint="Carte de séjour, autorisation provisoire, récépissé…"
/>
<Field
label="Référence"
name="reference"
required
defaultValue={permit.reference}
/>
<div className="flex flex-wrap gap-3">
<Field
label="Délivré le"
name="issuedAt"
type="date"
defaultValue={permit.issuedAt}
/>
<Field
label="Expire le"
name="expiresAt"
type="date"
required
defaultValue={permit.expiresAt}
/>
</div>
</>
) : null}
<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="Travailleur étranger"
value={permit.foreignWorker ? 'Oui' : 'Non'}
/>
{permit.foreignWorker ? (
<>
<InfoRow label="Nature du titre" value={permit.permitType} />
<InfoRow label="Référence" value={permit.reference} />
<InfoRow
label="Délivré le"
value={readableDate(permit.issuedAt)}
tnum
/>
<InfoRow
label="Expire le"
value={readableDate(permit.expiresAt)}
tnum
/>
{expired ? (
<p
role="alert"
className="mt-3 rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs text-danger-soft-ink"
>
Ce titre a expiré. Employer une personne dont l’autorisation
n’est plus valide est un délit : régularisez avant de la
planifier.
</p>
) : null}
</>
) : null}
{canEdit ? (
<div className="pt-3">
<Button onClick={() => setEditing(true)}>Modifier</Button>
</div>
) : null}
</>
)}
</InfoCard>
);
}
+24 -3
View File
@@ -2,9 +2,11 @@ import { notFound } from 'next/navigation';
import {
PersonalInfoPanel,
type PermitFields,
type ProfileFields,
type SensitiveFields,
} from '@/app/(app)/equipe/[id]/PersonalInfoPanel';
import { toCountryCode } from '@/domain/hr/geo';
import { EmptyState } from '@/components/ui/Card';
import { getEmployee } from '@/server/employees/queries';
@@ -41,9 +43,9 @@ export default async function PersonalTab({
? employee.profile.birthDate.toISOString().slice(0, 10)
: '',
birthPlace: text(employee.profile.birthPlace),
birthCountry: text(employee.profile.birthCountry),
birthCountry: toCountryCode(employee.profile.birthCountry),
birthDepartment: text(employee.profile.birthDepartment),
nationality: text(employee.profile.nationality),
nationality: toCountryCode(employee.profile.nationality),
maritalStatus: text(employee.profile.maritalStatus),
dependents:
employee.profile.dependents === null
@@ -57,7 +59,7 @@ export default async function PersonalTab({
addressLine2: text(employee.profile.addressLine2),
postalCode: text(employee.profile.postalCode),
city: text(employee.profile.city),
country: text(employee.profile.country),
country: toCountryCode(employee.profile.country),
emergencyContactName: text(employee.profile.emergencyContactName),
emergencyContactPhone: text(employee.profile.emergencyContactPhone),
};
@@ -72,11 +74,30 @@ export default async function PersonalTab({
}
: null;
const permit: PermitFields = employee.workPermit
? {
foreignWorker: true,
permitType: employee.workPermit.permitType,
reference: employee.workPermit.reference,
issuedAt: employee.workPermit.issuedAt
? employee.workPermit.issuedAt.toISOString().slice(0, 10)
: '',
expiresAt: employee.workPermit.expiresAt.toISOString().slice(0, 10),
}
: {
foreignWorker: false,
permitType: '',
reference: '',
issuedAt: '',
expiresAt: '',
};
return (
<PersonalInfoPanel
membershipId={employee.id}
profile={profile}
sensitive={sensitive}
permit={permit}
canEdit={employee.canEdit}
/>
);
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useId, useState } from 'react';
import {
DEFAULT_DIAL_COUNTRY,
DIAL_CODES,
countryLabel,
splitDial,
} from '@/domain/hr/geo';
/**
* Numéro de téléphone avec indicatif.
*
* Deux contrôles à l'écran, une seule valeur envoyée : l'indicatif et le reste
* sont recollés dans un champ caché. Enregistrer l'indicatif à part obligerait
* chaque lecture — annuaire, export, SMS — à le recomposer, et un numéro à
* moitié lu ne joint personne.
*
* La liste des indicatifs est courte à dessein. Le champ accepte un numéro
* international saisi en entier : c'est ce qui reste vrai pour les pays qui
* n'y figurent pas.
*/
export function PhoneField({
label,
name,
defaultValue = '',
hint,
}: {
label: string;
name: string;
defaultValue?: string;
hint?: string;
}) {
const generated = useId();
const initial = splitDial(defaultValue);
const [dial, setDial] = useState(initial.dial);
const [rest, setRest] = useState(initial.rest);
const options = Object.entries(DIAL_CODES)
.map(([code, value]) => ({
code,
value,
label: `${countryLabel(code) ?? code} ${value}`,
}))
.sort((a, b) => {
if (a.code === DEFAULT_DIAL_COUNTRY) return -1;
if (b.code === DEFAULT_DIAL_COUNTRY) return 1;
return a.label.localeCompare(b.label, 'fr');
});
// Un numéro vide reste vide : recoller l'indicatif seul enregistrerait
// « +33 » comme un numéro, et l'annuaire afficherait un contact fantôme.
const composed = rest.trim() ? `${dial} ${rest.trim()}` : '';
return (
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<label htmlFor={generated} className="text-sm font-medium">
{label}
</label>
<input type="hidden" name={name} value={composed} />
<div className="flex gap-2">
<select
aria-label={`Indicatif — ${label}`}
value={dial}
onChange={(event) => setDial(event.target.value)}
className="h-9 w-28 shrink-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus"
>
{options.map((option) => (
<option key={option.code} value={option.value}>
{option.label}
</option>
))}
</select>
<input
id={generated}
type="tel"
inputMode="tel"
autoComplete="tel-national"
value={rest}
onChange={(event) => setRest(event.target.value)}
{...(hint ? { 'aria-describedby': `${generated}-hint` } : {})}
className="h-9 min-w-0 flex-1 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none placeholder:text-ink-3 focus-visible:border-focus"
/>
</div>
{hint ? (
<span id={`${generated}-hint`} className="text-micro text-ink-3">
{hint}
</span>
) : null}
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Référentiels géographiques du dossier salarié.
*
* Les pays sont stockés par leur **code ISO 3166-1 alpha-2**, pas par leur nom.
* Un nom dépend de la langue de l'interface et se retouche au fil des versions
* d'ICU ; un code ne bouge pas. Les libellés sont produits à l'affichage par
* `Intl.DisplayNames`, ce qui évite d'entretenir à la main deux cent cinquante
* traductions et d'en rater les mises à jour.
*
* Les valeurs déjà en base d'avant ce référentiel — du texte libre, « France »
* le plus souvent — sont rendues telles quelles : elles restent lisibles, et
* rien ne se perd en attendant qu'un passage sur la fiche les normalise.
*/
/** ISO 3166-1 alpha-2, ordre alphabétique du code. */
export const COUNTRY_CODES = [
'AD','AE','AF','AG','AI','AL','AM','AO','AQ','AR','AS','AT','AU','AW','AX','AZ',
'BA','BB','BD','BE','BF','BG','BH','BI','BJ','BL','BM','BN','BO','BQ','BR','BS',
'BT','BV','BW','BY','BZ','CA','CC','CD','CF','CG','CH','CI','CK','CL','CM','CN',
'CO','CR','CU','CV','CW','CX','CY','CZ','DE','DJ','DK','DM','DO','DZ','EC','EE',
'EG','EH','ER','ES','ET','FI','FJ','FK','FM','FO','FR','GA','GB','GD','GE','GF',
'GG','GH','GI','GL','GM','GN','GP','GQ','GR','GS','GT','GU','GW','GY','HK','HM',
'HN','HR','HT','HU','ID','IE','IL','IM','IN','IO','IQ','IR','IS','IT','JE','JM',
'JO','JP','KE','KG','KH','KI','KM','KN','KP','KR','KW','KY','KZ','LA','LB','LC',
'LI','LK','LR','LS','LT','LU','LV','LY','MA','MC','MD','ME','MF','MG','MH','MK',
'ML','MM','MN','MO','MP','MQ','MR','MS','MT','MU','MV','MW','MX','MY','MZ','NA',
'NC','NE','NF','NG','NI','NL','NO','NP','NR','NU','NZ','OM','PA','PE','PF','PG',
'PH','PK','PL','PM','PN','PR','PS','PT','PW','PY','QA','RE','RO','RS','RU','RW',
'SA','SB','SC','SD','SE','SG','SH','SI','SJ','SK','SL','SM','SN','SO','SR','SS',
'ST','SV','SX','SY','SZ','TC','TD','TF','TG','TH','TJ','TK','TL','TM','TN','TO',
'TR','TT','TV','TW','TZ','UA','UG','UM','US','UY','UZ','VA','VC','VE','VG','VI',
'VN','VU','WF','WS','YE','YT','ZA','ZM','ZW',
] as const;
/**
* Indicatifs téléphoniques.
*
* Volontairement partiels : ceux des pays d'où l'on embauche couramment en
* France. Le champ accepte de toute façon un numéro international saisi en
* entier — proposer deux cent cinquante indicatifs allongerait la liste sans
* rendre la saisie plus sûre.
*/
export const DIAL_CODES: Record<string, string> = {
FR: '+33', BE: '+32', CH: '+41', LU: '+352', DE: '+49', ES: '+34', IT: '+39',
PT: '+351', GB: '+44', IE: '+353', NL: '+31', PL: '+48', RO: '+40', BG: '+359',
AT: '+43', DK: '+45', SE: '+46', NO: '+47', FI: '+358', GR: '+30', HU: '+36',
CZ: '+420', SK: '+421', HR: '+385', LT: '+370', LV: '+371', EE: '+372',
DZ: '+213', MA: '+212', TN: '+216', SN: '+221', CI: '+225', ML: '+223',
CM: '+237', CD: '+243', MG: '+261', HT: '+509', TR: '+90', UA: '+380',
US: '+1', CA: '+1', BR: '+55', CN: '+86', IN: '+91', JP: '+81',
GP: '+590', MQ: '+596', GF: '+594', RE: '+262', YT: '+262', NC: '+687',
PF: '+689',
};
/** Indicatif le plus courant ici : la saisie part de là. */
export const DEFAULT_DIAL_COUNTRY = 'FR';
const displayNames =
typeof Intl !== 'undefined' && 'DisplayNames' in Intl
? new Intl.DisplayNames(['fr'], { type: 'region' })
: null;
/**
* Libellé d'un pays.
*
* Rend la valeur telle quelle quand elle n'est pas un code connu : c'est le cas
* des dossiers saisis avant ce référentiel, et les blanchir serait pire que de
* les laisser en texte libre.
*/
export function countryLabel(value: string | null): string | null {
if (!value) return null;
if (!/^[A-Z]{2}$/.test(value)) return value;
return displayNames?.of(value) ?? value;
}
/** Liste triée pour les menus déroulants, dans l'ordre alphabétique français. */
export function countryOptions(): Array<readonly [string, string]> {
return COUNTRY_CODES.map(
(code) => [code, countryLabel(code) ?? code] as const,
).sort((a, b) => a[1].localeCompare(b[1], 'fr'));
}
/**
* Départements français, Corse et outre-mer compris.
*
* Stockés par leur code : « 2A » et « 2B » ne sont pas des nombres, et le
* département de naissance sert à la déclaration sociale, qui attend le code.
*/
export const DEPARTMENTS: Array<readonly [string, string]> = [
['01', 'Ain'], ['02', 'Aisne'], ['03', 'Allier'],
['04', 'Alpes-de-Haute-Provence'], ['05', 'Hautes-Alpes'],
['06', 'Alpes-Maritimes'], ['07', 'Ardèche'], ['08', 'Ardennes'],
['09', 'Ariège'], ['10', 'Aube'], ['11', 'Aude'], ['12', 'Aveyron'],
['13', 'Bouches-du-Rhône'], ['14', 'Calvados'], ['15', 'Cantal'],
['16', 'Charente'], ['17', 'Charente-Maritime'], ['18', 'Cher'],
['19', 'Corrèze'], ['2A', 'Corse-du-Sud'], ['2B', 'Haute-Corse'],
['21', "Côte-d'Or"], ['22', "Côtes-d'Armor"], ['23', 'Creuse'],
['24', 'Dordogne'], ['25', 'Doubs'], ['26', 'Drôme'], ['27', 'Eure'],
['28', 'Eure-et-Loir'], ['29', 'Finistère'], ['30', 'Gard'],
['31', 'Haute-Garonne'], ['32', 'Gers'], ['33', 'Gironde'],
['34', 'Hérault'], ['35', 'Ille-et-Vilaine'], ['36', 'Indre'],
['37', 'Indre-et-Loire'], ['38', 'Isère'], ['39', 'Jura'],
['40', 'Landes'], ['41', 'Loir-et-Cher'], ['42', 'Loire'],
['43', 'Haute-Loire'], ['44', 'Loire-Atlantique'], ['45', 'Loiret'],
['46', 'Lot'], ['47', 'Lot-et-Garonne'], ['48', 'Lozère'],
['49', 'Maine-et-Loire'], ['50', 'Manche'], ['51', 'Marne'],
['52', 'Haute-Marne'], ['53', 'Mayenne'], ['54', 'Meurthe-et-Moselle'],
['55', 'Meuse'], ['56', 'Morbihan'], ['57', 'Moselle'], ['58', 'Nièvre'],
['59', 'Nord'], ['60', 'Oise'], ['61', 'Orne'], ['62', 'Pas-de-Calais'],
['63', 'Puy-de-Dôme'], ['64', 'Pyrénées-Atlantiques'],
['65', 'Hautes-Pyrénées'], ['66', 'Pyrénées-Orientales'],
['67', 'Bas-Rhin'], ['68', 'Haut-Rhin'], ['69', 'Rhône'],
['70', 'Haute-Saône'], ['71', 'Saône-et-Loire'], ['72', 'Sarthe'],
['73', 'Savoie'], ['74', 'Haute-Savoie'], ['75', 'Paris'],
['76', 'Seine-Maritime'], ['77', 'Seine-et-Marne'], ['78', 'Yvelines'],
['79', 'Deux-Sèvres'], ['80', 'Somme'], ['81', 'Tarn'],
['82', 'Tarn-et-Garonne'], ['83', 'Var'], ['84', 'Vaucluse'],
['85', 'Vendée'], ['86', 'Vienne'], ['87', 'Haute-Vienne'],
['88', 'Vosges'], ['89', 'Yonne'], ['90', 'Territoire de Belfort'],
['91', 'Essonne'], ['92', 'Hauts-de-Seine'], ['93', 'Seine-Saint-Denis'],
['94', 'Val-de-Marne'], ['95', "Val-d'Oise"],
['971', 'Guadeloupe'], ['972', 'Martinique'], ['973', 'Guyane'],
['974', 'La Réunion'], ['976', 'Mayotte'],
['975', 'Saint-Pierre-et-Miquelon'], ['977', 'Saint-Barthélemy'],
['978', 'Saint-Martin'], ['984', 'Terres australes et antarctiques'],
['986', 'Wallis-et-Futuna'], ['987', 'Polynésie française'],
['988', 'Nouvelle-Calédonie'], ['989', 'Clipperton'],
];
const DEPARTMENT_NAMES = new Map(DEPARTMENTS);
export function departmentLabel(value: string | null): string | null {
if (!value) return null;
const name = DEPARTMENT_NAMES.get(value);
return name ? `${value} - ${name}` : value;
}
/** Options du menu, au format « 54 - Meurthe-et-Moselle ». */
export function departmentOptions(): Array<readonly [string, string]> {
return DEPARTMENTS.map(([code, name]) => [code, `${code} - ${name}`] as const);
}
export function isKnownCountry(value: string): boolean {
return (COUNTRY_CODES as readonly string[]).includes(value);
}
/**
* Ramène une valeur héritée à son code.
*
* Les dossiers saisis avant ce référentiel portent « France » en toutes
* lettres. Sans cette reconnaissance, le menu déroulant ne trouverait pas la
* valeur, retomberait sur « Non renseigné », et le premier enregistrement
* effacerait une information exacte.
*/
export function toCountryCode(value: string | null): string {
if (!value) return '';
if (isKnownCountry(value)) return value;
const needle = value.trim().toLocaleLowerCase('fr');
const match = COUNTRY_CODES.find(
(code) => countryLabel(code)?.toLocaleLowerCase('fr') === needle,
);
return match ?? '';
}
export function isKnownDepartment(value: string): boolean {
return DEPARTMENT_NAMES.has(value);
}
/**
* Découpe un numéro enregistré en indicatif et reste.
*
* L'indicatif le plus long l'emporte : « +1 » préfixe « +1 » mais aussi
* « +1242 », et tester dans l'ordre de la table donnerait le mauvais pays.
*/
export function splitDial(value: string): { dial: string; rest: string } {
const trimmed = value.trim();
const dials = [...new Set(Object.values(DIAL_CODES))].sort(
(a, b) => b.length - a.length,
);
const found = dials.find((dial) => trimmed.startsWith(dial));
if (!found) return { dial: DIAL_CODES[DEFAULT_DIAL_COUNTRY] as string, rest: trimmed };
return { dial: found, rest: trimmed.slice(found.length).trim() };
}
+145 -4
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError, can } from '@/domain/access/authorize';
import { isKnownCountry, isKnownDepartment } from '@/domain/hr/geo';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { encryptOptional } from '@/server/crypto';
@@ -44,6 +45,23 @@ const optionalEnum = <T extends readonly string[]>(values: T) =>
)
.transform((value) => (value === '' ? null : value));
const optionalCountry = () =>
z
.string()
.trim()
.refine((value) => value === '' || isKnownCountry(value), 'Pays inconnu')
.transform((value) => (value === '' ? null : value));
const optionalDepartment = () =>
z
.string()
.trim()
.refine(
(value) => value === '' || isKnownDepartment(value),
'Département inconnu',
)
.transform((value) => (value === '' ? null : value));
const profileInput = z.object({
membershipId: z.string().min(1),
gender: optionalEnum(['FEMALE', 'MALE', 'UNSPECIFIED'] as const),
@@ -52,9 +70,11 @@ const profileInput = z.object({
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),
// Les pays et le département viennent de listes fermées : accepter du texte
// libre rendrait le référentiel inutile dès la première saisie à la main.
birthCountry: optionalCountry(),
birthDepartment: optionalDepartment(),
nationality: optionalCountry(),
maritalStatus: optionalEnum([
'SINGLE',
'MARRIED',
@@ -87,7 +107,7 @@ const profileInput = z.object({
addressLine2: optionalText(180),
postalCode: optionalText(12),
city: optionalText(120),
country: optionalText(80),
country: optionalCountry(),
emergencyContactName: optionalText(120),
emergencyContactPhone: optionalText(30),
});
@@ -266,3 +286,124 @@ export async function updateSensitiveAction(
revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
const permitInput = z.object({
membershipId: z.string().min(1),
/** Décoché = le salarié n'a pas besoin d'autorisation : les titres tombent. */
foreignWorker: z.boolean(),
permitType: z.string().trim().max(120),
reference: z.string().trim().max(120),
issuedAt: z.string().trim().max(10),
expiresAt: z.string().trim().max(10),
});
/**
* Titre de séjour valant autorisation de travail.
*
* L'échéance est **obligatoire** dès qu'un titre est déclaré : un titre sans
* date de fin ne se surveille pas, et employer quelqu'un dont le titre a expiré
* est un délit — c'est précisément ce que cette date permet d'éviter.
*
* Décocher retire le titre plutôt que de le laisser dormir : un titre conservé
* pour un salarié qui n'en relève plus continuerait d'alarmer à son échéance.
*/
export async function updateWorkPermitAction(
_previous: ProfileActionState,
formData: FormData,
): Promise<ProfileActionState> {
const parsed = permitInput.safeParse({
membershipId: formData.get('membershipId') ?? '',
foreignWorker: formData.get('foreignWorker') === 'on',
permitType: formData.get('permitType') ?? '',
reference: formData.get('reference') ?? '',
issuedAt: formData.get('issuedAt') ?? '',
expiresAt: formData.get('expiresAt') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId, foreignWorker } = parsed.data;
if (foreignWorker) {
if (!parsed.data.permitType) {
return { error: 'Précisez la nature du titre.' };
}
if (!parsed.data.reference) {
return { error: 'La référence du titre est requise.' };
}
if (!parsed.data.expiresAt) {
return {
error:
'La date d’expiration est requise : un titre sans échéance ne peut pas être surveillé.',
};
}
}
const issuedAt = parsed.data.issuedAt
? new Date(`${parsed.data.issuedAt}T00:00:00Z`)
: null;
const expiresAt = parsed.data.expiresAt
? new Date(`${parsed.data.expiresAt}T00:00:00Z`)
: null;
if (foreignWorker && expiresAt && issuedAt && expiresAt < issuedAt) {
return { error: 'Le titre expire avant d’avoir été délivré.' };
}
try {
await mutate('members.edit', async (db, actor) => {
const before = await db.workPermit.findFirst({
where: { membershipId },
orderBy: { expiresAt: 'desc' },
select: { id: true, permitType: true, expiresAt: true },
});
// Remplacement et non accumulation : la fiche n'en porte qu'un, et
// empiler les renouvellements ferait surveiller une échéance périmée à
// côté de la bonne.
await db.workPermit.deleteMany({ where: { membershipId } });
if (foreignWorker && expiresAt) {
await db.workPermit.create({
data: {
membershipId,
permitType: parsed.data.permitType,
reference: parsed.data.reference,
issuedAt,
expiresAt,
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.work_permit.update',
entityType: 'Membership',
entityId: membershipId,
before: before
? {
permitType: before.permitType,
expiresAt: before.expiresAt.toISOString(),
}
: null,
after: foreignWorker
? {
permitType: parsed.data.permitType,
expiresAt: expiresAt?.toISOString() ?? null,
}
: { removed: true },
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: 'Vous n’avez pas le droit de modifier ce dossier.' };
}
throw error;
}
revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
+18
View File
@@ -305,6 +305,13 @@ export interface EmployeeDetail extends EmployeeListRow {
contractEnd: Date | null;
};
profile: EmployeeProfileDetail | null;
/** Titre de séjour en cours, `null` quand le salarié n'en relève pas. */
workPermit: {
permitType: string;
reference: string;
issuedAt: Date | null;
expiresAt: Date;
} | null;
contracts: Array<{
id: string;
type: string;
@@ -364,6 +371,9 @@ export const getEmployee = cache(async function getEmployee(
teams: {
select: { isPrimary: true, team: { select: { name: true } } },
},
// Le plus lointain : c'est celui qui couvre la période en cours quand
// un renouvellement a été enregistré avant l'expiration du précédent.
workPermits: { orderBy: { expiresAt: 'desc' }, take: 1 },
contracts: {
orderBy: { startDate: 'desc' },
include: {
@@ -467,6 +477,14 @@ export const getEmployee = cache(async function getEmployee(
: null,
contractEnd: active?.endDate ?? null,
},
workPermit: membership.workPermits[0]
? {
permitType: membership.workPermits[0].permitType,
reference: membership.workPermits[0].reference,
issuedAt: membership.workPermits[0].issuedAt,
expiresAt: membership.workPermits[0].expiresAt,
}
: null,
profile: membership.profile
? {
gender: membership.profile.gender,
+34 -3
View File
@@ -69,9 +69,13 @@ test('le dossier personnel se saisit et se conserve', async ({ page }) => {
.click();
await page.getByLabel('Date de naissance').fill('1988-03-12');
await page.getByLabel('Lieu de naissance').fill('Nancy');
await page.getByLabel('Nationalité').fill('France');
await page.getByLabel('Téléphone mobile').fill('+33 6 12 34 56 78');
await page.getByLabel('Commune de naissance').fill('Nancy');
// Pays et département viennent de listes fermées : la saisie libre ne peut
// plus y introduire « Frnace ».
await page.getByLabel('Nationalité').selectOption('FR');
await page.getByLabel('Département de naissance').selectOption('54');
// L'indicatif est un contrôle à part ; le champ ne porte que le reste.
await page.getByLabel('Téléphone mobile', { exact: true }).fill('6 12 34 56 78');
await page.getByLabel('Ville').fill('Frouard');
await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
@@ -82,6 +86,33 @@ test('le dossier personnel se saisit et se conserve', async ({ page }) => {
await page.goto(employee.url);
await expect(page.getByText('Nancy')).toBeVisible();
await expect(page.getByText('Frouard')).toBeVisible();
await expect(page.getByText('54 - Meurthe-et-Moselle')).toBeVisible();
await expect(page.getByText('+33 6 12 34 56 78')).toBeVisible();
});
test('un titre de séjour expiré est signalé sur la fiche', async ({ page }) => {
await createEmployee(page);
await page
.locator('form')
.filter({ hasText: 'Travailleur étranger' })
.or(page.getByText('Autorisation de travail'))
.first()
.scrollIntoViewIfNeeded();
const card = page
.locator('section')
.filter({ hasText: 'Autorisation de travail' });
await card.getByRole('button', { name: 'Modifier' }).click();
await card.getByLabel(/Travailleur étranger/).check();
// Sans échéance, rien ne peut alerter : le refus est le comportement voulu.
await card.getByLabel('Nature du titre').fill('Carte de séjour');
await card.getByLabel('Référence').fill('AGDREF-123456');
await card.getByLabel('Expire le').fill('2020-01-01');
await card.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText(/Ce titre a expiré/)).toBeVisible();
});
test('une date de naissance à venir est refusée', async ({ page }) => {
+51
View File
@@ -136,3 +136,54 @@ describe('matricule proposé', () => {
expect(nextEmployeeNumber(['SILAE-42', '00012', 'E0002'])).toBe('E0003');
});
});
describe('référentiels géographiques', () => {
it('nomme les pays en français depuis leur code', async () => {
const { countryLabel } = await import('@/domain/hr/geo');
expect(countryLabel('FR')).toBe('France');
expect(countryLabel('DE')).toBe('Allemagne');
});
it('laisse passer une valeur héritée plutôt que de la blanchir', async () => {
const { countryLabel } = await import('@/domain/hr/geo');
// Les dossiers saisis avant le référentiel portent le nom en clair.
expect(countryLabel('Nouvelle-Calédonie')).toBe('Nouvelle-Calédonie');
});
it('ramène un nom hérité à son code', async () => {
const { toCountryCode } = await import('@/domain/hr/geo');
expect(toCountryCode('France')).toBe('FR');
expect(toCountryCode('FR')).toBe('FR');
expect(toCountryCode('Pays imaginaire')).toBe('');
expect(toCountryCode(null)).toBe('');
});
it('porte la Corse et l’outre-mer', async () => {
const { departmentLabel, isKnownDepartment } = await import('@/domain/hr/geo');
expect(departmentLabel('2A')).toBe('2A - Corse-du-Sud');
expect(departmentLabel('974')).toBe('974 - La Réunion');
expect(isKnownDepartment('20')).toBe(false);
});
it('coupe un numéro sur l’indicatif le plus long', async () => {
const { splitDial } = await import('@/domain/hr/geo');
// « +352 » commence par « +3 » : tester dans l'ordre de la table
// donnerait la France pour un numéro luxembourgeois.
expect(splitDial('+352 621 12 34 56')).toEqual({
dial: '+352',
rest: '621 12 34 56',
});
expect(splitDial('+33 6 12 34 56 78')).toEqual({
dial: '+33',
rest: '6 12 34 56 78',
});
});
it('retombe sur la France pour un numéro sans indicatif', async () => {
const { splitDial } = await import('@/domain/hr/geo');
expect(splitDial('06 12 34 56 78')).toEqual({
dial: '+33',
rest: '06 12 34 56 78',
});
});
});