2876 lines
No EOL
124 KiB
TypeScript
2876 lines
No EOL
124 KiB
TypeScript
// components/staff/contracts/ContractEditor.tsx
|
||
"use client";
|
||
|
||
import { useMemo, useState, useEffect, useRef, useCallback } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||
import { toast } from "sonner";
|
||
import { CalendarRange, FilePlus2, FileText, Save, Search, FileDown, PenTool, RefreshCw, Mail, Clock, CheckCircle2, XCircle, Users, Send, Check, Upload, Ban } from "lucide-react";
|
||
import PayslipForm from "./PayslipForm";
|
||
import { api } from "@/lib/fetcher";
|
||
import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
|
||
import { LoadingModal } from "@/components/ui/loading-modal";
|
||
import { PayslipModal } from "./PayslipModal";
|
||
import { EmployerReminderModal } from "./EmployerReminderModal";
|
||
import { PayslipCard } from "./PayslipCard";
|
||
import { NotesSection } from "@/components/NotesSection";
|
||
import ESignConfirmModal from "./ESignConfirmModal";
|
||
import DatePickerCalendar from "@/components/DatePickerCalendar";
|
||
import DatesQuantityModal from "@/components/DatesQuantityModal";
|
||
import { parseDateString } from "@/lib/dateFormatter";
|
||
import { supabase } from "@/lib/supabaseClient";
|
||
import { ManualSignedContractUpload } from "./ManualSignedContractUpload";
|
||
import CancelContractModal from "./CancelContractModal";
|
||
|
||
type AnyObj = Record<string, any>;
|
||
|
||
// Types from NouveauCDDUForm
|
||
type SalarieOption = {
|
||
matricule: string;
|
||
nom: string;
|
||
email?: string | null;
|
||
code_salarie?: string | null;
|
||
prenom?: string | null;
|
||
civilite?: string | null;
|
||
};
|
||
type SpectacleOption = {
|
||
id?: string;
|
||
nom: string;
|
||
numero_objet?: string | null;
|
||
prod_type?: string | null;
|
||
director?: string | null;
|
||
};
|
||
type ProfessionOption = { code: string; label: string };
|
||
type TechnicienOption = ProfessionOption;
|
||
type ClientInfo = { id: string; name: string; api_name?: string } | null;
|
||
|
||
// Helper functions from NouveauCDDUForm
|
||
function norm(s: string) {
|
||
return s
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/\p{Diacritic}/gu, '')
|
||
.replace(/[^a-z0-9\s]/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
// Helper function to parse monetary amounts with French decimal notation (comma)
|
||
function parseMonetaryAmount(value: string | number): number | null {
|
||
if (!value) return null;
|
||
const str = String(value).trim();
|
||
if (!str) return null;
|
||
|
||
// Replace comma with dot for proper parseFloat
|
||
const normalized = str.replace(',', '.');
|
||
const parsed = parseFloat(normalized);
|
||
|
||
return isNaN(parsed) ? null : parsed;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Search hooks from NouveauCDDUForm
|
||
function useSearchSalaries(q: string) {
|
||
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());
|
||
|
||
return useQuery({
|
||
queryKey: ["search-salaries", q, clientInfo?.id],
|
||
queryFn: async () => {
|
||
const result = await api<{ items: SalarieOption[] }>(`/salaries?${params.toString()}`, {}, clientInfo);
|
||
return {
|
||
items: result.items ?? [],
|
||
};
|
||
},
|
||
enabled: q.trim().length > 1 && !!clientInfo,
|
||
staleTime: 10_000,
|
||
});
|
||
}
|
||
|
||
function useSearchSpectacles(q: string) {
|
||
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());
|
||
|
||
return useQuery({
|
||
queryKey: ["search-spectacles", q, clientInfo?.id],
|
||
queryFn: async () => {
|
||
const result = await api<{ items: SpectacleOption[] }>(`/spectacles?${params.toString()}`, {}, clientInfo);
|
||
return {
|
||
items: result.items ?? [],
|
||
};
|
||
},
|
||
enabled: q.trim().length > 1 && !!clientInfo,
|
||
staleTime: 10_000,
|
||
});
|
||
}
|
||
|
||
function slugify(input: string) {
|
||
return (input || "")
|
||
.toLowerCase()
|
||
.normalize("NFD")
|
||
.replace(/\p{Diacritic}/gu, "")
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/(^-|-$)/g, "");
|
||
}
|
||
|
||
// Hook pour récupérer les informations d'organisation avec la CCN
|
||
function useOrganizationDetails() {
|
||
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,
|
||
});
|
||
|
||
return useQuery({
|
||
queryKey: ["organization-details", clientInfo?.id],
|
||
queryFn: async () => {
|
||
if (!clientInfo?.id) return null;
|
||
|
||
try {
|
||
const res = await fetch(`/api/organizations/${clientInfo.id}/details`, {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include"
|
||
});
|
||
|
||
if (!res.ok) return null;
|
||
const orgData = await res.json();
|
||
return orgData;
|
||
} catch (error) {
|
||
console.error("Error fetching organization details:", error);
|
||
return null;
|
||
}
|
||
},
|
||
enabled: !!clientInfo?.id,
|
||
staleTime: 60_000, // Cache plus long car ces données changent rarement
|
||
});
|
||
}
|
||
|
||
export default function ContractEditor({
|
||
contract,
|
||
payslips,
|
||
organizationDetails: serverOrganizationDetails,
|
||
}: {
|
||
contract: AnyObj;
|
||
payslips: AnyObj[];
|
||
organizationDetails?: any;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
|
||
// Récupération des informations d'organisation avec la CCN
|
||
const { data: clientOrganizationDetails } = useOrganizationDetails();
|
||
|
||
// Utiliser les données serveur en priorité, sinon les données client
|
||
const organizationDetails = serverOrganizationDetails || clientOrganizationDetails;
|
||
|
||
// Préremplissages pour aligner l'affichage avec le formulaire de création CDDU
|
||
const prefill = useMemo(() => {
|
||
const spectacle = contract.production_name || contract.nom_du_spectacle || "";
|
||
const numero_objet = contract.objet_spectacle || contract.numero_objet || "";
|
||
// Priorité: salaries.salarie > salaries.nom+prenom > employee_name fallback
|
||
const salarie_nom = contract.salaries?.salarie
|
||
|| (contract.salaries?.nom
|
||
? `${contract.salaries.nom.toUpperCase()}${contract.salaries.prenom ? ' ' + contract.salaries.prenom.charAt(0).toUpperCase() + contract.salaries.prenom.slice(1) : ''}`
|
||
: contract.employee_name || "");
|
||
const salarie_matricule = contract.employee_matricule || contract.matricule || contract.salaries?.code_salarie || "";
|
||
const salarie_email = contract.salaries?.adresse_mail || null;
|
||
const salarie_prenom = contract.salaries?.prenom || null;
|
||
const salarie_code = contract.salaries?.code_salarie || null;
|
||
const categorie = contract.categorie_pro || contract.categorie_professionnelle || "";
|
||
const profession = contract.profession || "";
|
||
const date_debut = (contract.start_date || contract.debut_contrat || "").slice?.(0, 10) || "";
|
||
const date_fin = (contract.end_date || contract.fin_contrat || "").slice?.(0, 10) || "";
|
||
const jours_travail = contract.jours_travail || "";
|
||
const heures_total = contract.nombre_d_heures || contract.heures_travail || "";
|
||
const heures_annexe_8 = contract.heures_annexe_8 || "";
|
||
const type_salaire = contract.type_salaire || (contract.gross_pay ? "Brut" : contract.net_pay ? "Net avant PAS" : "");
|
||
const montant = contract.salaire || contract.gross_pay || contract.net_pay || "";
|
||
const panier_repas = contract.paniers_repas || contract.panier_repas || "";
|
||
const reference = contract.reference || contract.contract_number || "";
|
||
const analytique = contract.analytique || "";
|
||
const multi_mois = String(contract.multi_mois || "");
|
||
return {
|
||
spectacle,
|
||
numero_objet,
|
||
salarie_nom,
|
||
salarie_matricule,
|
||
salarie_email,
|
||
salarie_prenom,
|
||
salarie_code,
|
||
categorie,
|
||
profession,
|
||
date_debut,
|
||
date_fin,
|
||
jours_travail,
|
||
heures_total,
|
||
heures_annexe_8,
|
||
type_salaire,
|
||
montant,
|
||
panier_repas,
|
||
reference,
|
||
analytique,
|
||
multi_mois,
|
||
};
|
||
}, [contract]);
|
||
|
||
// CDDU form state
|
||
const [spectacleQuery, setSpectacleQuery] = useState("");
|
||
const [spectaclePick, setSpectaclePick] = useState<SpectacleOption | null>(
|
||
prefill.spectacle ? { nom: prefill.spectacle, numero_objet: prefill.numero_objet } : null
|
||
);
|
||
const [salarieQuery, setSalarieQuery] = useState("");
|
||
const [salarie, setSalarie] = useState<SalarieOption | null>(
|
||
prefill.salarie_nom && prefill.salarie_matricule ?
|
||
{
|
||
nom: prefill.salarie_nom,
|
||
matricule: prefill.salarie_matricule,
|
||
email: prefill.salarie_email,
|
||
prenom: prefill.salarie_prenom,
|
||
code_salarie: prefill.salarie_code
|
||
} : null
|
||
);
|
||
const [categoriePro, setCategoriePro] = useState<"Artiste" | "Technicien">(
|
||
(prefill.categorie as "Artiste" | "Technicien") || "Artiste"
|
||
);
|
||
const [professionQuery, setProfessionQuery] = useState("");
|
||
const [professionPick, setProfessionPick] = useState<ProfessionOption | null>(() => {
|
||
if (!prefill.profession) return null;
|
||
|
||
// Chercher le code dans les professions artistes
|
||
const artisteProfession = PROFESSIONS_ARTISTE.find(p =>
|
||
p.label.toLowerCase() === prefill.profession.toLowerCase()
|
||
);
|
||
|
||
if (artisteProfession) {
|
||
return artisteProfession;
|
||
}
|
||
|
||
// Si pas trouvé, créer avec un code vide (sera résolu plus tard pour les techniciens)
|
||
return { label: prefill.profession, code: "" };
|
||
});
|
||
const [professionFeminine, setProfessionFeminine] = useState("");
|
||
const [isSavingFeminisation, setIsSavingFeminisation] = useState(false);
|
||
const [techniciens, setTechniciens] = useState<TechnicienOption[] | null>(null);
|
||
const techLoadedRef = useRef(false);
|
||
|
||
// États pour les champs conditionnels (heures vs répétitions/représentations)
|
||
const [nbRepresentations, setNbRepresentations] = useState<number | "">(contract.cachets_representations || "");
|
||
const [nbServicesRepetition, setNbServicesRepetition] = useState<number | "">(contract.services_repetitions || "");
|
||
const [durationServices, setDurationServices] = useState<"3" | "4">("4"); // Durée des services (3 ou 4 heures)
|
||
const [datesRepresentations, setDatesRepresentations] = useState(contract.jours_representations || "");
|
||
const [datesRepresentationsDisplay, setDatesRepresentationsDisplay] = useState("");
|
||
const [datesRepresentationsOpen, setDatesRepresentationsOpen] = useState(false);
|
||
const [datesRepetitions, setDatesRepetitions] = useState(contract.jours_repetitions || "");
|
||
const [datesRepetitionsDisplay, setDatesRepetitionsDisplay] = useState("");
|
||
const [datesRepetitionsOpen, setDatesRepetitionsOpen] = useState(false);
|
||
const [heuresTotal, setHeuresTotal] = useState<number | "">(contract.nombre_d_heures || "");
|
||
const [minutesTotal, setMinutesTotal] = useState<"0" | "30">((contract.minutes_total || "0") as "0" | "30");
|
||
const [joursTravail, setJoursTravail] = useState(contract.jours_travail || "");
|
||
const [joursTravailDisplay, setJoursTravailDisplay] = useState("");
|
||
const [joursTravailOpen, setJoursTravailOpen] = useState(false);
|
||
|
||
// États pour la modale de précision des quantités
|
||
const [quantityModalOpen, setQuantityModalOpen] = useState(false);
|
||
const [quantityModalType, setQuantityModalType] = useState<"representations" | "repetitions" | "jours_travail" | "">("");
|
||
const [pendingDates, setPendingDates] = useState<string[]>([]);
|
||
|
||
const [nombreHeuresTotal, setNombreHeuresTotal] = useState<number | "">(contract.nombre_d_heures || "");
|
||
const [nombreHeuresParJour, setNombreHeuresParJour] = useState<number | "">(contract.nombre_d_heures_par_jour || "");
|
||
|
||
// Synchroniser les états avec les données du contrat
|
||
useEffect(() => {
|
||
// Calculer le mode (heures vs représentations/répétitions)
|
||
const isHeuresMode = (
|
||
categoriePro === "Technicien" ||
|
||
(categoriePro === "Artiste" && professionPick?.code === "MET040")
|
||
);
|
||
|
||
console.log("🔄 Synchronisation des champs avec les données du contrat:", {
|
||
cachets_representations: contract.cachets_representations,
|
||
services_repetitions: contract.services_repetitions,
|
||
jours_representations: contract.jours_representations,
|
||
jours_repetitions: contract.jours_repetitions,
|
||
nombre_d_heures: contract.nombre_d_heures,
|
||
minutes_total: contract.minutes_total,
|
||
jours_travail: contract.jours_travail,
|
||
jours_travail_non_artiste: contract.jours_travail_non_artiste,
|
||
isHeuresMode,
|
||
categoriePro,
|
||
professionCode: professionPick?.code
|
||
});
|
||
|
||
// Utiliser les vrais noms de colonnes de la base de données
|
||
setNbRepresentations(contract.cachets_representations || "");
|
||
setNbServicesRepetition(contract.services_repetitions || "");
|
||
|
||
// Initialiser les affichages avec formatage smart si présentes
|
||
// Déterminer si c'est du texte formaté PDFMonkey ou des dates brutes
|
||
if (contract.jours_representations) {
|
||
// Détection améliorée du texte formaté
|
||
const hasFormatIndicators =
|
||
contract.jours_representations.includes(" ; ") ||
|
||
contract.jours_representations.includes(" le ") ||
|
||
contract.jours_representations.includes(" du ") ||
|
||
contract.jours_representations.includes(" au ") ||
|
||
contract.jours_representations.includes(" par jour ") ||
|
||
contract.jours_representations.startsWith("du ") ||
|
||
contract.jours_representations.startsWith("le ") ||
|
||
/^\d+ /.test(contract.jours_representations) ||
|
||
contract.jours_representations.endsWith(".");
|
||
if (hasFormatIndicators) {
|
||
// C'est déjà du texte formaté, utiliser tel quel
|
||
setDatesRepresentations(contract.jours_representations);
|
||
setDatesRepresentationsDisplay(contract.jours_representations);
|
||
} else {
|
||
// C'est du texte brut (dates), parser et reformater
|
||
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
|
||
const parsed = parseDateString(contract.jours_representations, yearContext);
|
||
setDatesRepresentations(parsed.pdfFormatted);
|
||
setDatesRepresentationsDisplay(parsed.pdfFormatted);
|
||
}
|
||
} else {
|
||
setDatesRepresentations("");
|
||
setDatesRepresentationsDisplay("");
|
||
}
|
||
|
||
if (contract.jours_repetitions) {
|
||
const hasFormatIndicators =
|
||
contract.jours_repetitions.includes(" ; ") ||
|
||
contract.jours_repetitions.includes(" le ") ||
|
||
contract.jours_repetitions.includes(" du ") ||
|
||
contract.jours_repetitions.includes(" au ") ||
|
||
contract.jours_repetitions.includes(" par jour ") ||
|
||
contract.jours_repetitions.startsWith("du ") ||
|
||
contract.jours_repetitions.startsWith("le ") ||
|
||
/^\d+ /.test(contract.jours_repetitions) ||
|
||
contract.jours_repetitions.endsWith(".");
|
||
if (hasFormatIndicators) {
|
||
setDatesRepetitions(contract.jours_repetitions);
|
||
setDatesRepetitionsDisplay(contract.jours_repetitions);
|
||
// Détecter la durée des services dans le texte
|
||
if (contract.jours_repetitions.includes("de 3 heures")) {
|
||
setDurationServices("3");
|
||
} else if (contract.jours_repetitions.includes("de 4 heures")) {
|
||
setDurationServices("4");
|
||
}
|
||
} else {
|
||
const yearContext = contract.start_date || new Date().toISOString().slice(0, 10);
|
||
const parsed = parseDateString(contract.jours_repetitions, yearContext);
|
||
setDatesRepetitions(parsed.pdfFormatted);
|
||
setDatesRepetitionsDisplay(parsed.pdfFormatted);
|
||
}
|
||
} else {
|
||
setDatesRepetitions("");
|
||
setDatesRepetitionsDisplay("");
|
||
}
|
||
|
||
setHeuresTotal(contract.nombre_d_heures || "");
|
||
setMinutesTotal((contract.minutes_total || "0") as "0" | "30");
|
||
|
||
// Initialiser joursTravail avec formatage smart
|
||
// En mode heures (technicien/metteur en scène), charger depuis jours_travail_non_artiste
|
||
// En mode artiste classique, charger depuis jours_travail
|
||
const joursTravailSource = isHeuresMode ? contract.jours_travail_non_artiste : contract.jours_travail;
|
||
|
||
console.log("📍 Source pour joursTravail:", {
|
||
isHeuresMode,
|
||
source: isHeuresMode ? "jours_travail_non_artiste" : "jours_travail",
|
||
valeur: joursTravailSource,
|
||
contract_jours_travail: contract.jours_travail,
|
||
contract_jours_travail_non_artiste: contract.jours_travail_non_artiste
|
||
});
|
||
|
||
if (joursTravailSource) {
|
||
// Détection améliorée du texte formaté PDFMonkey
|
||
// - Contient " ; " (séparateur de dates multiples)
|
||
// - Contient des mots indicateurs : " le ", " du ", " au ", " par jour "
|
||
// - Commence par des mots clés : "du ", "le ", nombre + espace
|
||
// - Finit par un point (convention PDFMonkey)
|
||
const hasFormatIndicators =
|
||
joursTravailSource.includes(" ; ") ||
|
||
joursTravailSource.includes(" le ") ||
|
||
joursTravailSource.includes(" du ") ||
|
||
joursTravailSource.includes(" au ") ||
|
||
joursTravailSource.includes(" par jour ") ||
|
||
joursTravailSource.startsWith("du ") ||
|
||
joursTravailSource.startsWith("le ") ||
|
||
/^\d+ /.test(joursTravailSource) || // commence par un nombre + espace (ex: "5 heures le...")
|
||
joursTravailSource.endsWith(".");
|
||
|
||
console.log("🔍 Traitement de joursTravailSource:", {
|
||
joursTravailSource,
|
||
isFormatted: hasFormatIndicators,
|
||
action: hasFormatIndicators ? "utilisation directe" : "parsing"
|
||
});
|
||
if (hasFormatIndicators) {
|
||
setJoursTravail(joursTravailSource);
|
||
setJoursTravailDisplay(joursTravailSource);
|
||
console.log("✅ Valeurs définies (formaté):", {
|
||
joursTravail: joursTravailSource,
|
||
joursTravailDisplay: joursTravailSource
|
||
});
|
||
} else {
|
||
const yearContext = contract.date_debut || new Date().toISOString().slice(0, 10);
|
||
const parsed = parseDateString(joursTravailSource, yearContext);
|
||
setJoursTravail(parsed.pdfFormatted);
|
||
setJoursTravailDisplay(parsed.pdfFormatted);
|
||
console.log("✅ Valeurs définies (parsé):", {
|
||
joursTravail: parsed.pdfFormatted,
|
||
joursTravailDisplay: parsed.pdfFormatted
|
||
});
|
||
}
|
||
} else {
|
||
setJoursTravail("");
|
||
setJoursTravailDisplay("");
|
||
}
|
||
|
||
// Pré-remplir le nombre d'heures total
|
||
if (contract.nombre_d_heures) {
|
||
setNombreHeuresTotal(contract.nombre_d_heures);
|
||
} else if (!isHeuresMode && contract.services_repetitions && contract.jours_repetitions) {
|
||
// Si pas d'heures stockées, calculer à partir des services × durée
|
||
const nbServices = parseInt(contract.services_repetitions) || 0;
|
||
let duration = 4; // Durée par défaut
|
||
if (contract.jours_repetitions.includes("de 3 heures")) {
|
||
duration = 3;
|
||
}
|
||
setNombreHeuresTotal(nbServices * duration);
|
||
} else {
|
||
setNombreHeuresTotal("");
|
||
}
|
||
setNombreHeuresParJour(contract.nombre_d_heures_par_jour || "");
|
||
|
||
// Pré-remplir la date de signature selon les règles :
|
||
// - Date du jour si la date de début est le jour même ou dans le futur
|
||
// - Égale à la date de début si celle-ci est dans le passé
|
||
if (contract.date_signature) {
|
||
setDateSignature(contract.date_signature.slice(0, 10));
|
||
} else if (contract.date_debut) {
|
||
const today = new Date().toISOString().split("T")[0];
|
||
const dateDebut = contract.date_debut.split("T")[0];
|
||
// Si date_debut est dans le passé (avant aujourd'hui), utiliser date_debut
|
||
// Sinon, utiliser aujourd'hui
|
||
setDateSignature(dateDebut < today ? dateDebut : today);
|
||
} else {
|
||
setDateSignature("");
|
||
}
|
||
|
||
setPrecisionsSalaire(contract.precisions_salaire || "");
|
||
}, [contract, categoriePro, professionPick?.code]);
|
||
|
||
// États pour les autres champs CDDU actuellement grisés
|
||
const [typeSalaire, setTypeSalaire] = useState(prefill.type_salaire || "");
|
||
const [panierRepas, setPanierRepas] = useState(prefill.panier_repas || "");
|
||
const [reference, setReference] = useState(prefill.reference || "");
|
||
const [analytique, setAnalytique] = useState(prefill.analytique || "");
|
||
const [dateSignature, setDateSignature] = useState(contract.date_signature?.slice(0, 10) || "");
|
||
const [montant, setMontant] = useState(String(prefill.montant || ""));
|
||
const [precisionsSalaire, setPrecisionsSalaire] = useState(contract.precisions_salaire || "");
|
||
|
||
// Search states
|
||
const [spectacleActive, setSpectacleActive] = useState(0);
|
||
const [salarieActive, setSalarieActive] = useState(0);
|
||
const [professionActive, setProfessionActive] = useState(0);
|
||
|
||
// Loading states
|
||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isLaunchingSignature, setIsLaunchingSignature] = useState(false);
|
||
const [showPdfSuccess, setShowPdfSuccess] = useState(false);
|
||
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
|
||
|
||
// Payslip modal states
|
||
const [isPayslipModalOpen, setIsPayslipModalOpen] = useState(false);
|
||
const [editingPayslip, setEditingPayslip] = useState<any>(null);
|
||
|
||
// E-Sign confirmation modal states
|
||
const [showESignConfirmModal, setShowESignConfirmModal] = useState(false);
|
||
const [sendESignNotification, setSendESignNotification] = useState(true);
|
||
const [verifiedEmployeeEmail, setVerifiedEmployeeEmail] = useState<string>("");
|
||
|
||
// Manual upload modal state
|
||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||
|
||
// Cancel contract modal states
|
||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||
const [isCancelling, setIsCancelling] = useState(false);
|
||
|
||
// Handler pour annuler le contrat
|
||
const handleCancelContract = async () => {
|
||
setIsCancelling(true);
|
||
try {
|
||
const response = await fetch(`/api/staff/contracts/${contract.id}/cancel`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Erreur lors de l'annulation");
|
||
}
|
||
|
||
toast.success("Contrat annulé avec succès");
|
||
setShowCancelModal(false);
|
||
|
||
// Recharger la page pour afficher les mises à jour
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 1000);
|
||
} catch (error: any) {
|
||
console.error("Erreur annulation contrat:", error);
|
||
toast.error(`Erreur: ${error.message}`);
|
||
} finally {
|
||
setIsCancelling(false);
|
||
}
|
||
};
|
||
|
||
// Handler pour le calendrier des dates de représentations
|
||
// Ouvrir le modal de quantités pour permettre la précision par date
|
||
const handleDatesRepresentationsApply = (result: {
|
||
selectedDates: string[];
|
||
hasMultiMonth: boolean;
|
||
pdfFormatted: string;
|
||
}) => {
|
||
setPendingDates(result.selectedDates);
|
||
setQuantityModalType("representations");
|
||
setQuantityModalOpen(true);
|
||
};
|
||
|
||
// Handler pour le calendrier des dates de répétitions
|
||
// Ouvrir le modal de quantités pour permettre la précision par date
|
||
const handleDatesRepetitionsApply = (result: {
|
||
selectedDates: string[];
|
||
hasMultiMonth: boolean;
|
||
pdfFormatted: string;
|
||
}) => {
|
||
setPendingDates(result.selectedDates);
|
||
setQuantityModalType("repetitions");
|
||
setQuantityModalOpen(true);
|
||
};
|
||
|
||
// Handler pour le calendrier des jours travaillés
|
||
// Ouvrir le modal de quantités pour permettre la précision par date
|
||
const handleJoursTravailApply = (result: {
|
||
selectedDates: string[];
|
||
hasMultiMonth: boolean;
|
||
pdfFormatted: string;
|
||
}) => {
|
||
setPendingDates(result.selectedDates);
|
||
setQuantityModalType("jours_travail");
|
||
setQuantityModalOpen(true);
|
||
};
|
||
|
||
// Handler pour la modale de quantités (applique les données finales)
|
||
const handleQuantityApply = (result: {
|
||
selectedDates: string[];
|
||
hasMultiMonth: boolean;
|
||
pdfFormatted: string;
|
||
}) => {
|
||
// Calculer le nombre de jours/dates sélectionnées
|
||
const nbDates = result.selectedDates.length;
|
||
|
||
switch (quantityModalType) {
|
||
case "representations":
|
||
setDatesRepresentations(result.pdfFormatted);
|
||
setDatesRepresentationsDisplay(result.pdfFormatted);
|
||
// Auto-remplir le nombre de représentations
|
||
setNbRepresentations(nbDates);
|
||
break;
|
||
case "repetitions":
|
||
// Ajouter la durée des services au texte formaté
|
||
let formattedText = result.pdfFormatted;
|
||
if (durationServices) {
|
||
// Remplacer "service de répétition" par "service de répétition de X heures"
|
||
formattedText = formattedText
|
||
.replace(/service de répétition/g, `service de répétition de ${durationServices} heures`)
|
||
.replace(/services de répétition/g, `services de répétition de ${durationServices} heures chacun`);
|
||
}
|
||
setDatesRepetitions(formattedText);
|
||
setDatesRepetitionsDisplay(formattedText);
|
||
// Auto-remplir le nombre de services de répétition
|
||
setNbServicesRepetition(nbDates);
|
||
break;
|
||
case "jours_travail":
|
||
console.log("📅 Jours travail - result:", result);
|
||
setJoursTravail(result.pdfFormatted);
|
||
setJoursTravailDisplay(result.pdfFormatted);
|
||
console.log("📅 Jours travail mis à jour:", result.pdfFormatted);
|
||
break;
|
||
}
|
||
setQuantityModalOpen(false);
|
||
setPendingDates([]);
|
||
};
|
||
|
||
// Fonction pour ouvrir le PDF avec URL pré-signée
|
||
const openPdf = async () => {
|
||
try {
|
||
const response = await fetch(`/api/contrats/${contract.id}/pdf-url`);
|
||
if (!response.ok) {
|
||
throw new Error("Erreur lors de la récupération de l'URL du PDF");
|
||
}
|
||
const data = await response.json();
|
||
window.open(data.signedUrl, '_blank');
|
||
} catch (error) {
|
||
console.error("Erreur:", error);
|
||
toast.error("Impossible d'ouvrir le PDF");
|
||
}
|
||
};
|
||
|
||
const openSignedPdf = async () => {
|
||
if (signedPdfData?.signedUrl) {
|
||
window.open(signedPdfData.signedUrl, '_blank');
|
||
} else {
|
||
toast.error("URL du contrat signé non disponible");
|
||
}
|
||
};
|
||
|
||
// Fonction pour déterminer l'état de la signature électronique
|
||
const getSignatureStatus = () => {
|
||
const etatDemande = form.etat_de_la_demande || contract.etat_de_la_demande;
|
||
const contratSigneEmployeur = form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur;
|
||
const contratSigne = form.contrat_signe || contract.contrat_signe;
|
||
|
||
if (etatDemande !== "Traitée") {
|
||
return {
|
||
status: "not_sent",
|
||
label: "E-signature non envoyée",
|
||
icon: XCircle,
|
||
color: "text-gray-500",
|
||
bgColor: "bg-gray-50",
|
||
borderColor: "border-gray-200"
|
||
};
|
||
}
|
||
|
||
if (contratSigneEmployeur === "Non") {
|
||
return {
|
||
status: "waiting_employer",
|
||
label: "En attente employeur",
|
||
icon: Clock,
|
||
color: "text-orange-600",
|
||
bgColor: "bg-orange-50",
|
||
borderColor: "border-orange-200"
|
||
};
|
||
}
|
||
|
||
if (contratSigneEmployeur === "Oui" && contratSigne === "Non") {
|
||
return {
|
||
status: "waiting_employee",
|
||
label: "En attente salarié",
|
||
icon: Users,
|
||
color: "text-blue-600",
|
||
bgColor: "bg-blue-50",
|
||
borderColor: "border-blue-200"
|
||
};
|
||
}
|
||
|
||
if (contratSigne === "Oui") {
|
||
return {
|
||
status: "completed",
|
||
label: "E-signature finalisée",
|
||
icon: CheckCircle2,
|
||
color: "text-green-600",
|
||
bgColor: "bg-green-50",
|
||
borderColor: "border-green-200"
|
||
};
|
||
}
|
||
|
||
// État par défaut
|
||
return {
|
||
status: "unknown",
|
||
label: "Statut inconnu",
|
||
icon: XCircle,
|
||
color: "text-gray-500",
|
||
bgColor: "bg-gray-50",
|
||
borderColor: "border-gray-200"
|
||
};
|
||
};
|
||
|
||
const [form, setForm] = useState<AnyObj>({
|
||
role: contract.role ?? "",
|
||
start_date: contract.start_date?.slice(0, 10) ?? "",
|
||
end_date: contract.end_date?.slice(0, 10) ?? "",
|
||
hours: contract.hours ?? "",
|
||
gross_pay: contract.gross_pay ?? "",
|
||
net_pay: contract.net_pay ?? "",
|
||
contract_pdf_s3_key: contract.contract_pdf_s3_key ?? "",
|
||
notes: contract.notes ?? "",
|
||
etat_de_la_demande: contract.etat_de_la_demande ?? "",
|
||
contrat_signe: contract.contrat_signe ?? "",
|
||
contrat_signe_par_employeur: contract.contrat_signe_par_employeur ?? "",
|
||
dpae: contract.dpae ?? "",
|
||
etat_de_la_paie: contract.etat_de_la_paie ?? "",
|
||
aem: contract.aem ?? "",
|
||
});
|
||
|
||
const [rows, setRows] = useState<AnyObj[]>(
|
||
(payslips ?? []).map((p) => ({ ...p }))
|
||
);
|
||
|
||
const [open, setOpen] = useState(false);
|
||
|
||
// Search hooks
|
||
const debouncedSpectacleQuery = useDebouncedValue(spectacleQuery, 300);
|
||
const debouncedSalarieQuery = useDebouncedValue(salarieQuery, 300);
|
||
const { data: spectacleSearch, isFetching: fetchingSpectacles } = useSearchSpectacles(debouncedSpectacleQuery);
|
||
const { data: salarieSearch, isFetching: fetchingSalaries } = useSearchSalaries(debouncedSalarieQuery);
|
||
|
||
// Hook pour récupérer l'URL pré-signée du contrat signé
|
||
const { data: signedPdfData, isLoading: loadingSignedPdf } = useQuery({
|
||
queryKey: ["signed-contract-pdf", contract.id],
|
||
queryFn: async () => {
|
||
const response = await fetch(`/api/staff/contrats/${contract.id}/signed-pdf`);
|
||
if (!response.ok) {
|
||
if (response.status === 404) {
|
||
const errorData = await response.json();
|
||
return { hasSignedPdf: false, error: errorData.error };
|
||
}
|
||
throw new Error('Erreur lors de la récupération du contrat signé');
|
||
}
|
||
return await response.json();
|
||
},
|
||
enabled: !!contract.id,
|
||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||
retry: 1,
|
||
});
|
||
|
||
// Profession filtering
|
||
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]);
|
||
|
||
// Logique conditionnelle : heures pour techniciens, répétitions/représentations pour artistes
|
||
const useHeuresMode = useMemo(() => {
|
||
return (
|
||
categoriePro === "Technicien" ||
|
||
(categoriePro === "Artiste" && professionPick?.code === "MET040")
|
||
);
|
||
}, [categoriePro, professionPick]);
|
||
// Auto-calculer le nombre d'heures total basé sur le nombre de services x durée
|
||
useEffect(() => {
|
||
if (!useHeuresMode && datesRepetitions) {
|
||
// Extraire le nombre total de services depuis le texte formaté
|
||
// Exemple: "1 service de répétition de 4 heures le 29/10 ; 2 services de répétition de 4 heures chacun le 30/10"
|
||
// On doit sommer : 1 + 2 = 3
|
||
let totalServices = 0;
|
||
|
||
// Chercher tous les patterns "N service(s)"
|
||
const servicePattern = /(\d+)\s+service/g;
|
||
let match;
|
||
while ((match = servicePattern.exec(datesRepetitions)) !== null) {
|
||
totalServices += parseInt(match[1], 10);
|
||
}
|
||
|
||
if (totalServices > 0) {
|
||
const duration = parseInt(durationServices);
|
||
const totalHours = totalServices * duration;
|
||
setNombreHeuresTotal(totalHours);
|
||
}
|
||
}
|
||
}, [useHeuresMode, datesRepetitions, durationServices]);
|
||
|
||
// Load technicians list
|
||
async function ensureTechniciensLoaded() {
|
||
if (techLoadedRef.current || techniciens) return;
|
||
try {
|
||
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;
|
||
} catch (e) {
|
||
console.warn("Could not load technicians:", e);
|
||
}
|
||
}
|
||
|
||
// Functions for profession feminization
|
||
const loadFeminisation = async (professionCode: string) => {
|
||
try {
|
||
const res = await fetch(`/api/professions-feminisations?code=${encodeURIComponent(professionCode)}`);
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
return data ? data.profession_feminine : null;
|
||
} catch (error) {
|
||
console.warn("Erreur lors du chargement de la féminisation:", error);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const saveFeminisation = async (professionCode: string, professionLabel: string, professionFeminine: string) => {
|
||
try {
|
||
const res = await fetch('/api/professions-feminisations', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
profession_code: professionCode,
|
||
profession_label: professionLabel,
|
||
profession_feminine: professionFeminine.trim()
|
||
})
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errorData = await res.json().catch(() => ({}));
|
||
throw new Error(errorData.error || `HTTP ${res.status}`);
|
||
}
|
||
|
||
const result = await res.json();
|
||
console.log("✅ Féminisation sauvegardée avec succès:", result);
|
||
return true;
|
||
} catch (error) {
|
||
console.error("❌ Erreur lors de la sauvegarde de la féminisation:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Load technicians when category changes to Technicien
|
||
useEffect(() => {
|
||
if (categoriePro === "Technicien") {
|
||
ensureTechniciensLoaded();
|
||
}
|
||
}, [categoriePro]);
|
||
|
||
// Resolve profession code if missing (for technicians)
|
||
useEffect(() => {
|
||
if (professionPick && !professionPick.code && categoriePro === "Technicien" && techniciens) {
|
||
const technicienProfession = techniciens.find(p =>
|
||
p.label.toLowerCase() === professionPick.label.toLowerCase()
|
||
);
|
||
if (technicienProfession) {
|
||
console.log("🔧 Resolving technicien profession code:", technicienProfession);
|
||
setProfessionPick(technicienProfession);
|
||
}
|
||
}
|
||
}, [professionPick, categoriePro, techniciens]);
|
||
|
||
// Load feminization when profession changes
|
||
useEffect(() => {
|
||
if (professionPick?.code) {
|
||
loadFeminisation(professionPick.code).then(feminine => {
|
||
if (feminine) {
|
||
setProfessionFeminine(feminine);
|
||
} else {
|
||
setProfessionFeminine("");
|
||
}
|
||
});
|
||
} else {
|
||
setProfessionFeminine("");
|
||
}
|
||
}, [professionPick]);
|
||
|
||
// Save feminization when profession feminine is changed
|
||
const handleFeminineChange = (value: string) => {
|
||
console.log("📝 handleFeminineChange called with:", value);
|
||
setProfessionFeminine(value);
|
||
};
|
||
|
||
// Manual save feminization function
|
||
const saveCurrentFeminisation = async () => {
|
||
console.log("🔧 saveCurrentFeminisation called");
|
||
console.log("📊 Current state:", {
|
||
professionPick,
|
||
professionFeminine: `"${professionFeminine}"`,
|
||
professionFeminineTrimmed: `"${professionFeminine.trim()}"`,
|
||
hasCode: !!professionPick?.code,
|
||
hasLabel: !!professionPick?.label,
|
||
hasTrimmedFeminine: !!professionFeminine.trim()
|
||
});
|
||
|
||
// Ensure technicians are loaded if needed
|
||
if (categoriePro === "Technicien") {
|
||
await ensureTechniciensLoaded();
|
||
}
|
||
|
||
// Fallback: try to resolve code if missing
|
||
let codeToUse = professionPick?.code;
|
||
if (!codeToUse && professionPick?.label) {
|
||
console.log("🔍 Trying to resolve missing code for:", professionPick.label);
|
||
|
||
// Check in artiste professions
|
||
const artisteProfession = PROFESSIONS_ARTISTE.find(p =>
|
||
p.label.toLowerCase() === professionPick.label.toLowerCase()
|
||
);
|
||
if (artisteProfession) {
|
||
codeToUse = artisteProfession.code;
|
||
console.log("✅ Found artiste code:", codeToUse);
|
||
}
|
||
|
||
// Check in technicien professions if loaded
|
||
if (!codeToUse && techniciens) {
|
||
const technicienProfession = techniciens.find(p =>
|
||
p.label.toLowerCase() === professionPick.label.toLowerCase()
|
||
);
|
||
if (technicienProfession) {
|
||
codeToUse = technicienProfession.code;
|
||
console.log("✅ Found technicien code:", codeToUse);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!codeToUse || !professionPick?.label || !professionFeminine.trim()) {
|
||
console.log("❌ Missing required data - detailed check:");
|
||
console.log(" - resolved code:", codeToUse);
|
||
console.log(" - professionPick?.label:", professionPick?.label);
|
||
console.log(" - professionFeminine.trim():", `"${professionFeminine.trim()}"`);
|
||
toast.error("Veuillez sélectionner une profession et saisir sa forme féminine");
|
||
return;
|
||
}
|
||
|
||
console.log("✅ Starting save process with code:", codeToUse);
|
||
setIsSavingFeminisation(true);
|
||
|
||
try {
|
||
await saveFeminisation(codeToUse, professionPick.label, professionFeminine);
|
||
toast.success(`Féminisation sauvegardée : "${professionFeminine}"`);
|
||
console.log("✅ Féminisation sauvegardée avec succès");
|
||
} catch (error) {
|
||
console.error("❌ Erreur:", error);
|
||
toast.error("Erreur lors de la sauvegarde de la féminisation");
|
||
} finally {
|
||
setIsSavingFeminisation(false);
|
||
}
|
||
};
|
||
|
||
const nextPayNumber = useMemo(
|
||
() => (rows?.length ? Math.max(...rows.map((r) => Number(r.pay_number) || 0)) + 1 : 1),
|
||
[rows]
|
||
);
|
||
|
||
const handleContractChange = (key: string, value: any) => {
|
||
setForm((f: AnyObj) => ({ ...f, [key]: value }));
|
||
};
|
||
|
||
const saveContract = async () => {
|
||
setIsSaving(true);
|
||
try {
|
||
const payload: AnyObj = {
|
||
...form,
|
||
start_date: form.start_date || null,
|
||
end_date: form.end_date || null,
|
||
// CDDU fields - searchable/selectable
|
||
production_id: spectaclePick?.id || null,
|
||
production_name: spectaclePick?.nom || "",
|
||
objet_spectacle: spectaclePick?.numero_objet || "",
|
||
employee_name: salarie?.nom || "",
|
||
employee_matricule: salarie?.matricule || "",
|
||
categorie_pro: categoriePro,
|
||
profession: professionPick?.label || professionQuery || "",
|
||
// CDDU fields - manual inputs (nouvellement modifiables)
|
||
type_salaire: typeSalaire || null,
|
||
salaire: montant ? parseFloat(montant) : null,
|
||
paniers_repas: panierRepas || null,
|
||
reference: reference || null,
|
||
analytique: analytique || null,
|
||
date_signature: dateSignature || null,
|
||
// Conditional fields based on category
|
||
nb_representations: useHeuresMode ? null : nbRepresentations || null,
|
||
nb_services_repetition: useHeuresMode ? null : nbServicesRepetition || null,
|
||
dates_representations: useHeuresMode ? null : datesRepresentations || null,
|
||
dates_repetitions: useHeuresMode ? null : datesRepetitions || null,
|
||
heures_total: useHeuresMode ? heuresTotal || null : null,
|
||
minutes_total: useHeuresMode ? minutesTotal : null,
|
||
precisions_salaire: precisionsSalaire || null, // Nouveau champ pour précisions salaire
|
||
// Map to database fields
|
||
cachets_representations: useHeuresMode ? null : nbRepresentations || null,
|
||
services_repetitions: useHeuresMode ? null : nbServicesRepetition || null,
|
||
nombre_d_heures: useHeuresMode ? heuresTotal || null : nombreHeuresTotal || null,
|
||
nombre_d_heures_par_jour: nombreHeuresParJour || null,
|
||
jours_representations: useHeuresMode ? null : datesRepresentations || null,
|
||
jours_repetitions: useHeuresMode ? null : datesRepetitions || null,
|
||
// Envoyer jours_travail_non_artiste en mode heures (technicien ou metteur en scène)
|
||
jours_travail_non_artiste: useHeuresMode ? ((typeof joursTravail === "string" && joursTravail.trim() === "") ? null : (joursTravail || null)) : null,
|
||
// Envoyer jours_travail en mode artiste classique (représentations/répétitions)
|
||
jours_travail: !useHeuresMode ? ((typeof joursTravail === "string" && joursTravail.trim() === "") ? null : (joursTravail || null)) : null,
|
||
panier_repas: panierRepas || null,
|
||
n_objet: spectaclePick?.numero_objet || null,
|
||
brut: typeSalaire === "Brut" ? parseMonetaryAmount(montant) : null,
|
||
// Le champ gross_pay correspond au champ "Brut" de l'interface utilisateur
|
||
gross_pay: parseMonetaryAmount(form.gross_pay),
|
||
};
|
||
|
||
console.log("💾 États actuels avant sauvegarde:", {
|
||
nbRepresentations,
|
||
nbServicesRepetition,
|
||
datesRepresentations,
|
||
datesRepetitions,
|
||
heuresTotal,
|
||
minutesTotal,
|
||
joursTravail,
|
||
joursTravailDisplay,
|
||
useHeuresMode,
|
||
categoriePro,
|
||
professionCode: professionPick?.code
|
||
});
|
||
|
||
console.log("💾 Sauvegarde contrat - payload:", payload);
|
||
console.log("💾 Champs jours_travail dans le payload:", {
|
||
jours_travail: payload.jours_travail,
|
||
jours_travail_non_artiste: payload.jours_travail_non_artiste
|
||
});
|
||
const res = await fetch(`/api/staff/contracts/${contract.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ contract: payload }),
|
||
});
|
||
const j = await res.json();
|
||
if (!res.ok) {
|
||
toast.error(j.error ?? "Erreur lors de la mise à jour du contrat");
|
||
return;
|
||
}
|
||
|
||
setShowSaveSuccess(true);
|
||
toast.success("Contrat mis à jour");
|
||
} catch (error) {
|
||
console.error("Erreur lors de la sauvegarde:", error);
|
||
toast.error("Erreur lors de la sauvegarde du contrat");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const savePayslips = async () => {
|
||
const sanitized = rows.map((r) => ({
|
||
...r,
|
||
period_start: r.period_start?.slice?.(0, 10) ?? r.period_start,
|
||
period_end: r.period_end?.slice?.(0, 10) ?? r.period_end,
|
||
period_month: r.period_month?.slice?.(0, 10) ?? r.period_month,
|
||
pay_date: r.pay_date ? r.pay_date.slice(0, 10) : null,
|
||
processed: Boolean(r.processed),
|
||
transfer_done: Boolean(r.transfer_done),
|
||
contract_id: contract.id,
|
||
}));
|
||
const res = await fetch(`/api/staff/contracts/${contract.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ payslips: sanitized }),
|
||
});
|
||
const j = await res.json();
|
||
if (!res.ok) {
|
||
toast.error(j.error ?? "Erreur lors de l’enregistrement des payslips");
|
||
return;
|
||
}
|
||
setRows(j.payslips ?? []);
|
||
toast.success("Payslips enregistrés");
|
||
};
|
||
|
||
const onCreatePayslip = async (payload: AnyObj) => {
|
||
const res = await fetch(`/api/staff/contracts/${contract.id}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ payslip: payload }),
|
||
});
|
||
const j = await res.json();
|
||
if (!res.ok) {
|
||
toast.error(j.error ?? "Erreur lors de l’ajout");
|
||
return;
|
||
}
|
||
setRows((prev) => [...prev, j.payslip].sort((a, b) => (a.pay_number ?? 0) - (b.pay_number ?? 0)));
|
||
toast.success("Payslip ajouté");
|
||
setOpen(false);
|
||
};
|
||
|
||
const generatePdf = async () => {
|
||
setIsGeneratingPdf(true);
|
||
try {
|
||
const response = await fetch(`/api/contrats/${contract.id}/generate-pdf`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || `Erreur ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
setShowPdfSuccess(true);
|
||
toast.success("PDF créé avec succès !");
|
||
|
||
console.log("PDF généré:", result);
|
||
|
||
// Rafraîchir la page après un délai pour laisser le temps de voir le message de succès
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 2500);
|
||
} catch (error) {
|
||
console.error("Erreur lors de la génération du PDF:", error);
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Erreur lors de la génération du PDF"
|
||
);
|
||
} finally {
|
||
setIsGeneratingPdf(false);
|
||
}
|
||
};
|
||
|
||
const launchElectronicSignature = async (sendNotification: boolean = true) => {
|
||
console.log("🚀 [SIGNATURE] Fonction déclenchée");
|
||
console.log("🚀 [SIGNATURE] contract.contract_pdf_filename:", contract.contract_pdf_filename);
|
||
console.log("🚀 [SIGNATURE] salarie?.email:", salarie?.email);
|
||
console.log("🚀 [SIGNATURE] sendNotification:", sendNotification);
|
||
|
||
setIsLaunchingSignature(true);
|
||
try {
|
||
// Vérifier que le contrat a un PDF généré
|
||
if (!contract.contract_pdf_filename) {
|
||
toast.error("Veuillez d'abord générer le PDF du contrat");
|
||
return;
|
||
}
|
||
|
||
// ========================================
|
||
// 🔒 SÉCURITÉ MAXIMALE : Récupération et validation de l'email du salarié
|
||
// ========================================
|
||
let employeeEmail = salarie?.email;
|
||
let verifiedEmployeeName = "";
|
||
|
||
if (!employeeEmail) {
|
||
console.log("🔍 [SIGNATURE] Email non trouvé dans salarie, recherche dans la base...");
|
||
|
||
// 🛡️ VÉRIFICATION 1 : Le matricule doit être présent
|
||
if (!contract.employee_matricule) {
|
||
toast.error("❌ SÉCURITÉ : Le matricule du salarié est manquant dans le contrat. Signature annulée.");
|
||
console.error("❌ [SIGNATURE] SÉCURITÉ : Matricule manquant");
|
||
return;
|
||
}
|
||
|
||
// Normaliser le matricule (trim + conversion en string)
|
||
const matricule = String(contract.employee_matricule).trim();
|
||
console.log(`🔍 [SIGNATURE] Recherche EXACTE par matricule (code_salarie): "${matricule}" (type: ${typeof matricule}, longueur: ${matricule.length})`);
|
||
|
||
// 🛡️ VÉRIFICATION 2 : Recherche EXACTE par code_salarie (même méthode que bulk-esign)
|
||
const { data: salarieData, error: salarieError } = await supabase
|
||
.from('salaries')
|
||
.select('adresse_mail, nom, prenom, code_salarie, employer_id')
|
||
.eq('code_salarie', matricule)
|
||
.maybeSingle();
|
||
|
||
if (salarieError) {
|
||
console.error("❌ [SIGNATURE] Erreur Supabase lors de la recherche du salarié:", salarieError);
|
||
toast.error("❌ Erreur lors de la recherche du salarié. Signature annulée.");
|
||
return;
|
||
}
|
||
|
||
if (!salarieData) {
|
||
console.error(`❌ [SIGNATURE] SÉCURITÉ : Aucun salarié trouvé pour le matricule: ${matricule}`);
|
||
toast.error(`❌ SÉCURITÉ : Aucun salarié trouvé avec le matricule ${matricule}. Vérifiez les données du contrat.`);
|
||
return;
|
||
}
|
||
|
||
// 🛡️ VÉRIFICATION 3 : L'email doit exister
|
||
employeeEmail = salarieData.adresse_mail;
|
||
if (!employeeEmail) {
|
||
console.error(`❌ [SIGNATURE] SÉCURITÉ : Email manquant pour le salarié ${salarieData.prenom} ${salarieData.nom}`);
|
||
toast.error(`❌ SÉCURITÉ : Le salarié ${salarieData.prenom} ${salarieData.nom} n'a pas d'email renseigné. Ajoutez un email avant de lancer la signature.`);
|
||
return;
|
||
}
|
||
|
||
// 🛡️ VÉRIFICATION 4 : Le salarié doit appartenir à la même organisation que le contrat
|
||
if (contract.org_id && salarieData.employer_id && salarieData.employer_id !== contract.org_id) {
|
||
console.error(`❌ [SIGNATURE] SÉCURITÉ : Le salarié (org: ${salarieData.employer_id}) n'appartient pas à l'organisation du contrat (org: ${contract.org_id})`);
|
||
toast.error(`❌ SÉCURITÉ : Le salarié ${salarieData.prenom} ${salarieData.nom} n'appartient pas à l'organisation de ce contrat. Signature annulée.`);
|
||
return;
|
||
}
|
||
|
||
verifiedEmployeeName = `${salarieData.prenom || ''} ${salarieData.nom || ''}`.trim();
|
||
console.log(`✅ [SIGNATURE] VALIDÉ : Email trouvé et vérifié`);
|
||
console.log(` - Salarié: ${verifiedEmployeeName}`);
|
||
console.log(` - Matricule: ${salarieData.code_salarie}`);
|
||
console.log(` - Email: ${employeeEmail}`);
|
||
console.log(` - Organisation: ${salarieData.employer_id}`);
|
||
} else {
|
||
// Email déjà présent dans l'état local, récupérer le nom pour la vérification
|
||
verifiedEmployeeName = salarie?.nom || contract.employee_name || "";
|
||
console.log(`✅ [SIGNATURE] Email déjà présent dans l'état local: ${employeeEmail}`);
|
||
}
|
||
|
||
// 🛡️ VÉRIFICATION 5 : L'email doit avoir un format valide
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
if (!emailRegex.test(employeeEmail)) {
|
||
console.error(`❌ [SIGNATURE] SÉCURITÉ : Format d'email invalide: ${employeeEmail}`);
|
||
toast.error(`❌ SÉCURITÉ : Le format de l'email du salarié est invalide (${employeeEmail}). Signature annulée.`);
|
||
return;
|
||
}
|
||
|
||
// 🛡️ VÉRIFICATION 6 : Récupération du prénom du salarié depuis la base de données
|
||
let employeeFirstName = salarie?.prenom || "";
|
||
|
||
if (!employeeFirstName && contract.employee_matricule) {
|
||
console.log("🔍 [SIGNATURE] Récupération du prénom depuis la base de données...");
|
||
const matricule = String(contract.employee_matricule).trim();
|
||
|
||
const { data: prenomData, error: prenomError } = await supabase
|
||
.from('salaries')
|
||
.select('prenom')
|
||
.eq('code_salarie', matricule)
|
||
.maybeSingle();
|
||
|
||
if (prenomData?.prenom) {
|
||
employeeFirstName = prenomData.prenom;
|
||
console.log(`✅ [SIGNATURE] Prénom trouvé: ${employeeFirstName}`);
|
||
} else {
|
||
console.error(`❌ [SIGNATURE] Prénom non trouvé pour le matricule ${matricule}`);
|
||
toast.error(`Le prénom du salarié est manquant dans la base de données. Veuillez le renseigner avant de lancer la signature.`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!employeeFirstName) {
|
||
console.error(`❌ [SIGNATURE] Prénom du salarié manquant`);
|
||
toast.error(`Le prénom du salarié est manquant. Veuillez le renseigner avant de lancer la signature.`);
|
||
return;
|
||
}
|
||
|
||
// 🛡️ VÉRIFICATION 7 : Confirmation finale avant envoi
|
||
console.log("🔒 [SIGNATURE] VÉRIFICATIONS DE SÉCURITÉ RÉUSSIES");
|
||
console.log(` ✓ Matricule vérifié: ${contract.employee_matricule}`);
|
||
console.log(` ✓ Salarié identifié: ${verifiedEmployeeName}`);
|
||
console.log(` ✓ Prénom: ${employeeFirstName}`);
|
||
console.log(` ✓ Email validé: ${employeeEmail}`);
|
||
console.log(` ✓ Organisation vérifiée: ${contract.org_id}`);
|
||
console.log(` ✓ Contrat: ${contract.contract_number || contract.id}`);
|
||
|
||
// Récupérer les informations de l'organisation active (ou du contrat pour les staff)
|
||
console.log("🏢 [SIGNATURE] Récupération des infos organisation...");
|
||
|
||
let employerEmail = "";
|
||
let signerName = "";
|
||
let organizationName = "";
|
||
let employerCode = "";
|
||
|
||
const orgResponse = await fetch('/api/me');
|
||
const orgData = await orgResponse.json();
|
||
|
||
console.log("🏢 [SIGNATURE] Réponse /api/me:", JSON.stringify(orgData, null, 2));
|
||
|
||
if (orgData.is_staff) {
|
||
// Pour les staff, récupérer les infos depuis l'organisation du contrat
|
||
console.log("👨💼 [SIGNATURE] Utilisateur staff - récupération des infos du contrat...");
|
||
console.log("👨💼 [SIGNATURE] contract.org_id:", contract.org_id);
|
||
|
||
// Appel API pour récupérer les détails de l'organisation du contrat
|
||
const contractOrgResponse = await fetch(`/api/organizations/${contract.org_id}/details`);
|
||
|
||
if (contractOrgResponse.ok) {
|
||
const contractOrgData = await contractOrgResponse.json();
|
||
console.log("🏢 [SIGNATURE] Détails organisation contrat:", contractOrgData);
|
||
|
||
employerEmail = contractOrgData.email_signature || contractOrgData.email_contact || "paie@odentas.fr";
|
||
signerName = `${contractOrgData.prenom_signataire || ""} ${contractOrgData.nom_signataire || ""}`.trim() || "Responsable";
|
||
organizationName = contractOrgData.structure || contract.production_name || "Organisation";
|
||
employerCode = contractOrgData.code_employeur || "DEMO";
|
||
} else {
|
||
// Fallback avec les valeurs du contrat
|
||
console.log("⚠️ [SIGNATURE] Impossible de récupérer les détails org, utilisation des valeurs par défaut");
|
||
employerEmail = "paie@odentas.fr";
|
||
signerName = "Responsable";
|
||
organizationName = contract.production_name || "Organisation";
|
||
employerCode = "DEMO";
|
||
}
|
||
} else {
|
||
// Pour les clients normaux, récupérer les infos depuis leur organisation active
|
||
console.log("👤 [SIGNATURE] Utilisateur client - récupération des infos de l'organisation active...");
|
||
console.log("👤 [SIGNATURE] orgData.active_org_id:", orgData.active_org_id);
|
||
|
||
// Appel API pour récupérer les détails de l'organisation active du client
|
||
const clientOrgResponse = await fetch(`/api/organizations/${orgData.active_org_id}/details`);
|
||
|
||
if (clientOrgResponse.ok) {
|
||
const clientOrgData = await clientOrgResponse.json();
|
||
console.log("🏢 [SIGNATURE] Détails organisation client:", clientOrgData);
|
||
|
||
employerEmail = clientOrgData.email_signature || clientOrgData.email_contact || "paie@odentas.fr";
|
||
signerName = `${clientOrgData.prenom_signataire || ""} ${clientOrgData.nom_signataire || ""}`.trim() || "Responsable";
|
||
organizationName = clientOrgData.structure || orgData.active_org_name || "Organisation";
|
||
employerCode = clientOrgData.code_employeur || "CLIENT";
|
||
} else {
|
||
// Fallback avec les valeurs par défaut
|
||
console.log("⚠️ [SIGNATURE] Impossible de récupérer les détails org client, utilisation des valeurs par défaut");
|
||
employerEmail = "paie@odentas.fr";
|
||
signerName = "Responsable";
|
||
organizationName = orgData.active_org_name || "Organisation";
|
||
employerCode = "CLIENT";
|
||
}
|
||
}
|
||
|
||
console.log("✅ [SIGNATURE] Informations finales:", {
|
||
employerEmail,
|
||
signerName,
|
||
organizationName,
|
||
employerCode
|
||
});
|
||
|
||
// Construire la clé S3 du PDF généré
|
||
const pdfS3Key = `unsigned-contracts/${contract.contract_pdf_filename}`;
|
||
|
||
const signatureData = {
|
||
contractId: contract.id,
|
||
pdfS3Key: pdfS3Key, // Utilisation du PDF généré et stocké dans Documents
|
||
employerEmail: employerEmail,
|
||
employeeEmail: employeeEmail,
|
||
reference: reference || contract.reference || contract.contract_number,
|
||
employeeName: salarie?.nom || contract.employee_name,
|
||
startDate: form.start_date || contract.start_date,
|
||
role: professionPick?.label || contract.profession || contract.role,
|
||
analytique: analytique || contract.analytique || contract.production_name,
|
||
structure: organizationName,
|
||
signerFirstName: signerName,
|
||
employerCode: employerCode,
|
||
employeeFirstName: employeeFirstName,
|
||
matricule: salarie?.code_salarie || salarie?.matricule || contract.employee_matricule,
|
||
contractType: 'CDDU',
|
||
orgId: contract.org_id, // Passer l'org_id pour récupérer la signature
|
||
skipEmployerEmail: true, // Ne pas envoyer l'email individuel, seulement le récapitulatif si demandé
|
||
};
|
||
|
||
console.log("📤 [SIGNATURE] Données envoyées:", signatureData);
|
||
|
||
console.log("🌐 [SIGNATURE] Appel API docuseal-signature...");
|
||
const response = await fetch('/api/docuseal-signature', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(signatureData),
|
||
});
|
||
|
||
console.log("🌐 [SIGNATURE] Réponse HTTP:", response.status, response.statusText);
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
console.error("❌ [SIGNATURE] Erreur API DocuSeal:", {
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
errorData: errorData
|
||
});
|
||
throw new Error(errorData.error || `Erreur ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
toast.success("Signature électronique lancée avec succès !");
|
||
console.log("Signature électronique initiée:", result);
|
||
|
||
// Envoyer l'email de notification au client si demandé
|
||
if (sendNotification && contract.org_id) {
|
||
console.log("📧 [SIGNATURE] Envoi de l'email de notification au client...");
|
||
try {
|
||
const notificationResponse = await fetch('/api/staff/contracts/send-esign-notification', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
organizationId: contract.org_id,
|
||
contractCount: 1
|
||
}),
|
||
});
|
||
|
||
if (notificationResponse.ok) {
|
||
console.log("✅ [SIGNATURE] Email de notification envoyé avec succès");
|
||
} else {
|
||
const errorData = await notificationResponse.json();
|
||
console.error("⚠️ [SIGNATURE] Erreur lors de l'envoi de la notification:", errorData);
|
||
// Ne pas faire échouer toute l'opération si l'email de notif ne part pas
|
||
toast.warning("Signature créée, mais l'email de notification n'a pas pu être envoyé");
|
||
}
|
||
} catch (notifError) {
|
||
console.error("⚠️ [SIGNATURE] Exception lors de l'envoi de la notification:", notifError);
|
||
// Ne pas faire échouer toute l'opération si l'email de notif ne part pas
|
||
}
|
||
}
|
||
|
||
// Optionnel : rafraîchir les données du contrat
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 2000);
|
||
|
||
} catch (error) {
|
||
console.error("Erreur lors du lancement de la signature électronique:", error);
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Erreur lors du lancement de la signature électronique"
|
||
);
|
||
} finally {
|
||
setIsLaunchingSignature(false);
|
||
setShowESignConfirmModal(false);
|
||
}
|
||
};
|
||
|
||
// Handler for opening the e-sign confirmation modal
|
||
const handleESignButtonClick = async () => {
|
||
// Récupérer et vérifier l'email du salarié AVANT d'ouvrir le modal
|
||
let employeeEmail = salarie?.email;
|
||
|
||
if (!employeeEmail) {
|
||
console.log("🔍 [MODAL] Recherche de l'email du salarié...");
|
||
console.log("🔍 [MODAL] Contrat:", {
|
||
id: contract.id,
|
||
employee_matricule: contract.employee_matricule,
|
||
employee_name: contract.employee_name,
|
||
org_id: contract.org_id
|
||
});
|
||
|
||
if (!contract.employee_matricule) {
|
||
toast.error("❌ SÉCURITÉ : Le matricule du salarié est manquant dans le contrat.");
|
||
return;
|
||
}
|
||
|
||
// Normaliser le matricule (trim + conversion en string)
|
||
const matricule = String(contract.employee_matricule).trim();
|
||
console.log(`🔍 [MODAL] Recherche EXACTE par matricule (code_salarie): "${matricule}" (type: ${typeof matricule}, longueur: ${matricule.length})`);
|
||
|
||
const { data: salarieData, error: salarieError } = await supabase
|
||
.from('salaries')
|
||
.select('adresse_mail, code_salarie, employer_id, nom, prenom')
|
||
.eq('code_salarie', matricule)
|
||
.maybeSingle();
|
||
|
||
if (salarieError) {
|
||
console.error("❌ [MODAL] Erreur Supabase lors de la recherche du salarié:", salarieError);
|
||
toast.error("❌ Impossible de trouver le salarié. Vérifiez les données du contrat.");
|
||
return;
|
||
}
|
||
|
||
if (!salarieData) {
|
||
console.error(`❌ [MODAL] Aucun salarié trouvé pour le matricule: ${matricule}`);
|
||
toast.error("❌ Impossible de trouver le salarié. Vérifiez les données du contrat.");
|
||
return;
|
||
}
|
||
|
||
console.log("✅ [MODAL] Salarié trouvé:", salarieData);
|
||
|
||
employeeEmail = salarieData.adresse_mail;
|
||
|
||
if (!employeeEmail) {
|
||
toast.error("❌ Le salarié n'a pas d'email renseigné.");
|
||
return;
|
||
}
|
||
|
||
// Vérifier l'organisation
|
||
if (contract.org_id && salarieData.employer_id && salarieData.employer_id !== contract.org_id) {
|
||
toast.error("❌ SÉCURITÉ : Le salarié n'appartient pas à l'organisation de ce contrat.");
|
||
return;
|
||
}
|
||
|
||
// Valider le format
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
if (!emailRegex.test(employeeEmail)) {
|
||
toast.error(`❌ SÉCURITÉ : Le format de l'email est invalide (${employeeEmail}).`);
|
||
return;
|
||
}
|
||
|
||
console.log(`✅ [MODAL] Email vérifié: ${employeeEmail}`);
|
||
}
|
||
|
||
setVerifiedEmployeeEmail(employeeEmail);
|
||
setShowESignConfirmModal(true);
|
||
};
|
||
|
||
// Handler for confirming the e-sign launch
|
||
const handleConfirmESign = (sendNotification: boolean) => {
|
||
setSendESignNotification(sendNotification);
|
||
launchElectronicSignature(sendNotification);
|
||
};
|
||
|
||
// Payslip modal functions
|
||
const handleOpenPayslipModal = (payslip?: any) => {
|
||
setEditingPayslip(payslip || null);
|
||
setIsPayslipModalOpen(true);
|
||
};
|
||
|
||
const handleClosePayslipModal = () => {
|
||
setIsPayslipModalOpen(false);
|
||
setEditingPayslip(null);
|
||
};
|
||
|
||
const handlePayslipModalSuccess = () => {
|
||
// Refresh the payslips data
|
||
window.location.reload(); // Pour l'instant, on recharge. Idéalement on ferait un refetch
|
||
};
|
||
|
||
// Fonction pour rafraîchir les payslips après un upload
|
||
const handlePayslipUploadComplete = async () => {
|
||
try {
|
||
// Recharger les payslips depuis la base de données
|
||
const response = await fetch(`/api/staff/payslips?contract_id=${contract.id}`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setRows(data.payslips ?? []);
|
||
toast.success("Données mises à jour");
|
||
}
|
||
} catch (error) {
|
||
console.error("Erreur lors du rafraîchissement des payslips:", error);
|
||
// En cas d'erreur, on recharge la page
|
||
window.location.reload();
|
||
}
|
||
};
|
||
|
||
// Fonction pour marquer le contrat comme non signé
|
||
const [isMarkingUnsigned, setIsMarkingUnsigned] = useState(false);
|
||
|
||
const handleMarkAsUnsigned = async () => {
|
||
if (isMarkingUnsigned) return;
|
||
|
||
try {
|
||
setIsMarkingUnsigned(true);
|
||
|
||
const response = await fetch(`/api/staff/contrats/${contract.id}/mark-unsigned`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || `Erreur ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
toast.success(result.message || "Contrat marqué comme non signé avec succès !");
|
||
|
||
// Mettre à jour l'état local du formulaire
|
||
setForm(prev => ({
|
||
...prev,
|
||
contrat_signe: "Non",
|
||
contrat_signe_par_employeur: "Non"
|
||
}));
|
||
|
||
// Optionnel : rafraîchir après un délai
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 1500);
|
||
|
||
} catch (error) {
|
||
console.error("Erreur lors du marquage comme non signé:", error);
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Erreur lors du marquage comme non signé"
|
||
);
|
||
} finally {
|
||
setIsMarkingUnsigned(false);
|
||
}
|
||
};
|
||
|
||
// Vérifier si le bouton doit être désactivé (les deux colonnes sont déjà "Non")
|
||
const isBothUnsigned = (form.contrat_signe || contract.contrat_signe) === "Non" &&
|
||
(form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur) === "Non";
|
||
|
||
// États et fonctions pour la relance employeur
|
||
const [isReminderModalOpen, setIsReminderModalOpen] = useState(false);
|
||
const [isSendingReminder, setIsSendingReminder] = useState(false);
|
||
const [organizationData, setOrganizationData] = useState<{
|
||
email_signature?: string;
|
||
organization_name?: string;
|
||
organization_code?: string;
|
||
} | null>(null);
|
||
const [isLoadingOrgData, setIsLoadingOrgData] = useState(false);
|
||
|
||
const fetchOrganizationData = async () => {
|
||
if (!contract.org_id) {
|
||
toast.error("ID d'organisation manquant dans le contrat");
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
setIsLoadingOrgData(true);
|
||
|
||
// Utiliser l'API existante ou créer une requête directe
|
||
const response = await fetch(`/api/organizations/${contract.org_id}/details`, {
|
||
method: 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Erreur ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
setOrganizationData(result);
|
||
return true;
|
||
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des données d'organisation:", error);
|
||
toast.error("Impossible de récupérer les données de l'organisation");
|
||
return false;
|
||
} finally {
|
||
setIsLoadingOrgData(false);
|
||
}
|
||
};
|
||
|
||
const handleOpenReminderModal = async () => {
|
||
const success = await fetchOrganizationData();
|
||
if (success) {
|
||
setIsReminderModalOpen(true);
|
||
}
|
||
};
|
||
|
||
const handleCloseReminderModal = () => {
|
||
setIsReminderModalOpen(false);
|
||
};
|
||
|
||
const handleSendEmployerReminder = async () => {
|
||
if (isSendingReminder) return;
|
||
|
||
try {
|
||
setIsSendingReminder(true);
|
||
|
||
const response = await fetch(`/api/staff/contrats/${contract.id}/remind-employer`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || `Erreur ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
toast.success("Relance employeur envoyée avec succès !");
|
||
console.log("Relance employeur envoyée:", result);
|
||
|
||
// Fermer la modal
|
||
setIsReminderModalOpen(false);
|
||
|
||
} catch (error) {
|
||
console.error("Erreur lors de l'envoi de la relance employeur:", error);
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Erreur lors de l'envoi de la relance employeur"
|
||
);
|
||
} finally {
|
||
setIsSendingReminder(false);
|
||
}
|
||
};
|
||
|
||
const defaultNewPayslip = {
|
||
organization_id: contract.org_id,
|
||
contract_id: contract.id,
|
||
period_start: form.start_date || contract.start_date?.slice(0, 10) || "",
|
||
period_end: form.end_date || contract.end_date?.slice(0, 10) || "",
|
||
period_month:
|
||
(form.start_date || contract.start_date?.slice(0, 10) || "").slice(0, 7) + "-01",
|
||
pay_number: nextPayNumber,
|
||
gross_amount: contract.gross_pay ?? "",
|
||
net_amount: "",
|
||
employer_cost: "",
|
||
aem_status: "À traiter",
|
||
processed: false,
|
||
transfer_done: false,
|
||
analytic_tag: contract.analytique ?? null,
|
||
storage_path: `paies/${slugify(contract.structure || "")}/${contract.contract_number}.pdf`,
|
||
source_reference: contract.contract_number,
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-[100svh] bg-gradient-to-br from-white via-zinc-50 to-zinc-100 px-6 py-10">
|
||
<div className="mx-auto max-w-6xl space-y-6">
|
||
<header className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||
{contract.salaries?.salarie
|
||
|| (contract.salaries?.nom
|
||
? `${contract.salaries.nom.toUpperCase()}${contract.salaries.prenom ? ' ' + contract.salaries.prenom.charAt(0).toUpperCase() + contract.salaries.prenom.slice(1) : ''}`
|
||
: contract.employee_name || "")}
|
||
</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
{contract.production_name} — {contract.contract_number}
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={() => setShowCancelModal(true)}
|
||
disabled={isCancelling || form.etat_de_la_demande === "Annulée"}
|
||
variant="outline"
|
||
className="rounded-2xl px-5 text-red-600 border-red-300 hover:bg-red-50"
|
||
>
|
||
<Ban className="size-4 mr-2" />
|
||
{form.etat_de_la_demande === "Annulée" ? "Contrat annulé" : "Annuler le contrat"}
|
||
</Button>
|
||
<Button
|
||
onClick={async () => {
|
||
setIsRefreshing(true);
|
||
try {
|
||
const res = await fetch(`/api/staff/contracts/${contract.id}`);
|
||
if (!res.ok) throw new Error('Erreur lors du rechargement');
|
||
toast.success('Données régénérées');
|
||
window.location.reload();
|
||
} catch (e) {
|
||
console.error('Erreur lors du rafraîchissement:', e);
|
||
toast.error('Impossible de régénérer les données');
|
||
} finally {
|
||
setIsRefreshing(false);
|
||
}
|
||
}}
|
||
disabled={isRefreshing}
|
||
variant="ghost"
|
||
className="rounded-2xl px-3"
|
||
>
|
||
<RefreshCw className="size-4 mr-2" />
|
||
{isRefreshing ? 'Rafraîchissement...' : 'Régénérer'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2 justify-end">
|
||
<Button
|
||
onClick={saveContract}
|
||
disabled={isSaving}
|
||
className="rounded-2xl px-5"
|
||
>
|
||
<Save className="size-4 mr-2" />
|
||
{isSaving ? "Sauvegarde..." : "Enregistrer le contrat"}
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={generatePdf}
|
||
disabled={isGeneratingPdf}
|
||
variant="outline"
|
||
className="rounded-2xl px-5"
|
||
>
|
||
<FileDown className="size-4 mr-2" />
|
||
{isGeneratingPdf ? "Génération..." : "Créer le PDF"}
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={() => {
|
||
console.log("🖱️ [SIGNATURE] Bouton cliqué");
|
||
console.log("🖱️ [SIGNATURE] isLaunchingSignature:", isLaunchingSignature);
|
||
console.log("🖱️ [SIGNATURE] contract.contract_pdf_filename:", contract.contract_pdf_filename);
|
||
handleESignButtonClick();
|
||
}}
|
||
disabled={isLaunchingSignature || !contract.contract_pdf_filename}
|
||
variant="secondary"
|
||
className="rounded-2xl px-5"
|
||
>
|
||
<PenTool className="size-4 mr-2" />
|
||
{isLaunchingSignature ? "Lancement..." : "Lancer l'e-signature"}
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<Card className="col-span-2 rounded-3xl">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<FileText className="size-5" /> Contrat
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* États du contrat en haut */}
|
||
<div className="md:col-span-2">
|
||
<div className="text-xs font-medium text-muted-foreground mb-4">États du contrat</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">État de la demande</label>
|
||
<select
|
||
value={form.etat_de_la_demande || ""}
|
||
onChange={(e) => handleContractChange("etat_de_la_demande", e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="">Sélectionner...</option>
|
||
<option value="Reçue">Reçue</option>
|
||
<option value="En cours">En cours</option>
|
||
<option value="Traitée">Traitée</option>
|
||
<option value="Refusée">Refusée</option>
|
||
<option value="Annulée">Annulée</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">État de la paie</label>
|
||
<select
|
||
value={form.etat_de_la_paie || ""}
|
||
onChange={(e) => handleContractChange("etat_de_la_paie", e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="">Sélectionner...</option>
|
||
<option value="À traiter">À traiter</option>
|
||
<option value="En cours">En cours</option>
|
||
<option value="Traitée">Traitée</option>
|
||
<option value="Non concernée">Non concernée</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">DPAE</label>
|
||
<select
|
||
value={form.dpae || ""}
|
||
onChange={(e) => handleContractChange("dpae", e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="">Sélectionner...</option>
|
||
<option value="À faire">À faire</option>
|
||
<option value="Faite">Faite</option>
|
||
<option value="Non concernée">Non concernée</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Section CDDU modifiable avec recherche */}
|
||
<div className="md:col-span-2">
|
||
<div className="text-xs font-medium text-muted-foreground mb-4">Informations CDDU</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
|
||
{/* Spectacle avec recherche */}
|
||
<div className="relative md:col-span-2">
|
||
<label className="text-xs text-muted-foreground">Spectacle</label>
|
||
{spectaclePick ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 p-2 border rounded bg-green-50 text-sm">
|
||
<div className="font-medium">{spectaclePick.nom}</div>
|
||
{spectaclePick.numero_objet && (
|
||
<div className="text-xs text-muted-foreground">{spectaclePick.numero_objet}</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSpectaclePick(null);
|
||
setSpectacleQuery("");
|
||
}}
|
||
className="px-2 py-1 text-xs border rounded hover:bg-gray-50"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="relative">
|
||
<Input
|
||
value={spectacleQuery}
|
||
onChange={(e) => setSpectacleQuery(e.target.value)}
|
||
placeholder="Rechercher un spectacle..."
|
||
/>
|
||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||
</div>
|
||
{spectacleQuery.length > 1 && spectacleSearch?.items && (
|
||
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||
{spectacleSearch.items.length === 0 ? (
|
||
<div className="p-2 text-sm text-gray-500">Aucun résultat</div>
|
||
) : (
|
||
spectacleSearch.items.map((item, idx) => (
|
||
<button
|
||
key={idx}
|
||
type="button"
|
||
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
|
||
onClick={() => {
|
||
setSpectaclePick(item);
|
||
setSpectacleQuery("");
|
||
}}
|
||
>
|
||
<div className="font-medium">{item.nom}</div>
|
||
{(item.numero_objet || item.prod_type) && (
|
||
<div className="text-xs text-gray-500">
|
||
{item.numero_objet && <span>{item.numero_objet}</span>}
|
||
{item.numero_objet && item.prod_type && <span> • </span>}
|
||
{item.prod_type && <span>{item.prod_type}</span>}
|
||
</div>
|
||
)}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Référence */}
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Référence</label>
|
||
<Input
|
||
value={reference}
|
||
onChange={(e) => setReference(e.target.value)}
|
||
placeholder="Référence du contrat"
|
||
/>
|
||
</div>
|
||
|
||
{/* Salarié avec recherche */}
|
||
<div className="relative md:col-span-2">
|
||
<label className="text-xs text-muted-foreground">Salarié</label>
|
||
{salarie ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 p-2 border rounded bg-green-50 text-sm">
|
||
<div className="font-medium">{salarie.nom}</div>
|
||
<div className="text-xs text-muted-foreground">{salarie.matricule}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSalarie(null);
|
||
setSalarieQuery("");
|
||
}}
|
||
className="px-2 py-1 text-xs border rounded hover:bg-gray-50"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="relative">
|
||
<Input
|
||
value={salarieQuery}
|
||
onChange={(e) => setSalarieQuery(e.target.value)}
|
||
placeholder="Rechercher un·e salarié·e..."
|
||
/>
|
||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||
</div>
|
||
{salarieQuery.length > 1 && salarieSearch?.items && (
|
||
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||
{salarieSearch.items.length === 0 ? (
|
||
<div className="p-2 text-sm text-gray-500">Aucun résultat</div>
|
||
) : (
|
||
salarieSearch.items.map((item, idx) => (
|
||
<button
|
||
key={idx}
|
||
type="button"
|
||
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
|
||
onClick={() => {
|
||
setSalarie(item);
|
||
setSalarieQuery("");
|
||
}}
|
||
>
|
||
<div className="font-medium">{item.nom}</div>
|
||
<div className="text-xs text-gray-500">
|
||
<span>{item.matricule}</span>
|
||
{item.email && <span> • {item.email}</span>}
|
||
</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Catégorie professionnelle */}
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Catégorie professionnelle</label>
|
||
<select
|
||
value={categoriePro}
|
||
onChange={(e) => {
|
||
const newCat = e.target.value as "Artiste" | "Technicien";
|
||
setCategoriePro(newCat);
|
||
// Reset profession when category changes
|
||
setProfessionPick(null);
|
||
setProfessionQuery("");
|
||
}}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="Artiste">Artiste</option>
|
||
<option value="Technicien">Technicien</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Profession, Profession féminin, Analytique - 3 colonnes */}
|
||
<div className="md:col-span-3">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{/* Profession avec recherche conditionnelle */}
|
||
<div className="relative">
|
||
<label className="text-xs text-muted-foreground">Profession</label>
|
||
{professionPick ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 p-2 border rounded bg-green-50 text-sm">
|
||
<div className="font-medium">{professionPick.label}</div>
|
||
{professionPick.code && (
|
||
<div className="text-xs text-muted-foreground">{professionPick.code}</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setProfessionPick(null);
|
||
setProfessionQuery("");
|
||
setProfessionFeminine("");
|
||
}}
|
||
className="px-2 py-1 text-xs border rounded hover:bg-gray-50"
|
||
>
|
||
Changer
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<Input
|
||
value={professionQuery}
|
||
onChange={(e) => setProfessionQuery(e.target.value)}
|
||
placeholder={`Rechercher une profession ${categoriePro.toLowerCase()}...`}
|
||
/>
|
||
{professionQuery && (
|
||
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||
{categoriePro === "Artiste" ? (
|
||
filteredProfessions.length === 0 ? (
|
||
<div className="p-2 text-sm text-gray-500">Aucune profession trouvée</div>
|
||
) : (
|
||
filteredProfessions.map((prof, idx) => (
|
||
<button
|
||
key={idx}
|
||
type="button"
|
||
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
|
||
onClick={() => {
|
||
setProfessionPick(prof);
|
||
setProfessionQuery("");
|
||
}}
|
||
>
|
||
<div className="font-medium">{prof.label}</div>
|
||
<div className="text-xs text-gray-500">{prof.code}</div>
|
||
</button>
|
||
))
|
||
)
|
||
) : (
|
||
filteredTechniciens.length === 0 ? (
|
||
<div className="p-2 text-sm text-gray-500">
|
||
{techniciens ? "Aucune profession trouvée" : "Chargement..."}
|
||
</div>
|
||
) : (
|
||
filteredTechniciens.map((prof, idx) => (
|
||
<button
|
||
key={idx}
|
||
type="button"
|
||
className="w-full text-left p-2 hover:bg-gray-50 text-sm border-b last:border-b-0"
|
||
onClick={() => {
|
||
setProfessionPick(prof);
|
||
setProfessionQuery("");
|
||
}}
|
||
>
|
||
<div className="font-medium">{prof.label}</div>
|
||
<div className="text-xs text-gray-500">{prof.code}</div>
|
||
</button>
|
||
))
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Profession féminin */}
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Profession (féminin)</label>
|
||
<div className="relative">
|
||
<Input
|
||
value={professionFeminine}
|
||
onChange={(e) => handleFeminineChange(e.target.value)}
|
||
placeholder={professionPick ? "Saisir le féminin..." : "Sélectionnez d'abord une profession"}
|
||
disabled={!professionPick}
|
||
className={`${!professionPick ? "bg-gray-50 text-gray-400" : ""} pr-12`}
|
||
onKeyDown={(e) => {
|
||
console.log("🔍 KeyDown event:", e.key, {professionPick, professionFeminine});
|
||
if (e.key === 'Enter' && professionPick && professionFeminine.trim()) {
|
||
e.preventDefault();
|
||
console.log("🚀 Calling saveCurrentFeminisation from Enter key");
|
||
saveCurrentFeminisation();
|
||
}
|
||
}}
|
||
/>
|
||
{professionPick && professionFeminine.trim() && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
console.log("🚀 Button clicked, calling saveCurrentFeminisation");
|
||
saveCurrentFeminisation();
|
||
}}
|
||
disabled={isSavingFeminisation}
|
||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover:bg-green-100 text-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title="Sauvegarder la féminisation"
|
||
>
|
||
{isSavingFeminisation ? (
|
||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Check className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{professionPick && !professionFeminine && (
|
||
<div className="text-xs text-amber-600 mt-1">
|
||
ℹ️ Saisissez la forme féminine pour les contrats de femmes
|
||
</div>
|
||
)}
|
||
{professionPick && professionFeminine.trim() && (
|
||
<div className="text-xs text-green-600 mt-1">
|
||
💾 Cliquez sur ✓ ou appuyez sur Entrée pour sauvegarder
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Analytique */}
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Analytique</label>
|
||
<Input
|
||
value={analytique}
|
||
onChange={(e) => setAnalytique(e.target.value)}
|
||
placeholder="Code analytique"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Champs CDDU modifiables */}
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Type de salaire</label>
|
||
<select
|
||
value={typeSalaire}
|
||
onChange={(e) => setTypeSalaire(e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="">Sélectionner...</option>
|
||
<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>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Montant</label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
value={montant}
|
||
onChange={(e) => setMontant(e.target.value)}
|
||
placeholder="Montant en euros"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Panier repas</label>
|
||
<select
|
||
value={panierRepas}
|
||
onChange={(e) => setPanierRepas(e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="">Sélectionner...</option>
|
||
<option value="Oui">Oui</option>
|
||
<option value="Non">Non</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Date de signature</label>
|
||
<Input
|
||
type="date"
|
||
value={dateSignature}
|
||
onChange={(e) => setDateSignature(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Champs conditionnels selon la catégorie professionnelle */}
|
||
<div className="mt-4">
|
||
{!useHeuresMode ? (
|
||
<>
|
||
<h4 className="text-sm font-medium mb-3">Répétitions et représentations</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Nombre de représentations</label>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
value={nbRepresentations}
|
||
onChange={(e) => setNbRepresentations(e.target.value === "" ? "" : Number(e.target.value))}
|
||
className="flex-1"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDatesRepresentationsOpen(true)}
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
|
||
title="Préciser les dates et quantités"
|
||
>
|
||
Préciser
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Nombre de services de répétition / Durée</label>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
value={nbServicesRepetition}
|
||
onChange={(e) => setNbServicesRepetition(e.target.value === "" ? "" : Number(e.target.value))}
|
||
placeholder="Nombre"
|
||
className="flex-1"
|
||
/>
|
||
<span className="text-slate-400 font-medium">×</span>
|
||
{typeof nbServicesRepetition === "number" && nbServicesRepetition > 0 && (
|
||
<select
|
||
value={durationServices}
|
||
onChange={(e) => setDurationServices(e.target.value as "3" | "4")}
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm"
|
||
title="Durée des services de répétition"
|
||
>
|
||
<option value="3">3h</option>
|
||
<option value="4">4h</option>
|
||
</select>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => setDatesRepetitionsOpen(true)}
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
|
||
title="Préciser les dates et quantités"
|
||
>
|
||
Préciser
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Dates de représentations</label>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center">
|
||
{datesRepresentationsDisplay || (datesRepresentations ? datesRepresentations : "Cliquez pour sélectionner…")}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDatesRepresentationsOpen(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 className="text-xs text-muted-foreground">Dates de répétitions</label>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center">
|
||
{datesRepetitionsDisplay || (datesRepetitions ? datesRepetitions : "Cliquez pour sélectionner…")}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDatesRepetitionsOpen(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>
|
||
|
||
{/* Calendriers pour représentations et répétitions */}
|
||
<DatePickerCalendar
|
||
isOpen={datesRepresentationsOpen}
|
||
onClose={() => setDatesRepresentationsOpen(false)}
|
||
onApply={handleDatesRepresentationsApply}
|
||
initialDates={datesRepresentations ? datesRepresentations.split(", ") : []}
|
||
title="Sélectionner les dates de représentations"
|
||
minDate={contract.start_date?.slice(0, 10)}
|
||
maxDate={contract.end_date?.slice(0, 10)}
|
||
/>
|
||
<DatePickerCalendar
|
||
isOpen={datesRepetitionsOpen}
|
||
onClose={() => setDatesRepetitionsOpen(false)}
|
||
onApply={handleDatesRepetitionsApply}
|
||
initialDates={datesRepetitions ? datesRepetitions.split(", ") : []}
|
||
title="Sélectionner les dates de répétitions"
|
||
minDate={contract.start_date?.slice(0, 10)}
|
||
maxDate={contract.end_date?.slice(0, 10)}
|
||
/>
|
||
|
||
{/* Nouveaux champs pour heures total et par jour */}
|
||
<h4 className="text-sm font-medium mb-3 mt-6">Heures de travail</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Nombre d'heures total</label>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
step="0.5"
|
||
value={nombreHeuresTotal}
|
||
onChange={(e) => setNombreHeuresTotal(e.target.value === "" ? "" : Number(e.target.value))}
|
||
placeholder="ex : 24"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Nombre d'heures par jour</label>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
step="0.5"
|
||
value={nombreHeuresParJour}
|
||
onChange={(e) => setNombreHeuresParJour(e.target.value === "" ? "" : Number(e.target.value))}
|
||
placeholder="ex : 8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<h4 className="text-sm font-medium mb-3">Heures de travail</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">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"
|
||
/>
|
||
<select
|
||
value={minutesTotal}
|
||
onChange={(e) => setMinutesTotal(e.target.value as "0" | "30")}
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm"
|
||
>
|
||
<option value="0">+ 00 min</option>
|
||
<option value="30">+ 30 min</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Jours de travail</label>
|
||
<Input
|
||
value={joursTravail}
|
||
onChange={(e) => setJoursTravail(e.target.value)}
|
||
placeholder="ex : 12/10, 13/10, 24/10"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Separator className="md:col-span-2 my-2" />
|
||
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Début</label>
|
||
<Input
|
||
type="date"
|
||
value={form.start_date || prefill.date_debut}
|
||
onChange={(e) => handleContractChange("start_date", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Fin</label>
|
||
<Input
|
||
type="date"
|
||
value={form.end_date || prefill.date_fin}
|
||
onChange={(e) => handleContractChange("end_date", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Brut</label>
|
||
<Input
|
||
value={form.gross_pay || String(prefill.montant || "")}
|
||
onChange={(e) => handleContractChange("gross_pay", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label className="text-xs text-muted-foreground">S3 key (contrat PDF)</label>
|
||
<Input
|
||
value={form.contract_pdf_s3_key}
|
||
onChange={(e) =>
|
||
handleContractChange("contract_pdf_s3_key", e.target.value)
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="text-xs text-muted-foreground">Précisions salaire</label>
|
||
<Textarea
|
||
rows={3}
|
||
value={precisionsSalaire}
|
||
onChange={(e) => setPrecisionsSalaire(e.target.value)}
|
||
placeholder="Précisions sur le calcul du salaire"
|
||
/>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="text-xs text-muted-foreground">Définition "Cachet de répétition"</label>
|
||
<div className="relative">
|
||
<div
|
||
className="p-3 bg-gray-50 border rounded-md text-sm cursor-pointer hover:bg-gray-100 transition-colors"
|
||
onClick={async () => {
|
||
const ccn = organizationDetails?.organization_details?.ccn || "Convention Collective Nationale des Entreprises Artistiques & Culturelles";
|
||
const text = `On entend par 'Cachet de répétition' une journée de répétitions, qu'elle soit constituée d'un ou deux services, conformément aux dispositions de la ${ccn}`;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast.success("Texte copié dans le presse-papiers");
|
||
} catch (err) {
|
||
console.error("Erreur lors de la copie:", err);
|
||
toast.error("Erreur lors de la copie");
|
||
}
|
||
}}
|
||
title="Cliquer pour copier"
|
||
>
|
||
On entend par 'Cachet de répétition' une journée de répétitions, qu'elle soit constituée d'un ou deux services, conformément aux dispositions de la {organizationDetails?.organization_details?.ccn || "Convention Collective Nationale des Entreprises Artistiques & Culturelles"}
|
||
</div>
|
||
<div className="absolute top-2 right-2 text-xs text-gray-400 pointer-events-none">
|
||
📋 Cliquer pour copier
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="text-xs text-muted-foreground">Dates travaillées</label>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[100px] flex items-start pt-3">
|
||
{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 h-fit"
|
||
>
|
||
Modifier
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setJoursTravail(""); setJoursTravailDisplay(""); }}
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap h-fit"
|
||
title="Effacer toutes les dates travaillées"
|
||
>
|
||
Effacer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calendrier pour dates travaillées */}
|
||
<DatePickerCalendar
|
||
isOpen={joursTravailOpen}
|
||
onClose={() => setJoursTravailOpen(false)}
|
||
onApply={handleJoursTravailApply}
|
||
initialDates={joursTravail ? joursTravail.split(", ") : []}
|
||
title="Sélectionner les dates travaillées"
|
||
minDate={contract.date_debut}
|
||
maxDate={contract.date_fin}
|
||
/>
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="text-xs text-muted-foreground">Note interne</label>
|
||
<Textarea
|
||
rows={4}
|
||
value={form.notes ?? ""}
|
||
onChange={(e) => handleContractChange("notes", e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Champs de signature */}
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Contrat signé par salarié</label>
|
||
<select
|
||
value={form.contrat_signe || ""}
|
||
onChange={(e) => handleContractChange("contrat_signe", e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="">Sélectionner...</option>
|
||
<option value="Oui">Oui</option>
|
||
<option value="Non">Non</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-muted-foreground">Contrat signé par employeur</label>
|
||
<select
|
||
value={form.contrat_signe_par_employeur || ""}
|
||
onChange={(e) => handleContractChange("contrat_signe_par_employeur", e.target.value)}
|
||
className="w-full p-2 border rounded"
|
||
>
|
||
<option value="">Sélectionner...</option>
|
||
<option value="Oui">Oui</option>
|
||
<option value="Non">Non</option>
|
||
</select>
|
||
</div>
|
||
|
||
<Separator className="md:col-span-2 my-2" />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="space-y-6">
|
||
{/* Card de suivi de signature électronique */}
|
||
<Card className="rounded-3xl overflow-hidden">
|
||
<CardHeader className={`${getSignatureStatus().bgColor} ${getSignatureStatus().borderColor} border-b`}>
|
||
<CardTitle className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<PenTool className="size-5 text-indigo-600" />
|
||
<span>Signature électronique</span>
|
||
</div>
|
||
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${getSignatureStatus().bgColor} ${getSignatureStatus().color} border ${getSignatureStatus().borderColor}`}>
|
||
{(() => {
|
||
const IconComponent = getSignatureStatus().icon;
|
||
return <IconComponent className="size-4" />;
|
||
})()}
|
||
<span className="text-sm font-medium">{getSignatureStatus().label}</span>
|
||
</div>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="p-6">
|
||
<div className="space-y-4">
|
||
{/* Affichage détaillé du statut */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||
<div className="flex flex-col space-y-1">
|
||
<span className="text-gray-500 font-medium">État de la demande</span>
|
||
<span className={`font-semibold ${
|
||
(form.etat_de_la_demande || contract.etat_de_la_demande) === "Traitée" ? "text-green-600" : "text-gray-600"
|
||
}`}>
|
||
{form.etat_de_la_demande || contract.etat_de_la_demande || "Non défini"}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-col space-y-1">
|
||
<span className="text-gray-500 font-medium">Signature employeur</span>
|
||
<span className={`font-semibold ${
|
||
(form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur) === "Oui" ? "text-green-600" : "text-gray-600"
|
||
}`}>
|
||
{form.contrat_signe_par_employeur || contract.contrat_signe_par_employeur || "Non"}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-col space-y-1">
|
||
<span className="text-gray-500 font-medium">Signature salarié</span>
|
||
<span className={`font-semibold ${
|
||
(form.contrat_signe || contract.contrat_signe) === "Oui" ? "text-green-600" : "text-gray-600"
|
||
}`}>
|
||
{form.contrat_signe || contract.contrat_signe || "Non"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bouton de relance si en attente employeur */}
|
||
{getSignatureStatus().status === "waiting_employer" && (
|
||
<div className="pt-4 border-t">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="flex items-center gap-2 text-orange-600 border-orange-200 hover:bg-orange-50"
|
||
onClick={handleOpenReminderModal}
|
||
disabled={isLoadingOrgData}
|
||
>
|
||
{isLoadingOrgData ? (
|
||
<RefreshCw className="size-4 animate-spin" />
|
||
) : (
|
||
<Send className="size-4" />
|
||
)}
|
||
{isLoadingOrgData ? "Chargement..." : "Relancer l'employeur"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bouton "Marquer comme non signé" à la place de la progression */}
|
||
<div className="pt-4">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="flex items-center gap-2 text-red-600 border-red-200 hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
onClick={handleMarkAsUnsigned}
|
||
disabled={isBothUnsigned || isMarkingUnsigned}
|
||
>
|
||
{isMarkingUnsigned ? (
|
||
<RefreshCw className="size-4 animate-spin" />
|
||
) : (
|
||
<XCircle className="size-4" />
|
||
)}
|
||
{isMarkingUnsigned
|
||
? "Marquage en cours..."
|
||
: isBothUnsigned
|
||
? "Déjà marqué comme non signé"
|
||
: "Marquer comme non signé"
|
||
}
|
||
</Button>
|
||
{isBothUnsigned && (
|
||
<p className="text-xs text-gray-500 mt-2">
|
||
Les deux parties sont déjà marquées comme n'ayant pas signé le contrat.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="rounded-3xl">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<FileText className="size-5" /> Documents
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* Contrat CDDU non signé */}
|
||
{contract.contract_pdf_url ? (
|
||
<div
|
||
onClick={openPdf}
|
||
className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
|
||
>
|
||
<FileText className="size-5 text-blue-600" />
|
||
<div className="flex-1">
|
||
<p className="font-medium">Contrat CDDU (version préliminaire)</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{contract.contract_pdf_filename || 'contrat.pdf'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Contrat CDDU signé */}
|
||
{loadingSignedPdf ? (
|
||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-gray-50">
|
||
<RefreshCw className="size-5 text-gray-400 animate-spin" />
|
||
<div className="flex-1">
|
||
<p className="font-medium text-gray-600">Vérification du contrat signé...</p>
|
||
</div>
|
||
</div>
|
||
) : signedPdfData?.hasSignedPdf && signedPdfData?.signedUrl ? (
|
||
<div
|
||
onClick={openSignedPdf}
|
||
className="flex items-center gap-3 p-3 border-2 border-green-200 bg-green-50 rounded-lg cursor-pointer hover:bg-green-100 transition-colors"
|
||
>
|
||
<CheckCircle2 className="size-5 text-green-600" />
|
||
<div className="flex-1">
|
||
<p className="font-medium text-green-800">Contrat CDDU signé</p>
|
||
<p className="text-sm text-green-600">
|
||
Document électroniquement signé
|
||
</p>
|
||
</div>
|
||
<div className="text-xs text-green-600">
|
||
Cliquer pour ouvrir
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Message quand aucun document n'est disponible */}
|
||
{!contract.contract_pdf_url && !loadingSignedPdf && !signedPdfData?.hasSignedPdf && (
|
||
<div className="text-center text-muted-foreground py-4">
|
||
<FileText className="size-8 mx-auto mb-2 opacity-50" />
|
||
<p>Aucun document généré</p>
|
||
<p className="text-sm">Utilisez le bouton "Créer le PDF" pour générer le contrat</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bouton pour upload manuel du contrat signé */}
|
||
<Separator className="my-4" />
|
||
<div className="flex flex-col gap-2">
|
||
<p className="text-sm font-medium">Upload manuel</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setIsUploadModalOpen(true)}
|
||
className="w-full"
|
||
>
|
||
<Upload className="size-4 mr-2" />
|
||
Ajouter le contrat signé manuellement
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">
|
||
Si vous avez reçu le contrat signé par email ou autre moyen
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="rounded-3xl">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<CalendarRange className="size-5" /> Paies ({rows.length})
|
||
</div>
|
||
<Button
|
||
onClick={() => handleOpenPayslipModal()}
|
||
variant="outline"
|
||
size="sm"
|
||
className="rounded-2xl"
|
||
>
|
||
<FilePlus2 className="size-4 mr-2" />
|
||
Ajouter une paie
|
||
</Button>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{rows.length === 0 ? (
|
||
<div className="text-center text-gray-500 py-8">
|
||
<CalendarRange className="size-12 mx-auto mb-4 text-gray-300" />
|
||
<p>Aucune paie enregistrée</p>
|
||
<p className="text-sm">Cliquez sur "Ajouter une paie" pour commencer</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{rows.map((payslip, i) => (
|
||
<PayslipCard
|
||
key={payslip.id ?? i}
|
||
payslip={payslip}
|
||
index={i}
|
||
contractId={contract.id}
|
||
onClick={() => handleOpenPayslipModal(payslip)}
|
||
onUploadComplete={handlePayslipUploadComplete}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Section Notes */}
|
||
<div className="rounded-3xl overflow-hidden">
|
||
<NotesSection
|
||
contractId={contract.id}
|
||
contractRef={contract.contract_number || contract.reference}
|
||
source="Renaud [Staff Odentas]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Loading Modals */}
|
||
<LoadingModal
|
||
isOpen={isSaving}
|
||
title="Sauvegarde en cours..."
|
||
description="Veuillez patienter pendant la sauvegarde du contrat."
|
||
success={showSaveSuccess}
|
||
successMessage="Contrat sauvegardé avec succès !"
|
||
onClose={() => setShowSaveSuccess(false)}
|
||
/>
|
||
|
||
<LoadingModal
|
||
isOpen={isGeneratingPdf}
|
||
title="Génération du PDF en cours..."
|
||
description="Création du document de contrat, cela peut prendre quelques instants."
|
||
success={showPdfSuccess}
|
||
successMessage="PDF créé avec succès !"
|
||
onClose={() => setShowPdfSuccess(false)}
|
||
/>
|
||
|
||
{/* Payslip Modal */}
|
||
<PayslipModal
|
||
isOpen={isPayslipModalOpen}
|
||
payslip={editingPayslip}
|
||
contractId={contract.id}
|
||
contractNumber={contract.contract_number}
|
||
onClose={handleClosePayslipModal}
|
||
onSuccess={handlePayslipModalSuccess}
|
||
/>
|
||
|
||
{/* Employer Reminder Modal */}
|
||
<EmployerReminderModal
|
||
isOpen={isReminderModalOpen}
|
||
onClose={handleCloseReminderModal}
|
||
onConfirm={handleSendEmployerReminder}
|
||
isLoading={isSendingReminder}
|
||
contractData={{
|
||
reference: contract.reference || contract.contract_number,
|
||
employee_name: contract.employee_name,
|
||
employer_email: organizationData?.email_signature,
|
||
employer_name: organizationData?.organization_name
|
||
}}
|
||
/>
|
||
|
||
{/* E-Sign Confirmation Modal */}
|
||
<ESignConfirmModal
|
||
isOpen={showESignConfirmModal}
|
||
onClose={() => setShowESignConfirmModal(false)}
|
||
onConfirm={handleConfirmESign}
|
||
contractReference={reference || contract.reference || contract.contract_number}
|
||
employeeName={salarie?.nom || contract.employee_name}
|
||
employeeEmail={verifiedEmployeeEmail}
|
||
isLoading={isLaunchingSignature}
|
||
/>
|
||
|
||
{/* Manual Signed Contract Upload Modal */}
|
||
<ManualSignedContractUpload
|
||
contractId={contract.id}
|
||
contractNumber={contract.contract_number}
|
||
open={isUploadModalOpen}
|
||
onOpenChange={setIsUploadModalOpen}
|
||
onSuccess={() => {
|
||
// Invalider la query pour rafraîchir les données du contrat signé
|
||
queryClient.invalidateQueries({ queryKey: ["signed-contract-pdf", contract.id] });
|
||
}}
|
||
/>
|
||
|
||
{/* Dates Quantity Modal for precise selection */}
|
||
<DatesQuantityModal
|
||
isOpen={quantityModalOpen}
|
||
onClose={() => {
|
||
setQuantityModalOpen(false);
|
||
setPendingDates([]);
|
||
}}
|
||
selectedDates={pendingDates}
|
||
dateType={quantityModalType || "jours_travail"}
|
||
onApply={handleQuantityApply}
|
||
allowSkipHoursByDay={quantityModalType === "jours_travail"}
|
||
/>
|
||
|
||
{/* Cancel Contract Modal */}
|
||
<CancelContractModal
|
||
isOpen={showCancelModal}
|
||
onClose={() => setShowCancelModal(false)}
|
||
onConfirm={handleCancelContract}
|
||
isCancelling={isCancelling}
|
||
contractInfo={{
|
||
contract_number: contract.contract_number || contract.reference,
|
||
employee_name: contract.employee_name || contract.salaries?.salarie,
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
} |