espace-paie-odentas/components/contrats/NouveauCDDUForm.tsx
odentas c2a54ecd89 fix: Utiliser l'année réelle sélectionnée dans le calendrier au lieu de supposer
Problème: Le système supposait l'année basée sur le contexte, ce qui causait des erreurs.

Solution:
- Ajout de formatDateFrWithYear() pour retourner DD/MM/YYYY
- Le calendrier DatePickerCalendar retourne maintenant des dates avec l'année complète
- parseFrenchedDate() supporte maintenant DD/MM/YYYY et DD/MM
- Si l'année est dans la date (DD/MM/YYYY), elle est utilisée directement
- Plus besoin de supposer l'année basée sur oct/nov/déc -> jan/fev/mar
- Les dates de début et fin se calculent correctement à partir des vraies dates ISO

Cela garantit que:
1. Les dates en janvier 2026 restent bien en 2026
2. Les champs date début/date fin se remplissent avec les bonnes années
3. Aucune supposition erronée n'est faite
2025-12-19 17:58:33 +01:00

3372 lines
147 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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, Calculator as CalculatorIcon, ExternalLink } from "lucide-react";
import { NotesSection } from "@/components/NotesSection";
import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
import { useDemoMode } from "@/hooks/useDemoMode";
import Calculator from "@/components/Calculator";
import DatePickerCalendar from "@/components/DatePickerCalendar";
import DatesQuantityModal from "@/components/DatesQuantityModal";
import { parseDateString, parseFrenchedDate, generateDateRange, formatDateFr } from "@/lib/dateFormatter";
import { Tooltip } from "@/components/ui/tooltip";
/* =========================
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;
salaires_par_date?: any; // Structure JSON des salaires par date
};
/* =========================
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();
}
/**
* Parse une chaîne formatée (ex: "1 représentation par jour du 24/11 au 26/11 ; 2 représentations le 28/11.")
* et extrait toutes les dates individuelles avec leurs quantités.
*
* @param dateStr - Chaîne formatée avec dates et quantités
* @param yearContext - Contexte de l'année (ex: "2025-11-01")
* @returns Array de {date: "DD/MM", quantity: number, key: string}
*/
function parseFormattedDatesWithQuantities(
dateStr: string,
yearContext: string,
prefix: "rep" | "serv" | "jour"
): Array<{ date: string; quantity: number; index: number; key: string }> {
if (!dateStr || !dateStr.trim()) return [];
const result: Array<{ date: string; quantity: number; index: number; key: string }> = [];
const groups = dateStr.split(" ; ");
groups.forEach((group, groupIdx) => {
// Extraire la quantité (ex: "1 représentation", "2 services")
const qtyMatch = group.match(/^(\d+)\s+/);
const quantity = qtyMatch ? parseInt(qtyMatch[1]) : 1;
// Vérifier si c'est une plage (contient "du ... au ...")
const rangeMatch = group.match(/du\s+(\d{2}\/\d{2})\s+au\s+(\d{2}\/\d{2})/);
if (rangeMatch) {
// C'est une plage : générer toutes les dates intermédiaires
const startFr = rangeMatch[1];
const endFr = rangeMatch[2];
// Convertir en ISO
const startIso = parseFrenchedDate(startFr, yearContext);
const endIso = parseFrenchedDate(endFr, yearContext);
// Générer toutes les dates de la plage
const allDatesIso = generateDateRange(startIso, endIso);
// Ajouter chaque date avec sa quantité
allDatesIso.forEach((iso, dateIdx) => {
const dateFr = formatDateFr(iso);
result.push({
date: dateFr,
quantity: quantity,
index: dateIdx,
key: `${prefix}_${groupIdx}_${dateIdx}`
});
});
} else {
// Date isolée (ex: "le 28/11")
const dateMatch = group.match(/(\d{2}\/\d{2})/);
if (dateMatch) {
result.push({
date: dateMatch[1],
quantity: quantity,
index: 0,
key: `${prefix}_${groupIdx}_0`
});
}
}
});
return result;
}
/**
* Extrait uniquement les dates (format DD/MM) d'une chaîne formatée PDFMonkey
* Utile pour initialDates des modales de calendrier
*
* @param dateStr - Chaîne formatée (ex: "1 représentation par jour du 24/11 au 26/11 ; 2 représentations le 28/11.")
* @param yearContext - Contexte de l'année (ex: "2025-11-01")
* @returns Array de dates au format "DD/MM"
*/
function extractDatesFromFormatted(dateStr: string, yearContext: string): string[] {
if (!dateStr || !dateStr.trim()) return [];
const dates: string[] = [];
const groups = dateStr.split(" ; ");
groups.forEach((group) => {
// Vérifier si c'est une plage (contient "du ... au ...")
const rangeMatch = group.match(/du\s+(\d{2}\/\d{2})\s+au\s+(\d{2}\/\d{2})/);
if (rangeMatch) {
// C'est une plage : générer toutes les dates intermédiaires
const startFr = rangeMatch[1];
const endFr = rangeMatch[2];
// Convertir en ISO
const startIso = parseFrenchedDate(startFr, yearContext);
const endIso = parseFrenchedDate(endFr, yearContext);
// Générer toutes les dates de la plage
const allDatesIso = generateDateRange(startIso, endIso);
// Convertir en format DD/MM
allDatesIso.forEach((iso) => {
dates.push(formatDateFr(iso));
});
} else {
// Date isolée (ex: "le 28/11")
const dateMatch = group.match(/(\d{2}\/\d{2})/);
if (dateMatch) {
dates.push(dateMatch[1]);
}
}
});
return dates;
}
// 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;
// Autoriser les liens qui ouvrent dans un nouvel onglet
if (a.target === "_blank") 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 [manualDatesMode, setManualDatesMode] = useState(false); // Mode manuel pour les dates
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 [nbRepMetteur, setNbRepMetteur] = useState<number | "">(""); // Cachets pour metteur en scène
const [durationServices, setDurationServices] = useState<"3" | "4">("4"); // Durée des services (3 ou 4 heures)
const [datesRep, setDatesRep] = useState("");
const [datesRepDisplay, setDatesRepDisplay] = useState("");
const [datesRepOpen, setDatesRepOpen] = useState(false);
const [datesServ, setDatesServ] = useState("");
const [datesServDisplay, setDatesServDisplay] = useState("");
const [datesServOpen, setDatesServOpen] = useState(false);
const [joursTravailDisplay, setJoursTravailDisplay] = useState("");
const [joursTravailOpen, setJoursTravailOpen] = useState(false);
const [joursTravailRaw, setJoursTravailRaw] = useState<string[]>([]); // Format input ["12/10", "13/10"]
// États pour les modales de quantités après sélection des dates
const [quantityModalOpen, setQuantityModalOpen] = useState(false);
const [quantityModalType, setQuantityModalType] = useState<"representations" | "repetitions" | "jours_travail">("representations");
const [pendingDates, setPendingDates] = useState<string[]>([]);
const [typeSalaire, setTypeSalaire] = useState<"Brut" | "Net avant PAS" | "Coût total employeur" | "Minimum conventionnel">("Brut");
const [montantSalaire, setMontantSalaire] = useState<number | "">("");
// Indique si la valeur vient de la calculatrice pour forcer l'affichage en 2 décimales
const [montantFromCalculator, setMontantFromCalculator] = useState<boolean>(false);
const [panierRepas, setPanierRepas] = useState<"Oui" | "Non">("Non");
const [nombrePaniersRepas, setNombrePaniersRepas] = useState<number | "">("");
const [panierRepasCCN, setPanierRepasCCN] = useState<"Oui" | "Non">("Oui");
const [montantParPanier, setMontantParPanier] = useState<number | "">("");
// Mode de saisie du salaire : "global" ou "par_date"
const [salaryMode, setSalaryMode] = useState<"global" | "par_date">("global");
// Salaires par date : Record<dateISO, montant>
const [salariesByDate, setSalariesByDate] = useState<Record<string, number | "">>({});
// Stockage temporaire des salaires par date depuis le prefill (avant parsing des dates)
const [prefillSalariesData, setPrefillSalariesData] = useState<any>(null);
const [notes, setNotes] = useState("");
const [validerDirect, setValiderDirect] = useState<"Oui" | "Non">("Oui");
// Confirmation e-mail à la création
const [emailConfirm, setEmailConfirm] = useState<"Oui" | "Non">("Oui");
// Calculatrice
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
// É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);
// Handlers pour les calendriers de dates
// Au lieu d'appliquer directement, on ouvre la modale de quantités
const handleDatesRepApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setPendingDates(result.selectedDates);
setQuantityModalType("representations");
setQuantityModalOpen(true);
if (result.hasMultiMonth) {
setIsMultiMois("Oui");
}
};
const handleDatesServApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setPendingDates(result.selectedDates);
setQuantityModalType("repetitions");
setQuantityModalOpen(true);
if (result.hasMultiMonth) {
setIsMultiMois("Oui");
}
};
const handleJoursTravailApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
}) => {
setPendingDates(result.selectedDates);
setQuantityModalType("jours_travail");
setQuantityModalOpen(true);
setIsMultiMois(result.hasMultiMonth ? "Oui" : "Non");
};
// Handler pour la modale de quantités (applique les données finales)
const handleQuantityApply = (result: {
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
globalQuantity?: number;
globalDuration?: "3" | "4";
totalHours?: number;
totalQuantities?: number;
}) => {
// Si un nombre global est fourni, l'utiliser; sinon utiliser la somme des quantités; sinon calculer le nombre de dates
const quantity = result.globalQuantity || result.totalQuantities || result.selectedDates.length;
// Convertir les dates sélectionnées en ISO
// Les dates viennent maintenant du calendrier au format DD/MM/YYYY, donc pas besoin de yearContext
const currentYearContext = dateDebut || new Date().toISOString().slice(0, 10);
const currentIsos = result.selectedDates
.map(d => parseFrenchedDate(d.trim(), currentYearContext))
.filter(iso => iso && iso.length === 10);
// Récupérer toutes les dates ISO existantes selon le type
let allIsos: string[] = [];
if (quantityModalType === "representations") {
// Ajouter les nouvelles représentations
allIsos.push(...currentIsos);
// Ajouter les répétitions existantes
if (datesServ) {
const servIsos = datesServ.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...servIsos);
}
// Ajouter les jours de travail existants
if (joursTravail) {
const jtIsos = joursTravail.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...jtIsos);
}
} else if (quantityModalType === "repetitions") {
// Ajouter les représentations existantes
if (datesRep) {
const repIsos = datesRep.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...repIsos);
}
// Ajouter les nouvelles répétitions
allIsos.push(...currentIsos);
// Ajouter les jours de travail existants
if (joursTravail) {
const jtIsos = joursTravail.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...jtIsos);
}
} else if (quantityModalType === "jours_travail") {
// Ajouter les représentations existantes
if (datesRep) {
const repIsos = datesRep.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...repIsos);
}
// Ajouter les répétitions existantes
if (datesServ) {
const servIsos = datesServ.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...servIsos);
}
// Ajouter les nouveaux jours de travail
allIsos.push(...currentIsos);
}
// Trier et dédupliquer
const isos = [...new Set(allIsos)].sort();
// Calculer les dates min/max et multi-mois
let newDateDebut = dateDebut;
let newDateFin = dateFin;
let newIsMultiMois = isMultiMois;
if (isos.length > 0) {
newDateDebut = isos[0];
newDateFin = isos[isos.length - 1];
// Déterminer multi-mois
const hasMultiMonth = isos.some((iso, idx) => {
if (idx === 0) return false;
const prevMonth = isos[0].slice(0, 7);
const currMonth = iso.slice(0, 7);
return currMonth !== prevMonth;
});
newIsMultiMois = hasMultiMonth ? "Oui" : "Non";
}
// Maintenant faire tous les setState
switch (quantityModalType) {
case "representations":
setDatesRep(result.pdfFormatted);
setDatesRepDisplay(result.pdfFormatted);
setNbRep(quantity);
break;
case "repetitions":
setDatesServ(result.pdfFormatted);
setDatesServDisplay(result.pdfFormatted);
setNbServ(quantity);
if (result.globalDuration) {
setDurationServices(result.globalDuration);
}
break;
case "jours_travail":
setJoursTravail(result.pdfFormatted);
setJoursTravailDisplay(result.pdfFormatted);
setJoursTravailRaw(result.selectedDates); // Stocker les dates au format input
// Mettre à jour le total d'heures depuis la modale
// Si totalHours est fourni (heures par jour), on l'utilise
// Sinon si globalQuantity est fourni (mode "Ne pas appliquer d'heures par jour"), on l'utilise
const hoursToSet = result.totalHours ?? result.globalQuantity;
if (hoursToSet !== undefined) {
const hours = Math.floor(hoursToSet);
const remainder = hoursToSet - hours;
setHeuresTotal(hours);
// Si on a une demi-heure, on met 30 minutes
if (remainder >= 0.5) {
setMinutesTotal("30");
} else {
setMinutesTotal("0");
}
}
break;
}
// Mettre à jour les dates et multi-mois seulement si on n'est pas en mode manuel ET pas en mode RG
if (!manualDatesMode && !isRegimeRG) {
setDateDebut(newDateDebut);
setDateFin(newDateFin);
setIsMultiMois(newIsMultiMois);
}
setQuantityModalOpen(false);
setPendingDates([]);
};
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 ?? "");
// Initialiser les dates avec formatage smart
if (prefill.dates_representations) {
const dateStr = String(prefill.dates_representations);
const yearContext = prefill.date_debut || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(dateStr, yearContext);
setDatesRep(dateStr);
setDatesRepDisplay(parsed.pdfFormatted);
}
if (prefill.dates_repetitions) {
const dateStr = String(prefill.dates_repetitions);
const yearContext = prefill.date_debut || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(dateStr, yearContext);
setDatesServ(dateStr);
setDatesServDisplay(parsed.pdfFormatted);
}
setHeuresTotal(prefill.heures_total ?? "");
setMinutesTotal(prefill.minutes_total ?? "0");
if (prefill.jours_travail) {
const dateStr = String(prefill.jours_travail);
const yearContext = prefill.date_debut || new Date().toISOString().slice(0, 10);
const parsed = parseDateString(dateStr, yearContext);
setJoursTravail(dateStr);
setJoursTravailDisplay(parsed.pdfFormatted);
// Extraire les dates au format input pour initialDates
setJoursTravailRaw(parsed.allDatesFr);
}
if (typeof prefill.multi_mois === "boolean") setIsMultiMois(prefill.multi_mois ? "Oui" : "Non");
setTypeSalaire(prefill.type_salaire ?? "Brut");
// Charger salaires par date si présents dans le prefill
if (prefill.salaires_par_date && typeof prefill.salaires_par_date === "object") {
setSalaryMode("par_date");
// Stocker temporairement les données - elles seront mappées après le parsing des dates
setPrefillSalariesData(prefill.salaires_par_date);
setMontantSalaire("");
} else {
// Mode global : charger le montant classique
setMontantSalaire(prefill.montant ?? "");
setSalaryMode("global");
setPrefillSalariesData(null);
}
setMontantFromCalculator(false);
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));
setDatesRepDisplay(String(payload.dates_representations));
}
if (payload.dates_repetitions) {
setDatesServ(String(payload.dates_repetitions));
setDatesServDisplay(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);
setMontantFromCalculator(false);
}
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 - Ne pas pré-remplir automatiquement en mode RG
if (!isRegimeRG && !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 (!isRegimeRG && !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);
setDatesRepDisplay(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);
setDatesServDisplay(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);
setMontantFromCalculator(false);
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]);
// Reconstruction de salariesByDate depuis prefillSalariesData une fois que les dates sont parsées
useEffect(() => {
if (!prefillSalariesData || !dateDebut) return;
const newSalariesByDate: Record<string, number | ""> = {};
// Créer un mapping par date DD/MM et numéro d'item
const salaryMap = new Map<string, number>();
// Parser les représentations
if (prefillSalariesData.representations && Array.isArray(prefillSalariesData.representations)) {
prefillSalariesData.representations.forEach((rep: any) => {
if (rep.items && Array.isArray(rep.items)) {
rep.items.forEach((item: any) => {
salaryMap.set(`rep_${rep.date}_${item.numero}`, item.montant);
});
}
});
}
// Parser les répétitions
if (prefillSalariesData.repetitions && Array.isArray(prefillSalariesData.repetitions)) {
prefillSalariesData.repetitions.forEach((serv: any) => {
if (serv.items && Array.isArray(serv.items)) {
serv.items.forEach((item: any) => {
salaryMap.set(`serv_${serv.date}_${item.numero}`, item.montant);
});
}
});
}
// Parser les jours travaillés
if (prefillSalariesData.jours_travail && Array.isArray(prefillSalariesData.jours_travail)) {
prefillSalariesData.jours_travail.forEach((jour: any) => {
salaryMap.set(`jour_${jour.date}_1`, jour.montant);
});
}
// Maintenant, reconstruire salariesByDate avec les bonnes clés en fonction des dates parsées
const yearContext = dateDebut;
// Représentations
if (datesRep && datesRep.length > 0) {
const allRepDates = parseFormattedDatesWithQuantities(datesRep, yearContext, "rep");
allRepDates.forEach((dateInfo) => {
for (let i = 0; i < dateInfo.quantity; i++) {
const inputKey = `${dateInfo.key}_${i}`;
const dateFr = dateInfo.date; // format DD/MM
const lookupKey = `rep_${dateFr}_${i + 1}`;
const montant = salaryMap.get(lookupKey);
if (montant !== undefined) {
newSalariesByDate[inputKey] = montant;
}
}
});
}
// Répétitions
if (datesServ && datesServ.length > 0) {
const allServDates = parseFormattedDatesWithQuantities(datesServ, yearContext, "serv");
allServDates.forEach((dateInfo) => {
for (let i = 0; i < dateInfo.quantity; i++) {
const inputKey = `${dateInfo.key}_${i}`;
const dateFr = dateInfo.date;
const lookupKey = `serv_${dateFr}_${i + 1}`;
const montant = salaryMap.get(lookupKey);
if (montant !== undefined) {
newSalariesByDate[inputKey] = montant;
}
}
});
}
// Jours travaillés
if (joursTravail && joursTravail.length > 0) {
const allJoursDates = parseFormattedDatesWithQuantities(joursTravail, yearContext, "jour");
allJoursDates.forEach((dateInfo) => {
const inputKey = `${dateInfo.key}_0`;
const dateFr = dateInfo.date;
const lookupKey = `jour_${dateFr}_1`;
const montant = salaryMap.get(lookupKey);
if (montant !== undefined) {
newSalariesByDate[inputKey] = montant;
}
});
}
setSalariesByDate(newSalariesByDate);
// Une fois mappé, on peut effacer le prefill temporaire
setPrefillSalariesData(null);
}, [prefillSalariesData, datesRep, datesServ, joursTravail, dateDebut]);
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]);
// Générer automatiquement une note système pour les paniers repas
const panierRepasNote = useMemo(() => {
if (panierRepas !== "Oui") return "";
const details: string[] = [];
// Nombre de paniers
if (nombrePaniersRepas !== "" && nombrePaniersRepas > 0) {
details.push(`Nombre : ${nombrePaniersRepas} panier${nombrePaniersRepas > 1 ? "s" : ""}`);
}
// Type de panier (CCN ou montant personnalisé)
if (panierRepasCCN === "Oui") {
details.push("Type : Au minima CCN");
} else if (panierRepasCCN === "Non" && montantParPanier !== "" && montantParPanier > 0) {
details.push(`Type : Montant personnalisé (${EURO.format(Number(montantParPanier))} / panier)`);
}
if (details.length === 0) return "";
return `Panier(s) repas demandé(s)\n${details.join(" • ")}`;
}, [panierRepas, nombrePaniersRepas, panierRepasCCN, montantParPanier]);
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é
// Fonction de conversion des salaires par date en JSONB
function convertSalariesByDateToJSON(): any {
console.log("🔍 [convertSalariesByDateToJSON] Début de la conversion");
console.log("🔍 salaryMode:", salaryMode);
console.log("🔍 salariesByDate:", salariesByDate);
console.log("🔍 datesRep:", datesRep);
console.log("🔍 datesServ:", datesServ);
console.log("🔍 joursTravail:", joursTravail);
if (salaryMode !== "par_date") {
console.log("❌ salaryMode n'est pas 'par_date', retour null");
return null;
}
const representations: any[] = [];
const repetitions: any[] = [];
const jours_travail: any[] = [];
const yearContext = dateDebut || new Date().toISOString().slice(0, 10);
// Parser les représentations
if (datesRep && datesRep.length > 0) {
const allRepDates = parseFormattedDatesWithQuantities(datesRep, yearContext, "rep");
allRepDates.forEach((dateInfo) => {
const items: any[] = [];
for (let i = 0; i < dateInfo.quantity; i++) {
const key = `${dateInfo.key}_${i}`;
const montant = salariesByDate[key];
if (montant && typeof montant === "number") {
items.push({
numero: i + 1,
montant: montant,
});
}
}
if (items.length > 0) {
representations.push({
date: dateInfo.date,
items: items,
});
}
});
}
// Parser les répétitions
if (datesServ && datesServ.length > 0) {
const allServDates = parseFormattedDatesWithQuantities(datesServ, yearContext, "serv");
allServDates.forEach((dateInfo) => {
const items: any[] = [];
for (let i = 0; i < dateInfo.quantity; i++) {
const key = `${dateInfo.key}_${i}`;
const montant = salariesByDate[key];
if (montant && typeof montant === "number") {
items.push({
numero: i + 1,
montant: montant,
duree_heures: parseInt(durationServices) || 4,
});
}
}
if (items.length > 0) {
repetitions.push({
date: dateInfo.date,
items: items,
});
}
});
}
// Parser les jours travaillés
if (joursTravail && joursTravail.length > 0) {
const allJoursDates = parseFormattedDatesWithQuantities(joursTravail, yearContext, "jour");
allJoursDates.forEach((dateInfo) => {
const key = `${dateInfo.key}_0`;
const montant = salariesByDate[key];
if (montant && typeof montant === "number") {
jours_travail.push({
date: dateInfo.date,
montant: montant,
heures: dateInfo.quantity, // La quantité représente les heures pour les jours travaillés
});
}
});
}
// Calculer le total
const total_calcule = Object.values(salariesByDate).reduce((sum: number, val) => {
return sum + (typeof val === "number" ? val : 0);
}, 0);
const result = {
mode: "par_date",
type_salaire: typeSalaire,
representations: representations,
repetitions: repetitions,
jours_travail: jours_travail,
total_calcule: total_calcule,
};
console.log("✅ [convertSalariesByDateToJSON] Résultat:", result);
return result;
}
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é.");
// Validation du salaire selon le mode
if (salaryMode === "par_date") {
// En mode par date, vérifier qu'au moins un montant a été saisi
const hasAtLeastOneAmount = Object.values(salariesByDate).some(val => typeof val === "number" && val > 0);
if (!hasAtLeastOneAmount) {
return setErr("Veuillez saisir au moins un montant de salaire pour les dates.");
}
} else {
// En mode global
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 ? (useHeuresMode && professionPick?.code === "MET040" ? nbRepMetteur : 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 : (!isRegimeRG && typeof nbServ === "number" && nbServ > 0 ? nbServ * parseInt(durationServices) : undefined),
minutes_travail: !isRegimeRG && useHeuresMode ? minutesTotal : undefined,
jours_travail: !isRegimeRG && useHeuresMode ? (joursTravail || undefined) : undefined,
jours_travail_non_artiste: !isRegimeRG && useHeuresMode && (categoriePro === "Technicien" || professionPick?.code === "MET040") ? (joursTravail || undefined) : undefined,
type_salaire: typeSalaire,
montant: salaryMode === "par_date" ? undefined : (typeSalaire !== "Minimum conventionnel" ? (montantSalaire === "" ? undefined : montantSalaire) : undefined),
salaires_par_date: !isRegimeRG && salaryMode === "par_date" ? convertSalariesByDateToJSON() : undefined,
panier_repas: panierRepas,
nombre_paniers_repas: panierRepas === "Oui" && nombrePaniersRepas !== "" ? nombrePaniersRepas : undefined,
panier_repas_ccn: panierRepas === "Oui" ? panierRepasCCN : undefined,
si_non_montant_par_panier: panierRepas === "Oui" && panierRepasCCN === "Non" && montantParPanier !== "" ? montantParPanier : undefined,
reference,
notes: notes || undefined,
panier_repas_note: panierRepasNote || undefined, // Note spécifique pour les paniers repas
send_email_confirmation: emailConfirm === "Oui",
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,
panier_repas_note: payload.panier_repas_note, // Note spécifique paniers repas
// 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,
jours_travail_non_artiste: payload.jours_travail_non_artiste,
multi_mois: payload.multi_mois,
salaires_par_date: payload.salaires_par_date,
}),
// Champs paniers repas (communs aux deux régimes)
nombre_paniers_repas: payload.nombre_paniers_repas,
panier_repas_ccn: payload.panier_repas_ccn,
si_non_montant_par_panier: payload.si_non_montant_par_panier,
};
console.log("🚀 [onSubmit] contractData envoyé à l'API:", contractData);
console.log("🚀 [onSubmit] salaires_par_date dans contractData:", (contractData as any).salaires_par_date);
// 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,
});
}
// Autoriser la navigation après soumission + feedback
allowNavRef.current = true;
setRedirecting(true);
setTimeout(() => {
router.push("/contrats");
}, 3000);
} catch (e: any) {
setErr(e?.message || "Impossible denvoyer 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">
{isRegimeRG
? "Ce formulaire est dédié aux contrats en régime général."
: "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° dobjet : ${s.numero_objet}` : "Sans n° dobjet"}</div>
</button>
))
)}
</div>
)}
</>
)}
</div>
)}
</div>
<div>
<Label>Avez-vous déjà un n° dobjet ?</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 sil 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 napparaît pas dans lannexe, 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)
<>
{/* Question multi-mois masquée - la logique reste active en arrière-plan */}
</>
)}
{/* Sections spécifiques aux CDDU - masquées en mode RG */}
{!isRegimeRG && (
!useHeuresMode ? (
<>
<FieldRow>
<div>
<Label>Indiquez les dates de représentations</Label>
<div className="flex items-center gap-2">
<div
onClick={() => setDatesRepOpen(true)}
className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center cursor-pointer hover:bg-slate-100 transition"
>
{datesRepDisplay || "Cliquez pour sélectionner…"}
</div>
<button
type="button"
onClick={() => setDatesRepOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
>
Modifier
</button>
{datesRep && (
<button
type="button"
onClick={() => {
setDatesRep("");
setDatesRepDisplay("");
}}
className="px-3 py-2 rounded-lg border border-red-200 bg-white text-sm text-red-600 hover:bg-red-50 transition whitespace-nowrap"
>
Effacer
</button>
)}
</div>
</div>
<div>
<Label>Indiquez les dates de répétitions</Label>
<div className="flex items-center gap-2">
<div
onClick={() => setDatesServOpen(true)}
className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center cursor-pointer hover:bg-slate-100 transition"
>
{datesServDisplay || "Cliquez pour sélectionner…"}
</div>
<button
type="button"
onClick={() => setDatesServOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
>
Modifier
</button>
{datesServ && (
<button
type="button"
onClick={() => {
setDatesServ("");
setDatesServDisplay("");
}}
className="px-3 py-2 rounded-lg border border-red-200 bg-white text-sm text-red-600 hover:bg-red-50 transition whitespace-nowrap"
>
Effacer
</button>
)}
</div>
</div>
</FieldRow>
{/* Calendriers pour représentations et répétitions */}
<DatePickerCalendar
isOpen={datesRepOpen}
onClose={() => setDatesRepOpen(false)}
onApply={handleDatesRepApply}
initialDates={datesRep ? extractDatesFromFormatted(datesRep, dateDebut || new Date().toISOString().slice(0, 10)) : []}
title="Sélectionner les dates de représentations"
/>
<DatePickerCalendar
isOpen={datesServOpen}
onClose={() => setDatesServOpen(false)}
onApply={handleDatesServApply}
initialDates={datesServ ? extractDatesFromFormatted(datesServ, dateDebut || new Date().toISOString().slice(0, 10)) : []}
title="Sélectionner les dates de répétitions"
/>
</>
) : (
<>
<FieldRow>
<div>
<Label>Indiquez les jours de travail</Label>
<div className="flex items-center gap-2">
<div
onClick={() => setJoursTravailOpen(true)}
className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center cursor-pointer hover:bg-slate-100 transition"
>
{joursTravailDisplay || "Cliquez pour sélectionner…"}
</div>
<button
type="button"
onClick={() => setJoursTravailOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
>
Modifier
</button>
</div>
</div>
<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>
</FieldRow>
{/* Section cachets pour metteur en scène */}
{professionPick?.code === "MET040" && (
<FieldRow>
<div>
<Label>Nombre de représentations (cachets)</Label>
<input
type="number"
min={0}
value={nbRepMetteur}
onChange={(e) => setNbRepMetteur(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 2"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">
Nombre de cachets de représentation pour le metteur en scène.
</p>
</div>
</FieldRow>
)}
{/* Calendrier pour jours de travail */}
<DatePickerCalendar
isOpen={joursTravailOpen}
onClose={() => setJoursTravailOpen(false)}
onApply={handleJoursTravailApply}
initialDates={joursTravailRaw.length > 0 ? joursTravailRaw : (joursTravail ? extractDatesFromFormatted(joursTravail, dateDebut || new Date().toISOString().slice(0, 10)) : [])}
title="Sélectionner les jours de travail"
/>
</>
)
)}
{/* Sous-card avec les champs auto-remplis */}
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-indigo-500"></div>
<p className="text-xs font-medium text-slate-600">Se rempli automatiquement selon vos sélections de dates</p>
</div>
<button
type="button"
onClick={() => setManualDatesMode(!manualDatesMode)}
className="px-2 py-1 text-xs rounded border bg-white hover:bg-slate-50 transition"
>
{manualDatesMode ? "Mode automatique" : "Remplir manuellement"}
</button>
</div>
<FieldRow>
<div>
<Label required>Date de début du contrat</Label>
<input
type="date"
value={dateDebut}
onChange={(e) => {
if (manualDatesMode) {
const v = e.target.value;
setDateDebut(v);
setConfirmPastStart(false);
if (dateFin && v && dateFin < v) setDateFin(v);
}
}}
disabled={!manualDatesMode}
className={`w-full px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
</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) => manualDatesMode && setDateFin(e.target.value)}
disabled={!manualDatesMode}
className={`w-full px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
</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) => manualDatesMode && setNbRep(e.target.value === "" ? "" : Number(e.target.value))}
disabled={!manualDatesMode}
className={`w-full px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
</div>
<div>
<Label>Combien de services de répétition ? / Durée</Label>
<div className="flex gap-2 items-center">
<input
type="number"
min={0}
value={nbServ}
onChange={(e) => manualDatesMode && setNbServ(e.target.value === "" ? "" : Number(e.target.value))}
disabled={!manualDatesMode}
placeholder="Nombre"
className={`flex-1 px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
/>
<span className="text-slate-400 font-medium">×</span>
{typeof nbServ === "number" && nbServ > 0 && (
<select
value={durationServices}
onChange={(e) => manualDatesMode && setDurationServices(e.target.value as "3" | "4")}
disabled={!manualDatesMode}
className={`flex-1 px-3 py-2 rounded-lg border text-sm ${
manualDatesMode
? 'bg-white text-slate-900'
: 'bg-slate-100 text-slate-600 cursor-not-allowed'
}`}
title="Durée des services de répétition"
>
<option value="3">3 heures</option>
<option value="4">4 heures</option>
</select>
)}
</div>
</div>
</FieldRow>
)}
{/* Affichage du type de contrat */}
{!isRegimeRG && (
<p className="text-xs text-slate-500 mt-2">
Type : <span className="font-medium text-slate-700">{isMultiMois === "Oui" ? "CDDU multi-mois" : "CDDU mono-mois"}</span>
</p>
)}
</div>
</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 et Minima - Conteneur deux colonnes */}
<div className="flex gap-6">
{/* Rémunération - 50% de largeur */}
<div className="flex-1">
<Section title="Rémunération">
{/* Onglets pour choisir le mode de saisie du salaire */}
{typeSalaire !== "Minimum conventionnel" && (
<div className="mb-4 flex gap-2 border-b border-slate-200">
<button
type="button"
onClick={() => setSalaryMode("global")}
className={`px-4 py-2 font-medium text-sm transition-colors ${
salaryMode === "global"
? "text-indigo-600 border-b-2 border-indigo-600 -mb-0.5"
: "text-slate-600 hover:text-slate-900"
}`}
>
Saisir le salaire global
</button>
<button
type="button"
onClick={() => setSalaryMode("par_date")}
className={
`px-4 py-2 font-medium text-sm transition-colors ` +
(salaryMode === "par_date"
? "text-indigo-600 border-b-2 border-indigo-600 -mb-0.5"
: "text-slate-600 hover:text-slate-900")
}
>
Saisir le salaire par date
</button>
</div>
)}
{/* Mode salaire global */}
{salaryMode === "global" && (
<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("");
setMontantFromCalculator(false);
}
}}
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={
montantFromCalculator && montantSalaire !== ""
? Number(montantSalaire).toFixed(2)
: montantSalaire
}
onChange={(e) => {
setMontantFromCalculator(false);
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>
{/* Bouton Calculatrice avec gradient */}
<button
type="button"
onClick={() => setIsCalculatorOpen(true)}
className="px-3 py-2 rounded-lg border-0 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 transition-all flex items-center justify-center gap-2 text-sm flex-shrink-0 shadow-sm hover:shadow-md"
title="Ouvrir la calculatrice"
aria-label="Calculatrice"
>
<CalculatorIcon className="w-4 h-4 text-white flex-shrink-0" />
<span className="text-white font-medium whitespace-nowrap">Calculatrice</span>
</button>
{/* Bouton Minima */}
<a
href="/minima-ccn"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border-0 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 transition-all flex items-center justify-center gap-2 text-sm flex-shrink-0 shadow-sm hover:shadow-md"
title="Consulter les minima conventionnels"
>
<span className="text-white font-medium whitespace-nowrap">Minima</span>
<ExternalLink className="w-3.5 h-3.5 text-white flex-shrink-0" />
</a>
</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>
{/* Champs conditionnels si panier repas = Oui */}
{panierRepas === "Oui" && (
<div className="mt-4 space-y-3 pl-4 border-l-2 border-slate-200">
<div>
<Label>Nombre de paniers</Label>
<input
type="number"
min="0"
step="1"
value={nombrePaniersRepas}
onChange={(e) => setNombrePaniersRepas(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ex: 5"
/>
</div>
<div>
<Label>Au minima CCN ?</Label>
<div className="flex items-center gap-6 mt-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepasCCN === "Oui"} onChange={() => setPanierRepasCCN("Oui")} />
Oui
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepasCCN === "Non"} onChange={() => setPanierRepasCCN("Non")} />
Non
</label>
</div>
</div>
{panierRepasCCN === "Non" && (
<div>
<Label>Montant par panier (en )</Label>
<input
type="number"
min="0"
step="0.01"
value={montantParPanier}
onChange={(e) => setMontantParPanier(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ex: 20.50"
/>
</div>
)}
<p className="text-[11px] text-slate-500 italic">
N'hésitez pas à nous préciser vos besoins en termes de défraiements dans la zone Notes ci-dessous.
</p>
</div>
)}
{/* Affichage de la note générée pour les paniers repas */}
{panierRepas === "Oui" && panierRepasNote && (
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-semibold text-amber-900 mb-1">Note automatique - Paniers repas</p>
<p className="text-xs text-amber-800 whitespace-pre-line">{panierRepasNote}</p>
</div>
</div>
</div>
)}
</div>
</FieldRow>
)}
{/* Mode salaire par date (désactivé temporairement) */}
{salaryMode === "par_date" && (
<div>
<div className="mb-4">
<Label required>Type de salaire</Label>
<div className="flex items-center gap-2">
<select
value={typeSalaire}
onChange={(e) => {
const v = e.target.value as typeof typeSalaire;
setTypeSalaire(v);
}}
className="flex-1 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>
</select>
{/* Bouton Calculatrice */}
<button
type="button"
onClick={() => setIsCalculatorOpen(true)}
className="px-3 py-2 rounded-lg border-0 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 transition-all flex items-center justify-center gap-2 text-sm flex-shrink-0 shadow-sm hover:shadow-md"
title="Ouvrir la calculatrice"
aria-label="Calculatrice"
>
<CalculatorIcon className="w-4 h-4 text-white flex-shrink-0" />
<span className="text-white font-medium whitespace-nowrap">Calculatrice</span>
</button>
{/* Bouton Minima */}
<a
href="/minima-ccn"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border-0 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 transition-all flex items-center justify-center gap-2 text-sm flex-shrink-0 shadow-sm hover:shadow-md"
title="Consulter les minima conventionnels"
>
<span className="text-white font-medium whitespace-nowrap">Minima</span>
<ExternalLink className="w-3.5 h-3.5 text-white flex-shrink-0" />
</a>
</div>
</div>
{/* Tableau des dates avec salaires */}
{(datesRep.length > 0 || datesServ.length > 0 || joursTravail.length > 0) ? (
<>
{/* Tableau des dates avec salaires - version compacte */}
<div className="space-y-3 mb-4">
{/* Représentations */}
{datesRep && datesRep.length > 0 && (() => {
const yearContext = dateDebut || new Date().toISOString().slice(0, 10);
const allRepDates = parseFormattedDatesWithQuantities(datesRep, yearContext, "rep");
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-indigo-50 px-3 py-2 border-b border-indigo-100">
<span className="text-xs font-semibold text-indigo-900 uppercase tracking-wide">Représentations</span>
</div>
<div className="p-2 space-y-1.5">
{allRepDates.map((dateInfo) => (
<div key={dateInfo.key} className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-50">
<div className="text-xs font-medium text-slate-700 w-14 shrink-0">{dateInfo.date}</div>
<div className="flex items-center gap-1.5 flex-wrap">
{Array.from({ length: dateInfo.quantity }).map((_, i) => (
<div key={`${dateInfo.key}_${i}`} className="flex items-center gap-1">
<span className="text-xs text-slate-500">R{i + 1}</span>
<input
type="number"
min="0.01"
step="0.01"
value={salariesByDate[`${dateInfo.key}_${i}`] ?? ""}
onChange={(e) =>
setSalariesByDate({
...salariesByDate,
[`${dateInfo.key}_${i}`]: e.target.value === "" ? "" : Number(e.target.value),
})
}
placeholder="0.00"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
title={`Représentation #${i + 1}`}
/>
<span className="text-xs text-slate-400">€</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
})()}
{/* Répétitions */}
{datesServ && datesServ.length > 0 && (() => {
const yearContext = dateDebut || new Date().toISOString().slice(0, 10);
const allServDates = parseFormattedDatesWithQuantities(datesServ, yearContext, "serv");
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-purple-50 px-3 py-2 border-b border-purple-100">
<span className="text-xs font-semibold text-purple-900 uppercase tracking-wide">Répétitions</span>
</div>
<div className="p-2 space-y-1.5">
{allServDates.map((dateInfo) => (
<div key={dateInfo.key} className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-50">
<div className="text-xs font-medium text-slate-700 w-14 shrink-0">{dateInfo.date}</div>
<div className="flex items-center gap-1.5 flex-wrap">
{Array.from({ length: dateInfo.quantity }).map((_, i) => (
<div key={`${dateInfo.key}_${i}`} className="flex items-center gap-1">
<span className="text-xs text-slate-500">S{i + 1}</span>
<input
type="number"
min="0.01"
step="0.01"
value={salariesByDate[`${dateInfo.key}_${i}`] ?? ""}
onChange={(e) =>
setSalariesByDate({
...salariesByDate,
[`${dateInfo.key}_${i}`]: e.target.value === "" ? "" : Number(e.target.value),
})
}
placeholder="0.00"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
title={`Répétition #${i + 1}`}
/>
<span className="text-xs text-slate-400">€</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
})()}
{/* Jours travaillés */}
{joursTravail && joursTravail.length > 0 && (() => {
const yearContext = dateDebut || new Date().toISOString().slice(0, 10);
const allJoursDates = parseFormattedDatesWithQuantities(joursTravail, yearContext, "jour");
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-green-50 px-3 py-2 border-b border-green-100">
<span className="text-xs font-semibold text-green-900 uppercase tracking-wide">Jours travaillés</span>
</div>
<div className="p-2 space-y-1.5">
{allJoursDates.map((dateInfo) => (
<div key={dateInfo.key} className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-50">
<div className="text-xs font-medium text-slate-700 w-14 shrink-0">{dateInfo.date}</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<span className="text-xs text-slate-500">Jour</span>
<input
type="number"
min="0.01"
step="0.01"
value={salariesByDate[`${dateInfo.key}_0`] ?? ""}
onChange={(e) =>
setSalariesByDate({
...salariesByDate,
[`${dateInfo.key}_0`]: e.target.value === "" ? "" : Number(e.target.value),
})
}
placeholder="0.00"
className="w-20 px-2 py-1 rounded border border-slate-300 bg-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
<span className="text-xs text-slate-400">€</span>
</div>
</div>
</div>
))}
</div>
</div>
);
})()}
</div>
{/* Total du salaire */}
<div className="p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border-2 border-indigo-200 mb-4">
<div className="flex items-baseline justify-between">
<div>
<div className="text-xs uppercase tracking-wide text-indigo-700 font-semibold">Salaire total ({typeSalaire})</div>
<div className="text-3xl font-bold text-indigo-900 mt-1">
{(Object.values(salariesByDate).reduce((sum: number, val) => {
return sum + (typeof val === "number" ? val : 0);
}, 0) as number).toFixed(2)} €
</div>
</div>
</div>
</div>
</>
) : (
<div className="p-4 bg-slate-50 rounded-lg text-sm text-slate-600 text-center mb-4">
Veuillez sélectionner des dates avant de saisir les salaires par date.
</div>
)}
<div className="mt-4">
<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>
{/* Champs conditionnels si panier repas = Oui */}
{panierRepas === "Oui" && (
<div className="mt-4 space-y-3 pl-4 border-l-2 border-slate-200">
<div>
<Label>Nombre de paniers</Label>
<input
type="number"
min="0"
step="1"
value={nombrePaniersRepas}
onChange={(e) => setNombrePaniersRepas(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ex: 5"
/>
</div>
<div>
<Label>Au minima CCN ?</Label>
<div className="flex items-center gap-6 mt-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepasCCN === "Oui"} onChange={() => setPanierRepasCCN("Oui")} />
Oui
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input type="radio" checked={panierRepasCCN === "Non"} onChange={() => setPanierRepasCCN("Non")} />
Non
</label>
</div>
</div>
{panierRepasCCN === "Non" && (
<div>
<Label>Montant par panier (en €)</Label>
<input
type="number"
min="0"
step="0.01"
value={montantParPanier}
onChange={(e) => setMontantParPanier(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ex: 20.50"
/>
</div>
)}
<p className="text-[11px] text-slate-500 italic">
N'hésitez pas à nous préciser vos besoins en termes de défraiements dans la zone Notes ci-dessous.
</p>
</div>
)}
</div>
</div>
)}
{/* Type de salaire pour le mode global */}
{salaryMode === "global" && typeSalaire === "Minimum conventionnel" && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
Le salaire sera calculé selon le minimum conventionnel applicable.
</div>
)}
</Section>
</div>
{/* Encart Minima CCN entièrement masqué temporairement */}
</div>
{/* 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>
)}
{/* Calculatrice */}
<Calculator
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
onUseResult={salaryMode === "global" ? (value) => {
const rounded = Math.round((value + Number.EPSILON) * 100) / 100;
setMontantSalaire(rounded);
setMontantFromCalculator(true);
setIsCalculatorOpen(false);
} : undefined}
/>
{/* Modale de quantités pour les dates sélectionnées */}
<DatesQuantityModal
isOpen={quantityModalOpen}
onClose={() => {
setQuantityModalOpen(false);
setPendingDates([]);
}}
onApply={handleQuantityApply}
selectedDates={pendingDates}
dateType={quantityModalType}
minDate={dateDebut}
maxDate={dateFin}
allowSkipHoursByDay={true}
repetitionDuration={durationServices}
/>
</>
);
}