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

1139 lines
44 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, X, ExternalLink, AlertCircle, PenTool, XCircle } 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: "RG" | "CDDU_MULTI";
is_multi_mois?: boolean; // drapeau utile pour multi
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" | "Faite" | "Refusée" | "À traiter";
// 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
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-rg", 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;
},
});
}
function usePaies(id: string) {
return useQuery({
queryKey: ["contrat-rg-paies", id],
queryFn: async () => {
if (process.env.NODE_ENV !== 'production') {
console.log("🔍 Fetching paies for contract:", id);
}
const res = await fetch(`/api/contrats/${id}/paies?regime=RG`, {
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});
if (process.env.NODE_ENV !== 'production') {
console.log("📥 Paies response:", { status: res.status, statusText: res.statusText, contractId: id });
}
if (res.status === 403) {
const msg = await res.text().catch(() => "");
if (process.env.NODE_ENV !== 'production') {
console.error("❌ Paies access denied:", msg);
}
throw new Error(msg || "access_denied");
}
if (res.status === 404) {
if (process.env.NODE_ENV !== 'production') {
console.log(" Paies not found (404), returning empty list");
}
// Pas bloquant pour la page : retourne une liste vide
return { items: [] as PaieMulti[] };
}
if (!res.ok) {
const t = await res.text().catch(() => "");
if (process.env.NODE_ENV !== 'production') {
console.error("❌ Paies error:", { status: res.status, error: t, contractId: id });
}
throw new Error(t || `http_${res.status}`);
}
const data = (await res.json()) as { items: PaieMulti[] };
if (process.env.NODE_ENV !== 'production') {
console.log("✅ Paies data received:", { contractId: id, itemsCount: data.items?.length || 0, data });
}
return data;
},
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;
},
});
}
/* =========
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 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 formatDateFR(iso?: string) {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
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 RG`;
usePageTitle(contractTitle);
const { data: paiesData, isLoading: paiesLoading, isError: paiesError, error: paiesErrorObj } = usePaies(id);
const [paiesPage, setPaiesPage] = useState(1);
const PAIES_PER_PAGE = 5;
// State pour la modale de confirmation de paiement
const [paymentModal, setPaymentModal] = useState<{
isOpen: boolean;
payslipId: string | null;
currentStatus: boolean;
}>({
isOpen: false,
payslipId: null,
currentStatus: 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();
// Mutation pour marquer une paie RG 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-rg-paies", id] });
toast.success(variables.transferDone ? "Paiement marqué comme effectué !" : "Paiement marqué comme non effectué !");
setPaymentModal({ isOpen: false, payslipId: null, currentStatus: false });
},
onError: (error: Error) => {
toast.error("Erreur : " + error.message);
},
});
// Fonction pour ouvrir le modal de confirmation
const handleTogglePayment = (paieId: string, currentStatus: boolean) => {
setPaymentModal({
isOpen: true,
payslipId: paieId,
currentStatus: currentStatus,
});
};
// Fonction pour confirmer le changement de statut
const confirmTogglePayment = () => {
if (paymentModal.payslipId) {
markAsPaidMutation.mutate({
payslipId: paymentModal.payslipId,
transferDone: !paymentModal.currentStatus
});
}
};
// Reset pagination when contract id changes
useEffect(() => {
setPaiesPage(1);
}, [id]);
// Effet pour bloquer le défilement quand le modal DocuSeal est ouvert
useEffect(() => {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg && embedSrc) {
document.body.style.overflow = 'hidden';
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 {
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';
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);
};
// Fonctions pour la signature électronique
const getSignatureStatus = () => {
if (!data) return {
label: "En attente",
color: "text-gray-600",
bgColor: "bg-gray-50",
borderColor: "border-gray-200",
icon: Clock
};
const employerSigned = data.contrat_signe_employeur === 'oui';
const employeeSigned = data.contrat_signe_salarie === 'oui';
if (employerSigned && employeeSigned) {
return {
label: "Signature complète",
color: "text-green-600",
bgColor: "bg-green-50",
borderColor: "border-green-200",
icon: CheckCircle
};
}
if (employerSigned || employeeSigned) {
return {
label: "Signature en cours",
color: "text-amber-600",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
icon: Clock
};
}
return {
label: "En attente de signature",
color: "text-gray-600",
bgColor: "bg-gray-50",
borderColor: "border-gray-200",
icon: PenTool
};
};
const openSignature = async () => {
if (!data?.numero) {
toast.error("Numéro de contrat manquant");
return;
}
setIsLoadingSignature(true);
try {
const response = await fetch(`/api/contrats/${id}/docuseal-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contractReference: data.numero })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Erreur ${response.status}`);
}
const { url, signatureBase64 } = await response.json();
if (signatureBase64) {
setSignatureB64ForDocuSeal(signatureBase64);
}
setEmbedSrc(url);
setModalTitle(`Signature électronique - Contrat ${data.numero}`);
setTimeout(() => {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) {
dlg.showModal();
}
setIsLoadingSignature(false);
}, 100);
} catch (error: any) {
console.error('Erreur lors de l\'ouverture de la signature:', error);
setIsLoadingSignature(false);
setShowErrorModal(true);
toast.error(error.message || "Impossible de charger l'interface de signature");
}
};
// Calcule l'état du contrat en fonction des dates et de l'état de la demande
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]);
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>
);
}
const paies = paiesData?.items ?? [];
const totalPaies = paies.length;
const totalPages = Math.max(1, Math.ceil(totalPaies / PAIES_PER_PAGE));
const currentPage = Math.min(paiesPage, totalPages);
const start = (currentPage - 1) * PAIES_PER_PAGE;
const pagedPaies = paies
.slice()
.sort((a,b) => (b.ordre ?? 0) - (a.ordre ?? 0))
.slice(start, start + PAIES_PER_PAGE);
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">Régime général</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 Notes */}
<div className="flex flex-col gap-5">
{/* 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="Profession" value={data.profession} />
<Field label="Catégorie professionnelle" value={data.categorie_prof} />
<Field label="Type de salaire demandé" value={data.type_salaire} />
<Field
label="Salaire demandé"
value={data.salaire_demande ? formatEUR(data.salaire_demande) : undefined}
/>
<Field label="Début contrat" value={formatDateFR(data.date_debut)} />
<Field
label="Fin contrat"
value={(() => {
// Masquer la date de fin si c'est 01/01/2099 (CDI non terminé)
if (!data.date_fin) return "—";
const date = new Date(data.date_fin);
if (date.getFullYear() === 2099 && date.getMonth() === 0 && date.getDate() === 1) {
return <span className="text-slate-400 italic">CDI en cours</span>;
}
return formatDateFR(data.date_fin);
})()}
/>
<Field label="Panier repas" value={boolBadge(data.panier_repas)} />
</Section>
<NotesSection contractId={id} contractRef={data?.numero} />
</div>
{/* Colonne droite : Signature, Déclarations et 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">
<Field
label="DPAE"
value={
data.dpae === 'envoyee' || data.dpae === 'retour_ok' || data.dpae === 'Faite' ? (
<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>
) : data.dpae === 'refusee' || data.dpae === 'Refusée' ? (
<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>
) : data.dpae === 'a_traiter' || data.dpae === 'À traiter' ? (
<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>
) : (
<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.
</div>
) : (
<div className="space-y-4">
{pagedPaies.map((p) => {
const label = payLabel(p) || (p.ordre ? `Paie ${p.ordre}` : 'Paie');
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 dans le coin bas droit */}
{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 ? "Marquer comme non payée" : "Noter comme payée"}
>
<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. 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-3 gap-3 text-sm">
<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>
);
return p.paie_pdf ? (
<div
key={p.id}
onClick={() => openPayslipInModal(p.paie_pdf!, label)}
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 className="flex items-center justify-between pt-2">
<div className="text-xs text-slate-500">
{totalPaies > 0 ? `${start + 1}${Math.min(start + PAIES_PER_PAGE, totalPaies)} sur ${totalPaies}` : `0 sur 0`}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPaiesPage((p) => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="px-2 py-1 text-sm rounded border disabled:opacity-50"
aria-label="Page précédente"
>
Précédent
</button>
<span className="text-xs text-slate-500">Page {currentPage} / {totalPages}</span>
<button
type="button"
onClick={() => setPaiesPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="px-2 py-1 text-sm rounded border disabled:opacity-50"
aria-label="Page suivante"
>
Suivant
</button>
</div>
</div>
</div>
)}
</Section>
</div>
</div>
{/* Modal de confirmation pour le paiement */}
{paymentModal.isOpen && (
<ConfirmationModal
isOpen={paymentModal.isOpen}
onCancel={() => setPaymentModal({ isOpen: false, payslipId: null, currentStatus: false })}
onConfirm={confirmTogglePayment}
title={paymentModal.currentStatus ? "Marquer comme non payée" : "Noter comme payée"}
description={
paymentModal.currentStatus
? "Êtes-vous sûr de vouloir marquer cette paie comme non payée ?"
: "Êtes-vous sûr de vouloir marquer cette paie comme payée ?"
}
confirmButtonVariant="gradient"
isLoading={markAsPaidMutation.isPending}
/>
)}
{/* Script DocuSeal */}
<Script src="https://cdn.docuseal.co/js/form.js" strategy="afterInteractive" />
{/* Dialog pour la signature DocuSeal */}
<dialog id="dlg-signature" className="rounded-2xl shadow-2xl backdrop:bg-black/50 w-full max-w-5xl p-0 overflow-hidden">
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-white border-b">
<h2 className="text-lg font-semibold text-slate-800">{modalTitle}</h2>
<button
onClick={() => {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) dlg.close();
setEmbedSrc("");
}}
className="text-slate-500 hover:text-slate-700"
aria-label="Fermer"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="bg-white min-h-[500px]">
{embedSrc ? (
<div dangerouslySetInnerHTML={{
__html: (() => {
const signatureB64 = signatureB64ForDocuSeal;
const signatureAttr = signatureB64 ? `data-signature="${signatureB64.replace(/"/g, '&quot;')}"` : '';
return `<docuseal-form
data-src="${embedSrc}"
data-language="fr"
data-with-title="false"
data-background-color="#fff"
data-allow-typed-signature="false"
${signatureAttr}>
</docuseal-form>`;
})()
}} />
) : (
<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 d'erreur DocuSeal */}
<ConfirmationModal
isOpen={showErrorModal}
title="Erreur de signature"
description="Impossible de charger l'interface de signature. Veuillez réessayer plus tard."
confirmText="Fermer"
onConfirm={() => setShowErrorModal(false)}
onCancel={() => setShowErrorModal(false)}
/>
{/* 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>
);
}