espace-paie-odentas/app/(app)/contrats-multi/[id]/page.tsx

1587 lines
66 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

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

"use client";
import Link from "next/link";
import Script from "next/script";
import { useParams } from "next/navigation";
import { useMemo, useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Download, Info, Loader2, Clock, CheckCircle, Euro, PenTool, XCircle, Users, Send, AlertTriangle, X, ExternalLink, AlertCircle } from "lucide-react";
import { NotesSection } from "@/components/NotesSection";
import { Button } from "@/components/ui/button";
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
import { LoadingModal } from "@/components/ui/loading-modal";
import DocumentsCard from "@/components/contrats/DocumentsCard";
import { toast } from "sonner";
import { usePageTitle } from "@/hooks/usePageTitle";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
/* =========================
Types attendus du backend
========================= */
type StatutSimple = "oui" | "non" | "na" | "en_attente";
type EtatDemande =
| "pre-demande"
| "demande-recue"
| "envoye"
| "signe"
| "modification"
| "traitee"
| "non_commence";
type ContratMultiDetail = {
id: string;
numero: string; // ex YW2KSC85
regime: "CDDU_MULTI";
is_multi_mois: true; // drapeau utile
salarie: { nom: string; email?: string };
salarie_matricule?: string; // Matricule API du/de la salarié·e pour lien fiche
production: string;
objet?: string;
profession: string;
categorie_prof?: string;
type_salaire?: string;
salaire_demande?: string;
date_debut: string; // ISO
date_fin: string; // ISO
panier_repas?: StatutSimple;
// État & signatures
etat_demande: EtatDemande;
contrat_signe_employeur: StatutSimple;
contrat_signe_salarie: StatutSimple;
etat_contrat?: "non_commence" | "en_cours" | "termine";
// PDFs rattachés à la demande
pdf_contrat?: { available: boolean; url?: string };
pdf_avenant?: { available: boolean; url?: string };
// Déclarations
dpae?: "a_traiter" | "envoyee" | "refusee" | "retour_ok";
// Temps de travail cumulé
jours_travailles?: string | number;
nb_representations?: number;
nb_services_repetitions?: number;
nb_heures_repetitions?: number;
nb_heures_annexes?: number;
nb_cachets_aem?: number;
nb_heures_aem?: number;
};
type PaieMulti = {
id: string;
ordre: number; // 1,2,3… (Numéro de paie)
mois: number; // 1..12
annee: number; // 2024
aem_statut?: "a_traiter" | "ok" | string | null;
paie_pdf?: string | null; // URL
net_a_payer?: string | null;
net_avant_pas?: string | null;
brut?: string | null;
cout_total?: string | null;
transfer_done?: boolean | null; // Champ pour le statut de paiement (même que payslips)
traite?: "oui" | "non" | string | null;
paie_traitee?: string | null; // Période de paie (ex: "01/08/2025 31/08/2025")
};
/* ==============
Data fetching
============== */
function useContrat(id: string) {
return useQuery({
queryKey: ["contrat-multi", id],
queryFn: async () => {
const res = await fetch(`/api/contrats/${id}`, {
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});
if (res.status === 403) {
const msg = await res.text().catch(() => "");
throw new Error(msg || "access_denied");
}
if (res.status === 404) {
throw new Error("not_found");
}
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(t || `http_${res.status}`);
}
const data = (await res.json()) as ContratMultiDetail;
return data;
},
staleTime: 15_000,
enabled: Boolean(id),
retry: (failureCount, error: any) => {
const msg = String(error?.message || "");
if (msg.includes("access_denied") || msg.includes("not_found")) return false;
return failureCount < 3;
},
});
}
type Payslip = {
id: string;
contract_id: string;
period_start: string;
period_end: string;
period_month: string;
pay_number: number;
gross_amount: string;
net_amount: string;
net_after_withholding: string;
employer_cost: string;
pay_date: string | null;
processed: boolean;
aem_status: string;
transfer_done: boolean;
analytic_tag: string;
storage_path: string;
source_reference: string;
created_at: string;
};
// Type pour les URLs pré-signées retournées par l'API
type PayslipWithUrl = {
id: string;
pay_number: number;
period_start: string;
period_end: string;
pay_date: string | null;
gross_amount: string;
net_amount: string;
net_after_withholding: string;
processed: boolean;
aem_status: string;
signedUrl: string | null;
hasDocument: boolean;
};
function usePaies(id: string) {
return useQuery({
queryKey: ["contrat-multi-paies", id],
queryFn: async () => {
if (process.env.NODE_ENV !== 'production') {
console.log("🔍 Fetching payslips from Supabase for contract:", id);
}
// Utiliser le même endpoint que les contrats normaux
const url = `/api/payslips?contract_id=${encodeURIComponent(id)}`;
const res = await fetch(url, {
credentials: "include",
headers: { Accept: "application/json" }
});
if (process.env.NODE_ENV !== 'production') {
console.log("📥 Payslips response:", { status: res.status, statusText: res.statusText, contractId: id });
}
if (res.status === 403) {
if (process.env.NODE_ENV !== 'production') {
console.error("❌ Payslips access denied");
}
throw new Error("access_denied");
}
if (res.status === 404) {
if (process.env.NODE_ENV !== 'production') {
console.log(" Payslips not found (404), returning empty list");
}
// Retourner une liste vide avec le format attendu
return { items: [] as PaieMulti[] };
}
if (!res.ok) {
const t = await res.text().catch(() => "");
if (process.env.NODE_ENV !== 'production') {
console.error("❌ Payslips error:", { status: res.status, error: t, contractId: id });
}
throw new Error(t || `HTTP ${res.status}`);
}
const payslips = (await res.json()) as Payslip[];
if (process.env.NODE_ENV !== 'production') {
console.log("✅ Payslips data received:", { contractId: id, count: payslips.length, payslips });
}
// Convertir les payslips en format PaieMulti
const items: PaieMulti[] = payslips.map((slip) => ({
id: slip.id,
ordre: slip.pay_number,
mois: parseInt(slip.period_month.split('-')[1] || '0'),
annee: parseInt(slip.period_month.split('-')[0] || '0'),
aem_statut: slip.aem_status || null,
paie_pdf: slip.storage_path || null,
net_a_payer: slip.net_after_withholding || null,
net_avant_pas: slip.net_amount || null,
brut: slip.gross_amount || null,
cout_total: slip.employer_cost || null,
transfer_done: slip.transfer_done,
traite: slip.processed ? "oui" : "non",
paie_traitee: `${slip.period_start} ${slip.period_end}`,
}));
return { items };
},
staleTime: 15_000,
enabled: Boolean(id),
retry: (failureCount, error: any) => {
const msg = String(error?.message || "");
if (msg.includes("access_denied")) return false;
return failureCount < 3;
},
});
}
// Nouveau hook pour récupérer les URLs pré-signées
function usePayslipUrls(id: string) {
return useQuery({
queryKey: ["payslip-urls", id],
queryFn: async () => {
const response = await fetch(`/api/contrats/${id}/payslip-urls`, {
credentials: 'include',
headers: { Accept: 'application/json' }
});
if (!response.ok) {
console.error("Erreur lors de la récupération des URLs de fiches de paie");
return { payslips: [] as PayslipWithUrl[] };
}
const data = await response.json();
return { payslips: data.payslips || [] as PayslipWithUrl[] };
},
staleTime: 15_000,
enabled: Boolean(id),
});
}
/* =========
Helpers
========= */
function Section({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
</section>
);
}
function formatPeriodDisplay(periodStr?: string | null) {
if (!periodStr) return null;
// Expected formats: "YYYY-MM-DD YYYY-MM-DD" or similar. Split on '' or '-'
const parts = periodStr.split(/\s*\s*|\s*-\s*/);
if (parts.length === 0) return periodStr;
const left = parts[0]?.trim();
const right = parts[1]?.trim();
try {
const parseISO = (s?: string | null) => {
if (!s) return null;
// If format is YYYY-MM-DD or YYYY-MM, ensure correct parsing
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
const m2 = s.match(/^(\d{4})-(\d{2})$/);
if (m2) return new Date(Number(m2[1]), Number(m2[2]) - 1, 1);
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
};
const L = parseISO(left);
const R = parseISO(right);
if (L && R) return `${formatDateFR(L)} ${formatDateFR(R)}`;
if (L) return formatDateFR(L);
if (R) return formatDateFR(R);
return periodStr;
} catch (e) {
return periodStr;
}
}
function formatDateFR(iso?: string | Date) {
if (!iso) return "—";
const d = iso instanceof Date ? iso : new Date(String(iso));
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function Field({
label,
value,
action,
}: {
label: string;
value?: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-[220px_1fr_auto] gap-2 items-center py-2">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-sm">{value ?? <span className="text-slate-400"></span>}</div>
{action ? <div className="justify-self-end">{action}</div> : null}
</div>
);
}
function Badge({
children,
tone = "default" as "default" | "ok" | "warn" | "error" | "info",
}: {
children: React.ReactNode;
tone?: "default" | "ok" | "warn" | "error" | "info";
}) {
const cls =
tone === "ok"
? "bg-emerald-100 text-emerald-800"
: tone === "warn"
? "bg-amber-100 text-amber-800"
: tone === "error"
? "bg-rose-100 text-rose-800"
: tone === "info"
? "bg-sky-100 text-sky-800"
: "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
function boolBadge(v?: StatutSimple | boolean | null) {
if (typeof v === "boolean") return v ? <Badge tone="ok">Oui</Badge> : <Badge tone="error">Non</Badge>;
if (v === "oui") return <Badge tone="ok">Oui</Badge>;
if (v === "non") return <Badge tone="error">Non</Badge>;
if (v === "en_attente") return <Badge tone="info">En cours</Badge>;
return <Badge>n/a</Badge>;
}
function stateBadgeDemande(s?: EtatDemande) {
const input = String(s || "").trim();
const normalized = input
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/\s+/g, " ")
.replace(/-/g, " ")
.replace(/_/g, " ");
// Mapping des 4 états principaux avec couleurs adaptées
type Style = { bg: string; border: string; text: string; label: string };
// Détection intelligente des états
let style: Style;
if (normalized.includes("pre") || normalized.includes("demande")) {
// Pré-demande - Gris (neutre)
style = {
bg: "bg-slate-50",
border: "border-slate-200",
text: "text-slate-700",
label: "Pré-demande"
};
} else if (normalized.includes("recu") || normalized.includes("recue")) {
// Reçue - Bleu (information)
style = {
bg: "bg-blue-50",
border: "border-blue-200",
text: "text-blue-800",
label: "Reçue"
};
} else if (normalized.includes("cours") || normalized.includes("traitement")) {
// En cours de traitement - Orange (en attente)
style = {
bg: "bg-orange-50",
border: "border-orange-200",
text: "text-orange-800",
label: "En cours de traitement"
};
} else if (normalized.includes("traitee") || normalized.includes("traite")) {
// Traitée - Vert (succès)
style = {
bg: "bg-emerald-50",
border: "border-emerald-200",
text: "text-emerald-800",
label: "Traitée"
};
} else {
// Fallback - Affichage de la valeur brute avec style neutre
style = {
bg: "bg-slate-50",
border: "border-slate-200",
text: "text-slate-700",
label: input || "—"
};
}
return (
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs ${style.bg} ${style.border} ${style.text}`}>
{style.label}
</div>
);
}
function formatEUR(v?: string | number | null) {
if (v === null || v === undefined || v === "") return "—";
// Autorise nombres sous forme de chaîne avec séparateurs
const num = typeof v === "number" ? v : Number(String(v).replace(/[^0-9,.-]/g, "").replace(",", "."));
if (!Number.isFinite(num)) return String(v);
return num.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
function monthLabelSafe(m?: number) {
if (typeof m !== "number" || !Number.isFinite(m) || m < 1 || m > 12) return null;
return new Date(2025, m - 1, 1).toLocaleDateString("fr-FR", { month: "long" });
}
function payLabel(p: PaieMulti) {
const ml = monthLabelSafe(p.mois);
if (ml && p.annee) return `${ml} ${p.annee}`;
if (ml) return ml;
if (p.annee) return String(p.annee);
return "";
}
/* =====
Page
===== */
export default function ContratMultiPage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading, isError, error } = useContrat(id);
// Titre dynamique basé sur le numéro du contrat
const contractTitle = data?.numero ? `Contrat ${data.numero}` : `Contrat multi-mois`;
usePageTitle(contractTitle);
const { data: paiesData, isLoading: paiesLoading, isError: paiesError, error: paiesErrorObj } = usePaies(id);
const { data: payslipUrlsData } = usePayslipUrls(id);
// State pour la modale de confirmation de paiement
const [showPaymentModal, setShowPaymentModal] = useState<boolean>(false);
const [selectedPaieId, setSelectedPaieId] = useState<string>("");
const [selectedPaieStatus, setSelectedPaieStatus] = useState<boolean>(false);
// State pour la modale de signature DocuSeal
const [embedSrc, setEmbedSrc] = useState<string>("");
const [modalTitle, setModalTitle] = useState<string>("");
const [signatureB64ForDocuSeal, setSignatureB64ForDocuSeal] = useState<string | null>(null);
// State pour la modale de chargement
const [isLoadingSignature, setIsLoadingSignature] = useState<boolean>(false);
// State pour la modale d'erreur DocuSeal
const [showErrorModal, setShowErrorModal] = useState<boolean>(false);
// State pour la modale de visualisation des fiches de paie
const [isPayslipModalOpen, setIsPayslipModalOpen] = useState<boolean>(false);
const [currentPayslipUrl, setCurrentPayslipUrl] = useState<string>("");
const [currentPayslipTitle, setCurrentPayslipTitle] = useState<string>("");
const [payslipPdfError, setPayslipPdfError] = useState<boolean>(false);
// Query client pour la mise à jour du cache
const queryClient = useQueryClient();
// Effet pour bloquer le défilement quand le modal DocuSeal est ouvert
useEffect(() => {
// Vérifier si le dialog est ouvert en surveillant embedSrc
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg && embedSrc) {
// Bloquer le défilement du body
document.body.style.overflow = 'hidden';
// Observer pour détecter la fermeture du dialog
const observer = new MutationObserver(() => {
if (!dlg.open) {
document.body.style.overflow = '';
}
});
observer.observe(dlg, { attributes: true, attributeFilter: ['open'] });
return () => {
observer.disconnect();
document.body.style.overflow = '';
};
} else {
// S'assurer que le défilement est rétabli si embedSrc est vide
document.body.style.overflow = '';
}
}, [embedSrc]);
// Effet pour bloquer le défilement quand le modal de fiche de paie est ouvert
useEffect(() => {
if (isPayslipModalOpen) {
document.body.style.overflow = 'hidden';
// Handler pour fermer avec Escape
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsPayslipModalOpen(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleEscape);
};
} else {
document.body.style.overflow = '';
}
}, [isPayslipModalOpen]);
// Fonction pour ouvrir une fiche de paie dans le modal
const openPayslipInModal = (url: string, title: string) => {
setCurrentPayslipUrl(url);
setCurrentPayslipTitle(title);
setPayslipPdfError(false);
setIsPayslipModalOpen(true);
};
// Mutation pour marquer une paie multi comme payée/non payée
const markAsPaidMutation = useMutation({
mutationFn: async ({ payslipId, transferDone }: { payslipId: string; transferDone: boolean }) => {
const response = await fetch(`/api/payslips/${payslipId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ transfer_done: transferDone }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la mise à jour');
}
return response.json();
},
onSuccess: (_, variables) => {
// Recharger les données des paies
queryClient.invalidateQueries({ queryKey: ["contrat-multi-paies", id] });
toast.success(variables.transferDone ? "Paiement marqué comme effectué !" : "Paiement marqué comme non effectué !");
setShowPaymentModal(false);
setSelectedPaieId("");
setSelectedPaieStatus(false);
},
onError: (error: Error) => {
toast.error("Erreur : " + error.message);
},
});
// Fonction pour ouvrir le modal de confirmation
const handleTogglePayment = (paieId: string, currentStatus: boolean) => {
setSelectedPaieId(paieId);
setSelectedPaieStatus(!currentStatus);
setShowPaymentModal(true);
};
// Fonction pour confirmer le changement de statut
const confirmTogglePayment = () => {
if (selectedPaieId) {
markAsPaidMutation.mutate({
payslipId: selectedPaieId,
transferDone: selectedPaieStatus
});
}
};
// Calcule l'état du contrat en fonction des dates et de l'état de la demande (même logique que contrats/[id])
const etatContratCalcule = useMemo(() => {
if (!data) return undefined;
// Si l'état de la demande est Annulée → Annulé
const etatDemandeNorm = String(data.etat_demande || "")
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "");
if (etatDemandeNorm.includes("annule")) {
return "Annulé" as const;
}
// Si dates manquantes, on retombe sur l'ancien fallback
if (!data.date_debut || !data.date_fin) return undefined;
const toLocalDateOnly = (iso: string) => {
const d = new Date(iso);
// Normalise à minuit local pour une comparaison en J/J inclusif
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
};
const start = toLocalDateOnly(data.date_debut);
const end = toLocalDateOnly(data.date_fin);
const today = new Date();
const current = new Date(today.getFullYear(), today.getMonth(), today.getDate());
if (current < start) return "Non commencé" as const;
if (current > end) return "Terminé" as const;
return "En cours" as const; // inclusif entre début et fin
}, [data]);
const title = useMemo(() => (data ? `Contrat n° ${data.numero}` : "Contrat"), [data]);
// Créer un map des URLs signées par ID de payslip (AVANT les retours conditionnels)
const payslipUrlsMap = useMemo(() => {
const map = new Map<string, string>();
if (payslipUrlsData?.payslips) {
for (const payslip of payslipUrlsData.payslips) {
if (payslip.signedUrl) {
map.set(payslip.id, payslip.signedUrl);
}
}
}
return map;
}, [payslipUrlsData]);
if (isLoading) {
return (
<div className="rounded-2xl border bg-white p-10 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement du contrat
</div>
);
}
if (isError || !data) {
return (
<div className="rounded-2xl border bg-white p-6">
<div className="text-rose-600 font-medium mb-2">Impossible de charger ce contrat.</div>
<div className="text-sm text-slate-500">{(error as any)?.message || "Erreur inconnue"}</div>
<div className="mt-4">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border" href="/contrats">
<ArrowLeft className="w-4 h-4" /> Retour aux contrats
</Link>
</div>
</div>
);
}
// Fonction pour déterminer l'état de la signature électronique
const getSignatureStatus = () => {
const etatDemande = data.etat_demande;
const contratSigneEmployeur = data.contrat_signe_employeur;
const contratSigne = data.contrat_signe_salarie;
// Détermine le statut
if (contratSigneEmployeur === "oui" && contratSigne === "oui") {
return {
status: "completed" as const,
label: "Complété",
icon: CheckCircle,
color: "text-green-600",
bgColor: "bg-green-50",
borderColor: "border-green-200"
};
} else if (contratSigneEmployeur === "oui" && contratSigne !== "oui") {
return {
status: "waiting_employee" as const,
label: "En attente salarié",
icon: Users,
color: "text-blue-600",
bgColor: "bg-blue-50",
borderColor: "border-blue-200"
};
} else if (String(etatDemande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').includes("traitee") || String(etatDemande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').includes("traitée")) {
return {
status: "waiting_employer" as const,
label: "En attente employeur",
icon: Send,
color: "text-orange-600",
bgColor: "bg-orange-50",
borderColor: "border-orange-200"
};
} else {
return {
status: "not_sent" as const,
label: "Non envoyé",
icon: Clock,
color: "text-gray-600",
bgColor: "bg-gray-50",
borderColor: "border-gray-200"
};
}
};
// Fonction pour ouvrir la signature DocuSeal
async function openSignature() {
if (!data) return;
// Afficher la modale de chargement
setIsLoadingSignature(true);
let embed: string | null = null;
const title = `Signature (Employeur) · ${data.numero}`;
setModalTitle(title);
console.log('🔍 [SIGNATURE] Debug - data complète:', data);
console.log('🔍 [SIGNATURE] Debug - data.id:', data.id);
// Utiliser notre API pour récupérer les données de signature avec service role
try {
const response = await fetch(`/api/contrats/${data.id}/signature`, {
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
if (response.status === 404) {
setIsLoadingSignature(false);
toast.error("Ce contrat n'a pas encore de signature électronique configurée. Veuillez d'abord créer la signature via l'interface d'édition du contrat.");
return;
}
throw new Error(`Erreur API: ${response.status}`);
}
const result = await response.json();
console.log('📋 [SIGNATURE] Données contrat depuis API:', result);
console.log('🔍 [SIGNATURE] result.data:', result.data);
console.log('🔍 [SIGNATURE] result.data.signature_b64:', result.data?.signature_b64);
if (!result.success || !result.data) {
setIsLoadingSignature(false);
toast.error("Aucune donnée de signature trouvée pour ce contrat");
return;
}
const contractData = result.data;
console.log('📦 [SIGNATURE] contractData extrait:', contractData);
// Stocker la signature si disponible
const signatureB64 = contractData.signature_b64;
console.log('🖊️ [SIGNATURE] signatureB64 extraite:', {
exists: !!signatureB64,
type: typeof signatureB64,
length: signatureB64?.length,
preview: signatureB64?.substring(0, 50)
});
if (signatureB64) {
console.log('✅ [SIGNATURE] Signature B64 disponible, longueur:', signatureB64.length);
} else {
console.log('⚠️ [SIGNATURE] Aucune signature B64 disponible');
}
// Vérifier si la soumission DocuSeal n'a pas été créée
if (!contractData.docuseal_submission_id && contractData.signature_status === "Non initiée") {
setIsLoadingSignature(false);
setShowErrorModal(true);
return;
}
// 1) Si on a un signature_link direct, l'utiliser
if (contractData.signature_link) {
console.log('🔗 [SIGNATURE] Signature link trouvé:', contractData.signature_link);
// Extraire le docuseal_id du lien de signature
const signatureLinkMatch = contractData.signature_link.match(/docuseal_id=([^&]+)/);
if (signatureLinkMatch) {
const docusealId = signatureLinkMatch[1];
// L'URL doit être propre sans paramètres
embed = `https://docuseal.eu/s/${docusealId}`;
console.log('🔗 [SIGNATURE] URL embed depuis signature_link:', embed);
}
}
// 2) Sinon, récupérer via l'API DocuSeal à partir du template_id
if (!embed && contractData.docuseal_template_id) {
console.log('🔍 [SIGNATURE] Template ID trouvé:', contractData.docuseal_template_id);
try {
const tId = String(contractData.docuseal_template_id);
const subRes = await fetch(`/api/docuseal/templates/${encodeURIComponent(tId)}/submissions`, { cache: 'no-store' });
const subData = await subRes.json();
console.log('📋 [SIGNATURE] Submissions DocuSeal:', subData);
const first = Array.isArray(subData?.data) ? subData.data[0] : (Array.isArray(subData) ? subData[0] : subData);
const subId = first?.id;
if (subId) {
const detRes = await fetch(`/api/docuseal/submissions/${encodeURIComponent(subId)}`, { cache: 'no-store' });
const detData = await detRes.json();
console.log('📋 [SIGNATURE] Détails submission DocuSeal:', detData);
const roles = detData?.submitters || detData?.roles || [];
const employer = roles.find((r: any) => (r.role || r.name) === 'Employeur') || {};
if (employer?.slug) {
// URL propre sans paramètres
embed = `https://docuseal.eu/s/${employer.slug}`;
console.log('🔗 [SIGNATURE] URL embed depuis DocuSeal API:', embed);
} else {
embed = employer?.embed_src || employer?.sign_src || detData?.embed_src || null;
console.log('🔗 [SIGNATURE] URL embed alternative:', embed);
}
}
} catch (e) {
console.warn('❌ [SIGNATURE] DocuSeal fetch failed', e);
}
}
if (embed) {
console.log('✅ [SIGNATURE] URL embed trouvée:', embed);
setEmbedSrc(embed);
// Stocker la signature dans l'etat React pour l'ajouter au composant
console.log('🔍 [SIGNATURE] Stockage de la signature dans l\'etat React...');
console.log('🔍 [SIGNATURE] signatureB64 value:', signatureB64);
if (signatureB64) {
console.log('✅ [SIGNATURE] Signature B64 disponible pour pre-remplissage');
setSignatureB64ForDocuSeal(signatureB64);
} else {
console.log('⚠️ [SIGNATURE] Aucune signature B64 disponible');
setSignatureB64ForDocuSeal(null);
}
// Masquer la modale de chargement
setIsLoadingSignature(false);
// Ouvrir la modale
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) {
if (typeof dlg.showModal === 'function') {
dlg.showModal();
} else {
dlg.setAttribute('open', '');
}
}
} else {
console.warn('❌ [SIGNATURE] Aucune URL d\'embed trouvée');
setIsLoadingSignature(false);
toast.error("Impossible de récupérer le lien de signature");
}
} catch (error) {
console.error('❌ [SIGNATURE] Erreur:', error);
setIsLoadingSignature(false);
toast.error("Erreur lors du chargement de la signature");
}
}
const paies = paiesData?.items ?? [];
return (
<div className="space-y-5">
{/* Titre + breadcrumb */}
<div className="flex flex-col md:flex-row md:items-center gap-3">
<Link href="/contrats" className="inline-flex items-center gap-1 text-sm underline">
<ArrowLeft className="w-4 h-4" /> Retour contrats terminés
</Link>
</div>
<div className="rounded-2xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-lg font-semibold">{title}</div>
<div className="h-4 w-px bg-slate-200 mx-1" />
<div className="text-sm text-slate-500">CDDU · Multi-mois</div>
</div>
</div>
{/* Disposition 2 colonnes (colonnes indépendantes en hauteur) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Colonne gauche : Documents, Demande puis Temps de travail réel */}
<div className="flex flex-col gap-5">
{/* Card Documents */}
<DocumentsCard
contractId={id}
contractNumber={data.numero}
contractData={{
pdf_contrat: data.pdf_contrat,
contrat_signe_employeur: data.contrat_signe_employeur,
contrat_signe_salarie: data.contrat_signe_salarie
}}
showPayslips={false}
/>
<Section title="Demande">
<Field
label="Salarié"
value={
data.salarie_matricule ? (
<Link href={`/salaries/${encodeURIComponent(data.salarie_matricule)}`} className="underline">
{data.salarie?.nom || data.salarie_matricule}
</Link>
) : (
data.salarie?.nom
)
}
/>
<Field label="État de la demande" value={stateBadgeDemande(data.etat_demande)} />
<Field label="Contrat signé par employeur" value={boolBadge(data.contrat_signe_employeur)} />
<Field label="Contrat signé par salarié·e" value={boolBadge(data.contrat_signe_salarie)} />
<Field
label="État du contrat"
value={
etatContratCalcule === "Annulé" ? (
<Badge tone="error">Annulé</Badge>
) : etatContratCalcule === "En cours" ? (
<Badge tone="info">En cours</Badge>
) : etatContratCalcule === "Terminé" ? (
<Badge tone="ok">Terminé</Badge>
) : etatContratCalcule === "Non commencé" ? (
<Badge>Non commencé</Badge>
) : (
// Fallback sur l'ancien champ si le calcul n'est pas possible
data.etat_contrat === "en_cours" ? <Badge tone="info">En cours</Badge>
: data.etat_contrat === "termine" ? <Badge tone="ok">Terminé</Badge>
: <Badge>Non commencé</Badge>
)
}
/>
<Field label="Spectacle" value={data.production} />
<Field label="Numéro dobjet" value={data.objet} />
<Field label="Profession" value={data.profession} />
<Field label="Catégorie professionnelle" value={data.categorie_prof} />
<Field label="Type de salaire demandé" value={data.type_salaire} />
{/* Affichage conditionnel : soit salaire global, soit détail par date */}
{(data as any).salaires_par_date ? (
<>
<Field label="Salaire demandé" value={(data as any).salaires_par_date.total_calcule} />
<div className="col-span-full">
<div className="mt-2 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="text-xs font-semibold text-slate-700 mb-2">Détail des salaires par date</div>
{/* Représentations */}
{(data as any).salaires_par_date.representations && (data as any).salaires_par_date.representations.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold text-indigo-700 uppercase tracking-wide mb-1">Représentations</div>
<div className="overflow-hidden rounded-md border border-slate-200">
<table className="w-full text-xs">
<thead className="bg-slate-100">
<tr>
<th className="text-left px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Date</th>
<th className="text-left px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Type</th>
<th className="text-right px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Montant</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{(data as any).salaires_par_date.representations.map((rep: any, repIdx: number) => {
if (!rep.items || rep.items.length === 0) return null;
// Regrouper tous les items de cette date en une seule ligne
const typesLabel = rep.items.map((item: any) => `R${item.numero}`).join(', ');
const totalMontant = rep.items.reduce((sum: number, item: any) => sum + item.montant, 0);
return (
<tr key={repIdx} className="hover:bg-slate-50">
<td className="px-3 py-2 font-medium text-slate-700">{rep.date}</td>
<td className="px-3 py-2 text-slate-600">{typesLabel}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-900">{totalMontant.toFixed(2)} </td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Répétitions */}
{(data as any).salaires_par_date.repetitions && (data as any).salaires_par_date.repetitions.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold text-purple-700 uppercase tracking-wide mb-1">Répétitions</div>
<div className="overflow-hidden rounded-md border border-slate-200">
<table className="w-full text-xs">
<thead className="bg-slate-100">
<tr>
<th className="text-left px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Date</th>
<th className="text-left px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Type</th>
<th className="text-right px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Montant</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{(data as any).salaires_par_date.repetitions.map((rep: any, repIdx: number) => {
if (!rep.items || rep.items.length === 0) return null;
// Regrouper tous les items de cette date en une seule ligne
const typesLabel = rep.items.map((item: any) => `S${item.numero}`).join(', ');
const totalMontant = rep.items.reduce((sum: number, item: any) => sum + item.montant, 0);
return (
<tr key={repIdx} className="hover:bg-slate-50">
<td className="px-3 py-2 font-medium text-slate-700">{rep.date}</td>
<td className="px-3 py-2 text-slate-600">{typesLabel}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-900">{totalMontant.toFixed(2)} </td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Jours travaillés */}
{(data as any).salaires_par_date.jours_travail && (data as any).salaires_par_date.jours_travail.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold text-green-700 uppercase tracking-wide mb-1">Jours travaillés</div>
<div className="overflow-hidden rounded-md border border-slate-200">
<table className="w-full text-xs">
<thead className="bg-slate-100">
<tr>
<th className="text-left px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Date</th>
<th className="text-left px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Type</th>
<th className="text-right px-3 py-2 font-semibold text-slate-700 border-b border-slate-200">Montant</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{(data as any).salaires_par_date.jours_travail.map((jour: any, jourIdx: number) => (
<tr key={jourIdx} className="hover:bg-slate-50">
<td className="px-3 py-2 font-medium text-slate-700">{jour.date}</td>
<td className="px-3 py-2 text-slate-600">Jour</td>
<td className="px-3 py-2 text-right font-semibold text-slate-900">{jour.montant.toFixed(2)} </td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Total */}
<div className="pt-2 border-t border-slate-200">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-slate-700">Total</span>
<span className="text-sm font-bold text-indigo-900">{(data as any).salaires_par_date.total_calcule.toFixed(2)} </span>
</div>
</div>
</div>
</div>
</>
) : (
<Field label="Salaire demandé" value={data.salaire_demande} />
)}
<Field label="Début contrat" value={formatDateFR(data.date_debut)} />
<Field label="Fin contrat" value={formatDateFR(data.date_fin)} />
<Field label="Panier repas" value={boolBadge(data.panier_repas)} />
</Section>
<Section title="Temps de travail réel">
<Field label="Jours travaillés" value={data.jours_travailles ?? "—"} />
<Field label="Nbre de représentations" value={data.nb_representations ?? 0} />
<Field label="Nbre de services répétitions" value={data.nb_services_repetitions ?? 0} />
<Field label="Nbre d'heures répétitions" value={data.nb_heures_repetitions ?? 0} />
<Field label="Nbre d'heures Annexes 8" value={data.nb_heures_annexes ?? 0} />
<Field label="Nombre de cachets AEM" value={data.nb_cachets_aem ?? 0} />
<Field label="Nombre d'heures AEM" value={data.nb_heures_aem ?? 0} />
</Section>
</div>
{/* Colonne droite : Signature électronique, Déclarations, puis Paies */}
<div className="flex flex-col gap-5">
{/* Card 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 l'envoi</span>
<span className={`font-semibold ${
(() => {
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
return etatNormalise.includes("traitee") || etatNormalise.includes("traitée") ? "text-green-600" : "text-gray-600";
})()
}`}>
{(() => {
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
return etatNormalise.includes("traitee") || etatNormalise.includes("traitée") ? "Envoyé" : "En cours";
})()}
</span>
</div>
<div className="flex flex-col space-y-1">
<span className="text-gray-500 font-medium">Signature employeur</span>
<span className={`font-semibold ${
data.contrat_signe_employeur === "oui" ? "text-green-600" : "text-gray-600"
}`}>
{data.contrat_signe_employeur === "oui" ? "Oui" : "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 ${
data.contrat_signe_salarie === "oui" ? "text-green-600" : "text-gray-600"
}`}>
{data.contrat_signe_salarie === "oui" ? "Oui" : "Non"}
</span>
</div>
</div>
{/* Bouton Signer maintenant */}
{(() => {
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const isTraitee = etatNormalise.includes("traitee") || etatNormalise.includes("traitée");
const signatureEmployeurNon = data.contrat_signe_employeur !== "oui";
return (isTraitee && signatureEmployeurNon) ? (
<div className="pt-4 border-t">
<Button
onClick={openSignature}
className="w-full bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300"
>
<PenTool className="size-4 mr-2 text-black" />
Signer maintenant
</Button>
</div>
) : null;
})()}
</div>
</CardContent>
</Card>
<Section title="Déclarations">
{(() => {
const raw = String(data.dpae || "");
const norm = raw.normalize("NFD").replace(/\p{Diacritic}/gu, "").trim().toLowerCase();
// États "OK" / "Effectuée"
if (norm === "ok" || norm === "faite" || norm === "envoyee" || norm === "retourok" || norm === "retour_ok") {
return (
<Field
label="DPAE"
value={(
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
<CheckCircle className="w-3 h-3" /> Effectuée
</span>
)}
/>
);
}
// États "À traiter" / "En cours"
if (norm === "a traiter" || norm === "a_traiter" || norm === "a faire" || norm === "a_faire") {
return (
<Field
label="DPAE"
value={(
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
<Clock className="w-3 h-3" /> En cours
</span>
)}
/>
);
}
// État "Refusée"
if (norm === "refusee" || norm === "refuse") {
return (
<Field
label="DPAE"
value={(
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-rose-100 text-rose-800">
<Clock className="w-3 h-3" /> Refusée
</span>
)}
/>
);
}
// Si une valeur existe mais n'est pas reconnue, l'afficher telle quelle
if (raw) {
return <Field label="DPAE" value={<span className="text-xs text-slate-600">{raw}</span>} />;
}
// Aucune valeur
return <Field label="DPAE" value={<span className="text-xs text-slate-400">—</span>} />;
})()}
</Section>
<Section title={<><span>Paies</span><span className="ml-2 text-xs italic text-slate-500">- Cliquez sur la carte d'une paie pour afficher ses documents de paie et sur le symbole pour la marquer comme payée ou non payée.</span></>}>
{paiesLoading ? (
<div className="px-3 py-8 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" /> Chargement des paies
</div>
) : paiesError ? (
<div className="px-3 py-8 text-center text-rose-600">
Erreur lors du chargement des paies.
{process.env.NODE_ENV !== 'production' && (
<div className="mt-2 text-xs text-slate-400">{(paiesErrorObj as any)?.message || 'Erreur inconnue'}</div>
)}
</div>
) : paies.length === 0 ? (
<div className="px-3 py-8 text-center text-slate-500">
Aucune paie trouvée.
{process.env.NODE_ENV === 'development' && (
<div className="mt-2 text-xs text-slate-400 space-y-1">
<div>Debug: Contract ID = {id}</div>
<div>Debug: Contract Reference = {data?.numero || 'non définie'}</div>
<div className="text-yellow-600">
Vérifiez que le champ "Référence API" dans la table "Paies CDDU Multi-Mois" contient la valeur "{data?.numero || '???'}".
</div>
</div>
)}
</div>
) : (
<div className="space-y-4">
{paies
.slice()
.sort((a,b) => (b.ordre ?? 0) - (a.ordre ?? 0))
.map((p) => {
const label = payLabel(p) || (p.ordre ? `Paie ${p.ordre}` : 'Paie');
// Normalize aem_statut for robust checks (API can return 'OK', 'ok', 'Traité', etc.)
const aemNorm = String((p as any).aem_statut || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').trim().toLowerCase();
// Vérifier d'abord "À traiter" pour éviter les faux positifs
const aemPending = aemNorm.includes('a_traiter') || aemNorm.includes('a traiter');
const aemOk = !aemPending && (aemNorm === 'ok' || aemNorm === 'oui' || aemNorm.includes('ok') || aemNorm === 'valide');
const CardInner = (
<div className="h-full rounded-2xl border bg-white p-4 hover:shadow-md transition-shadow relative">
{/* Bouton de paiement en position absolue */}
{p.traite === 'oui' && (
<Button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleTogglePayment(p.id, p.transfer_done || false);
}}
className="absolute bottom-2 right-2 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300 text-xs px-2 py-1 h-auto z-10"
disabled={markAsPaidMutation.isPending}
title={p.transfer_done ? "Valider" : "Valider"}
>
<Euro className="w-3 h-3" />
</Button>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<div className="flex items-center gap-2">
{/* Numéro de paie */}
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800 border border-indigo-200/60">
# {p.ordre ?? '—'}
</span>
{/* Période (format texte) */}
{label && (
<span className="text-[11px] px-2 py-1 rounded-lg bg-gradient-to-br from-sky-50 to-blue-100 text-blue-800 border border-blue-200/70 shadow-[0_1px_0_rgba(255,255,255,0.6)]">
{label}
</span>
)}
</div>
<div className="sm:ml-auto flex items-center gap-2">
{/* 1. Traitée */}
{p.traite === 'non' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
<Clock className="w-3 h-3" /> À traiter
</span>
) : p.traite === 'oui' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
<CheckCircle className="w-3 h-3" /> Traitée
</span>
) : null}
{/* 2. AEM */}
{aemPending ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
<Clock className="w-3 h-3" /> AEM
</span>
) : aemOk ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
<CheckCircle className="w-3 h-3" /> AEM OK
</span>
) : (
<span className="text-xs text-slate-400">AEM </span>
)}
{/* 3. Payée avec même style que les autres */}
{p.transfer_done ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
<CheckCircle className="w-3 h-3" /> Payée
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
<Clock className="w-3 h-3" /> Non payée
</span>
)}
</div>
</div>
<div className="mt-3 grid grid-cols-4 gap-3 text-sm">
<div>
<div className="text-slate-500">Net avant PAS</div>
<div className="font-medium">{formatEUR((p as any).net_avant_pas ?? p.net_avant_pas)}</div>
</div>
<div>
<div className="text-slate-500">Net à payer</div>
<div className="font-medium">{formatEUR(p.net_a_payer)}</div>
</div>
<div>
<div className="text-slate-500">Brut</div>
<div>{formatEUR(p.brut)}</div>
</div>
<div>
<div className="text-slate-500">Coût total</div>
<div>{formatEUR(p.cout_total)}</div>
</div>
</div>
</div>
);
// Récupérer l'URL signée depuis le map
const signedUrl = payslipUrlsMap.get(p.id);
const payslipTitle = payLabel(p) || (p.ordre ? `Paie ${p.ordre}` : 'Fiche de paie');
return signedUrl ? (
<div
key={p.id}
onClick={() => openPayslipInModal(signedUrl, payslipTitle)}
className="group block cursor-pointer"
>
{CardInner}
</div>
) : (
<div key={p.id} className="opacity-70 cursor-not-allowed group block" title="PDF de paie indisponible">
{CardInner}
</div>
);
})}
</div>
)}
</Section>
</div>
</div>
{/* Notes */}
<NotesSection contractId={id} contractRef={data?.numero} />
{/* Script DocuSeal */}
<Script src="https://cdn.docuseal.com/js/form.js" strategy="lazyOnload" />
{/* Modale d'erreur DocuSeal */}
{showErrorModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl max-w-md mx-4 p-6 shadow-xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<h2 className="text-lg font-semibold text-slate-900">Signature non disponible</h2>
</div>
<div className="text-slate-600 mb-6">
<p className="mb-3">Nous nous excusons pour la gêne occasionnée.</p>
<p>La signature électronique n'est pas encore prête pour ce contrat. Nos équipes travaillent activement sur la préparation des documents.</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowErrorModal(false)}
className="flex-1"
>
Fermer
</Button>
<Button
onClick={() => {
window.location.href = '/support';
}}
className="flex-1 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300"
>
Nous contacter
</Button>
</div>
</div>
</div>
)}
{/* Modale de signature DocuSeal */}
<dialog id="dlg-signature" className="rounded-lg border max-w-5xl w-[96vw] p-0">
<div className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b bg-white">
<strong>{modalTitle || 'Signature électronique'}</strong>
<button
onClick={() => {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) dlg.close();
}}
className="p-1.5 rounded hover:bg-slate-50"
aria-label="Fermer"
title="Fermer"
>
<XCircle className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-0" style={{ height: '80vh', minHeight: 520, overflowY: 'auto' }}>
{embedSrc ? (
<div dangerouslySetInnerHTML={{
__html: (() => {
console.log('🎨 [SIGNATURE RENDER] Génération du HTML docuseal-form');
console.log('🎨 [SIGNATURE RENDER] embedSrc:', embedSrc);
// Utiliser la signature depuis l'etat React au lieu de sessionStorage
const signatureB64 = signatureB64ForDocuSeal;
console.log('🎨 [SIGNATURE RENDER] Signature depuis l\'etat React:', {
exists: !!signatureB64,
length: signatureB64?.length,
preview: signatureB64?.substring(0, 50)
});
const signatureAttr = signatureB64 ? `data-signature="${signatureB64.replace(/"/g, '&quot;')}"` : '';
console.log('🎨 [SIGNATURE RENDER] signatureAttr généré:', {
hasAttr: !!signatureAttr,
attrLength: signatureAttr.length,
attrPreview: signatureAttr.substring(0, 100)
});
const html = `<docuseal-form
data-src="${embedSrc}"
data-language="fr"
data-with-title="false"
data-background-color="#fff"
data-allow-typed-signature="false"
${signatureAttr}>
</docuseal-form>`;
console.log('🎨 [SIGNATURE RENDER] HTML final généré');
console.log('🎨 [SIGNATURE RENDER] HTML length:', html.length);
console.log('🎨 [SIGNATURE RENDER] HTML contient data-signature:', html.includes('data-signature='));
return html;
})()
}} />
) : (
<div className="p-4 text-slate-500">Préparation du formulaire</div>
)}
</div>
</dialog>
{/* Modale de chargement */}
<LoadingModal
isOpen={isLoadingSignature}
title="Préparation de la signature"
description="Chargement de l'interface de signature électronique..."
onClose={() => setIsLoadingSignature(false)}
/>
{/* Modale de confirmation de paiement */}
<ConfirmationModal
isOpen={showPaymentModal}
title={selectedPaieStatus ? "Confirmer le paiement" : "Confirmer l'annulation"}
description={
selectedPaieStatus
? "Êtes-vous sûr de vouloir marquer cette paie comme payée ? Cette action mettra à jour le statut de paiement dans le système."
: "Êtes-vous sûr de vouloir marquer cette paie comme non payée ? Cette action annulera le statut de paiement dans le système."
}
confirmText={selectedPaieStatus ? "Valider" : "Valider"}
cancelText="Annuler"
onConfirm={confirmTogglePayment}
onCancel={() => {
setShowPaymentModal(false);
setSelectedPaieId("");
setSelectedPaieStatus(false);
}}
confirmButtonVariant="gradient"
isLoading={markAsPaidMutation.isPending}
/>
{/* Modale de visualisation des fiches de paie */}
{isPayslipModalOpen && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/50"
onClick={() => setIsPayslipModalOpen(false)}
>
<div
className="relative w-full max-w-6xl h-[90vh] bg-white rounded-2xl shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header sticky */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-white border-b rounded-t-2xl">
<h2 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
{currentPayslipTitle}
{data?.numero && (
<>
<span className="text-slate-400"></span>
<span className="text-sm font-normal text-slate-500">
Contrat {data.numero}
</span>
</>
)}
</h2>
<div className="flex items-center gap-2">
<Button
onClick={() => window.open(currentPayslipUrl, '_blank')}
variant="outline"
size="sm"
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
Ouvrir dans un nouvel onglet
</Button>
<Button
onClick={() => {
const link = document.createElement('a');
link.href = currentPayslipUrl;
link.download = `${currentPayslipTitle.replace(/\s+/g, '_')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
variant="outline"
size="sm"
className="gap-2"
>
<Download className="w-4 h-4" />
Télécharger
</Button>
<Button
onClick={() => setIsPayslipModalOpen(false)}
variant="ghost"
size="sm"
className="gap-2"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden">
{payslipPdfError ? (
<div className="flex flex-col items-center justify-center h-full text-slate-500 gap-4">
<AlertCircle className="w-16 h-16 text-slate-400" />
<p>Impossible de charger le PDF</p>
<Button
onClick={() => window.open(currentPayslipUrl, '_blank')}
variant="outline"
size="sm"
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
Ouvrir dans un nouvel onglet
</Button>
</div>
) : (
<iframe
src={currentPayslipUrl}
className="w-full h-full border-0"
title={currentPayslipTitle}
onError={() => setPayslipPdfError(true)}
/>
)}
</div>
</div>
</div>
)}
</div>
);
}