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
3372 lines
147 KiB
TypeScript
3372 lines
147 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { usePostHog } from "posthog-js/react";
|
||
import { api } from "@/lib/fetcher";
|
||
import { Loader2, Search, Info, 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 d’envoyer le formulaire.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-5">
|
||
{!prefill && (
|
||
<div className="rounded-2xl border bg-white p-4">
|
||
<div className="text-lg font-semibold">{isRegimeRG ? "Nouveau contrat Régime Général" : "Nouveau CDDU"}</div>
|
||
<p className="text-sm text-slate-600 mt-1">
|
||
{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° d’objet : ${s.numero_objet}` : "Sans n° d’objet"}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label>Avez-vous déjà un n° d’objet ?</Label>
|
||
<input
|
||
value={objet}
|
||
onChange={(e) => setObjet(e.target.value)}
|
||
placeholder="ex : 2B728735689"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Si vous choisissez une production existante, le numéro se remplit automatiquement s’il est connu. Si vous n'avez pas encore de n° d'objet pour une nouvelle production, nous nous occuperons gratuitement de la démarche.
|
||
</p>
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
)}
|
||
|
||
{/* Salarié·e */}
|
||
<Section title="Salarié">
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Salarié</Label>
|
||
{salarie ? (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
readOnly
|
||
value={`${salarie.nom} — ${salarie.matricule}`}
|
||
className="w-full px-3 py-2 rounded-lg border bg-slate-50 text-sm"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setUrlSalarieDismissed(true); setSalarie(null); setSalarieQuery(""); }}
|
||
className="text-xs px-2 py-2 rounded-lg border"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<div className="relative">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
value={salarieQuery}
|
||
onChange={(e) => setSalarieQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
const items = salarieSearch?.items || [];
|
||
if (items.length === 0) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setSalarieActive(i => Math.min(i + 1, items.length - 1)); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); setSalarieActive(i => Math.max(i - 1, 0)); }
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const s = items[salarieActive];
|
||
if (s) setSalarie(s);
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
(e.currentTarget as HTMLInputElement).blur();
|
||
}
|
||
}}
|
||
placeholder="Rechercher un salarié…"
|
||
className="w-full pl-9 px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div className="mt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setNewSalarieOpen(true)}
|
||
className="inline-flex items-center gap-2 text-xs px-2 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow hover:opacity-90 transition"
|
||
>
|
||
Nouveau salarié
|
||
</button>
|
||
</div>
|
||
{fetchingSalaries && (
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
<Loader2 className="w-3.5 h-3.5 inline animate-spin mr-1" />
|
||
Recherche…
|
||
</div>
|
||
)}
|
||
{salarieSearch?.items && (
|
||
<div className="mt-2 max-h-56 overflow-auto rounded-lg border">
|
||
{salarieSearch.items.length === 0 ? (
|
||
<div className="p-3 text-sm text-slate-500">Aucun résultat.</div>
|
||
) : (
|
||
salarieSearch.items.map((s, idx) => (
|
||
<button
|
||
type="button"
|
||
key={s.matricule}
|
||
onClick={() => setSalarie(s)}
|
||
className={`w-full text-left px-3 py-2 text-sm ${idx === salarieActive ? 'bg-slate-50' : 'hover:bg-slate-50'}`}
|
||
onMouseEnter={() => setSalarieActive(idx)}
|
||
>
|
||
<div className="font-medium">{s.nom}</div>
|
||
<div className="text-xs text-slate-500">{s.matricule} {s.email ? `• ${s.email}` : ""}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label>Son adresse email</Label>
|
||
<input
|
||
value={salarie?.email || ""}
|
||
readOnly
|
||
placeholder="—"
|
||
className="w-full px-3 py-2 rounded-lg border bg-slate-50 text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Nous utilisons l'adresse e-mail de votre salarié pour lui envoyer une demande de justificatifs et d'état-civil, puis les notifications afférentes à la gestion de son contrat de travail. Cette zone peut être laissée vide si le salarié est déjà connu de nos services.
|
||
</p>
|
||
</div>
|
||
</FieldRow>
|
||
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Catégorie professionnelle</Label>
|
||
<select
|
||
value={categoriePro}
|
||
onChange={(e) => setCategoriePro(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
{isRegimeRG ? (
|
||
<>
|
||
<option>Cadre</option>
|
||
<option>Non Cadre</option>
|
||
<option>Je ne sais pas</option>
|
||
</>
|
||
) : (
|
||
<>
|
||
<option>Artiste</option>
|
||
<option>Technicien</option>
|
||
</>
|
||
)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<Label required>Profession pour ce contrat — {categoriePro}</Label>
|
||
{isRegimeRG ? (
|
||
// En mode RG, simple champ de saisie libre
|
||
<div>
|
||
<input
|
||
value={profession}
|
||
onChange={(e) => setProfession(e.target.value)}
|
||
placeholder="Saisissez la profession..."
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Saisissez librement la profession du salarié pour ce contrat.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
// Mode CDDU avec listes de professions
|
||
(categoriePro === "Artiste" || categoriePro === "Technicien") ? (
|
||
professionPick ? (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
readOnly
|
||
value={`${professionPick.label} — ${professionPick.code}`}
|
||
className="w-full px-3 py-2 rounded-lg border bg-slate-50 text-sm"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setProfessionPick(null); setProfessionQuery(""); setProfession(""); }}
|
||
className="text-xs px-2 py-2 rounded-lg border"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Cette liste contient l'ensemble des professions de l'Annexe 8 (Techniciens) ou 10 (Artistes) et leur Code Emploi. Si une profession n'apparaît pas, il n'est pas possible de réaliser un CDDU pour ce contrat.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<div className="relative">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
value={professionQuery}
|
||
onFocus={() => { if (categoriePro === 'Technicien') ensureTechniciensLoaded(); }}
|
||
onChange={(e) => { setProfessionQuery(e.target.value); if (categoriePro === 'Technicien') ensureTechniciensLoaded(); }}
|
||
onKeyDown={(e) => {
|
||
const items = (categoriePro === 'Artiste' ? filteredProfessions : filteredTechniciens);
|
||
if (items.length === 0) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setProfessionActive(i => Math.min(i + 1, items.length - 1)); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); setProfessionActive(i => Math.max(i - 1, 0)); }
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const p = items[professionActive];
|
||
if (p) { setProfessionPick(p as any); setProfession((p as any).label); }
|
||
}
|
||
if (e.key === 'Escape') { e.preventDefault(); (e.currentTarget as HTMLInputElement).blur(); }
|
||
}}
|
||
placeholder={`Rechercher une profession ${categoriePro.toLowerCase()} (min. 1 lettre)…`}
|
||
className="w-full pl-9 px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
{techLoading && categoriePro === 'Technicien' && (
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
<Loader2 className="w-3.5 h-3.5 inline animate-spin mr-1" />
|
||
Chargement de la liste des professions…
|
||
</div>
|
||
)}
|
||
<div className="mt-2 max-h-56 overflow-auto rounded-lg border">
|
||
{(
|
||
(categoriePro === 'Artiste' ? filteredProfessions : filteredTechniciens)
|
||
).length === 0 ? (
|
||
<div className="p-3 text-sm text-slate-500">Aucun résultat.</div>
|
||
) : (
|
||
(categoriePro === 'Artiste' ? filteredProfessions : filteredTechniciens).map((p, idx) => (
|
||
<button
|
||
type="button"
|
||
key={p.code}
|
||
onClick={() => { setProfessionPick(p); setProfession(p.label); }}
|
||
className={`w-full text-left px-3 py-2 text-sm ${idx === professionActive ? 'bg-slate-50' : 'hover:bg-slate-50'}`}
|
||
onMouseEnter={() => setProfessionActive(idx)}
|
||
>
|
||
<div className="font-medium">{p.label}</div>
|
||
<div className="text-xs text-slate-500">{p.code}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Cette liste contient l'ensemble des professions de l'Annexe 8 (Techniciens) ou 10 (Artistes) et leur Code Emploi. Si une profession n'apparaît pas, il n'est pas possible de réaliser un CDDU pour ce contrat.
|
||
</p>
|
||
</div>
|
||
)
|
||
) : (
|
||
<>
|
||
<input
|
||
value={profession}
|
||
onChange={(e) => setProfession(e.target.value)}
|
||
placeholder="ex : Régisseur.se, …"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 mt-1">
|
||
Si une profession n’apparaît pas dans l’annexe, indique-la précisément (ex: « Régisseur.se plateau »).
|
||
</p>
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
</FieldRow>
|
||
</Section>
|
||
|
||
{/* Période / volumes en mode CDDU, Contrat en mode RG */}
|
||
<Section title={isRegimeRG ? "Contrat" : "Période et volumes"}>
|
||
{isRegimeRG ? (
|
||
// Contenu pour Régime Général
|
||
<>
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Type de contrat</Label>
|
||
<select
|
||
value={typeContratRG}
|
||
onChange={(e) => setTypeContratRG(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option>CDD de droit commun</option>
|
||
<option>CDI</option>
|
||
<option>Stage</option>
|
||
<option>Contrat de professionnalisation</option>
|
||
<option>Contrat d'apprentissage</option>
|
||
<option>Autre</option>
|
||
</select>
|
||
</div>
|
||
</FieldRow>
|
||
|
||
{/* Motif CDD si CDD sélectionné */}
|
||
{typeContratRG === "CDD de droit commun" && (
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Motif du CDD</Label>
|
||
<select
|
||
value={motifCDD}
|
||
onChange={(e) => setMotifCDD(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option>Remplacement d'une absence</option>
|
||
<option>Accroissement temporaire d'activité</option>
|
||
</select>
|
||
</div>
|
||
</FieldRow>
|
||
)}
|
||
|
||
{/* Champs conditionnels selon le motif CDD */}
|
||
{typeContratRG === "CDD de droit commun" && motifCDD === "Remplacement d'une absence" && (
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Nom de la personne remplacée</Label>
|
||
<input
|
||
value={nomPersonneRemplacee}
|
||
onChange={(e) => setNomPersonneRemplacee(e.target.value)}
|
||
placeholder="Nom et prénom de la personne remplacée"
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label required>Situation de la personne remplacée</Label>
|
||
<select
|
||
value={situationPersonneRemplacee}
|
||
onChange={(e) => setSituationPersonneRemplacee(e.target.value as any)}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option>Absent temporairement ou contrat suspendu (maladie, maternité, congés payés, congé parental, etc...)</option>
|
||
<option>Salarié provisoirement à temps partiel (congé parental, congé création d'entreprise, etc...)</option>
|
||
<option>Salarié ayant quitté définitivement l'entreprise, poste en attente de suppression</option>
|
||
<option>Remplacement du chef d'entreprise</option>
|
||
</select>
|
||
</div>
|
||
</FieldRow>
|
||
)}
|
||
|
||
{typeContratRG === "CDD de droit commun" && motifCDD === "Accroissement temporaire d'activité" && (
|
||
<FieldRow>
|
||
<div>
|
||
<Label required>Décrivez le motif de l'accroissement d'activité</Label>
|
||
<textarea
|
||
value={motifAccroissement}
|
||
onChange={(e) => setMotifAccroissement(e.target.value)}
|
||
placeholder="Détaillez les raisons de l'accroissement temporaire d'activité..."
|
||
rows={3}
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
/>
|
||
</div>
|
||
</FieldRow>
|
||
)}
|
||
</>
|
||
) : (
|
||
// Contenu pour CDDU (existant)
|
||
<>
|
||
{/* 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}
|
||
/>
|
||
</>
|
||
);
|
||
}
|