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,