diff --git a/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx
index 5d94764..2d86f53 100644
--- a/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx
+++ b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx
@@ -11,6 +11,13 @@ import {
genderLabel,
maritalStatusLabel,
} from '@/domain/hr/civil-status';
+import {
+ COUNTRY_OPTIONS,
+ NATIONALITY_OPTIONS,
+ countryLabel,
+ nationalityLabel,
+} from '@/domain/legal/countries';
+import { DEPARTMENTS, departmentLabel } from '@/domain/legal/departments';
import {
updateProfileAction,
updateSensitiveAction,
@@ -153,22 +160,31 @@ export function PersonalInfoPanel({
defaultValue={profile.birthName}
/>
-
+
-
-
-
+
@@ -267,10 +288,13 @@ export function PersonalInfoPanel({
-
+
-
-
+
+
-
+
diff --git a/src/domain/legal/countries.ts b/src/domain/legal/countries.ts
new file mode 100644
index 0000000..b3475a2
--- /dev/null
+++ b/src/domain/legal/countries.ts
@@ -0,0 +1,184 @@
+/**
+ * Pays et nationalités — pour le dossier salarié et le registre du personnel.
+ *
+ * Liste fermée plutôt que champ libre. « France », « FRANCE », « Française »,
+ * « FR » et « france » désignaient la même chose et se retrouvaient toutes en
+ * base ; le registre unique du personnel les imprime telles quelles, et un
+ * contrôle y lit cinq pays.
+ *
+ * Le **code ISO 3166-1 alpha-2** est stocké, jamais le libellé : il ne change
+ * pas d'orthographe, tient dans deux caractères, et c'est lui que reprennent
+ * les déclarations sociales. Le libellé reste un affichage.
+ *
+ * La liste couvre les nationalités rencontrées dans un commerce de détail
+ * français : l'Europe, le pourtour méditerranéen, l'Afrique francophone, et les
+ * principaux pays d'origine relevés par l'INSEE. Elle n'est pas universelle et
+ * ne prétend pas l'être — un pays absent s'ajoute ici, en une ligne, plutôt que
+ * de rouvrir la saisie libre pour tout le monde.
+ */
+
+export interface Country {
+ /** ISO 3166-1 alpha-2. */
+ code: string;
+ name: string;
+ /** Gentilé au féminin singulier, forme retenue au registre. */
+ nationality: string;
+}
+
+export const COUNTRIES: readonly Country[] = [
+ { code: 'FR', name: 'France', nationality: 'Française' },
+ { code: 'DE', name: 'Allemagne', nationality: 'Allemande' },
+ { code: 'AD', name: 'Andorre', nationality: 'Andorrane' },
+ { code: 'AO', name: 'Angola', nationality: 'Angolaise' },
+ { code: 'DZ', name: 'Algérie', nationality: 'Algérienne' },
+ { code: 'AR', name: 'Argentine', nationality: 'Argentine' },
+ { code: 'AM', name: 'Arménie', nationality: 'Arménienne' },
+ { code: 'AU', name: 'Australie', nationality: 'Australienne' },
+ { code: 'AT', name: 'Autriche', nationality: 'Autrichienne' },
+ { code: 'BE', name: 'Belgique', nationality: 'Belge' },
+ { code: 'BJ', name: 'Bénin', nationality: 'Béninoise' },
+ { code: 'BY', name: 'Biélorussie', nationality: 'Biélorusse' },
+ { code: 'BO', name: 'Bolivie', nationality: 'Bolivienne' },
+ { code: 'BA', name: 'Bosnie-Herzégovine', nationality: 'Bosnienne' },
+ { code: 'BR', name: 'Brésil', nationality: 'Brésilienne' },
+ { code: 'BG', name: 'Bulgarie', nationality: 'Bulgare' },
+ { code: 'BF', name: 'Burkina Faso', nationality: 'Burkinabè' },
+ { code: 'KH', name: 'Cambodge', nationality: 'Cambodgienne' },
+ { code: 'CM', name: 'Cameroun', nationality: 'Camerounaise' },
+ { code: 'CA', name: 'Canada', nationality: 'Canadienne' },
+ { code: 'CV', name: 'Cap-Vert', nationality: 'Cap-verdienne' },
+ { code: 'CL', name: 'Chili', nationality: 'Chilienne' },
+ { code: 'CN', name: 'Chine', nationality: 'Chinoise' },
+ { code: 'CY', name: 'Chypre', nationality: 'Chypriote' },
+ { code: 'CO', name: 'Colombie', nationality: 'Colombienne' },
+ { code: 'KM', name: 'Comores', nationality: 'Comorienne' },
+ { code: 'CG', name: 'Congo', nationality: 'Congolaise' },
+ { code: 'CD', name: 'Congo (RDC)', nationality: 'Congolaise (RDC)' },
+ { code: 'KR', name: 'Corée du Sud', nationality: 'Sud-coréenne' },
+ { code: 'CI', name: "Côte d'Ivoire", nationality: 'Ivoirienne' },
+ { code: 'HR', name: 'Croatie', nationality: 'Croate' },
+ { code: 'DK', name: 'Danemark', nationality: 'Danoise' },
+ { code: 'DJ', name: 'Djibouti', nationality: 'Djiboutienne' },
+ { code: 'EG', name: 'Égypte', nationality: 'Égyptienne' },
+ { code: 'AE', name: 'Émirats arabes unis', nationality: 'Émirienne' },
+ { code: 'EC', name: 'Équateur', nationality: 'Équatorienne' },
+ { code: 'ES', name: 'Espagne', nationality: 'Espagnole' },
+ { code: 'EE', name: 'Estonie', nationality: 'Estonienne' },
+ { code: 'US', name: 'États-Unis', nationality: 'Américaine' },
+ { code: 'ET', name: 'Éthiopie', nationality: 'Éthiopienne' },
+ { code: 'FI', name: 'Finlande', nationality: 'Finlandaise' },
+ { code: 'GA', name: 'Gabon', nationality: 'Gabonaise' },
+ { code: 'GE', name: 'Géorgie', nationality: 'Géorgienne' },
+ { code: 'GH', name: 'Ghana', nationality: 'Ghanéenne' },
+ { code: 'GR', name: 'Grèce', nationality: 'Grecque' },
+ { code: 'GN', name: 'Guinée', nationality: 'Guinéenne' },
+ { code: 'HT', name: 'Haïti', nationality: 'Haïtienne' },
+ { code: 'HU', name: 'Hongrie', nationality: 'Hongroise' },
+ { code: 'IN', name: 'Inde', nationality: 'Indienne' },
+ { code: 'ID', name: 'Indonésie', nationality: 'Indonésienne' },
+ { code: 'IQ', name: 'Irak', nationality: 'Irakienne' },
+ { code: 'IR', name: 'Iran', nationality: 'Iranienne' },
+ { code: 'IE', name: 'Irlande', nationality: 'Irlandaise' },
+ { code: 'IS', name: 'Islande', nationality: 'Islandaise' },
+ { code: 'IL', name: 'Israël', nationality: 'Israélienne' },
+ { code: 'IT', name: 'Italie', nationality: 'Italienne' },
+ { code: 'JP', name: 'Japon', nationality: 'Japonaise' },
+ { code: 'JO', name: 'Jordanie', nationality: 'Jordanienne' },
+ { code: 'KZ', name: 'Kazakhstan', nationality: 'Kazakhe' },
+ { code: 'KE', name: 'Kenya', nationality: 'Kényane' },
+ { code: 'XK', name: 'Kosovo', nationality: 'Kosovare' },
+ { code: 'LV', name: 'Lettonie', nationality: 'Lettone' },
+ { code: 'LB', name: 'Liban', nationality: 'Libanaise' },
+ { code: 'LY', name: 'Libye', nationality: 'Libyenne' },
+ { code: 'LT', name: 'Lituanie', nationality: 'Lituanienne' },
+ { code: 'LU', name: 'Luxembourg', nationality: 'Luxembourgeoise' },
+ { code: 'MK', name: 'Macédoine du Nord', nationality: 'Macédonienne' },
+ { code: 'MG', name: 'Madagascar', nationality: 'Malgache' },
+ { code: 'MY', name: 'Malaisie', nationality: 'Malaisienne' },
+ { code: 'ML', name: 'Mali', nationality: 'Malienne' },
+ { code: 'MT', name: 'Malte', nationality: 'Maltaise' },
+ { code: 'MA', name: 'Maroc', nationality: 'Marocaine' },
+ { code: 'MU', name: 'Maurice', nationality: 'Mauricienne' },
+ { code: 'MR', name: 'Mauritanie', nationality: 'Mauritanienne' },
+ { code: 'MX', name: 'Mexique', nationality: 'Mexicaine' },
+ { code: 'MD', name: 'Moldavie', nationality: 'Moldave' },
+ { code: 'MC', name: 'Monaco', nationality: 'Monégasque' },
+ { code: 'ME', name: 'Monténégro', nationality: 'Monténégrine' },
+ { code: 'MZ', name: 'Mozambique', nationality: 'Mozambicaine' },
+ { code: 'NE', name: 'Niger', nationality: 'Nigérienne' },
+ { code: 'NG', name: 'Nigeria', nationality: 'Nigériane' },
+ { code: 'NO', name: 'Norvège', nationality: 'Norvégienne' },
+ { code: 'NZ', name: 'Nouvelle-Zélande', nationality: 'Néo-zélandaise' },
+ { code: 'PK', name: 'Pakistan', nationality: 'Pakistanaise' },
+ { code: 'NL', name: 'Pays-Bas', nationality: 'Néerlandaise' },
+ { code: 'PE', name: 'Pérou', nationality: 'Péruvienne' },
+ { code: 'PH', name: 'Philippines', nationality: 'Philippine' },
+ { code: 'PL', name: 'Pologne', nationality: 'Polonaise' },
+ { code: 'PT', name: 'Portugal', nationality: 'Portugaise' },
+ { code: 'RO', name: 'Roumanie', nationality: 'Roumaine' },
+ { code: 'GB', name: 'Royaume-Uni', nationality: 'Britannique' },
+ { code: 'RU', name: 'Russie', nationality: 'Russe' },
+ { code: 'RW', name: 'Rwanda', nationality: 'Rwandaise' },
+ { code: 'SN', name: 'Sénégal', nationality: 'Sénégalaise' },
+ { code: 'RS', name: 'Serbie', nationality: 'Serbe' },
+ { code: 'SG', name: 'Singapour', nationality: 'Singapourienne' },
+ { code: 'SK', name: 'Slovaquie', nationality: 'Slovaque' },
+ { code: 'SI', name: 'Slovénie', nationality: 'Slovène' },
+ { code: 'SD', name: 'Soudan', nationality: 'Soudanaise' },
+ { code: 'LK', name: 'Sri Lanka', nationality: 'Sri-lankaise' },
+ { code: 'SE', name: 'Suède', nationality: 'Suédoise' },
+ { code: 'CH', name: 'Suisse', nationality: 'Suisse' },
+ { code: 'SY', name: 'Syrie', nationality: 'Syrienne' },
+ { code: 'TW', name: 'Taïwan', nationality: 'Taïwanaise' },
+ { code: 'TD', name: 'Tchad', nationality: 'Tchadienne' },
+ { code: 'CZ', name: 'Tchéquie', nationality: 'Tchèque' },
+ { code: 'TH', name: 'Thaïlande', nationality: 'Thaïlandaise' },
+ { code: 'TG', name: 'Togo', nationality: 'Togolaise' },
+ { code: 'TN', name: 'Tunisie', nationality: 'Tunisienne' },
+ { code: 'TR', name: 'Turquie', nationality: 'Turque' },
+ { code: 'UA', name: 'Ukraine', nationality: 'Ukrainienne' },
+ { code: 'UY', name: 'Uruguay', nationality: 'Uruguayenne' },
+ { code: 'VE', name: 'Venezuela', nationality: 'Vénézuélienne' },
+ { code: 'VN', name: 'Viêt Nam', nationality: 'Vietnamienne' },
+];
+
+const BY_CODE = new Map(COUNTRIES.map((country) => [country.code, country]));
+
+/**
+ * Options du sélecteur de pays : la France en tête, le reste par ordre
+ * alphabétique.
+ *
+ * Placer le pays le plus fréquent en premier évite un défilement à chaque
+ * saisie. L'ordre alphabétique ensuite, parce qu'un classement par fréquence
+ * supposée serait deviné, et se lirait comme un jugement.
+ */
+export const COUNTRY_OPTIONS: ReadonlyArray = [
+ ['FR', 'France'],
+ ...COUNTRIES.filter((country) => country.code !== 'FR')
+ .map((country) => [country.code, country.name] as const)
+ .sort((a, b) => a[1].localeCompare(b[1], 'fr')),
+];
+
+/** Mêmes pays, mais nommés par leur gentilé. */
+export const NATIONALITY_OPTIONS: ReadonlyArray = [
+ ['FR', 'Française'],
+ ...COUNTRIES.filter((country) => country.code !== 'FR')
+ .map((country) => [country.code, country.nationality] as const)
+ .sort((a, b) => a[1].localeCompare(b[1], 'fr')),
+];
+
+export function countryLabel(code: string | null): string | null {
+ if (!code) return null;
+ // Les dossiers saisis avant la liste portent un libellé en clair : le rendre
+ // tel quel vaut mieux qu'afficher un vide sur une donnée qui existe.
+ return BY_CODE.get(code)?.name ?? code;
+}
+
+export function nationalityLabel(code: string | null): string | null {
+ if (!code) return null;
+ return BY_CODE.get(code)?.nationality ?? code;
+}
+
+export function isCountryCode(value: string): boolean {
+ return BY_CODE.has(value);
+}
diff --git a/src/domain/legal/departments.ts b/src/domain/legal/departments.ts
new file mode 100644
index 0000000..a292dbf
--- /dev/null
+++ b/src/domain/legal/departments.ts
@@ -0,0 +1,147 @@
+/**
+ * Départements français — pour le lieu de naissance et le rattachement d'un
+ * établissement.
+ *
+ * Liste fermée plutôt que champ libre : « 54 », « Meurthe-et-Moselle »,
+ * « Meurthe et Moselle » et « meurthe-et-moselle » désignent le même
+ * département et se retrouvaient tous les quatre en base. Le registre unique du
+ * personnel les imprime tels quels, et un contrôle y lit quatre départements.
+ *
+ * Le code est stocké, pas le libellé : il tient dans deux caractères, ne change
+ * pas d'orthographe, et c'est lui que reprennent les déclarations sociales.
+ *
+ * La Corse porte `2A` et `2B` — jamais `20`, qui n'existe plus depuis 1976 mais
+ * survit dans beaucoup de saisies manuelles.
+ */
+export const DEPARTMENTS: ReadonlyArray = [
+ ['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'],
+];
+
+const LABELS = new Map(DEPARTMENTS);
+
+export const DEPARTMENT_CODES: readonly string[] = DEPARTMENTS.map(
+ ([code]) => code,
+);
+
+export function departmentLabel(code: string | null): string | null {
+ if (!code) return null;
+ const label = LABELS.get(code);
+ return label ? `${code} — ${label}` : code;
+}
+
+export function isDepartmentCode(value: string): boolean {
+ return LABELS.has(value);
+}
+
+/**
+ * Départements du régime local d'Alsace-Moselle.
+ *
+ * Deux jours fériés de plus — Vendredi saint et 26 décembre — et un régime de
+ * maintien de salaire distinct. Un établissement de Moselle ne se planifie donc
+ * pas comme un établissement de Meurthe-et-Moselle, à quinze kilomètres de là.
+ */
+export const ALSACE_MOSELLE = ['57', '67', '68'] as const;
+
+export function isAlsaceMoselle(code: string | null): boolean {
+ return code !== null && (ALSACE_MOSELLE as readonly string[]).includes(code);
+}
diff --git a/src/server/employees/profile-actions.ts b/src/server/employees/profile-actions.ts
index 05632c1..64f72d4 100644
--- a/src/server/employees/profile-actions.ts
+++ b/src/server/employees/profile-actions.ts
@@ -4,6 +4,8 @@ import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError, can } from '@/domain/access/authorize';
+import { COUNTRIES } from '@/domain/legal/countries';
+import { DEPARTMENT_CODES } from '@/domain/legal/departments';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { encryptOptional } from '@/server/crypto';
@@ -44,6 +46,8 @@ const optionalEnum = (values: T) =>
)
.transform((value) => (value === '' ? null : value));
+const COUNTRY_CODES = COUNTRIES.map((country) => country.code);
+
const profileInput = z.object({
membershipId: z.string().min(1),
gender: optionalEnum(['FEMALE', 'MALE', 'UNSPECIFIED'] as const),
@@ -52,9 +56,14 @@ 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),
+ // Codes de liste fermée, pas des libellés. Un dossier saisi avant la liste
+ // porte encore « France » en clair : le sélecteur ne trouve pas d'option
+ // correspondante, affiche « non renseigné », et la première correction du
+ // dossier remplace la valeur par son code. Elle n'est ni perdue en silence
+ // ni opposée à qui vient corriger un champ voisin.
+ birthCountry: optionalEnum(COUNTRY_CODES),
+ birthDepartment: optionalEnum(DEPARTMENT_CODES),
+ nationality: optionalEnum(COUNTRY_CODES),
maritalStatus: optionalEnum([
'SINGLE',
'MARRIED',
@@ -87,7 +96,7 @@ const profileInput = z.object({
addressLine2: optionalText(180),
postalCode: optionalText(12),
city: optionalText(120),
- country: optionalText(80),
+ country: optionalEnum(COUNTRY_CODES),
emergencyContactName: optionalText(120),
emergencyContactPhone: optionalText(30),
});
diff --git a/src/server/employees/rup.ts b/src/server/employees/rup.ts
index 3e7b62d..71f401a 100644
--- a/src/server/employees/rup.ts
+++ b/src/server/employees/rup.ts
@@ -2,6 +2,8 @@ import 'server-only';
import { cache } from 'react';
+import { nationalityLabel } from '@/domain/legal/countries';
+
import { genderShort } from '@/domain/hr/civil-status';
import type { RupPerson } from '@/domain/legal/rup';
import { peopleWithGaps } from '@/domain/legal/rup';
@@ -82,7 +84,9 @@ export const getRegisterData = cache(async function getRegisterData(
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
- nationality: contract.membership.profile?.nationality ?? null,
+ nationality: nationalityLabel(
+ contract.membership.profile?.nationality ?? null,
+ ),
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId
? (jobTitleNames.get(contract.jobTitleId) ?? null)
@@ -152,7 +156,9 @@ export const listRegisterLocations = cache(
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
- nationality: contract.membership.profile?.nationality ?? null,
+ nationality: nationalityLabel(
+ contract.membership.profile?.nationality ?? null,
+ ),
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId ? 'x' : null,
qualification: contract.classification,