863 lines
35 KiB
TypeScript
863 lines
35 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import Link from "next/link";
|
||
import { Loader2, ArrowLeft, X, Check } from "lucide-react";
|
||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||
import { useDemoMode } from "@/hooks/useDemoMode";
|
||
|
||
type ClientInfo = {
|
||
id: string;
|
||
name: string;
|
||
api_name?: string;
|
||
} | null;
|
||
|
||
// Si ces composants existent déjà dans le projet (utilisés sur Nouveau CDDU), on les réutilise.
|
||
// Sinon, le markup Tailwind ci-dessous suffit car on produit des <div>/<label>/<input> standard.
|
||
|
||
function Label({ children, required = false }: { children: React.ReactNode; required?: boolean }) {
|
||
return (
|
||
<label className="block text-sm font-medium">
|
||
{children} {required && <span className="text-red-500">*</span>}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<section className="rounded-2xl border bg-white p-5">
|
||
<h2 className="text-base font-semibold mb-4">{title}</h2>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function FieldRow({ children }: { children: React.ReactNode }) {
|
||
return <div className="grid grid-cols-1 md:grid-cols-2 gap-5">{children}</div>;
|
||
}
|
||
|
||
// Helpers
|
||
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i;
|
||
function upper(s: string) {
|
||
return s.toUpperCase();
|
||
}
|
||
function capitalizeFirst(s: string) {
|
||
if (!s) return s;
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
// IBAN helpers (mod 97)
|
||
function normalizeIban(s: string) {
|
||
return s.replace(/\s+/g, "").toUpperCase();
|
||
}
|
||
function ibanToDigits(s: string) {
|
||
// A=10 ... Z=35
|
||
return s.replace(/[A-Z]/g, (ch) => String(ch.charCodeAt(0) - 55));
|
||
}
|
||
function isValidIBAN(input: string): boolean {
|
||
if (!input) return false;
|
||
const iban = normalizeIban(input);
|
||
if (iban.length < 15 || iban.length > 34) return false;
|
||
if (!/^[A-Z0-9]+$/.test(iban)) return false;
|
||
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
||
const digits = ibanToDigits(rearranged);
|
||
let rem = 0;
|
||
for (let i = 0; i < digits.length; i++) {
|
||
const code = digits.charCodeAt(i) - 48; // '0' => 48
|
||
if (code < 0 || code > 9) return false;
|
||
rem = (rem * 10 + code) % 97;
|
||
}
|
||
return rem === 1;
|
||
}
|
||
|
||
export default function NouveauSalariePage() {
|
||
usePageTitle("Nouveau salarié");
|
||
|
||
const router = useRouter();
|
||
const search = useSearchParams();
|
||
const embed = (search.get("embed") || "").toLowerCase() === "1" || (search.get("embed") || "").toLowerCase() === "true";
|
||
const { isDemoMode } = useDemoMode();
|
||
|
||
// Récupération dynamique des infos client via /api/me
|
||
const { data: clientInfo } = useQuery({
|
||
queryKey: ["client-info"],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await fetch("/api/me", {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include"
|
||
});
|
||
if (!res.ok) return null;
|
||
const me = await res.json();
|
||
|
||
return {
|
||
id: me.active_org_id || "unknown",
|
||
name: me.active_org_name || "Organisation",
|
||
api_name: me.active_org_api_name
|
||
} as ClientInfo;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
staleTime: 30_000, // Cache 30s
|
||
});
|
||
|
||
// UI states
|
||
const [loading, setLoading] = useState(false);
|
||
const [redirecting, setRedirecting] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
// Onglets formulaire: simplifié / complet (design similaire à /contrats)
|
||
const [formMode, setFormMode] = useState<"simplifie" | "complet">("simplifie");
|
||
|
||
// États pour la gestion des organisations (staff uniquement)
|
||
const [isStaff, setIsStaff] = useState(false);
|
||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||
const [selectedOrg, setSelectedOrg] = useState<{ id: string; name: string } | null>(null);
|
||
|
||
// Form states
|
||
const [civilite, setCivilite] = useState<"Monsieur" | "Madame" | "">("");
|
||
const [nom, setNom] = useState("");
|
||
const [prenom, setPrenom] = useState("");
|
||
const [nomNaissance, setNomNaissance] = useState("");
|
||
const [pseudo, setPseudo] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [adresse, setAdresse] = useState("");
|
||
const [complementAdresse, setComplementAdresse] = useState("");
|
||
const [telephone, setTelephone] = useState("");
|
||
// Autocomplete adresse.data.gouv.fr
|
||
const [addrQuery, setAddrQuery] = useState("");
|
||
const [addrOpen, setAddrOpen] = useState(false);
|
||
const [addrLoading, setAddrLoading] = useState(false);
|
||
const [addrResults, setAddrResults] = useState<Array<any>>([]);
|
||
const [addrTimer, setAddrTimer] = useState<any>(null);
|
||
const [addrMeta, setAddrMeta] = useState<{
|
||
postcode?: string; city?: string; insee?: string; lat?: string; lon?: string
|
||
} | null>(null);
|
||
const [dateNaissance, setDateNaissance] = useState(""); // YYYY-MM-DD
|
||
const [lieuNaissance, setLieuNaissance] = useState("");
|
||
const [nir, setNir] = useState("");
|
||
const [congesSpectacles, setCongesSpectacles] = useState("");
|
||
const [iban, setIban] = useState("");
|
||
const [ibanError, setIbanError] = useState<string | null>(null);
|
||
const [bic, setBic] = useState("");
|
||
const [notes, setNotes] = useState("");
|
||
const [files, setFiles] = useState<FileList | null>(null);
|
||
|
||
// ------- Leave-confirm (unsaved changes) -------
|
||
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
|
||
const pendingHrefRef = useRef<string | null>(null);
|
||
const allowNavRef = useRef(false);
|
||
|
||
// Astuces de saisie
|
||
const prenomInputRef = useRef<HTMLInputElement | null>(null);
|
||
useEffect(() => {
|
||
// capitalise la 1ère lettre au fur et à mesure
|
||
if (prenom) {
|
||
const fixed = capitalizeFirst(prenom);
|
||
if (fixed !== prenom) setPrenom(fixed);
|
||
}
|
||
}, [prenom]);
|
||
|
||
// Vérifier si l'utilisateur est staff et récupérer les organisations
|
||
useEffect(() => {
|
||
const checkStaffAndLoadOrgs = async () => {
|
||
try {
|
||
const res = await fetch("/api/me", {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include"
|
||
});
|
||
|
||
if (res.ok) {
|
||
const me = await res.json();
|
||
const userIsStaff = me.is_staff || false;
|
||
setIsStaff(userIsStaff);
|
||
|
||
// Si l'utilisateur est staff, récupérer la liste des organisations
|
||
if (userIsStaff) {
|
||
const orgRes = await fetch("/api/organizations", {
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include"
|
||
});
|
||
|
||
if (orgRes.ok) {
|
||
const orgData = await orgRes.json();
|
||
setOrganizations(orgData.items || []);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Erreur lors de la vérification staff:", error);
|
||
}
|
||
};
|
||
|
||
checkStaffAndLoadOrgs();
|
||
}, []);
|
||
|
||
// Validation simple
|
||
const canSubmit = useMemo(() => {
|
||
const coreOk = civilite !== "" && nom.trim() && prenom.trim() && emailRx.test(email.trim());
|
||
const ibanOk = !iban || isValidIBAN(iban);
|
||
// Si staff, vérifier qu'une organisation est sélectionnée
|
||
const orgOk = !isStaff || (isStaff && selectedOrg !== null);
|
||
return coreOk && ibanOk && orgOk;
|
||
}, [civilite, nom, prenom, email, iban, isStaff, selectedOrg]);
|
||
|
||
// considère le formulaire comme "modifié" si l'un des champs est rempli/modifié
|
||
const isDirty = useMemo(() => {
|
||
return (
|
||
civilite !== "" ||
|
||
nom.trim() !== "" ||
|
||
prenom.trim() !== "" ||
|
||
nomNaissance.trim() !== "" ||
|
||
pseudo.trim() !== "" ||
|
||
email.trim() !== "" ||
|
||
adresse.trim() !== "" ||
|
||
complementAdresse.trim() !== "" ||
|
||
telephone.trim() !== "" ||
|
||
dateNaissance !== "" ||
|
||
lieuNaissance.trim() !== "" ||
|
||
nir.trim() !== "" ||
|
||
congesSpectacles.trim() !== "" ||
|
||
iban.trim() !== "" ||
|
||
bic.trim() !== "" ||
|
||
notes.trim() !== "" ||
|
||
(files && files.length > 0) ||
|
||
addrQuery.trim() !== ""
|
||
);
|
||
}, [civilite, nom, prenom, nomNaissance, pseudo, email, adresse, complementAdresse, telephone, dateNaissance, lieuNaissance, nir, congesSpectacles, iban, bic, notes, files, addrQuery]);
|
||
|
||
async function onSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setErr(null);
|
||
|
||
if (!canSubmit) {
|
||
return setErr("Merci de remplir au minimum civilité, nom, prénom et une adresse e-mail valide.");
|
||
}
|
||
|
||
setLoading(true);
|
||
// autorise explicitement la navigation après envoi
|
||
allowNavRef.current = true;
|
||
try {
|
||
if (iban && !isValidIBAN(iban)) {
|
||
setIbanError("IBAN invalide (vérifiez la clé et le format).");
|
||
throw new Error("IBAN invalide");
|
||
}
|
||
const fd = new FormData();
|
||
// identité
|
||
fd.append("civilite_salarie", civilite);
|
||
fd.append("nomusage", upper(nom.trim()));
|
||
fd.append("prenom", capitalizeFirst(prenom.trim()));
|
||
if (nomNaissance.trim()) fd.append("nomnaissance_salarie", upper(nomNaissance.trim()));
|
||
if (pseudo.trim()) fd.append("pseudonyme", pseudo.trim());
|
||
// contact
|
||
fd.append("email_salarie", email.trim());
|
||
if (adresse.trim()) fd.append("adresse_salarie", adresse.trim());
|
||
if (complementAdresse.trim()) fd.append("adresse_complement", complementAdresse.trim());
|
||
if (telephone.trim()) fd.append("tel_salarie", telephone.trim());
|
||
if (addrMeta?.postcode) fd.append("adresse_postcode", addrMeta.postcode);
|
||
if (addrMeta?.city) fd.append("adresse_city", addrMeta.city);
|
||
if (addrMeta?.insee) fd.append("adresse_insee", addrMeta.insee);
|
||
if (addrMeta?.lat) fd.append("adresse_lat", addrMeta.lat);
|
||
if (addrMeta?.lon) fd.append("adresse_lon", addrMeta.lon);
|
||
// état civil
|
||
if (dateNaissance) fd.append("dob_salarie", dateNaissance);
|
||
if (lieuNaissance.trim()) fd.append("cob_salarie", lieuNaissance.trim());
|
||
if (nir.trim()) fd.append("ss_salarie", nir.trim());
|
||
if (congesSpectacles.trim()) fd.append("cs_salarie", congesSpectacles.trim());
|
||
// bancaires
|
||
if (iban.trim()) fd.append("iban_salarie", iban.trim());
|
||
if (bic.trim()) fd.append("bic_salarie", bic.trim());
|
||
// autres
|
||
if (notes.trim()) fd.append("notes", notes.trim());
|
||
|
||
// fichiers
|
||
if (files && files.length > 0) {
|
||
Array.from(files).forEach((f) => fd.append("pieces_jointes", f, f.name));
|
||
}
|
||
|
||
// contexte employeur (récupéré automatiquement via les headers de l'API)
|
||
// Plus besoin de récupérer depuis localStorage car les headers sont automatiquement ajoutés
|
||
if (clientInfo?.name) {
|
||
fd.append("structure", clientInfo.name);
|
||
}
|
||
|
||
// Send to our internal API which will insert into Supabase `salaries` table
|
||
const payload: any = {
|
||
civilite_salarie: civilite,
|
||
nom: nom.trim(),
|
||
prenom: prenom.trim(),
|
||
nomnaissance_salarie: nomNaissance.trim() || undefined,
|
||
pseudonyme: pseudo.trim() || undefined,
|
||
email_salarie: email.trim(),
|
||
adresse_salarie: adresse.trim() || undefined,
|
||
adresse_complement: complementAdresse.trim() || undefined,
|
||
adresse_postcode: addrMeta?.postcode || undefined,
|
||
adresse_city: addrMeta?.city || undefined,
|
||
adresse_insee: addrMeta?.insee || undefined,
|
||
adresse_lat: addrMeta?.lat || undefined,
|
||
adresse_lon: addrMeta?.lon || undefined,
|
||
tel_salarie: telephone.trim() || undefined,
|
||
dob_salarie: dateNaissance || undefined,
|
||
cob_salarie: lieuNaissance.trim() || undefined,
|
||
ss_salarie: nir.trim() || undefined,
|
||
cs_salarie: congesSpectacles.trim() || undefined,
|
||
iban_salarie: iban.trim() || undefined,
|
||
bic_salarie: bic.trim() || undefined,
|
||
notes: notes.trim() || undefined,
|
||
structure: selectedOrg?.name || clientInfo?.name || undefined,
|
||
// Ajouter l'organisation sélectionnée si staff
|
||
employer_id: isStaff && selectedOrg ? selectedOrg.id : undefined,
|
||
};
|
||
|
||
const res = await fetch('/api/salaries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||
const result = await res.json().catch(() => ({}));
|
||
if (!res.ok || !result?.ok) {
|
||
const msg = result?.error || result?.message || `Erreur HTTP ${res.status}`;
|
||
throw new Error(msg);
|
||
}
|
||
|
||
if (embed) {
|
||
// En mode embed: pas de redirection, pas d'overlay bloquant
|
||
setRedirecting(false);
|
||
} else {
|
||
// succès → overlay 3s puis liste des salariés
|
||
setRedirecting(true);
|
||
setTimeout(() => router.push("/salaries"), 3000);
|
||
}
|
||
} catch (e: any) {
|
||
setErr(e?.message || "Impossible d’envoyer le formulaire.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
// Intercepte les clics sur liens internes si le formulaire est modifié
|
||
useEffect(() => {
|
||
function onClick(e: MouseEvent) {
|
||
if (!isDirty || allowNavRef.current) return;
|
||
// N'intercepte pas les ouvertures dans un nouvel onglet ou les clics non-gauche
|
||
if ((e as MouseEvent).button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||
|
||
const target = e.target as HTMLElement | null;
|
||
const anchor = target?.closest && (target.closest('a[href]') as HTMLAnchorElement | null);
|
||
if (!anchor) return;
|
||
// ignore nouveaux onglets/téléchargements/externes
|
||
if (anchor.target === '_blank' || anchor.download) return;
|
||
const href = anchor.getAttribute('href');
|
||
if (!href) return;
|
||
if (href.startsWith('http') && !href.startsWith(window.location.origin)) return;
|
||
|
||
// Bloque la navigation client avant que Next.js ne la déclenche
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
pendingHrefRef.current = href;
|
||
setShowLeaveConfirm(true);
|
||
}
|
||
|
||
function onBeforeUnload(ev: BeforeUnloadEvent) {
|
||
if (!isDirty || allowNavRef.current) return;
|
||
ev.preventDefault();
|
||
ev.returnValue = '';
|
||
}
|
||
|
||
document.addEventListener('click', onClick, true);
|
||
window.addEventListener('beforeunload', onBeforeUnload);
|
||
return () => {
|
||
document.removeEventListener('click', onClick, true);
|
||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||
};
|
||
}, [isDirty]);
|
||
|
||
function confirmLeave() {
|
||
const href = pendingHrefRef.current;
|
||
setShowLeaveConfirm(false);
|
||
if (href) {
|
||
allowNavRef.current = true;
|
||
// utilise le routeur pour rester en navigation client
|
||
router.push(href);
|
||
}
|
||
}
|
||
function cancelLeave() {
|
||
pendingHrefRef.current = null;
|
||
setShowLeaveConfirm(false);
|
||
}
|
||
|
||
function formatAddr(feature: any) {
|
||
const l = feature?.properties;
|
||
return l?.label || "";
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!addrQuery || addrQuery.trim().length < 3) {
|
||
setAddrResults([]);
|
||
setAddrOpen(false);
|
||
if (addrTimer) clearTimeout(addrTimer);
|
||
return;
|
||
}
|
||
if (addrTimer) clearTimeout(addrTimer);
|
||
const t = setTimeout(async () => {
|
||
try {
|
||
setAddrLoading(true);
|
||
const url = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(addrQuery)}&limit=7`;
|
||
const res = await fetch(url);
|
||
const json = await res.json();
|
||
const feats = Array.isArray(json?.features) ? json.features : [];
|
||
setAddrResults(feats);
|
||
setAddrOpen(true);
|
||
} catch {
|
||
setAddrResults([]);
|
||
setAddrOpen(false);
|
||
} finally {
|
||
setAddrLoading(false);
|
||
}
|
||
}, 250);
|
||
setAddrTimer(t);
|
||
return () => clearTimeout(t);
|
||
}, [addrQuery]);
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-5">
|
||
{!embed && (
|
||
<>
|
||
{/* Fil d'Ariane / retour */}
|
||
<div className="flex items-center gap-2">
|
||
<Link href="/salaries" className="inline-flex items-center gap-1 text-sm underline">
|
||
<ArrowLeft className="w-4 h-4" /> Retour aux salariés
|
||
</Link>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!embed && <h1 className="text-xl font-semibold">Nouveau salarié</h1>}
|
||
|
||
<Section title="Enregistrement d'un nouveau salarié">
|
||
<p className="text-sm text-slate-600">
|
||
Pour enregistrer votre nouveau salarié, indiquez les informations ci‑dessous. Nous avons besoin a minima de son
|
||
<strong> nom</strong>, son <strong>prénom</strong> et son <strong>adresse e‑mail</strong>.
|
||
</p>
|
||
<p className="text-sm text-slate-600 mt-2">
|
||
Un e‑mail lui sera envoyé pour déposer ses justificatifs et, si nécessaire, compléter son état‑civil.
|
||
</p>
|
||
</Section>
|
||
{/* Onglets (design calqué sur /contrats) */}
|
||
<div className="inline-flex rounded-xl border p-1 bg-slate-50">
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormMode("simplifie")}
|
||
className={`px-3 py-1.5 text-sm rounded-lg ${formMode==='simplifie' ? 'bg-white shadow border' : 'opacity-80'}`}
|
||
>
|
||
Formulaire simplifiée
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormMode("complet")}
|
||
className={`px-3 py-1.5 text-sm rounded-lg ${formMode==='complet' ? 'bg-white shadow border' : 'opacity-80'}`}
|
||
>
|
||
Formulaire complet
|
||
</button>
|
||
</div>
|
||
|
||
{/* Sélection d'organisation (pour les utilisateurs staff uniquement) */}
|
||
{isStaff && (
|
||
<Section title="Organisation">
|
||
<div className="grid grid-cols-1 md:grid-cols-[240px_1fr] items-center gap-4">
|
||
<Label required>Structure</Label>
|
||
<div>
|
||
<select
|
||
value={selectedOrg?.id || ""}
|
||
onChange={(e) => {
|
||
const orgId = e.target.value;
|
||
const org = organizations.find(o => o.id === orgId);
|
||
setSelectedOrg(org || null);
|
||
}}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
required
|
||
>
|
||
<option value="">Sélectionner une structure...</option>
|
||
{organizations.map((org) => (
|
||
<option key={org.id} value={org.id}>
|
||
{org.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Sélectionnez l'organisation pour laquelle vous créez ce salarié.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
<form onSubmit={onSubmit} className="space-y-5">
|
||
{/* Formulaire simplifié: civilité, nom d’usage, prénom, e‑mail */}
|
||
{formMode === "simplifie" && (
|
||
<>
|
||
<Section title="Identité">
|
||
<div className="space-y-3">
|
||
<Label required>Sa civilité</Label>
|
||
<div className="flex items-center gap-6">
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" name="civilite" checked={civilite === "Monsieur"} onChange={() => setCivilite("Monsieur")} />
|
||
Monsieur
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" name="civilite" checked={civilite === "Madame"} onChange={() => setCivilite("Madame")} />
|
||
Madame
|
||
</label>
|
||
</div>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Son nom de famille (ou nom d’usage)</Label>
|
||
<input
|
||
value={nom}
|
||
onChange={(e) => setNom(upper(e.target.value))}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">La saisie se fait automatiquement en majuscules.</p>
|
||
</div>
|
||
<div>
|
||
<Label required>Son prénom</Label>
|
||
<input
|
||
ref={prenomInputRef}
|
||
value={prenom}
|
||
onChange={(e) => setPrenom(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Une majuscule est automatiquement ajoutée au début du prénom.</p>
|
||
</div>
|
||
</FieldRow>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Coordonnées">
|
||
<div>
|
||
<Label required>Son adresse e‑mail</Label>
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</Section>
|
||
</>
|
||
)}
|
||
|
||
{/* Formulaire complet: contenu existant */}
|
||
{formMode === "complet" && (
|
||
<>
|
||
<Section title="Identité">
|
||
<div className="space-y-3">
|
||
<Label required>Sa civilité</Label>
|
||
<div className="flex items-center gap-6">
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" name="civilite" checked={civilite === "Monsieur"} onChange={() => setCivilite("Monsieur")} />
|
||
Monsieur
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" name="civilite" checked={civilite === "Madame"} onChange={() => setCivilite("Madame")} />
|
||
Madame
|
||
</label>
|
||
</div>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Son nom de famille (ou nom d’usage)</Label>
|
||
<input
|
||
value={nom}
|
||
onChange={(e) => setNom(upper(e.target.value))}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">La saisie se fait automatiquement en majuscules.</p>
|
||
</div>
|
||
<div>
|
||
<Label required>Son prénom</Label>
|
||
<input
|
||
ref={prenomInputRef}
|
||
value={prenom}
|
||
onChange={(e) => setPrenom(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Une majuscule est automatiquement ajoutée au début du prénom.</p>
|
||
</div>
|
||
</FieldRow>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label>Son nom de naissance</Label>
|
||
<input
|
||
value={nomNaissance}
|
||
onChange={(e) => setNomNaissance(upper(e.target.value))}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Remplir uniquement s’il diffère du nom d’usage (automatiquement en majuscules).</p>
|
||
</div>
|
||
<div>
|
||
<Label>Son pseudonyme</Label>
|
||
<input
|
||
value={pseudo}
|
||
onChange={(e) => setPseudo(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Si votre salarié utilise un nom de scène ou de plume, nous le ferons apparaître sur ses contrats.</p>
|
||
</div>
|
||
</FieldRow>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Coordonnées">
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Son adresse e‑mail</Label>
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div className="relative">
|
||
<Label>Son adresse postale complète</Label>
|
||
<div className="relative">
|
||
<input
|
||
value={adresse}
|
||
onChange={(e) => { setAdresse(e.target.value); setAddrQuery(e.target.value); setAddrOpen(false); setAddrMeta(null); }}
|
||
onFocus={() => { if (addrResults.length > 0) setAddrOpen(true); }}
|
||
placeholder="Saisir au moins 3 caractères (ex : 10 rue de Rivoli, Paris)"
|
||
className="w-full pr-9 px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
{adresse && (
|
||
<button
|
||
type="button"
|
||
aria-label="Effacer l’adresse"
|
||
onClick={() => { setAdresse(""); setAddrQuery(""); setAddrResults([]); setAddrOpen(false); setAddrMeta(null); }}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-slate-100 text-slate-500"
|
||
title="Effacer"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{(addrLoading || (addrOpen && addrResults.length > 0)) && (
|
||
<div className="absolute z-20 mt-1 w-full rounded-lg border bg-white shadow-lg max-h-64 overflow-auto">
|
||
{addrLoading && (
|
||
<div className="p-3 text-xs text-slate-500">Recherche d’adresses…</div>
|
||
)}
|
||
{!addrLoading && addrResults.length === 0 && (
|
||
<div className="p-3 text-xs text-slate-500">Aucun résultat</div>
|
||
)}
|
||
{!addrLoading && addrResults.length > 0 && (
|
||
<ul className="divide-y divide-slate-100">
|
||
{addrResults.map((f: any, idx: number) => {
|
||
const p = f?.properties || {};
|
||
const label = formatAddr(f);
|
||
return (
|
||
<li key={idx}>
|
||
<button
|
||
type="button"
|
||
className="block w-full text-left px-3 py-2 hover:bg-slate-50 text-sm"
|
||
onClick={() => {
|
||
setAdresse(label);
|
||
setAddrOpen(false);
|
||
setAddrResults([]);
|
||
setAddrQuery("");
|
||
setAddrMeta({
|
||
postcode: p.postcode,
|
||
city: p.city || p.context?.split(", ")?.[0],
|
||
insee: p.citycode,
|
||
lat: Array.isArray(f.geometry?.coordinates) ? String(f.geometry.coordinates[1]) : undefined,
|
||
lon: Array.isArray(f.geometry?.coordinates) ? String(f.geometry.coordinates[0]) : undefined,
|
||
});
|
||
}}
|
||
>
|
||
<div className="font-medium">{label}</div>
|
||
<div className="text-[11px] text-slate-500">{p.postcode || ""} {p.city || ""}</div>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</FieldRow>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label>Son numéro de téléphone</Label>
|
||
<input
|
||
value={telephone}
|
||
onChange={(e) => setTelephone(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Utilisé uniquement pour les SMS de signature (si numéros français).</p>
|
||
</div>
|
||
<div>
|
||
<Label>Complément d’adresse</Label>
|
||
<input
|
||
value={complementAdresse}
|
||
onChange={(e) => setComplementAdresse(e.target.value)}
|
||
placeholder="Bâtiment, étage, appartement, digicode…"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
|
||
<Section title="État civil">
|
||
<FieldRow>
|
||
<div>
|
||
<Label>Sa date de naissance</Label>
|
||
<input
|
||
type="date"
|
||
value={dateNaissance}
|
||
onChange={(e) => setDateNaissance(e.target.value)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Son lieu de naissance</Label>
|
||
<input
|
||
value={lieuNaissance}
|
||
onChange={(e) => setLieuNaissance(e.target.value)}
|
||
placeholder="Ville, département, pays le cas échéant"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</FieldRow>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label>Son numéro de Sécurité Sociale</Label>
|
||
<input
|
||
value={nir}
|
||
onChange={(e) => setNir(e.target.value)}
|
||
placeholder="15 chiffres ou numéro provisoire"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Indiquez le NIR complet ou provisoire si pas encore définitif.</p>
|
||
</div>
|
||
<div>
|
||
<Label>Son numéro Congés Spectacles</Label>
|
||
<input
|
||
value={congesSpectacles}
|
||
onChange={(e) => setCongesSpectacles(e.target.value)}
|
||
placeholder="ex : X123456"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
|
||
<Section title="Coordonnées bancaires">
|
||
<FieldRow>
|
||
<div>
|
||
<Label>Son IBAN</Label>
|
||
<input
|
||
value={iban}
|
||
onChange={(e) => { setIban(e.target.value); if (ibanError) setIbanError(null); }}
|
||
onBlur={() => setIbanError(iban && !isValidIBAN(iban) ? "IBAN invalide (vérifiez la clé et le format)." : null)}
|
||
placeholder="FR.. .. .. .. .. .. .. .. .. .."
|
||
className={`w-full px-3 py-2 rounded-lg border bg-white text-sm ${ibanError ? 'border-red-400' : 'border-slate-300'}`}
|
||
/>
|
||
{ibanError ? (
|
||
<p className="text-[11px] text-red-600 mt-1" aria-live="polite">{ibanError}</p>
|
||
) : iban && isValidIBAN(iban) ? (
|
||
<p className="text-[11px] text-emerald-600 mt-1 inline-flex items-center gap-1" aria-live="polite">
|
||
<Check className="w-3.5 h-3.5" /> IBAN valide
|
||
</p>
|
||
) : (
|
||
<p className="text-[11px] text-slate-500 mt-1">Doit provenir d’une banque de la zone SEPA.</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<Label>Son BIC</Label>
|
||
<input value={bic} onChange={(e) => setBic(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-white text-sm" />
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
|
||
<Section title="Autres informations">
|
||
<div>
|
||
<Label>Notes</Label>
|
||
<textarea
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
rows={4}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<Label>Envoi de fichier·s</Label>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
onChange={(e) => setFiles(e.target.files)}
|
||
className="block w-full text-sm text-slate-600 file:mr-3 file:py-2 file:px-3 file:rounded-lg file:border file:bg-white file:border-slate-300"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Vous pouvez transmettre tous fichiers que vous jugez utiles (20Mo max.).</p>
|
||
</div>
|
||
</Section>
|
||
</>
|
||
)}
|
||
|
||
{err && (
|
||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-800 p-3">{err}</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Link href="/salaries" className="px-4 py-2 rounded-lg border text-sm">Relire avant envoi</Link>
|
||
<button
|
||
type="submit"
|
||
disabled={!canSubmit || loading || isDemoMode}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white disabled:opacity-60 disabled:cursor-not-allowed"
|
||
title={isDemoMode ? "Désactivé en mode démo" : ""}
|
||
>
|
||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||
Envoyer
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{showLeaveConfirm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||
<div className="w-full max-w-md rounded-2xl border bg-white p-5 shadow-xl">
|
||
<div className="text-base font-semibold">Quitter cette page ?</div>
|
||
<p className="text-sm text-slate-600 mt-2">
|
||
Vous avez une saisie en cours. En quittant maintenant, vous perdrez les informations non envoyées.
|
||
</p>
|
||
<div className="mt-4 flex items-center justify-end gap-3">
|
||
<button onClick={cancelLeave} className="px-4 py-2 rounded-lg border text-sm">Rester</button>
|
||
<button onClick={confirmLeave} className="px-4 py-2 rounded-lg bg-rose-600 text-white text-sm hover:bg-rose-700">Quitter sans enregistrer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{redirecting && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/70">
|
||
<div className="rounded-2xl border bg-white p-6 text-center shadow-xl">
|
||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-3" />
|
||
<div className="font-medium">Envoi réussi</div>
|
||
<p className="text-sm text-slate-600 mt-1">Redirection dans quelques secondes…</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{embed && (
|
||
<style jsx global>{`
|
||
header, .site-header, [data-app-header] { display: none !important; }
|
||
body { background: transparent !important; }
|
||
`}</style>
|
||
)}
|
||
</>
|
||
);
|
||
}
|