From 481af181fa75899062e30b8509034327fc4b4443 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 10 Aug 2026 17:48:51 +0200 Subject: [PATCH] =?UTF-8?q?Fermer=20les=20listes=20du=20dossier,=20et=20d?= =?UTF-8?q?=C3=A9clarer=20les=20titres=20de=20s=C3=A9jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/(app)/equipe/AddEmployeeForm.tsx | 5 +- .../(app)/equipe/[id]/PersonalInfoPanel.tsx | 227 +++++++++++++++++- src/app/(app)/equipe/[id]/page.tsx | 27 ++- src/components/ui/PhoneField.tsx | 93 +++++++ src/domain/hr/geo.ts | 183 ++++++++++++++ src/server/employees/profile-actions.ts | 149 +++++++++++- src/server/employees/queries.ts | 18 ++ tests/e2e/fiche.spec.ts | 37 ++- tests/unit/rup.test.ts | 51 ++++ 9 files changed, 766 insertions(+), 24 deletions(-) create mode 100644 src/components/ui/PhoneField.tsx create mode 100644 src/domain/hr/geo.ts diff --git a/src/app/(app)/equipe/AddEmployeeForm.tsx b/src/app/(app)/equipe/AddEmployeeForm.tsx index 5d4b472..b993edc 100644 --- a/src/app/(app)/equipe/AddEmployeeForm.tsx +++ b/src/app/(app)/equipe/AddEmployeeForm.tsx @@ -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." /> - + - + {locations.length > 0 ? ( diff --git a/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx index 5d94764..3e87a09 100644 --- a/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx +++ b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx @@ -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; + hint?: string; }) { return ( ); } @@ -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} /> - + - + - + + + + + {foreign ? ( + <> + + +
+ + +
+ + ) : null} + + {state.error} + +
+ Enregistrer + +
+ + ) : ( + <> + + {permit.foreignWorker ? ( + <> + + + + + {expired ? ( +

+ Ce titre a expiré. Employer une personne dont l’autorisation + n’est plus valide est un délit : régularisez avant de la + planifier. +

+ ) : null} + + ) : null} + + {canEdit ? ( +
+ +
+ ) : null} + + )} + + ); +} diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx index 262eea8..99f170d 100644 --- a/src/app/(app)/equipe/[id]/page.tsx +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -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 ( ); diff --git a/src/components/ui/PhoneField.tsx b/src/components/ui/PhoneField.tsx new file mode 100644 index 0000000..f69abfa --- /dev/null +++ b/src/components/ui/PhoneField.tsx @@ -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 ( +
+ + +
+ + 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" + /> +
+ {hint ? ( + + {hint} + + ) : null} +
+ ); +} diff --git a/src/domain/hr/geo.ts b/src/domain/hr/geo.ts new file mode 100644 index 0000000..7fbb028 --- /dev/null +++ b/src/domain/hr/geo.ts @@ -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 = { + 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 { + 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 = [ + ['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 { + 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() }; +} diff --git a/src/server/employees/profile-actions.ts b/src/server/employees/profile-actions.ts index 05632c1..4d1f935 100644 --- a/src/server/employees/profile-actions.ts +++ b/src/server/employees/profile-actions.ts @@ -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 = (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 { + 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 }; +} diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts index fd84d05..e95f118 100644 --- a/src/server/employees/queries.ts +++ b/src/server/employees/queries.ts @@ -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, diff --git a/tests/e2e/fiche.spec.ts b/tests/e2e/fiche.spec.ts index f711fe6..8e64e1f 100644 --- a/tests/e2e/fiche.spec.ts +++ b/tests/e2e/fiche.spec.ts @@ -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 }) => { diff --git a/tests/unit/rup.test.ts b/tests/unit/rup.test.ts index ae9a9ec..f428949 100644 --- a/tests/unit/rup.test.ts +++ b/tests/unit/rup.test.ts @@ -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', + }); + }); +});