Fermer les listes du dossier, et déclarer les titres de séjour #1
No files matched your search
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user