2143 lines
91 KiB
TypeScript
2143 lines
91 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { usePostHog } from "posthog-js/react";
|
||
import { api } from "@/lib/fetcher";
|
||
import { Loader2, Search, Info } from "lucide-react";
|
||
import { NotesSection } from "@/components/NotesSection";
|
||
import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
|
||
import { useDemoMode } from "@/hooks/useDemoMode";
|
||
|
||
/* =========================
|
||
Types
|
||
========================= */
|
||
type SalarieOption = { matricule: string; nom: string; email?: string | null };
|
||
type SpectacleOption = { id?: string; nom: string; numero_objet?: string | null };
|
||
type ProfessionOption = { code: string; label: string };
|
||
type TechnicienOption = ProfessionOption;
|
||
type ClientInfo = { id: string; name: string; api_name?: string } | null;
|
||
|
||
// Préremplissage pour le mode édition + override d'envoi
|
||
type NouveauCDDUPrefill = {
|
||
spectacle?: string;
|
||
numero_objet?: string | null;
|
||
salarie?: { matricule: string; nom: string; email?: string | null } | null;
|
||
categorie?: "Artiste" | "Technicien" | "Autre";
|
||
profession?: { code?: string; label?: string } | null;
|
||
date_debut?: string; // YYYY-MM-DD
|
||
date_fin?: string; // YYYY-MM-DD
|
||
nb_representations?: number | "";
|
||
nb_services_repetition?: number | "";
|
||
dates_representations?: string;
|
||
dates_repetitions?: string;
|
||
heures_total?: number | "";
|
||
minutes_total?: "0" | "30";
|
||
jours_travail?: string;
|
||
type_salaire?: "Brut" | "Net avant PAS" | "Coût total employeur" | "Minimum conventionnel";
|
||
montant?: number | "";
|
||
panier_repas?: "Oui" | "Non";
|
||
notes?: string;
|
||
reference?: string;
|
||
multi_mois?: boolean;
|
||
nb_heures_annexes?: number | string;
|
||
};
|
||
|
||
/* =========================
|
||
Constantes & helpers
|
||
========================= */
|
||
const NEW_SALARIE_PATH =
|
||
process.env.NEXT_PUBLIC_NEW_SALARIE_PATH || "/salaries/nouveau?embed=1";
|
||
|
||
const EURO = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
|
||
|
||
function todayYmd() {
|
||
const d = new Date();
|
||
const tzOff = d.getTimezoneOffset();
|
||
const local = new Date(d.getTime() - tzOff * 60000);
|
||
return local.toISOString().slice(0, 10); // YYYY-MM-DD
|
||
}
|
||
|
||
function formatFr(d: string) {
|
||
const [y, m, day] = d.split("-");
|
||
return `${day}/${m}/${y}`;
|
||
}
|
||
|
||
function generateReference() {
|
||
const letters = "ABCDEFGHIKLMNPQRSTUVWXYZ"; // sans O
|
||
const digits = "123456789"; // sans 0
|
||
const pool = letters + digits;
|
||
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
|
||
while (true) {
|
||
let ref = "";
|
||
for (let i = 0; i < 8; i++) ref += pick(pool);
|
||
if (ref.startsWith("RG")) continue; // ne pas commencer par RG
|
||
if (!/[A-Z]/.test(ref)) continue; // au moins une lettre
|
||
if (!/[1-9]/.test(ref)) continue; // au moins un chiffre
|
||
return ref;
|
||
}
|
||
}
|
||
|
||
// Helper: parse base64-encoded JSON safely
|
||
function parseBase64Json<T = any>(b64: string | null): T | null {
|
||
if (!b64) return null;
|
||
try {
|
||
const json = atob(b64);
|
||
return JSON.parse(json) as T;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function norm(s: string){
|
||
return s
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/\p{Diacritic}/gu, '')
|
||
.replace(/[^a-z0-9\s]/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
// Petit hook local de debounce (réutilisable)
|
||
function useDebouncedValue<T>(value: T, delay = 300) {
|
||
const [debounced, setDebounced] = useState<T>(value);
|
||
useEffect(() => {
|
||
const id = setTimeout(() => setDebounced(value), delay);
|
||
return () => clearTimeout(id);
|
||
}, [value, delay]);
|
||
return debounced;
|
||
}
|
||
|
||
/* =========================
|
||
Hook: blocage navigation si brouillon
|
||
========================= */
|
||
function useUnsavedChangesPrompt(enabled: boolean) {
|
||
const allowNavRef = useRef(false);
|
||
const pendingHrefRef = useRef<string | null>(null);
|
||
const [open, setOpen] = useState(false);
|
||
const [, forceRender] = useState(0);
|
||
|
||
useEffect(() => {
|
||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||
if (!enabled || allowNavRef.current) return;
|
||
e.preventDefault();
|
||
e.returnValue = ""; // Chrome a besoin d'une valeur
|
||
}
|
||
if (enabled) window.addEventListener("beforeunload", onBeforeUnload);
|
||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||
}, [enabled]);
|
||
|
||
useEffect(() => {
|
||
function onDocClick(ev: MouseEvent) {
|
||
if (!enabled || allowNavRef.current) return;
|
||
const target = ev.target as HTMLElement | null;
|
||
if (!target) return;
|
||
const a = target.closest?.("a[href]") as HTMLAnchorElement | null;
|
||
if (!a) return;
|
||
const url = new URL(a.href, window.location.href);
|
||
const sameOrigin = url.origin === window.location.origin;
|
||
if (!sameOrigin) return; // laisser sortir du site sans bloquer
|
||
if (url.pathname === window.location.pathname && url.hash) return; // autoriser ancres
|
||
ev.preventDefault();
|
||
pendingHrefRef.current = url.toString();
|
||
setOpen(true);
|
||
}
|
||
document.addEventListener("click", onDocClick, true);
|
||
return () => document.removeEventListener("click", onDocClick, true);
|
||
}, [enabled]);
|
||
|
||
useEffect(() => {
|
||
if (!enabled) return;
|
||
history.pushState({ _block: true }, "");
|
||
function onPopState(ev: PopStateEvent) {
|
||
if (allowNavRef.current || !enabled) return;
|
||
ev.preventDefault?.();
|
||
history.pushState({ _block: true }, "");
|
||
setOpen(true);
|
||
}
|
||
window.addEventListener("popstate", onPopState);
|
||
return () => window.removeEventListener("popstate", onPopState);
|
||
}, [enabled]);
|
||
|
||
const confirmLeave = useCallback(() => {
|
||
allowNavRef.current = true;
|
||
const href = pendingHrefRef.current;
|
||
setOpen(false);
|
||
if (href) { window.location.assign(href); } else { history.back(); }
|
||
}, []);
|
||
|
||
const cancelLeave = useCallback(() => {
|
||
pendingHrefRef.current = null;
|
||
setOpen(false);
|
||
forceRender((n) => n + 1);
|
||
}, []);
|
||
|
||
return { open, confirmLeave, cancelLeave, allowNavRef };
|
||
}
|
||
|
||
/* =========================
|
||
UI atoms
|
||
========================= */
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<section className="rounded-2xl border bg-white">
|
||
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
|
||
{title}
|
||
</div>
|
||
<div className="p-4 space-y-4">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
function Label({ children, required = false }: { children: React.ReactNode; required?: boolean }) {
|
||
return (
|
||
<label className="text-xs text-slate-600 block">
|
||
{children} {required && <span className="text-rose-600">*</span>}
|
||
</label>
|
||
);
|
||
}
|
||
function FieldRow({ children }: { children: React.ReactNode }) {
|
||
return <div className="grid grid-cols-1 md:grid-cols-2 gap-4">{children}</div>;
|
||
}
|
||
|
||
/* =========================
|
||
API searches
|
||
========================= */
|
||
function useSearchSalaries(q: string, orgId?: string | null) {
|
||
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 || null, name: me.active_org_name || "Organisation", api_name: me.active_org_api_name } as ClientInfo;
|
||
} catch { return null; }
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const params = new URLSearchParams();
|
||
params.set("page", "1");
|
||
params.set("limit", "10");
|
||
if (q.trim()) params.set("q", q.trim());
|
||
// Utiliser l'org_id fourni ou celui du clientInfo
|
||
if (orgId) params.set("org_id", orgId);
|
||
|
||
return useQuery({
|
||
queryKey: ["search-salaries", q, orgId || clientInfo?.id],
|
||
queryFn: () =>
|
||
api<{ items: SalarieOption[] }>(`/salaries?${params.toString()}`, {}, clientInfo).then((r) => ({
|
||
items: r.items?.map((s) => ({ matricule: s.matricule, nom: s.nom, email: s.email ?? undefined })) ?? [],
|
||
})),
|
||
enabled: (!!orgId || !!clientInfo),
|
||
staleTime: 10_000,
|
||
});
|
||
}
|
||
|
||
function useSearchSpectacles(q: string, orgId?: string | null) {
|
||
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 || null, name: me.active_org_name || "Organisation", api_name: me.active_org_api_name } as ClientInfo;
|
||
} catch { return null; }
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const params = new URLSearchParams();
|
||
params.set("page", "1");
|
||
params.set("limit", "10");
|
||
if (q.trim()) params.set("q", q.trim());
|
||
// Utiliser l'org_id fourni ou celui du clientInfo
|
||
if (orgId) params.set("org_id", orgId);
|
||
|
||
return useQuery({
|
||
queryKey: ["search-spectacles", q, orgId || clientInfo?.id],
|
||
queryFn: () =>
|
||
api<{ items: SpectacleOption[] }>(`/spectacles?${params.toString()}`, {}, clientInfo).then((r) => ({
|
||
items:
|
||
r.items?.map((s) => ({
|
||
id: (s as any).id || null,
|
||
nom: (s as any).nom || (s as any).Spectacle || (s as any).titre || "",
|
||
numero_objet: (s as any).numero_objet || (s as any)["n° d'objet"] || (s as any).numero || null,
|
||
})) ?? [],
|
||
})),
|
||
enabled: !!clientInfo,
|
||
staleTime: 10_000,
|
||
});
|
||
}
|
||
|
||
/* =========================
|
||
Formulaire principal
|
||
========================= */
|
||
export function NouveauCDDUForm({
|
||
prefill,
|
||
onSubmitOverride,
|
||
contractIdForNotes,
|
||
}: {
|
||
prefill?: NouveauCDDUPrefill;
|
||
onSubmitOverride?: (payload: {
|
||
regime: "CDDU_MONO" | "CDDU_MULTI";
|
||
multi_mois: boolean;
|
||
spectacle: string;
|
||
numero_objet?: string | null;
|
||
salarie: { matricule: string; nom: string; email?: string | null };
|
||
categorie_professionnelle: "Artiste" | "Technicien" | "Autre";
|
||
profession: string;
|
||
profession_code?: string;
|
||
date_debut: string;
|
||
date_fin?: string;
|
||
nb_representations?: number | "";
|
||
nb_services_repetition?: number | "";
|
||
dates_representations?: string;
|
||
dates_repetitions?: string;
|
||
heures_travail?: number | "";
|
||
minutes_travail?: "0" | "30";
|
||
jours_travail?: string;
|
||
type_salaire: "Brut" | "Net avant PAS" | "Coût total employeur" | "Minimum conventionnel";
|
||
montant?: number | "";
|
||
panier_repas: "Oui" | "Non";
|
||
reference: string;
|
||
notes?: string;
|
||
valider_direct: boolean;
|
||
}) => Promise<void>;
|
||
contractIdForNotes?: string;
|
||
}) {
|
||
const router = useRouter();
|
||
const posthog = usePostHog();
|
||
const isDemoMode = useDemoMode();
|
||
|
||
// --- form state
|
||
const [reference, setReference] = useState<string>("");
|
||
const [spectacle, setSpectacle] = useState("");
|
||
const [objet, setObjet] = useState("");
|
||
const [spectacleQuery, setSpectacleQuery] = useState("");
|
||
const [spectaclePick, setSpectaclePick] = useState<SpectacleOption | null>(null);
|
||
const [spectacleManual, setSpectacleManual] = useState(false);
|
||
const spectacleInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [salarieQuery, setSalarieQuery] = useState("");
|
||
const [salarie, setSalarie] = useState<SalarieOption | null>(null);
|
||
// L'utilisateur a explicitement cliqué sur "Changer" (ne plus forcer le salarie depuis l'URL)
|
||
const [urlSalarieDismissed, setUrlSalarieDismissed] = useState(false);
|
||
const [categoriePro, setCategoriePro] = useState<"Artiste" | "Technicien" | "Autre" | "Cadre" | "Non Cadre" | "Je ne sais pas">("Artiste");
|
||
const [profession, setProfession] = useState("");
|
||
const [professionQuery, setProfessionQuery] = useState("");
|
||
const [professionPick, setProfessionPick] = useState<ProfessionOption | null>(null);
|
||
// Index actif (navigation clavier) pour les listes de résultats
|
||
const [spectacleActive, setSpectacleActive] = useState(0);
|
||
const [salarieActive, setSalarieActive] = useState(0);
|
||
const [professionActive, setProfessionActive] = useState(0);
|
||
const [techniciens, setTechniciens] = useState<TechnicienOption[] | null>(null);
|
||
const [techLoading, setTechLoading] = useState(false);
|
||
const techLoadedRef = useRef(false);
|
||
const [isMultiMois, setIsMultiMois] = useState<"Oui" | "Non">("Non");
|
||
const [dateDebut, setDateDebut] = useState("");
|
||
const [dateFin, setDateFin] = useState("");
|
||
const [confirmPastStart, setConfirmPastStart] = useState(false);
|
||
const [heuresTotal, setHeuresTotal] = useState<number | "">("");
|
||
const [minutesTotal, setMinutesTotal] = useState<"0" | "30">("0");
|
||
const [joursTravail, setJoursTravail] = useState("");
|
||
const [nbRep, setNbRep] = useState<number | "">("");
|
||
const [nbServ, setNbServ] = useState<number | "">("");
|
||
const [datesRep, setDatesRep] = useState("");
|
||
const [datesServ, setDatesServ] = useState("");
|
||
const [typeSalaire, setTypeSalaire] = useState<"Brut" | "Net avant PAS" | "Coût total employeur" | "Minimum conventionnel">("Brut");
|
||
const [montantSalaire, setMontantSalaire] = useState<number | "">("");
|
||
const [panierRepas, setPanierRepas] = useState<"Oui" | "Non">("Non");
|
||
const [notes, setNotes] = useState("");
|
||
const [validerDirect, setValiderDirect] = useState<"Oui" | "Non">("Oui");
|
||
// Confirmation e-mail à la création
|
||
const [emailConfirm, setEmailConfirm] = useState<"Oui" | "Non">("Oui");
|
||
|
||
// États spécifiques au Régime Général
|
||
const [isRegimeRG, setIsRegimeRG] = useState(false);
|
||
const [typeContratRG, setTypeContratRG] = useState<"CDD de droit commun" | "CDI" | "Stage" | "Contrat de professionnalisation" | "Contrat d'apprentissage" | "Autre">("CDD de droit commun");
|
||
const [motifCDD, setMotifCDD] = useState<"Remplacement d'une absence" | "Accroissement temporaire d'activité">("Remplacement d'une absence");
|
||
const [nomPersonneRemplacee, setNomPersonneRemplacee] = useState("");
|
||
const [situationPersonneRemplacee, setSituationPersonneRemplacee] = useState<"Absent temporairement ou contrat suspendu (maladie, maternité, congés payés, congé parental, etc...)" | "Salarié provisoirement à temps partiel (congé parental, congé création d'entreprise, etc...)" | "Salarié ayant quitté définitivement l'entreprise, poste en attente de suppression" | "Remplacement du chef d'entreprise">("Absent temporairement ou contrat suspendu (maladie, maternité, congés payés, congé parental, etc...)");
|
||
const [motifAccroissement, setMotifAccroissement] = useState("");
|
||
|
||
// États pour la gestion des organisations (staff)
|
||
const [selectedOrg, setSelectedOrg] = useState<{ id: string; name: string } | null>(null);
|
||
const [isStaff, setIsStaff] = useState(false);
|
||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [redirecting, setRedirecting] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
// Empêcher la re-application du préremplissage via ?dupe= après modifications utilisateur
|
||
const [dupeApplied, setDupeApplied] = useState(false);
|
||
// Afficher un modal de chargement pendant le pré-remplissage (duplication)
|
||
const [prefillLoading, setPrefillLoading] = useState(false);
|
||
|
||
// Détection de saisie en cours (dirty)
|
||
const isDirty = useMemo(() => {
|
||
const hasSpectacle = spectacle.trim() !== "" || spectaclePick !== null || spectacleManual;
|
||
const hasSalarie = salarie !== null || salarieQuery.trim() !== "";
|
||
const hasProfession = profession.trim() !== "" || professionPick !== null || professionQuery.trim() !== "";
|
||
const hasDates = !!dateDebut || !!dateFin;
|
||
const hasVolumes = (nbRep !== "" && Number(nbRep) > 0) || (nbServ !== "" && Number(nbServ) > 0) || (heuresTotal !== "") || (joursTravail.trim() !== "") || (datesRep.trim() !== "") || (datesServ.trim() !== "");
|
||
const hasSalaire = typeSalaire !== "Brut" || (montantSalaire !== "" && Number(montantSalaire) > 0) || panierRepas !== "Non";
|
||
const hasAutres = objet.trim() !== "" || notes.trim() !== "" || isMultiMois === "Oui";
|
||
const hasRG = isRegimeRG && (
|
||
typeContratRG !== "CDD de droit commun" ||
|
||
(typeContratRG === "CDD de droit commun" && motifCDD === "Remplacement d'une absence" && (nomPersonneRemplacee.trim() !== "" || situationPersonneRemplacee !== "Absent temporairement ou contrat suspendu (maladie, maternité, congés payés, congé parental, etc...)")) ||
|
||
(typeContratRG === "CDD de droit commun" && motifCDD === "Accroissement temporaire d'activité" && motifAccroissement.trim() !== "")
|
||
);
|
||
return hasSpectacle || hasSalarie || hasProfession || hasDates || hasVolumes || hasSalaire || hasAutres || hasRG;
|
||
}, [
|
||
spectacle, spectaclePick, spectacleManual,
|
||
salarie, salarieQuery,
|
||
profession, professionPick, professionQuery,
|
||
dateDebut, dateFin,
|
||
nbRep, nbServ, heuresTotal, joursTravail, datesRep, datesServ,
|
||
typeSalaire, montantSalaire, panierRepas,
|
||
objet, notes, isMultiMois,
|
||
isRegimeRG, typeContratRG, motifCDD, nomPersonneRemplacee, situationPersonneRemplacee, motifAccroissement,
|
||
]);
|
||
|
||
// Modal "Nouveau salarié"
|
||
const [newSalarieOpen, setNewSalarieOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!newSalarieOpen) return;
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => {
|
||
document.body.style.overflow = prev;
|
||
};
|
||
}, [newSalarieOpen]);
|
||
|
||
// Hydrate le formulaire si des valeurs initiales sont fournies (mode édition)
|
||
useEffect(() => {
|
||
if (!prefill) return;
|
||
setSpectacle(prefill.spectacle || "");
|
||
setObjet(String(prefill.numero_objet ?? ""));
|
||
// Afficher la production dans la zone sélectionnée (lecture seule) quand on connaît le nom/numéro
|
||
if (prefill.spectacle) {
|
||
setSpectaclePick({ nom: prefill.spectacle, numero_objet: prefill.numero_objet ?? null });
|
||
setSpectacleManual(false);
|
||
setSpectacleQuery("");
|
||
}
|
||
setSalarie(prefill.salarie ?? null);
|
||
setCategoriePro(prefill.categorie ?? "Artiste");
|
||
setProfession(prefill.profession?.label || "");
|
||
setProfessionPick(
|
||
prefill.profession?.label
|
||
? { code: prefill.profession.code || "", label: prefill.profession.label }
|
||
: null
|
||
);
|
||
setDateDebut(prefill.date_debut || "");
|
||
setDateFin(prefill.date_fin || "");
|
||
setNbRep(prefill.nb_representations ?? "");
|
||
setNbServ(prefill.nb_services_repetition ?? "");
|
||
setDatesRep(prefill.dates_representations || "");
|
||
setDatesServ(prefill.dates_repetitions || "");
|
||
setHeuresTotal(prefill.heures_total ?? "");
|
||
setMinutesTotal(prefill.minutes_total ?? "0");
|
||
setJoursTravail(prefill.jours_travail || "");
|
||
if (typeof prefill.multi_mois === "boolean") setIsMultiMois(prefill.multi_mois ? "Oui" : "Non");
|
||
setTypeSalaire(prefill.type_salaire ?? "Brut");
|
||
setMontantSalaire(prefill.montant ?? "");
|
||
setPanierRepas(prefill.panier_repas ?? "Non");
|
||
setNotes(prefill.notes || "");
|
||
// Si technicien: préremplir heures/minutes depuis nb_heures_annexes si fourni
|
||
const cat = prefill.categorie ?? "Artiste";
|
||
if (
|
||
cat === "Technicien" &&
|
||
prefill.nb_heures_annexes !== undefined &&
|
||
prefill.nb_heures_annexes !== null &&
|
||
prefill.nb_heures_annexes !== "" &&
|
||
(prefill.heures_total === undefined || prefill.heures_total === "")
|
||
) {
|
||
const raw = String(prefill.nb_heures_annexes).replace(",", ".");
|
||
const total = parseFloat(raw);
|
||
if (Number.isFinite(total)) {
|
||
const h = Math.floor(total);
|
||
const frac = total - h;
|
||
// Si décimale, on met +30 min (comportement demandé)
|
||
const mins: "0" | "30" = frac > 0 ? "30" : "0";
|
||
setHeuresTotal(h);
|
||
setMinutesTotal(mins);
|
||
}
|
||
}
|
||
if (prefill.reference) setReference(prefill.reference);
|
||
}, [prefill]);
|
||
|
||
// Détection du paramètre regime=RG dans l'URL
|
||
useEffect(() => {
|
||
let search = "";
|
||
try {
|
||
search = window.location.search || "";
|
||
} catch {
|
||
return;
|
||
}
|
||
const urlParams = new URLSearchParams(search);
|
||
const regime = urlParams.get("regime");
|
||
|
||
if (regime === "RG") {
|
||
setIsRegimeRG(true);
|
||
// Adapter les valeurs par défaut pour le RG
|
||
setCategoriePro("Non Cadre");
|
||
setIsMultiMois("Non"); // Pas de multi-mois en RG
|
||
} else {
|
||
setIsRegimeRG(false);
|
||
setCategoriePro("Artiste");
|
||
}
|
||
}, []);
|
||
|
||
// Pré-remplissage du salarié via paramètre d'URL ?salarie=MATRICULE (ou ?matricule=)
|
||
useEffect(() => {
|
||
// Si déjà défini (via prefill ou choix manuel), ou si l'utilisateur a explicitement cliqué sur "Changer", ne rien faire
|
||
if (urlSalarieDismissed || salarie || prefill?.salarie) return;
|
||
|
||
// Lecture du paramètre d'URL (SSR safe)
|
||
let search = "";
|
||
try {
|
||
search = window.location.search || "";
|
||
} catch {
|
||
return;
|
||
}
|
||
const urlParams = new URLSearchParams(search);
|
||
const matriculeParam = urlParams.get("salarie") || urlParams.get("matricule");
|
||
if (!matriculeParam) return;
|
||
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
// Récupérer le contexte client (même logique que dans les hooks)
|
||
let clientInfo: ClientInfo = null;
|
||
try {
|
||
const res = await fetch("/api/me", {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include",
|
||
});
|
||
if (res.ok) {
|
||
const me = await res.json();
|
||
clientInfo = {
|
||
id: me.active_org_id || null,
|
||
name: me.active_org_name || "Organisation",
|
||
api_name: me.active_org_api_name,
|
||
} as ClientInfo;
|
||
}
|
||
} catch {}
|
||
|
||
// 1) Essai par endpoint détail
|
||
try {
|
||
const detail: any = await api(`/salaries/${encodeURIComponent(matriculeParam)}`, {}, clientInfo);
|
||
if (!cancelled && detail) {
|
||
const displayName = (
|
||
[detail.prenom, detail.nom_usage || detail.nom]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.trim()
|
||
) ||
|
||
detail.nom_usage ||
|
||
detail.nom ||
|
||
detail.pseudo ||
|
||
matriculeParam;
|
||
|
||
setSalarie({
|
||
matricule: detail.matricule ?? matriculeParam,
|
||
nom: displayName,
|
||
email: detail.email ?? undefined,
|
||
});
|
||
return;
|
||
}
|
||
} catch {
|
||
// 2) Fallback: recherche générique
|
||
try {
|
||
const p = new URLSearchParams();
|
||
p.set("page", "1");
|
||
p.set("limit", "1");
|
||
p.set("q", matriculeParam);
|
||
const result: any = await api(`/salaries?${p.toString()}`, {}, clientInfo);
|
||
const it = result?.items?.[0];
|
||
if (!cancelled && it) {
|
||
const displayName = ([it.prenom, it.nom].filter(Boolean).join(" ") || it.nom || it.pseudo || it.matricule).trim();
|
||
setSalarie({ matricule: it.matricule, nom: displayName, email: it.email ?? undefined });
|
||
}
|
||
} catch {}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [salarie, prefill?.salarie, urlSalarieDismissed]);
|
||
|
||
// Dans /components/contrats/NouveauCDDUForm.tsx
|
||
// Remplacez la section useEffect de duplication (lignes ~195-270) par ce code amélioré :
|
||
|
||
// Duplication via URL par identifiant : ?dupe_id=UUID
|
||
useEffect(() => {
|
||
// Ne pas appliquer si on est déjà en mode édition avec prefill ou si déjà appliqué
|
||
if (prefill || dupeApplied) return;
|
||
|
||
// Lecture sécurisée du paramètre
|
||
let search = "";
|
||
try { search = window.location.search || ""; } catch { return; }
|
||
const params = new URLSearchParams(search);
|
||
const dupeId = params.get("dupe_id");
|
||
if (!dupeId) return;
|
||
|
||
setPrefillLoading(true);
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
try {
|
||
// 1) Contexte client
|
||
let clientInfo: ClientInfo = null;
|
||
try {
|
||
const res = await fetch("/api/me", { cache: "no-store", headers: { Accept: "application/json" }, credentials: "include" });
|
||
if (res.ok) {
|
||
const me = await res.json();
|
||
clientInfo = { id: me.active_org_id || null, name: me.active_org_name || "Organisation", api_name: me.active_org_api_name } as ClientInfo;
|
||
}
|
||
} catch {}
|
||
|
||
// 2) Récupération du contrat complet
|
||
const contrat: any = await api(`/contrats/${encodeURIComponent(dupeId)}`, {}, clientInfo);
|
||
if (cancelled || !contrat) { setPrefillLoading(false); return; }
|
||
|
||
// 3) Mapping vers le format de pré-remplissage du formulaire
|
||
const payload = {
|
||
spectacle: contrat.production || "",
|
||
numero_objet: contrat.objet ?? "",
|
||
salarie: (contrat.salarie_matricule || contrat.salarie) ? {
|
||
matricule: contrat.salarie_matricule || "",
|
||
nom: (contrat.salarie?.nom || contrat.salarie_nom || "").toString(),
|
||
email: contrat.salarie?.email || undefined,
|
||
} : null,
|
||
categorie: (() => {
|
||
const c = (contrat.categorie_prof || "").toString().toLowerCase();
|
||
return c.includes("tech") ? "Technicien" : "Artiste";
|
||
})(),
|
||
profession: { label: contrat.profession || "", code: contrat.profession_code || "" },
|
||
date_debut: contrat.date_debut || "",
|
||
date_fin: contrat.date_fin || "",
|
||
nb_representations: Number.isFinite(Number(contrat.nb_representations)) ? Number(contrat.nb_representations) : "",
|
||
nb_services_repetition: Number.isFinite(Number(contrat.nb_services_repetitions)) ? Number(contrat.nb_services_repetitions) : "",
|
||
dates_representations: contrat.dates_representations || "",
|
||
dates_repetitions: contrat.dates_repetitions || "",
|
||
heures_total: (() => {
|
||
const aem = Number(contrat.nb_heures_aem);
|
||
if (Number.isFinite(aem) && aem >= 0) return aem;
|
||
const rep = Number(contrat.nb_heures_repetitions || 0);
|
||
const ann = Number(contrat.nb_heures_annexes || 0);
|
||
const total = rep + ann;
|
||
return Number.isFinite(total) && total > 0 ? total : "";
|
||
})(),
|
||
minutes_total: (contrat.minutes_total === "30" ? "30" : "0") as "0" | "30",
|
||
jours_travail: typeof contrat.jours_travailles === "number" ? String(contrat.jours_travailles) : (contrat.jours_travail || ""),
|
||
type_salaire: (() => {
|
||
const t = (contrat.type_salaire || "").toString().toLowerCase();
|
||
if (t.includes("net") && t.includes("pas")) return "Net avant PAS" as const;
|
||
if (t.includes("coût") || t.includes("cout") || t.includes("total")) return "Coût total employeur" as const;
|
||
if (t.includes("minimum") || t.includes("conventionnel")) return "Minimum conventionnel" as const;
|
||
return "Brut" as const;
|
||
})(),
|
||
montant: (() => {
|
||
const raw = (contrat.salaire_demande || contrat.montant || "").toString();
|
||
const cleaned = raw.replace(/[^0-9,.-]/g, '').replace(/,(?=\d{1,2}$)/, '.').replace(/,/g, '.');
|
||
const num = parseFloat(cleaned);
|
||
return Number.isFinite(num) && num > 0 ? num : "";
|
||
})(),
|
||
panier_repas: (() => {
|
||
const v = (contrat.panier_repas || "").toString().toLowerCase();
|
||
if (["oui","true","1"].includes(v)) return "Oui" as const;
|
||
if (["non","false","0"].includes(v)) return "Non" as const;
|
||
return "Non" as const;
|
||
})(),
|
||
notes: contrat.notes || "",
|
||
multi_mois: !!(contrat.is_multi_mois || (contrat.regime && String(contrat.regime).toUpperCase()==="CDDU_MULTI")),
|
||
};
|
||
|
||
// 4) Appliquer au formulaire (mêmes setters que pour ?dupe=)
|
||
if (payload.spectacle) {
|
||
setSpectacle(String(payload.spectacle));
|
||
setSpectaclePick({ nom: String(payload.spectacle), numero_objet: (payload.numero_objet as any) ?? null });
|
||
setSpectacleManual(false);
|
||
setSpectacleQuery("");
|
||
}
|
||
if (payload.numero_objet !== undefined) setObjet(String(payload.numero_objet ?? ""));
|
||
|
||
if (payload.salarie) setSalarie({ matricule: payload.salarie.matricule, nom: payload.salarie.nom, email: payload.salarie.email });
|
||
if (payload.categorie) setCategoriePro(payload.categorie as any);
|
||
|
||
if (payload.profession?.label) {
|
||
setProfession(String(payload.profession.label));
|
||
setProfessionPick({ code: String(payload.profession.code || ""), label: String(payload.profession.label) });
|
||
}
|
||
|
||
if (payload.date_debut) setDateDebut(String(payload.date_debut));
|
||
if (payload.date_fin) setDateFin(String(payload.date_fin));
|
||
|
||
if (payload.nb_representations !== "") setNbRep(payload.nb_representations as any);
|
||
if (payload.nb_services_repetition !== "") setNbServ(payload.nb_services_repetition as any);
|
||
|
||
if (payload.dates_representations) setDatesRep(String(payload.dates_representations));
|
||
if (payload.dates_repetitions) setDatesServ(String(payload.dates_repetitions));
|
||
|
||
if (payload.heures_total !== "") setHeuresTotal(payload.heures_total as any);
|
||
if (payload.minutes_total) setMinutesTotal(payload.minutes_total);
|
||
if (payload.jours_travail) setJoursTravail(String(payload.jours_travail));
|
||
|
||
if (payload.type_salaire) setTypeSalaire(payload.type_salaire);
|
||
if (payload.montant !== "") setMontantSalaire(payload.montant as any);
|
||
if (payload.panier_repas) setPanierRepas(payload.panier_repas);
|
||
if (payload.notes) setNotes(String(payload.notes));
|
||
setIsMultiMois(payload.multi_mois ? "Oui" : "Non");
|
||
|
||
// 5) Nouvelle référence systématique
|
||
setReference(generateReference());
|
||
|
||
// 6) Marquer appliqué
|
||
setDupeApplied(true);
|
||
setPrefillLoading(false);
|
||
} catch (e) {
|
||
console.warn("⚠️ Duplication par id échouée:", e);
|
||
setPrefillLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => { cancelled = true; setPrefillLoading(false); };
|
||
}, [prefill, dupeApplied]);
|
||
|
||
// Duplication via URL: ?dupe=base64(json) ou ?duplicate=base64(json)
|
||
useEffect(() => {
|
||
// Ne pas appliquer si on est déjà en mode édition avec prefill ou si déjà appliqué
|
||
if (prefill || dupeApplied) return;
|
||
|
||
// SSR safe-guard
|
||
let search = "";
|
||
try {
|
||
search = window.location.search || "";
|
||
} catch {
|
||
return;
|
||
}
|
||
const params = new URLSearchParams(search);
|
||
const dupe = params.get("dupe") || params.get("duplicate");
|
||
if (!dupe) return;
|
||
|
||
const data = parseBase64Json<Partial<NouveauCDDUPrefill>>(dupe);
|
||
if (!data) {
|
||
console.warn("❌ Impossible de décoder les données de duplication");
|
||
return;
|
||
}
|
||
|
||
setPrefillLoading(true);
|
||
console.log("🔄 Données de duplication reçues:", data); // Debug
|
||
|
||
// Application des données avec vérifications
|
||
// ========================================
|
||
|
||
// Production/Spectacle
|
||
if (data.spectacle) {
|
||
const spectacleName = String(data.spectacle).trim();
|
||
if (spectacleName) {
|
||
setSpectacle(spectacleName);
|
||
setSpectaclePick({ nom: spectacleName, numero_objet: (data.numero_objet as any) ?? null });
|
||
setSpectacleManual(false);
|
||
setSpectacleQuery("");
|
||
console.log("✅ Production pré-remplie:", spectacleName);
|
||
}
|
||
}
|
||
|
||
// Numéro d'objet
|
||
if (!objet && data.numero_objet !== undefined && data.numero_objet !== null) {
|
||
const objetValue = String(data.numero_objet).trim();
|
||
setObjet(objetValue);
|
||
if (objetValue) {
|
||
console.log("✅ Numéro d'objet pré-rempli:", objetValue);
|
||
}
|
||
}
|
||
|
||
// Salarié
|
||
if (!salarie && data.salarie) {
|
||
const salarieData = {
|
||
matricule: String(data.salarie.matricule || "").trim(),
|
||
nom: String(data.salarie.nom || "").trim(),
|
||
email: data.salarie.email ? String(data.salarie.email).trim() : undefined,
|
||
};
|
||
|
||
if (salarieData.matricule && salarieData.nom) {
|
||
setSalarie(salarieData);
|
||
console.log("✅ Salarié pré-rempli:", salarieData);
|
||
|
||
// Si l'email n'est pas fourni dans la duplication, tenter un enrichissement
|
||
if (!salarieData.email || salarieData.email === "") {
|
||
(async () => {
|
||
try {
|
||
let clientInfo: ClientInfo = null;
|
||
const res = await fetch("/api/me", {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include",
|
||
});
|
||
if (res.ok) {
|
||
const me = await res.json();
|
||
clientInfo = {
|
||
id: me.active_org_id || null,
|
||
name: me.active_org_name || "Organisation",
|
||
api_name: me.active_org_api_name,
|
||
} as ClientInfo;
|
||
}
|
||
|
||
const detail: any = await api(`/salaries/${encodeURIComponent(salarieData.matricule)}`, {}, clientInfo);
|
||
if (detail?.email) {
|
||
setSalarie((prev) => (prev ? { ...prev, email: detail.email } : prev));
|
||
console.log("✅ Email salarié enrichi:", detail.email);
|
||
}
|
||
} catch (e) {
|
||
console.warn("⚠️ Impossible d'enrichir l'email du salarié:", e);
|
||
}
|
||
})();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Catégorie professionnelle avec vérification améliorée
|
||
if (data.categorie) {
|
||
const categorieStr = String(data.categorie).trim().toLowerCase();
|
||
let newCategorie: "Artiste" | "Technicien" | "Autre" = "Artiste";
|
||
|
||
if (categorieStr.includes("technicien") || categorieStr === "technicien") {
|
||
newCategorie = "Technicien";
|
||
} else if (categorieStr.includes("artiste") || categorieStr === "artiste") {
|
||
newCategorie = "Artiste";
|
||
} else if (categorieStr.includes("autre") || categorieStr === "autre") {
|
||
newCategorie = "Autre";
|
||
}
|
||
|
||
setCategoriePro(newCategorie);
|
||
console.log("✅ Catégorie professionnelle pré-remplie:", newCategorie);
|
||
}
|
||
|
||
// Profession
|
||
if (!profession && (data.profession?.label || data.profession)) {
|
||
const professionData = data.profession?.label ? data.profession : { label: String(data.profession), code: "" };
|
||
const professionLabel = String(professionData.label).trim();
|
||
|
||
if (professionLabel) {
|
||
setProfession(professionLabel);
|
||
setProfessionPick({
|
||
code: String(professionData.code || "").trim(),
|
||
label: professionLabel,
|
||
});
|
||
console.log("✅ Profession pré-remplie:", professionLabel);
|
||
}
|
||
}
|
||
|
||
// Dates
|
||
if (!dateDebut && data.date_debut) {
|
||
const debutValue = String(data.date_debut).trim();
|
||
if (debutValue.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||
setDateDebut(debutValue);
|
||
console.log("✅ Date début pré-remplie:", debutValue);
|
||
}
|
||
}
|
||
|
||
if (!dateFin && data.date_fin) {
|
||
const finValue = String(data.date_fin).trim();
|
||
if (finValue.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||
setDateFin(finValue);
|
||
console.log("✅ Date fin pré-remplie:", finValue);
|
||
}
|
||
}
|
||
|
||
// Nombres avec vérifications
|
||
if (nbRep === "" && data.nb_representations !== undefined && data.nb_representations !== null) {
|
||
const repValue = Number(data.nb_representations);
|
||
if (Number.isInteger(repValue) && repValue >= 0) {
|
||
setNbRep(repValue);
|
||
console.log("✅ Nb représentations pré-rempli:", repValue);
|
||
}
|
||
}
|
||
|
||
if (nbServ === "" && data.nb_services_repetition !== undefined && data.nb_services_repetition !== null) {
|
||
const servValue = Number(data.nb_services_repetition);
|
||
if (Number.isInteger(servValue) && servValue >= 0) {
|
||
setNbServ(servValue);
|
||
console.log("✅ Nb services répétition pré-rempli:", servValue);
|
||
}
|
||
}
|
||
|
||
// Dates textuelles
|
||
if (!datesRep && data.dates_representations) {
|
||
const datesRepValue = String(data.dates_representations).trim();
|
||
if (datesRepValue) {
|
||
setDatesRep(datesRepValue);
|
||
console.log("✅ Dates représentations pré-remplies:", datesRepValue);
|
||
}
|
||
}
|
||
|
||
if (!datesServ && data.dates_repetitions) {
|
||
const datesServValue = String(data.dates_repetitions).trim();
|
||
if (datesServValue) {
|
||
setDatesServ(datesServValue);
|
||
console.log("✅ Dates répétitions pré-remplies:", datesServValue);
|
||
}
|
||
}
|
||
|
||
// Heures et minutes
|
||
if (heuresTotal === "" && data.heures_total !== undefined && data.heures_total !== null) {
|
||
const heuresValue = Number(data.heures_total);
|
||
if (Number.isFinite(heuresValue) && heuresValue >= 0) {
|
||
setHeuresTotal(Math.floor(heuresValue)); // Arrondir à l'entier inférieur
|
||
console.log("✅ Heures total pré-remplies:", Math.floor(heuresValue));
|
||
}
|
||
}
|
||
|
||
if (data.minutes_total && (data.minutes_total === "0" || data.minutes_total === "30")) {
|
||
setMinutesTotal(data.minutes_total);
|
||
console.log("✅ Minutes total pré-remplies:", data.minutes_total);
|
||
}
|
||
|
||
if (!joursTravail && data.jours_travail) {
|
||
const joursTravailValue = String(data.jours_travail).trim();
|
||
if (joursTravailValue) {
|
||
setJoursTravail(joursTravailValue);
|
||
console.log("✅ Jours travail pré-remplis:", joursTravailValue);
|
||
}
|
||
}
|
||
|
||
// Type de salaire avec vérification
|
||
if (data.type_salaire) {
|
||
const typeValue = String(data.type_salaire).trim();
|
||
const validTypes: Array<"Brut" | "Net avant PAS" | "Coût total employeur" | "Minimum conventionnel"> =
|
||
["Brut", "Net avant PAS", "Coût total employeur", "Minimum conventionnel"];
|
||
|
||
if (validTypes.includes(typeValue as any)) {
|
||
setTypeSalaire(typeValue as any);
|
||
console.log("✅ Type salaire pré-rempli:", typeValue);
|
||
} else {
|
||
console.warn("⚠️ Type de salaire invalide:", typeValue);
|
||
}
|
||
}
|
||
|
||
// Montant avec validation
|
||
if (montantSalaire === "" && data.montant !== undefined && data.montant !== null) {
|
||
const montantValue = Number(data.montant);
|
||
if (Number.isFinite(montantValue) && montantValue > 0) {
|
||
setMontantSalaire(montantValue);
|
||
console.log("✅ Montant pré-rempli:", montantValue);
|
||
}
|
||
}
|
||
|
||
// Panier repas
|
||
if (data.panier_repas && (data.panier_repas === "Oui" || data.panier_repas === "Non")) {
|
||
setPanierRepas(data.panier_repas);
|
||
console.log("✅ Panier repas pré-rempli:", data.panier_repas);
|
||
}
|
||
|
||
// Multi-mois
|
||
if (typeof data.multi_mois === "boolean") {
|
||
setIsMultiMois(data.multi_mois ? "Oui" : "Non");
|
||
console.log("✅ Multi-mois pré-rempli:", data.multi_mois ? "Oui" : "Non");
|
||
}
|
||
|
||
// Toujours régénérer une nouvelle référence pour la duplication
|
||
const newRef = generateReference();
|
||
setReference(newRef);
|
||
console.log("✅ Nouvelle référence générée:", newRef);
|
||
|
||
// Marquer comme appliqué pour ne pas réécraser les changements utilisateur
|
||
setDupeApplied(true);
|
||
setPrefillLoading(false);
|
||
|
||
}, [prefill, dupeApplied]);
|
||
|
||
const { open: showLeaveConfirm, confirmLeave, cancelLeave, allowNavRef } = useUnsavedChangesPrompt(isDirty && !redirecting && !loading);
|
||
|
||
useEffect(() => {
|
||
// En création seulement : pas de prefill.reference -> on génère
|
||
if (!prefill?.reference) {
|
||
setReference(generateReference());
|
||
}
|
||
}, [prefill?.reference]);
|
||
|
||
const filteredProfessions = useMemo(() => {
|
||
const q = professionQuery.trim().toLowerCase();
|
||
if (!q) return PROFESSIONS_ARTISTE;
|
||
return PROFESSIONS_ARTISTE.filter(p =>
|
||
p.code.toLowerCase().includes(q) || p.label.toLowerCase().includes(q)
|
||
).slice(0, 20);
|
||
}, [professionQuery]);
|
||
|
||
const filteredTechniciens = useMemo(() => {
|
||
if (!techniciens) return [] as TechnicienOption[];
|
||
const q = norm(professionQuery);
|
||
if (!q) return techniciens.slice(0, 30);
|
||
return techniciens
|
||
.filter(p => norm(p.code).includes(q) || norm(p.label).includes(q))
|
||
.slice(0, 30);
|
||
}, [techniciens, professionQuery]);
|
||
|
||
const useHeuresMode = useMemo(() => {
|
||
return (
|
||
categoriePro === "Technicien" ||
|
||
(categoriePro === "Artiste" && professionPick?.code === "MET040")
|
||
);
|
||
}, [categoriePro, professionPick]);
|
||
|
||
const debouncedSalarieQuery = useDebouncedValue(salarieQuery, 300);
|
||
const { data: salarieSearch, isFetching: fetchingSalaries } = useSearchSalaries(debouncedSalarieQuery, selectedOrg?.id);
|
||
const { data: spectacleSearch, isFetching: fetchingSpectacles } = useSearchSpectacles(spectacleQuery, selectedOrg?.id);
|
||
|
||
// 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();
|
||
}, []);
|
||
|
||
// Réinitialiser l'index actif quand la query ou le nombre de résultats change
|
||
useEffect(() => {
|
||
setSpectacleActive(0);
|
||
}, [spectacleQuery, spectacleSearch?.items?.length]);
|
||
|
||
useEffect(() => {
|
||
setSalarieActive(0);
|
||
}, [salarieQuery, salarieSearch?.items?.length]);
|
||
|
||
const currentProfList = (categoriePro === 'Artiste' ? filteredProfessions : filteredTechniciens);
|
||
useEffect(() => {
|
||
setProfessionActive(0);
|
||
}, [professionQuery, currentProfList.length, categoriePro]);
|
||
|
||
const isPastStart = useMemo(() => {
|
||
if (!dateDebut) return false;
|
||
return dateDebut < todayYmd();
|
||
}, [dateDebut]);
|
||
|
||
async function ensureTechniciensLoaded(){
|
||
if (techLoadedRef.current || techniciens) return;
|
||
try {
|
||
setTechLoading(true);
|
||
const res = await fetch('/data/professions-techniciens.json', { cache: 'force-cache' });
|
||
if (!res.ok) throw new Error('HTTP '+res.status);
|
||
const list: TechnicienOption[] = await res.json();
|
||
setTechniciens(list);
|
||
techLoadedRef.current = true;
|
||
} finally {
|
||
setTechLoading(false);
|
||
}
|
||
}
|
||
|
||
// Upload de fichiers supprimé
|
||
|
||
async function onSubmit() {
|
||
setErr(null);
|
||
|
||
// validations minimales
|
||
if (!isRegimeRG && !spectacle.trim()) return setErr("Le champ Spectacle est obligatoire.");
|
||
if (!salarie) return setErr("Veuillez sélectionner un·e salarié·e.");
|
||
if (!profession.trim()) return setErr("La profession est obligatoire.");
|
||
if (!dateDebut) return setErr("La date de début du contrat est obligatoire.");
|
||
|
||
// Validations spécifiques au RG
|
||
if (isRegimeRG) {
|
||
if (typeContratRG === "CDD de droit commun") {
|
||
if (motifCDD === "Remplacement d'une absence") {
|
||
if (!nomPersonneRemplacee.trim()) return setErr("Veuillez indiquer le nom de la personne remplacée.");
|
||
}
|
||
if (motifCDD === "Accroissement temporaire d'activité") {
|
||
if (!motifAccroissement.trim()) return setErr("Veuillez décrire le motif de l'accroissement d'activité.");
|
||
}
|
||
}
|
||
// Pour CDI, pas de date de fin obligatoire
|
||
if (typeContratRG !== "CDI" && !dateFin) {
|
||
return setErr("La date de fin est obligatoire pour ce type de contrat.");
|
||
}
|
||
}
|
||
|
||
if (!isRegimeRG && useHeuresMode) {
|
||
if (heuresTotal === "" || Number(heuresTotal) < 0)
|
||
return setErr("Veuillez indiquer le nombre d'heures de travail.");
|
||
}
|
||
if (dateFin && dateDebut && dateFin < dateDebut)
|
||
return setErr("La date de fin doit être postérieure ou égale à la date de début.");
|
||
if (isPastStart && !confirmPastStart)
|
||
return setErr("Veuillez cocher la case de confirmation liée au démarrage dans le passé.");
|
||
|
||
if (typeSalaire !== "Minimum conventionnel") {
|
||
if (montantSalaire === "" || Number(montantSalaire) <= 0)
|
||
return setErr("Veuillez saisir un montant de rémunération valide.");
|
||
}
|
||
|
||
// Valeurs structurées communes (PATCH ou webhook)
|
||
const payload = {
|
||
regime: isRegimeRG ? "RG" : (isMultiMois === "Oui" ? "CDDU_MULTI" : "CDDU_MONO"),
|
||
multi_mois: !isRegimeRG && isMultiMois === "Oui",
|
||
spectacle: isRegimeRG ? undefined : spectacle,
|
||
numero_objet: isRegimeRG ? undefined : (objet || undefined),
|
||
// Champs spécifiques au RG
|
||
type_contrat_rg: isRegimeRG ? typeContratRG : undefined,
|
||
motif_cdd: isRegimeRG && typeContratRG === "CDD de droit commun" ? motifCDD : undefined,
|
||
nom_personne_remplacee: isRegimeRG && typeContratRG === "CDD de droit commun" && motifCDD === "Remplacement d'une absence" ? nomPersonneRemplacee : undefined,
|
||
situation_personne_remplacee: isRegimeRG && typeContratRG === "CDD de droit commun" && motifCDD === "Remplacement d'une absence" ? situationPersonneRemplacee : undefined,
|
||
motif_accroissement: isRegimeRG && typeContratRG === "CDD de droit commun" && motifCDD === "Accroissement temporaire d'activité" ? motifAccroissement : undefined,
|
||
// Champs communs
|
||
salarie: salarie!, // validé plus haut
|
||
categorie_professionnelle: categoriePro,
|
||
profession,
|
||
profession_code: !isRegimeRG ? (professionPick?.code || undefined) : undefined,
|
||
date_debut: dateDebut,
|
||
date_fin: (isRegimeRG && typeContratRG === "CDI") ? undefined : (dateFin || undefined),
|
||
nb_representations: !isRegimeRG ? nbRep : undefined,
|
||
nb_services_repetition: !isRegimeRG ? nbServ : undefined,
|
||
dates_representations: !isRegimeRG ? (datesRep || undefined) : undefined,
|
||
dates_repetitions: !isRegimeRG ? (datesServ || undefined) : undefined,
|
||
heures_travail: !isRegimeRG && useHeuresMode ? heuresTotal : undefined,
|
||
minutes_travail: !isRegimeRG && useHeuresMode ? minutesTotal : undefined,
|
||
jours_travail: !isRegimeRG && useHeuresMode ? (joursTravail || undefined) : undefined,
|
||
type_salaire: typeSalaire,
|
||
montant: typeSalaire !== "Minimum conventionnel" ? (montantSalaire === "" ? undefined : montantSalaire) : undefined,
|
||
panier_repas: panierRepas,
|
||
reference,
|
||
notes: notes || undefined,
|
||
valider_direct: validerDirect === "Oui",
|
||
} as const;
|
||
|
||
setLoading(true);
|
||
try {
|
||
if (onSubmitOverride) {
|
||
// MODE ÉDITION → PATCH via l'API Next (proxy Lambda)
|
||
await onSubmitOverride(payload as any);
|
||
} else {
|
||
// MODE CRÉATION → utiliser l'API appropriée selon le régime
|
||
const contractData = {
|
||
// Champs communs
|
||
salarie_matricule: payload.salarie.matricule,
|
||
salarie_nom: payload.salarie.nom,
|
||
salarie_email: payload.salarie.email,
|
||
// Préférence d'envoi d'e-mail de confirmation
|
||
send_email_confirmation: emailConfirm === "Oui",
|
||
categorie: payload.categorie_professionnelle,
|
||
profession: payload.profession,
|
||
date_debut: payload.date_debut,
|
||
date_fin: payload.date_fin,
|
||
type_salaire: payload.type_salaire,
|
||
montant: payload.montant,
|
||
panier_repas: payload.panier_repas,
|
||
reference: payload.reference,
|
||
notes: payload.notes,
|
||
// Ajouter l'organisation sélectionnée si staff
|
||
org_id: selectedOrg?.id || null,
|
||
|
||
// Champs spécifiques selon le régime
|
||
...(isRegimeRG ? {
|
||
// Champs RG
|
||
regime: "RG",
|
||
type_contrat: payload.type_contrat_rg,
|
||
motif_cdd: payload.motif_cdd,
|
||
nom_personne_remplacee: payload.nom_personne_remplacee,
|
||
situation_personne_remplacee: payload.situation_personne_remplacee,
|
||
motif_accroissement: payload.motif_accroissement,
|
||
} : {
|
||
// Champs CDDU
|
||
regime: payload.regime,
|
||
production_id: spectaclePick?.id || null,
|
||
spectacle: payload.spectacle,
|
||
numero_objet: payload.numero_objet,
|
||
profession_code: payload.profession_code,
|
||
profession_label: payload.profession,
|
||
nb_representations: payload.nb_representations,
|
||
nb_services_repetition: payload.nb_services_repetition,
|
||
dates_representations: payload.dates_representations,
|
||
dates_repetitions: payload.dates_repetitions,
|
||
heures_total: payload.heures_travail,
|
||
minutes_total: payload.minutes_travail,
|
||
jours_travail: payload.jours_travail,
|
||
multi_mois: payload.multi_mois,
|
||
})
|
||
};
|
||
|
||
// Utiliser l'endpoint approprié
|
||
const apiEndpoint = isRegimeRG ? "/api/rg-contracts" : "/api/cddu-contracts";
|
||
|
||
const res = await fetch(apiEndpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(contractData)
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errorData = await res.json().catch(() => ({}));
|
||
throw new Error(errorData.error || `Erreur HTTP ${res.status}`);
|
||
}
|
||
|
||
// 🎯 Tracker l'événement de création de contrat avec PostHog
|
||
const result = await res.json().catch(() => ({}));
|
||
posthog?.capture('contract_created', {
|
||
contract_type: isRegimeRG ? 'RG' : 'CDDU',
|
||
regime: isRegimeRG ? 'Régime Général' : payload.regime,
|
||
multi_mois: payload.multi_mois,
|
||
categorie_professionnelle: payload.categorie_professionnelle,
|
||
contract_id: result?.contract?.id || result?.contract?.contract_number,
|
||
has_notes: !!payload.notes,
|
||
validation_immediate: payload.valider_direct,
|
||
});
|
||
console.log('📊 PostHog: Événement contract_created envoyé');
|
||
}
|
||
|
||
// Autoriser la navigation après soumission + feedback
|
||
allowNavRef.current = true;
|
||
setRedirecting(true);
|
||
setTimeout(() => {
|
||
router.push("/contrats");
|
||
}, 3000);
|
||
} catch (e: any) {
|
||
setErr(e?.message || "Impossible d’envoyer le formulaire.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-5">
|
||
{!prefill && (
|
||
<div className="rounded-2xl border bg-white p-4">
|
||
<div className="text-lg font-semibold">{isRegimeRG ? "Nouveau contrat Régime Général" : "Nouveau CDDU"}</div>
|
||
<p className="text-sm text-slate-600 mt-1">
|
||
Ce formulaire est dédié aux CDD d’usage (intermittents du spectacle).
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Référence */}
|
||
<Section title="Référence Contrat">
|
||
<div className="grid grid-cols-1 md:grid-cols-[240px_1fr] items-center gap-4">
|
||
<Label>Référence</Label>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<input
|
||
value={reference || "(génération en cours…)"}
|
||
onFocus={() => {
|
||
if (!reference && !prefill?.reference) {
|
||
setReference(generateReference());
|
||
}
|
||
}}
|
||
onChange={isStaff ? (e) => setReference(e.target.value) : undefined}
|
||
readOnly={!isStaff}
|
||
className={`w-full px-3 py-2 rounded-lg border text-sm ${
|
||
isStaff
|
||
? "bg-white"
|
||
: "bg-slate-50"
|
||
}`}
|
||
/>
|
||
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
|
||
<Info className="w-3.5 h-3.5" />
|
||
{isStaff ? "Générée automatiquement - modifiable par Staff" : "Générée automatiquement par l'Espace Paie"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Sélection d'organisation (pour les utilisateurs staff) */}
|
||
{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>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{/* Production - Masquée en mode Régime Général */}
|
||
{!isRegimeRG && (
|
||
<Section title="Production">
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Production</Label>
|
||
{spectaclePick ? (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
readOnly
|
||
value={`${spectaclePick.nom}${spectaclePick.numero_objet ? ` — ${spectaclePick.numero_objet}` : ""}`}
|
||
className="w-full px-3 py-2 rounded-lg border bg-slate-50 text-sm"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setSpectaclePick(null); setSpectacleQuery(""); }}
|
||
className="text-xs px-2 py-2 rounded-lg border"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
{spectacleManual ? (
|
||
<>
|
||
<input
|
||
ref={spectacleInputRef}
|
||
value={spectacle}
|
||
onChange={(e) => setSpectacle(e.target.value)}
|
||
placeholder="Saisir le nom de la nouvelle production…"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setSpectacleManual(false); setSpectacleQuery(""); setSpectaclePick(null); }}
|
||
className="inline-flex items-center gap-2 text-xs px-2 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow hover:opacity-90 transition"
|
||
>
|
||
Rechercher une production existante
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="relative">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
value={spectacleQuery}
|
||
onChange={(e) => setSpectacleQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
const items = spectacleSearch?.items || [];
|
||
if (items.length === 0) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setSpectacleActive(i => Math.min(i + 1, items.length - 1)); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); setSpectacleActive(i => Math.max(i - 1, 0)); }
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const s = items[spectacleActive];
|
||
if (s) {
|
||
setSpectaclePick(s);
|
||
setSpectacle(s.nom);
|
||
if (s.numero_objet) setObjet(String(s.numero_objet));
|
||
}
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
(e.currentTarget as HTMLInputElement).blur();
|
||
}
|
||
}}
|
||
placeholder="Rechercher une production…"
|
||
className="w-full pl-9 px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSpectacleManual(true);
|
||
setSpectaclePick(null);
|
||
setSpectacleQuery("");
|
||
setSpectacle("");
|
||
setTimeout(() => spectacleInputRef.current?.focus(), 0);
|
||
}}
|
||
className="inline-flex items-center gap-2 text-xs px-2 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow hover:opacity-90 transition"
|
||
>
|
||
Nouvelle production
|
||
</button>
|
||
</div>
|
||
|
||
{fetchingSpectacles && (
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
<Loader2 className="w-3.5 h-3.5 inline animate-spin mr-1" />
|
||
Recherche…
|
||
</div>
|
||
)}
|
||
{spectacleSearch?.items && (
|
||
<div className="mt-2 max-h-56 overflow-auto rounded-lg border">
|
||
{spectacleSearch.items.length === 0 ? (
|
||
<div className="p-3 text-sm text-slate-500">Aucun résultat.</div>
|
||
) : (
|
||
spectacleSearch.items.map((s, idx) => (
|
||
<button
|
||
type="button"
|
||
key={`${s.nom}-${s.numero_objet ?? idx}`}
|
||
onClick={() => {
|
||
setSpectaclePick(s);
|
||
setSpectacle(s.nom);
|
||
if (s.numero_objet) setObjet(String(s.numero_objet));
|
||
}}
|
||
className={`w-full text-left px-3 py-2 text-sm ${idx === spectacleActive ? 'bg-slate-50' : 'hover:bg-slate-50'}`}
|
||
onMouseEnter={() => setSpectacleActive(idx)}
|
||
>
|
||
<div className="font-medium">{s.nom}</div>
|
||
<div className="text-xs text-slate-500">{s.numero_objet ? `n° d’objet : ${s.numero_objet}` : "Sans n° d’objet"}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label>Avez-vous déjà un n° d’objet ?</Label>
|
||
<input
|
||
value={objet}
|
||
onChange={(e) => setObjet(e.target.value)}
|
||
placeholder="ex : 2B728735689"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Si vous choisissez une production existante, le numéro se remplit automatiquement s’il est connu. Si vous n'avez pas encore de n° d'objet pour une nouvelle production, nous nous occuperons gratuitement de la démarche.
|
||
</p>
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
)}
|
||
|
||
{/* Salarié·e */}
|
||
<Section title="Salarié">
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Salarié</Label>
|
||
{salarie ? (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
readOnly
|
||
value={`${salarie.nom} — ${salarie.matricule}`}
|
||
className="w-full px-3 py-2 rounded-lg border bg-slate-50 text-sm"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setUrlSalarieDismissed(true); setSalarie(null); setSalarieQuery(""); }}
|
||
className="text-xs px-2 py-2 rounded-lg border"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<div className="relative">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
value={salarieQuery}
|
||
onChange={(e) => setSalarieQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
const items = salarieSearch?.items || [];
|
||
if (items.length === 0) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setSalarieActive(i => Math.min(i + 1, items.length - 1)); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); setSalarieActive(i => Math.max(i - 1, 0)); }
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const s = items[salarieActive];
|
||
if (s) setSalarie(s);
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
(e.currentTarget as HTMLInputElement).blur();
|
||
}
|
||
}}
|
||
placeholder="Rechercher un salarié…"
|
||
className="w-full pl-9 px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div className="mt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setNewSalarieOpen(true)}
|
||
className="inline-flex items-center gap-2 text-xs px-2 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow hover:opacity-90 transition"
|
||
>
|
||
Nouveau salarié
|
||
</button>
|
||
</div>
|
||
{fetchingSalaries && (
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
<Loader2 className="w-3.5 h-3.5 inline animate-spin mr-1" />
|
||
Recherche…
|
||
</div>
|
||
)}
|
||
{salarieSearch?.items && (
|
||
<div className="mt-2 max-h-56 overflow-auto rounded-lg border">
|
||
{salarieSearch.items.length === 0 ? (
|
||
<div className="p-3 text-sm text-slate-500">Aucun résultat.</div>
|
||
) : (
|
||
salarieSearch.items.map((s, idx) => (
|
||
<button
|
||
type="button"
|
||
key={s.matricule}
|
||
onClick={() => setSalarie(s)}
|
||
className={`w-full text-left px-3 py-2 text-sm ${idx === salarieActive ? 'bg-slate-50' : 'hover:bg-slate-50'}`}
|
||
onMouseEnter={() => setSalarieActive(idx)}
|
||
>
|
||
<div className="font-medium">{s.nom}</div>
|
||
<div className="text-xs text-slate-500">{s.matricule} {s.email ? `• ${s.email}` : ""}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label>Son adresse email</Label>
|
||
<input
|
||
value={salarie?.email || ""}
|
||
readOnly
|
||
placeholder="—"
|
||
className="w-full px-3 py-2 rounded-lg border bg-slate-50 text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Nous utilisons l'adresse e-mail de votre salarié pour lui envoyer une demande de justificatifs et d'état-civil, puis les notifications afférentes à la gestion de son contrat de travail. Cette zone peut être laissée vide si le salarié est déjà connu de nos services.
|
||
</p>
|
||
</div>
|
||
</FieldRow>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Catégorie professionnelle</Label>
|
||
<select
|
||
value={categoriePro}
|
||
onChange={(e) => setCategoriePro(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
{isRegimeRG ? (
|
||
<>
|
||
<option>Cadre</option>
|
||
<option>Non Cadre</option>
|
||
<option>Je ne sais pas</option>
|
||
</>
|
||
) : (
|
||
<>
|
||
<option>Artiste</option>
|
||
<option>Technicien</option>
|
||
</>
|
||
)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<Label required>Profession pour ce contrat — {categoriePro}</Label>
|
||
{isRegimeRG ? (
|
||
// En mode RG, simple champ de saisie libre
|
||
<div>
|
||
<input
|
||
value={profession}
|
||
onChange={(e) => setProfession(e.target.value)}
|
||
placeholder="Saisissez la profession..."
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Saisissez librement la profession du salarié pour ce contrat.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
// Mode CDDU avec listes de professions
|
||
(categoriePro === "Artiste" || categoriePro === "Technicien") ? (
|
||
professionPick ? (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
readOnly
|
||
value={`${professionPick.label} — ${professionPick.code}`}
|
||
className="w-full px-3 py-2 rounded-lg border bg-slate-50 text-sm"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setProfessionPick(null); setProfessionQuery(""); setProfession(""); }}
|
||
className="text-xs px-2 py-2 rounded-lg border"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Cette liste contient l'ensemble des professions de l'Annexe 8 (Techniciens) ou 10 (Artistes) et leur Code Emploi. Si une profession n'apparaît pas, il n'est pas possible de réaliser un CDDU pour ce contrat.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<div className="relative">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
value={professionQuery}
|
||
onFocus={() => { if (categoriePro === 'Technicien') ensureTechniciensLoaded(); }}
|
||
onChange={(e) => { setProfessionQuery(e.target.value); if (categoriePro === 'Technicien') ensureTechniciensLoaded(); }}
|
||
onKeyDown={(e) => {
|
||
const items = (categoriePro === 'Artiste' ? filteredProfessions : filteredTechniciens);
|
||
if (items.length === 0) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setProfessionActive(i => Math.min(i + 1, items.length - 1)); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); setProfessionActive(i => Math.max(i - 1, 0)); }
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const p = items[professionActive];
|
||
if (p) { setProfessionPick(p as any); setProfession((p as any).label); }
|
||
}
|
||
if (e.key === 'Escape') { e.preventDefault(); (e.currentTarget as HTMLInputElement).blur(); }
|
||
}}
|
||
placeholder={`Rechercher une profession ${categoriePro.toLowerCase()} (min. 1 lettre)…`}
|
||
className="w-full pl-9 px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
{techLoading && categoriePro === 'Technicien' && (
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
<Loader2 className="w-3.5 h-3.5 inline animate-spin mr-1" />
|
||
Chargement de la liste des professions…
|
||
</div>
|
||
)}
|
||
<div className="mt-2 max-h-56 overflow-auto rounded-lg border">
|
||
{(
|
||
(categoriePro === 'Artiste' ? filteredProfessions : filteredTechniciens)
|
||
).length === 0 ? (
|
||
<div className="p-3 text-sm text-slate-500">Aucun résultat.</div>
|
||
) : (
|
||
(categoriePro === 'Artiste' ? filteredProfessions : filteredTechniciens).map((p, idx) => (
|
||
<button
|
||
type="button"
|
||
key={p.code}
|
||
onClick={() => { setProfessionPick(p); setProfession(p.label); }}
|
||
className={`w-full text-left px-3 py-2 text-sm ${idx === professionActive ? 'bg-slate-50' : 'hover:bg-slate-50'}`}
|
||
onMouseEnter={() => setProfessionActive(idx)}
|
||
>
|
||
<div className="font-medium">{p.label}</div>
|
||
<div className="text-xs text-slate-500">{p.code}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Cette liste contient l'ensemble des professions de l'Annexe 8 (Techniciens) ou 10 (Artistes) et leur Code Emploi. Si une profession n'apparaît pas, il n'est pas possible de réaliser un CDDU pour ce contrat.
|
||
</p>
|
||
</div>
|
||
)
|
||
) : (
|
||
<>
|
||
<input
|
||
value={profession}
|
||
onChange={(e) => setProfession(e.target.value)}
|
||
placeholder="ex : Régisseur.se, …"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Si une profession n’apparaît pas dans l’annexe, indique-la précisément (ex: « Régisseur.se plateau »).
|
||
</p>
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
|
||
{/* Période / volumes en mode CDDU, Contrat en mode RG */}
|
||
<Section title={isRegimeRG ? "Contrat" : "Période et volumes"}>
|
||
{isRegimeRG ? (
|
||
// Contenu pour Régime Général
|
||
<>
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Type de contrat</Label>
|
||
<select
|
||
value={typeContratRG}
|
||
onChange={(e) => setTypeContratRG(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option>CDD de droit commun</option>
|
||
<option>CDI</option>
|
||
<option>Stage</option>
|
||
<option>Contrat de professionnalisation</option>
|
||
<option>Contrat d'apprentissage</option>
|
||
<option>Autre</option>
|
||
</select>
|
||
</div>
|
||
</FieldRow>
|
||
|
||
{/* Motif CDD si CDD sélectionné */}
|
||
{typeContratRG === "CDD de droit commun" && (
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Motif du CDD</Label>
|
||
<select
|
||
value={motifCDD}
|
||
onChange={(e) => setMotifCDD(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option>Remplacement d'une absence</option>
|
||
<option>Accroissement temporaire d'activité</option>
|
||
</select>
|
||
</div>
|
||
</FieldRow>
|
||
)}
|
||
|
||
{/* Champs conditionnels selon le motif CDD */}
|
||
{typeContratRG === "CDD de droit commun" && motifCDD === "Remplacement d'une absence" && (
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Nom de la personne remplacée</Label>
|
||
<input
|
||
value={nomPersonneRemplacee}
|
||
onChange={(e) => setNomPersonneRemplacee(e.target.value)}
|
||
placeholder="Nom et prénom de la personne remplacée"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label required>Situation de la personne remplacée</Label>
|
||
<select
|
||
value={situationPersonneRemplacee}
|
||
onChange={(e) => setSituationPersonneRemplacee(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option>Absent temporairement ou contrat suspendu (maladie, maternité, congés payés, congé parental, etc...)</option>
|
||
<option>Salarié provisoirement à temps partiel (congé parental, congé création d'entreprise, etc...)</option>
|
||
<option>Salarié ayant quitté définitivement l'entreprise, poste en attente de suppression</option>
|
||
<option>Remplacement du chef d'entreprise</option>
|
||
</select>
|
||
</div>
|
||
</FieldRow>
|
||
)}
|
||
|
||
{typeContratRG === "CDD de droit commun" && motifCDD === "Accroissement temporaire d'activité" && (
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Décrivez le motif de l'accroissement d'activité</Label>
|
||
<textarea
|
||
value={motifAccroissement}
|
||
onChange={(e) => setMotifAccroissement(e.target.value)}
|
||
placeholder="Détaillez les raisons de l'accroissement temporaire d'activité..."
|
||
rows={3}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</FieldRow>
|
||
)}
|
||
</>
|
||
) : (
|
||
// Contenu pour CDDU (existant)
|
||
<>
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Le contrat est-il multi-mois ?</Label>
|
||
<div className="flex items-center gap-6 mt-2">
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={isMultiMois === "Oui"} onChange={() => setIsMultiMois("Oui")} />
|
||
Oui
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={isMultiMois === "Non"} onChange={() => setIsMultiMois("Non")} />
|
||
Non
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</FieldRow>
|
||
</>
|
||
)}
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Date de début du contrat</Label>
|
||
<input
|
||
type="date"
|
||
value={dateDebut}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
setDateDebut(v);
|
||
setConfirmPastStart(false);
|
||
if (dateFin && v && dateFin < v) setDateFin(v);
|
||
}}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
{/* Masquer la date de fin si CDI en mode RG */}
|
||
{!(isRegimeRG && typeContratRG === "CDI") && (
|
||
<div>
|
||
<Label>Date de fin du contrat</Label>
|
||
<input
|
||
type="date"
|
||
value={dateFin}
|
||
min={dateDebut || undefined}
|
||
onChange={(e) => setDateFin(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">Pour un contrat d'une journée, sélectionnez la même date que début.</p>
|
||
</div>
|
||
)}
|
||
</FieldRow>
|
||
|
||
{/* Sections spécifiques aux CDDU - masquées en mode RG */}
|
||
{!isRegimeRG && (
|
||
!useHeuresMode ? (
|
||
<>
|
||
<FieldRow>
|
||
<div>
|
||
<Label>Combien de représentations ?</Label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={nbRep}
|
||
onChange={(e) => setNbRep(e.target.value === "" ? "" : Number(e.target.value))}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Combien de services de répétition ?</Label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={nbServ}
|
||
onChange={(e) => setNbServ(e.target.value === "" ? "" : Number(e.target.value))}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</FieldRow>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label>Indiquez les dates de représentations</Label>
|
||
<input
|
||
value={datesRep}
|
||
onChange={(e) => setDatesRep(e.target.value)}
|
||
placeholder="ex : 12/10, 13/10, 24/10"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Indiquez les dates de répétitions</Label>
|
||
<input
|
||
value={datesServ}
|
||
onChange={(e) => setDatesServ(e.target.value)}
|
||
placeholder="ex : 10/10, 11/10"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</FieldRow>
|
||
</>
|
||
) : (
|
||
<>
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Combien d'heures de travail au total ?</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={heuresTotal}
|
||
onChange={(e) => setHeuresTotal(e.target.value === "" ? "" : Number(e.target.value))}
|
||
placeholder="ex : 3"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<select
|
||
value={minutesTotal}
|
||
onChange={(e) => setMinutesTotal(e.target.value as "0" | "30")}
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm"
|
||
aria-label="Minutes"
|
||
>
|
||
<option value="0">+ 00 min</option>
|
||
<option value="30">+ 30 min</option>
|
||
</select>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Utilisez le menu pour ajouter 30 minutes (ex. 3 h 30).
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>Indiquez les jours de travail</Label>
|
||
<input
|
||
value={joursTravail}
|
||
onChange={(e) => setJoursTravail(e.target.value)}
|
||
placeholder="ex : 12/10, 13/10, 24/10"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">Listez les jours travaillés séparés par des virgules.</p>
|
||
</div>
|
||
</FieldRow>
|
||
</>
|
||
)
|
||
)}
|
||
</Section>
|
||
|
||
{isPastStart && (
|
||
<div className="rounded-2xl border border-amber-300 bg-amber-50 text-amber-950 p-4">
|
||
<div className="text-lg font-semibold flex items-center gap-2">
|
||
<span className="inline-block text-amber-600">⚠️</span>
|
||
Attention
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-3 text-sm leading-relaxed">
|
||
<p>Vous avez saisi une date de démarrage du contrat dans le passé.</p>
|
||
<p>
|
||
Si vous validez la demande avec cette date, nous effectuerons la DPAE dans les 24 heures ouvrées, mais veuillez noter que
|
||
nous recevrons une alerte de l'URSSAF.
|
||
</p>
|
||
<p>
|
||
En effet, conformément aux articles L1221-10 à L1221-12-1 du Code du travail, la <strong>Déclaration Préalable à l'Embauche</strong> est
|
||
obligatoire et doit être effectuée dans les <strong>7 jours qui précèdent</strong> le commencement du contrat. Tout manquement peut être
|
||
sanctionné administrativement d'une pénalité de 300 fois le taux horaire du minimum garanti, soit en 2025, de <strong>1266€ par DPAE
|
||
manquante</strong>.
|
||
</p>
|
||
<p>Nous vous recommandons donc vivement d'anticiper vos demandes de contrats.</p>
|
||
</div>
|
||
|
||
<label className="mt-4 flex items-start gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={confirmPastStart}
|
||
onChange={(e) => setConfirmPastStart(e.target.checked)}
|
||
className="mt-1"
|
||
/>
|
||
<span>
|
||
J'ai lu et compris l'alerte ci-dessus et valide la date de début de contrat au <strong>{formatFr(dateDebut)}</strong>.
|
||
</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rémunération */}
|
||
<Section title="Rémunération">
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Type de salaire</Label>
|
||
<select
|
||
value={typeSalaire}
|
||
onChange={(e) => {
|
||
const v = e.target.value as typeof typeSalaire;
|
||
setTypeSalaire(v);
|
||
if (v === "Minimum conventionnel") setMontantSalaire("");
|
||
}}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option value="Brut">Brut</option>
|
||
<option value="Net avant PAS">Net avant PAS</option>
|
||
<option value="Coût total employeur">Coût total employeur</option>
|
||
<option value="Minimum conventionnel">Minimum conventionnel</option>
|
||
</select>
|
||
|
||
{typeSalaire !== "Minimum conventionnel" && (
|
||
<div className="mt-2">
|
||
<Label required>Montant</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step="0.01"
|
||
value={montantSalaire}
|
||
onChange={(e) => setMontantSalaire(e.target.value === "" ? "" : Number(e.target.value))}
|
||
placeholder="ex : 250,00"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<span className="text-sm text-slate-600">€</span>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Saisissez le montant en euros correspondant au type sélectionné.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<Label required>Panier(s) repas</Label>
|
||
<div className="flex items-center gap-6 mt-2">
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={panierRepas === "Oui"} onChange={() => setPanierRepas("Oui")} />
|
||
Oui
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={panierRepas === "Non"} onChange={() => setPanierRepas("Non")} />
|
||
Non
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
|
||
{/* Autres infos */}
|
||
<Section title="Autres informations">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* Colonne gauche: champs du formulaire */}
|
||
<div className="space-y-4">
|
||
<div>
|
||
<Label>Notes</Label>
|
||
<textarea
|
||
rows={4}
|
||
value={notes}
|
||
onChange={(e) => setNotes(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">Utilisez ce champ pour toute info complémentaire.</p>
|
||
</div>
|
||
|
||
<div>
|
||
{/* Envoi de fichiers retiré */}
|
||
</div>
|
||
|
||
<div>
|
||
<Label required>Voulez-vous directement valider cette demande ?</Label>
|
||
<div className="flex items-center gap-6 mt-2">
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={validerDirect === "Oui"} onChange={() => setValiderDirect("Oui")} />
|
||
Oui
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={validerDirect === "Non"} onChange={() => setValiderDirect("Non")} />
|
||
Non
|
||
</label>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Si vous choisissez “Non”, la demande sera enregistrée en brouillon. Vous devrez la valider ultérieurement pour que nous puissions la traiter.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<Label required>Voulez-vous recevoir une confirmation par e-mail ?</Label>
|
||
<div className="flex items-center gap-6 mt-2">
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={emailConfirm === "Oui"} onChange={() => setEmailConfirm("Oui")} />
|
||
Oui
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="radio" checked={emailConfirm === "Non"} onChange={() => setEmailConfirm("Non")} />
|
||
Non
|
||
</label>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Si vous choisissez “Non”, aucune notification e-mail ne sera envoyée à la création du contrat.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Colonne droite: Notes du contrat (mode édition uniquement) */}
|
||
<div>
|
||
{contractIdForNotes ? (
|
||
<NotesSection contractId={contractIdForNotes} title="Notes du contrat" showAddButton={false} compact />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center justify-end gap-3">
|
||
{err && <div className="text-sm text-rose-600 mr-auto">{err}</div>}
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push("/contrats")}
|
||
className="px-4 py-2 rounded-lg border"
|
||
disabled={loading}
|
||
>
|
||
Revenir sans envoyer
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={loading || isDemoMode}
|
||
className="px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title={isDemoMode ? "Désactivé en mode démo" : ""}
|
||
>
|
||
{loading ? (<><Loader2 className="w-4 h-4 inline animate-spin mr-2" /> Envoi…</>) : "Envoyer"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Overlays */}
|
||
{prefillLoading && (
|
||
<div className="fixed inset-0 z-[55] 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">Pré-remplissage en cours…</div>
|
||
<p className="text-sm text-slate-600 mt-1">Merci de patienter pendant que nous chargeons les informations du contrat.</p>
|
||
</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>
|
||
)}
|
||
|
||
{showLeaveConfirm && (
|
||
<div className="fixed inset-0 z-[60] 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-2">
|
||
<button onClick={cancelLeave} className="px-3 py-2 rounded-lg border">Rester</button>
|
||
<button onClick={confirmLeave} className="px-3 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700">Quitter sans enregistrer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{newSalarieOpen && (
|
||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||
<div className="absolute inset-0 bg-black/40" />
|
||
<div className="relative w-[96vw] max-w-3xl h-[90vh] rounded-2xl overflow-hidden shadow-2xl border border-slate-200 bg-white flex flex-col">
|
||
<div className="px-5 py-3 bg-gradient-to-r from-emerald-600 to-teal-600 text-white flex items-center justify-between">
|
||
<div className="text-lg font-semibold truncate">Créer un nouveau salarié</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setNewSalarieOpen(false)}
|
||
className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm"
|
||
>
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<iframe
|
||
src={NEW_SALARIE_PATH}
|
||
className="w-full h-full min-h-[600px] border-0 bg-white"
|
||
title="Nouveau salarié"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|