1587 lines
66 KiB
TypeScript
1587 lines
66 KiB
TypeScript
"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 d’objet" 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, '"')}"` : '';
|
||
|
||
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>
|
||
);
|
||
}
|