1139 lines
44 KiB
TypeScript
1139 lines
44 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, 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, '"')}"` : '';
|
||
|
||
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>
|
||
);
|
||
}
|