espace-paie-odentas/app/(app)/contrats/[id]/page.tsx
odentas 897af4b23a feat: Ajout fonctionnalités virements, facturation, signatures et emails
- Ajout sous-header total net à payer sur page virements-salaires
- Migration transfer_done_at pour tracking précis des virements
- Nouvelle page saisie tableau pour création factures en masse
- APIs bulk pour mise à jour dates signature et jours technicien
- API demande mandat SEPA avec email template
- Webhook DocuSeal pour signature contrats (mode TEST)
- Composants modaux détails et vérification PDF fiches de paie
- Upload/suppression/remplacement PDFs dans PayslipsGrid
- Amélioration affichage colonnes et filtres grilles contrats/paies
- Template email mandat SEPA avec sous-texte CTA
- APIs bulk facturation (création, update statut/date paiement)
- API clients sans facture pour période donnée
- Corrections calculs dates et montants avec auto-remplissage
2025-11-02 23:26:19 +01:00

1710 lines
No EOL
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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";
// ---------- Currency helpers ----------
const fmtEUR = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
function formatEUR(value?: string | number | null): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "number" && Number.isFinite(value)) return fmtEUR.format(value);
if (typeof value === "string") {
// Nettoyage: garde chiffres/.,, et signe ; remplace virgule par point pour le parse
const raw = value.trim();
// Si la chaîne contient déjà un € et semble formatée, on tente d'en extraire un nombre
const cleaned = raw.replace(/[^0-9,.-]/g, "").replace(/,(?=\d{1,2}$)/, ".");
const num = parseFloat(cleaned.replace(/,/g, "."));
if (!Number.isNaN(num)) return fmtEUR.format(num);
// Sinon, on renvoie tel quel
return raw;
}
return undefined;
}
import Link from "next/link";
import Script from "next/script";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------- Hook récupération fiches de paie Supabase ----------
// ...existing code...
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;
};
function usePayslips(contractId: string) {
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 usePayslips debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
contractId
});
// 🎭 Mode démo : utiliser les données fictives pour les contrats demo
if (isDemoMode && contractId === 'demo-cont-001') {
console.log('🎭 Demo mode detected, loading demo payslips...');
const DEMO_PAYSLIPS: Payslip[] = [
{
id: "demo-payslip-001",
contract_id: "demo-cont-001",
period_start: "2024-01-15",
period_end: "2024-06-30",
period_month: "2024-06",
pay_number: 1,
gross_amount: "850.00",
net_amount: "623.45",
net_after_withholding: "623.45",
employer_cost: "1247.50",
pay_date: "2024-07-15",
processed: true,
aem_status: "valide",
transfer_done: true,
analytic_tag: "SPECTACLE-2024",
storage_path: "/demo/payslips/demo-payslip-001.pdf",
source_reference: "DEMO-PAY-001",
created_at: "2024-07-01T10:00:00Z"
}
];
console.log('✅ Demo payslips loaded:', DEMO_PAYSLIPS.length);
return {
data: DEMO_PAYSLIPS,
isLoading: false,
error: null,
isError: false,
isFetching: false
};
}
// Mode normal : récupération via API
return useQuery<Payslip[]>({
queryKey: ["payslips", contractId],
queryFn: async () => {
// Call the server-side API so that the Supabase server client can forward the
// user's JWT (from cookies) and RLS policies will be evaluated correctly.
const url = `/api/payslips?contract_id=${encodeURIComponent(contractId)}`;
const res = await fetch(url, { credentials: "include", headers: { Accept: "application/json" } });
if (!res.ok) {
if (res.status === 403) throw new Error("access_denied");
if (res.status === 404) throw new Error("not_found");
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
return Array.isArray(json) ? json : [];
},
staleTime: 15_000,
});
}
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Loader2, ArrowLeft, Check, Pencil, Download, Info, AlertTriangle, CheckCircle, Clock, Copy, PenTool, XCircle, Users, Send, FileText, CreditCard, Shield, Calendar, StickyNote, Euro } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { NotesSection } from "@/components/NotesSection";
import { LoadingModal } from "@/components/ui/loading-modal";
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
import DocumentsCard from "@/components/contrats/DocumentsCard";
import { useMemo, useState, useEffect } from "react";
import { toast } from "sonner";
// ---------- Types ----------
type StatutSimple = "oui" | "non" | "na" | "en_attente";
type EtatDemande = "Pré-demande" | "Reçue" | "Traitée" | "signe" | "modification" | "non_commence";
// Types pour la timeline
type TimelineStepStatus = "completed" | "current" | "upcoming";
type TimelineStep = {
id: string;
label: string;
status: TimelineStepStatus;
};
// Fonction pour déterminer l'état de chaque étape de la timeline
function getTimelineSteps(etat_demande: EtatDemande, contrat_signe_salarie: StatutSimple, payslips?: any[]): TimelineStep[] {
const steps: TimelineStep[] = [];
// Étape 1: Réception/Pré-demande
if (etat_demande === "Pré-demande") {
steps.push({ id: "reception", label: "Pré-demande", status: "upcoming" });
} else {
steps.push({ id: "reception", label: "Reçu", status: "completed" });
}
// Étape 2: Préparation/Envoi du contrat
if (etat_demande === "Pré-demande") {
steps.push({ id: "envoi", label: "Envoi du contrat", status: "upcoming" });
} else if (etat_demande === "Reçue") {
steps.push({ id: "envoi", label: "Préparation du contrat", status: "current" });
} else if (etat_demande === "Traitée") {
steps.push({ id: "envoi", label: "Envoyé", status: "completed" });
} else {
// Pour les autres états (signe, modification, non_commence)
steps.push({ id: "envoi", label: "Envoyé", status: "completed" });
}
// Étape 3: Signature du contrat
const step3Completed = contrat_signe_salarie === "oui";
if (etat_demande === "Pré-demande" || etat_demande === "Reçue") {
steps.push({ id: "signature", label: "Signature du contrat", status: "upcoming" });
} else if (etat_demande === "Traitée" && contrat_signe_salarie === "non") {
steps.push({ id: "signature", label: "Signature en cours", status: "current" });
} else if (step3Completed) {
steps.push({ id: "signature", label: "Signé", status: "completed" });
} else {
// État de fallback
steps.push({ id: "signature", label: "Signature du contrat", status: "upcoming" });
}
// Étape 4: Traitement de la paie
// Récupérer la première paie (il ne peut y en avoir qu'une selon la spécification)
const firstPayslip = payslips && payslips.length > 0 ? payslips[0] : null;
const step4Completed = firstPayslip && firstPayslip.processed === true;
if (!step3Completed) {
// Si l'étape 3 n'est pas validée, mettre en gris
steps.push({ id: "paie", label: "Traitement de la paie", status: "upcoming" });
} else if (firstPayslip) {
// Si on a une paie
if (step4Completed) {
steps.push({ id: "paie", label: "Paie traitée", status: "completed" });
} else {
steps.push({ id: "paie", label: "Paie en cours de traitement", status: "current" });
}
} else {
// Pas de paie encore créée mais étape 3 validée
steps.push({ id: "paie", label: "Traitement de la paie", status: "current" });
}
// Étape 5: Paiement du salaire
if (!step4Completed) {
// Si l'étape 4 n'est pas validée, mettre en gris
steps.push({ id: "paiement", label: "Paiement du salaire", status: "upcoming" });
} else if (firstPayslip) {
// Si on a une paie traitée
if (firstPayslip.transfer_done === true) {
steps.push({ id: "paiement", label: "Salaire versé", status: "completed" });
} else {
steps.push({ id: "paiement", label: "Salaire à verser", status: "current" });
}
} else {
// Cas de fallback (ne devrait pas arriver si step4Completed est true)
steps.push({ id: "paiement", label: "Paiement du salaire", status: "upcoming" });
}
return steps;
}
type ContratDetail = {
id: string;
numero: string; // ex: "YW2KSC85"
regime: "CDDU_MONO"; // cette page cible ce cas
salarie: { nom: string; email?: string };
salarie_matricule?: string; // Matricule API du salarié pour lien fiche
production: string; // Spectacle / Prod
objet?: string; // Numéro d'objet
profession: string; // code + libellé
categorie_prof?: string;
type_salaire?: string; // Brut / Net etc.
salaire_demande?: string; // "622,40€"
salaires_par_date?: { // Détail des salaires par date (JSONB)
mode: "par_date";
type_salaire: string;
representations?: Array<{
date: string;
items: Array<{ numero: number; montant: number }>;
}>;
repetitions?: Array<{
date: string;
items: Array<{ numero: number; montant: number; duree_heures?: number }>;
}>;
jours_travail?: Array<{
date: string;
montant: number;
heures?: number;
}>;
total_calcule: number;
};
date_debut: string; // ISO
date_fin: string; // ISO
panier_repas?: StatutSimple;
// Paie & pdf
pdf_contrat?: { available: boolean; url?: string };
pdf_avenant?: { available: boolean; url?: string }; // souvent n/a mono-mois
pdf_paie?: { available: boolean; url?: string }; // Paie, AEM, Congés Spectacles (bundle ou séparés)
etat_traitement?: "a_traiter" | "en_cours" | "termine";
virement_effectue?: boolean | string; // Peut arriver en "Oui"/"Non" depuis l'API
salaire_net_avant_pas?: string; // "Bientôt disponible" sinon
net_a_payer_rib?: string;
salaire_brut?: string;
cout_employeur?: string;
precisions_salaire?: string;
// Signatures & contrat
etat_demande: EtatDemande; // "Reçue", etc.
contrat_signe_employeur: StatutSimple;
contrat_signe_salarie: StatutSimple;
etat_contrat?: "non_commence" | "en_cours" | "termine";
// Déclarations
dpae?: "À traiter" | "OK";
aem?: "À traiter" | "OK";
// Temps de travail réel
jours_travailles?: number;
jours_travail?: string;
jours_travail_non_artiste?: string;
dates_representations?: string;
dates_repetitions?: string;
nb_representations?: number;
nb_services_repetitions?: number;
nb_heures_repetitions?: number;
nb_heures_annexes?: number;
nb_cachets_aem?: number;
nb_heures_aem?: number;
// Métadonnées
created_at?: string;
updated_at?: string;
};
// ---------- Data hooks ----------
function useContratDetail(id: string) {
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 useContratDetail debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
contractId: id
});
// 🎭 Mode démo : utiliser les données fictives pour l'ID demo
if (isDemoMode && id === 'demo-cont-001') {
console.log('🎭 Demo mode detected, loading demo contract details...');
const DEMO_CONTRACT_DETAIL: ContratDetail = {
id: "demo-cont-001",
numero: "DEMO-2024-001",
regime: "CDDU_MONO",
salarie: {
nom: "MARTIN Alice",
email: "alice.martin@demo.fr"
},
salarie_matricule: "demo-sal-001",
production: "Les Misérables - Tournée 2024",
objet: "PROD-2024-15",
profession: "04201 - Comédien",
categorie_prof: "Artiste interprète",
type_salaire: "Forfait cachet",
salaire_demande: "850,00€",
date_debut: "2024-01-15",
date_fin: "2024-06-30",
panier_repas: "oui",
// PDFs et documents
pdf_contrat: { available: true, url: "/demo/contrat-demo.pdf" },
pdf_avenant: { available: false },
pdf_paie: { available: true, url: "/demo/paie-demo.pdf" },
// États et statuts
etat_traitement: "termine",
virement_effectue: true,
salaire_net_avant_pas: "623,45€",
net_a_payer_rib: "623,45€",
salaire_brut: "850,00€",
cout_employeur: "1.247,50€",
precisions_salaire: "Contrat démo - Tarif spectacle vivant",
// Signatures et contrat
etat_demande: "Traitée",
contrat_signe_employeur: "oui",
contrat_signe_salarie: "oui",
etat_contrat: "termine",
// Déclarations
dpae: "OK",
aem: "OK",
// Temps de travail
jours_travailles: 25,
nb_representations: 18,
nb_services_repetitions: 12,
nb_heures_repetitions: 48,
nb_heures_annexes: 8,
nb_cachets_aem: 18,
nb_heures_aem: 0,
// Métadonnées
created_at: "2024-01-10T09:00:00Z",
updated_at: "2024-07-01T16:30:00Z"
};
console.log('✅ Demo contract details loaded');
return {
data: DEMO_CONTRACT_DETAIL,
isLoading: false,
error: null,
isError: false,
isFetching: false
};
}
// Mode normal : récupération via API
return useQuery<ContratDetail>({
queryKey: ["contrat", id],
queryFn: async () => {
// TEMPORAIRE : Contournement pour test (À SUPPRIMER après avoir le bon token)
const bypassSecurity = false; // CHANGEZ EN FALSE APRÈS !
if (bypassSecurity) {
console.log('⚠️ BYPASS SÉCURITÉ ACTIVÉ - À SUPPRIMER EN PROD');
// Appel direct à l'ancien système
const directResponse = await api<ContratDetail>(`/contrats/${id}`);
return directResponse;
}
try {
// ✅ FORCER l'appel via l'API Next.js sécurisée (pas le Lambda direct)
const response = await fetch(`/api/contrats/${id}`, {
credentials: 'include', // Important pour les cookies de session
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (!response.ok) {
if (response.status === 403) {
throw new Error("access_denied");
}
if (response.status === 404) {
throw new Error("not_found");
}
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
// Gestion spécifique des erreurs d'accès
if (message.includes('403') || message.includes('forbidden') || message === 'access_denied') {
throw new Error("access_denied");
}
if (message.includes('404') || message === 'not_found') {
throw new Error("not_found");
}
throw error;
}
},
staleTime: 15_000,
retry: (failureCount: number, error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
// Ne pas réessayer si c'est un problème d'accès
if (message === "access_denied" || message === "not_found") {
return false;
}
return failureCount < 3;
}
});
}
// ---------- UI helpers ----------
function Section({ title, icon: Icon, children }: { title: string; icon?: React.ElementType; children: React.ReactNode }) {
return (
<Card className="rounded-3xl overflow-hidden">
<CardHeader className="bg-slate-50/60 border-b">
<CardTitle className="flex items-center gap-3">
{Icon && <Icon className="size-5 text-slate-600" />}
<span className="text-lg font-semibold">{title}</span>
</CardTitle>
</CardHeader>
<CardContent className="p-6">
{children}
</CardContent>
</Card>
);
}
function Field({ label, value, hint, action }: { label: string; value?: React.ReactNode; hint?: string; 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> : hint ? <div className="text-xs text-slate-400">{hint}</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) {
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 | string) {
const input = String(s || "").trim();
const normalized = input
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/\s+/g, " ");
// Mapping des 5 états officiels -> style mini-card harmonisé
// Couleurs:
// - Reçue: bleu
// - Pré-demande: gris
// - En cours de traitement: ambre
// - Traitée: vert
// - En cours d'envoi: indigo
type Style = { bg: string; border: string; text: string; label: string };
const styles: Record<string, Style> = {
"Reçue": { bg: "bg-sky-50", border: "border-sky-200", text: "text-sky-800", label: "Reçue" },
"Pré-demande": { bg: "bg-slate-50", border: "border-slate-200", text: "text-slate-700", label: "Pré-demande" },
"En cours de traitement": { bg: "bg-amber-50", border: "border-amber-200", text: "text-amber-800", label: "En cours de traitement" },
"Traitée": { bg: "bg-emerald-50", border: "border-emerald-200", text: "text-emerald-800", label: "Traitée" },
"En cours d'envoi": { bg: "bg-indigo-50", border: "border-indigo-200", text: "text-indigo-800", label: "En cours d'envoi" },
};
// Normalisations alternatives depuis l'API
const alt = normalized
.replace(/-/g, " ")
.replace(/_/g, " ");
const style =
styles[normalized] ||
styles[alt] ||
// fallback générique
{ 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" });
}
// ---------- Composant d'erreur d'accès ----------
function AccessDeniedError({ contractId }: { contractId: string }) {
return (
<div className="rounded-2xl border bg-white p-8">
<div className="flex flex-col items-center text-center max-w-md mx-auto space-y-4">
<div className="w-16 h-16 rounded-full bg-rose-100 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-rose-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 mb-2">
Accès non autorisé
</h2>
<p className="text-sm text-slate-600 leading-relaxed">
Vous n'avez pas l'autorisation de consulter ce contrat. Il est possible qu'il appartienne à une autre organisation ou qu'il n'existe pas.
</p>
</div>
<div className="text-xs text-slate-500 bg-slate-50 px-3 py-2 rounded-lg font-mono">
ID: {contractId}
</div>
<Link
href="/contrats"
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux contrats
</Link>
</div>
</div>
);
}
// ---------- Page ----------
export default function ContratPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { data, isLoading, isError, error } = useContratDetail(id);
// Définir le titre basé sur les données du contrat
const contractTitle = data?.numero
? `Contrat ${data.numero}`
: `Contrat CDDU`;
usePageTitle(contractTitle);
const payslipsQuery = usePayslips(id);
const [signedPayslipUrls, setSignedPayslipUrls] = useState<Record<string, string>>({});
// State pour la modale de signature DocuSeal
const [embedSrc, setEmbedSrc] = useState<string>("");
const [modalTitle, setModalTitle] = useState<string>("");
const [signatureB64ForDocuSeal, setSignatureB64ForDocuSeal] = useState<string | null>(null); // Signature pour pré-remplissage
// 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 confirmation de paiement
const [showPaymentModal, setShowPaymentModal] = useState<boolean>(false);
const [selectedPayslipId, setSelectedPayslipId] = useState<string>("");
const [selectedPayslipStatus, setSelectedPayslipStatus] = 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]);
// Mutation pour marquer une paie 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 payslips
queryClient.invalidateQueries({ queryKey: ["payslips", id] });
toast.success(variables.transferDone ? "Paiement marqué comme effectué !" : "Paiement marqué comme non effectué !");
setShowPaymentModal(false);
setSelectedPayslipId("");
setSelectedPayslipStatus(false);
},
onError: (error: Error) => {
toast.error("Erreur : " + error.message);
},
});
// Fonction pour ouvrir le modal de confirmation
const handleTogglePayment = (payslipId: string, currentStatus: boolean) => {
setSelectedPayslipId(payslipId);
setSelectedPayslipStatus(!currentStatus);
setShowPaymentModal(true);
};
// Fonction pour confirmer le changement de statut
const confirmTogglePayment = () => {
if (selectedPayslipId) {
markAsPaidMutation.mutate({
payslipId: selectedPayslipId,
transferDone: selectedPayslipStatus
});
}
};
// 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("Signature électronique non disponible pour ce contrat");
}
} catch (error) {
console.error('❌ [SIGNATURE] Erreur:', error);
setIsLoadingSignature(false);
toast.error("Erreur lors de l'ouverture de la signature électronique");
}
}
useEffect(() => {
async function fetchSignedUrls() {
if (payslipsQuery.data) {
const urls: Record<string, string> = {};
for (const slip of payslipsQuery.data) {
if (slip.storage_path) {
try {
const res = await fetch("/api/payslips/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ storage_path: slip.storage_path, expiresInSeconds: 3600 })
});
const json = await res.json();
urls[slip.id] = json.url || "";
console.log("Presigned URL API pour", slip.id, ":", json.url);
} catch (err) {
urls[slip.id] = "";
console.warn("Erreur API presign pour", slip.id, err);
}
} else {
console.warn("Pas de storage_path pour la paie", slip.id);
}
}
setSignedPayslipUrls(urls);
}
}
fetchSignedUrls();
}, [payslipsQuery.data]);
// Redirection de sécurité côté client
useEffect(() => {
// Si l'utilisateur essaie d'accéder à un ID suspect, on peut ajouter des vérifications
if (id && !/^[a-zA-Z0-9\-_]{1,50}$/.test(id)) {
console.warn("Suspicious contract ID format:", id);
router.push("/contrats");
}
}, [id, router]);
const title = useMemo(() => (data ? `Contrat n° ${data.numero}` : "Contrat"), [data]);
// virementOn: déduire un booléen de la valeur potentiellement "Oui"/"Non"/boolean
const virementOn = useMemo(() => {
const v = data?.virement_effectue;
if (typeof v === "boolean") return v;
const s = String(v || "").trim().toLowerCase();
return s === "oui" || s === "true" || s === "1";
}, [data?.virement_effectue]);
// 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;
}
// Calcul basé sur les dates
if (!data.date_debut || !data.date_fin) return undefined;
function toLocalDateOnly(isoString: string) {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const today = toLocalDateOnly(new Date().toISOString());
const start = toLocalDateOnly(data.date_debut);
const end = toLocalDateOnly(data.date_fin);
if (today < start) return "Non commencé" as const;
if (today > end) return "Terminé" as const;
return "En cours" as const;
}, [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>
);
}
// Gestion spécifique des erreurs d'accès
if (isError) {
const errorMessage = (error as any)?.message || "";
if (errorMessage === "access_denied") {
return <AccessDeniedError contractId={id} />;
}
if (errorMessage === "not_found") {
return (
<div className="rounded-2xl border bg-white p-6">
<div className="text-amber-600 font-medium mb-2">Contrat introuvable</div>
<div className="text-sm text-slate-500">Le contrat demandé n'existe pas ou a été supprimé.</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>
);
}
// Erreur générique
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">{errorMessage || "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>
);
}
if (!data) {
return <AccessDeniedError contractId={id} />;
}
// 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"
};
}
};
return (
<div className="space-y-5">
{/* Barre titre + actions */}
<div className="flex flex-col md:flex-row md:items-center gap-3">
<div className="flex 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 en cours
</Link>
</div>
<div className="md:ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => {
// Construire le payload de duplication avec vérifications améliorées
const payload: any = {
// Production : utiliser production (nom de la production)
spectacle: data.production || "",
numero_objet: data.objet || null,
// Salarié : améliorer la récupération des informations
salarie: data.salarie_matricule || data.salarie ? {
matricule: data.salarie_matricule || "",
nom: data.salarie?.nom || data.salarie_matricule || "",
email: data.salarie?.email || null,
} : null,
// Catégorie professionnelle : améliorer la logique de détection
categorie: (() => {
// Récupérer la catégorie depuis différentes sources possibles
const catSource = (
data.categorie_prof ||
""
).toString().toLowerCase();
// Logique de détection améliorée
if (catSource.includes("tech") || catSource.includes("technicien")) {
return "Technicien";
} else if (catSource.includes("artiste") || catSource.includes("artist")) {
return "Artiste";
} else {
// Fallback : essayer de déduire depuis la profession
const professionStr = (data.profession || "").toString().toLowerCase();
if (professionStr.includes("tech") || professionStr.includes("ingé") || professionStr.includes("régiss")) {
return "Technicien";
}
// Par défaut : Artiste (plus fréquent)
return "Artiste";
}
})(),
// Profession : améliorer l'extraction
profession: {
label: data.profession || "",
},
// Dates
date_debut: data.date_debut || "",
date_fin: data.date_fin || "",
// Représentations et services
nb_representations: typeof data.nb_representations === 'number' ? data.nb_representations : "",
nb_services_repetition: typeof data.nb_services_repetitions === 'number' ? data.nb_services_repetitions : "",
// Heures : logique améliorée
heures_total: (() => {
// Essayer plusieurs sources d'heures
const sources = [
data.nb_heures_aem,
(Number(data.nb_heures_repetitions || 0) + Number(data.nb_heures_annexes || 0))
];
for (const source of sources) {
const num = Number(source);
if (Number.isFinite(num) && num > 0) return num;
}
return "";
})(),
minutes_total: "0", // Par défaut 0 minutes
// Jours de travail
jours_travail: (() => {
if (typeof data.jours_travailles === "number" && data.jours_travailles > 0) {
return String(data.jours_travailles);
}
return "";
})(),
// Type de salaire : normaliser les valeurs
type_salaire: (() => {
const typeStr = (data.type_salaire || "").toString().toLowerCase();
const validTypes = ["Brut", "Net avant PAS", "Coût total employeur", "Minimum conventionnel"];
// Recherche par correspondance partielle
for (const validType of validTypes) {
if (typeStr.includes(validType.toLowerCase()) ||
validType.toLowerCase().includes(typeStr)) {
return validType;
}
}
// Fallbacks spécifiques
if (typeStr.includes("brut")) return "Brut";
if (typeStr.includes("net") && typeStr.includes("pas")) return "Net avant PAS";
if (typeStr.includes("coût") || typeStr.includes("cout") || typeStr.includes("total")) return "Coût total employeur";
if (typeStr.includes("minimum") || typeStr.includes("conventionnel")) return "Minimum conventionnel";
return "Brut"; // Par défaut
})(),
// Montant : améliorer l'extraction numérique
montant: (() => {
const sources = [data.salaire_demande];
for (const source of sources) {
if (!source) continue;
const raw = source.toString();
// Nettoyer : garder seulement chiffres, points, virgules et signes moins
const cleaned = raw.replace(/[^0-9,.-]/g, '');
// Remplacer la dernière virgule par un point (format décimal français)
const normalized = cleaned.replace(/,(?=\d{1,2}$)/, '.');
// Puis remplacer les autres virgules par rien (séparateurs de milliers)
const finalClean = normalized.replace(/,/g, '.');
const num = parseFloat(finalClean);
if (Number.isFinite(num) && num > 0) return num;
}
return "";
})(),
// Panier repas : normaliser
panier_repas: (() => {
const val = (data.panier_repas || "").toString().toLowerCase();
if (val === 'oui' || val === 'true' || val === '1') return 'Oui';
if (val === 'non' || val === 'false' || val === '0') return 'Non';
return 'Non'; // Par défaut
})(),
notes: "", // Vide pour une nouvelle demande
multi_mois: false, // Par défaut mono-mois
};
// Encoder en base64 et rediriger
const json = JSON.stringify(payload);
let b64 = "";
try {
b64 = btoa(json);
} catch {
// Fallback Node.js
b64 = Buffer.from(json, 'utf8').toString('base64');
}
router.push(`/contrats/nouveau?dupe=${encodeURIComponent(b64)}`);
}}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border"
title="Dupliquer ce contrat"
>
<Copy className="w-4 h-4" />
Dupliquer
</button>
<Link href={`/contrats/${data.id}/edit`} className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border">
<Pencil className="w-4 h-4" /> Modifier
</Link>
<button className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border opacity-60 cursor-not-allowed" title="À définir">
<Check className="w-4 h-4" /> Valider
</button>
</div>
</div>
<div className="rounded-2xl border bg-white p-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-center">
{/* Titre du contrat - 1 colonne */}
<div className="lg:col-span-1">
<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</div>
</div>
</div>
{/* Timeline - 3 colonnes */}
<div className="lg:col-span-3">
<div className="flex items-center justify-between relative">
{/* Ligne de progression */}
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-slate-200 -translate-y-1/2 z-0"></div>
{(() => {
const steps = getTimelineSteps(data.etat_demande, data.contrat_signe_salarie, payslipsQuery.data);
const completedSteps = steps.filter(s => s.status === "completed").length;
const progressPercentage = Math.max(0, Math.min(100, (completedSteps / steps.length) * 100));
return (
<div
className="absolute top-1/2 left-0 h-0.5 bg-blue-500 -translate-y-1/2 z-0"
style={{ width: `${progressPercentage}%` }}
></div>
);
})()}
{/* Étapes */}
<div className="flex items-center justify-between w-full relative z-10">
{getTimelineSteps(data.etat_demande, data.contrat_signe_salarie, payslipsQuery.data).map((step, index) => (
<div key={step.id} className="flex flex-col items-center bg-white px-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
step.status === "completed"
? "bg-green-500"
: step.status === "current"
? "bg-blue-500"
: "bg-slate-300"
}`}>
{step.status === "completed" ? (
<Check className="w-4 h-4 text-white" />
) : step.status === "current" ? (
<Clock className="w-4 h-4 text-white" />
) : (
<Clock className="w-4 h-4 text-slate-500" />
)}
</div>
<div className={`text-xs font-medium text-center ${
step.status === "upcoming"
? "text-slate-500"
: "text-slate-700"
}`}>
{step.label}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Grille 2 colonnes */}
<div className="flex flex-col md:grid md:grid-cols-2 gap-5 md:items-start">
{/* Left column: Documents, Demande, Notes - ordre 1 sur mobile */}
<div className="space-y-5 order-1">
{/* 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
}}
/>
<Section title="Demande" icon={FileText}>
<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="Avenant contrat PDF"
value={
data.pdf_avenant?.available ? (
<a className="inline-flex items-center gap-2 underline" href={data.pdf_avenant.url} target="_blank" rel="noreferrer">
<Download className="w-4 h-4" /> Télécharger
</a>
) : (
<span className="text-slate-400">n/a</span>
)
}
/>
<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.salaires_par_date ? (
<Field
label="Salaire demandé (par date)"
value={
<div className="space-y-2 text-sm">
{/* Représentations */}
{data.salaires_par_date.representations && data.salaires_par_date.representations.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">Représentations</div>
{data.salaires_par_date.representations.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
R{item.numero}: <span className="font-semibold">{formatEUR(item.montant)}</span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Répétitions */}
{data.salaires_par_date.repetitions && data.salaires_par_date.repetitions.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-purple-700 uppercase tracking-wide">Répétitions</div>
{data.salaires_par_date.repetitions.map((rep: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{rep.date}</span>
<div className="flex flex-wrap gap-2">
{rep.items.map((item: any, itemIdx: number) => (
<span key={itemIdx} className="text-slate-600">
S{item.numero}: <span className="font-semibold">{formatEUR(item.montant)}</span>
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Jours travaillés */}
{data.salaires_par_date.jours_travail && data.salaires_par_date.jours_travail.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-green-700 uppercase tracking-wide">Jours travaillés</div>
{data.salaires_par_date.jours_travail.map((jour: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<span className="font-medium text-slate-700 w-12">{jour.date}</span>
<span className="text-slate-600">
Jour: <span className="font-semibold">{formatEUR(jour.montant)}</span>
</span>
</div>
))}
</div>
)}
{/* Total */}
<div className="pt-2 border-t border-slate-200 mt-2">
<span className="text-xs font-semibold text-slate-700">Total: </span>
<span className="text-sm font-bold text-indigo-900">{formatEUR(data.salaires_par_date.total_calcule)}</span>
</div>
</div>
}
/>
) : (
<Field label="Salaire demandé" value={formatEUR(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 Notes */}
<NotesSection contractId={id} contractRef={data.numero} />
</div>
{/* Right column: Signature électronique, Déclarations, Paie, Temps de travail réel - ordre 2 sur mobile */}
<div className="space-y-5 order-2">
{/* 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" icon={Shield}>
<Field
label="DPAE"
value={(() => {
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 (
<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 (
<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 (
<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 <span className="text-xs text-slate-600">{raw}</span>;
}
// Aucune valeur
return <span className="text-xs text-slate-400">—</span>;
})()}
/>
</Section>
<Section title="Paie" icon={CreditCard}>
{/* Nouvelle logique : affichage des fiches de paie Supabase */}
{payslipsQuery.isLoading ? (
<div className="text-slate-400">Chargement des fiches de paie…</div>
) : payslipsQuery.data && payslipsQuery.data.length > 0 ? (
payslipsQuery.data.map((slip) => (
<div key={slip.id} className="mb-4 p-4 rounded-lg border bg-slate-50 relative">
{/* Bouton de paiement en position absolue dans le coin bas droit */}
{slip.processed && (
<Button
onClick={() => handleTogglePayment(slip.id, slip.transfer_done)}
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={slip.transfer_done ? "Marquer comme non payée" : "Noter comme payée"}
>
<Euro className="w-3 h-3" />
</Button>
)}
<div className="mb-3">
<div className="font-medium text-slate-700">
Période : {formatDateFR(slip.period_start)} {formatDateFR(slip.period_end)}
</div>
</div>
<Field label="État de traitement" value={slip.processed ? <Badge tone="ok">Terminé</Badge> : <Badge tone="warn">À traiter</Badge>} />
<Field label="Virement effectué" value={slip.transfer_done ? <Badge tone="ok">Oui</Badge> : <Badge tone="error">Non</Badge>} />
<Field label="Net avant PAS" value={formatEUR(slip.net_amount)} />
<Field label="Net après PAS" value={formatEUR(slip.net_after_withholding)} />
<Field label="Brut" value={formatEUR(slip.gross_amount)} />
<Field label="Coût total employeur" value={formatEUR(slip.employer_cost)} />
<Field label="AEM" value={(() => {
const aemNorm = String(slip.aem_status || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').trim().toLowerCase();
const aemOk = aemNorm === 'ok' || aemNorm === 'oui' || aemNorm.includes('ok') || aemNorm === 'traite' || aemNorm === 'traitee' || aemNorm.includes('traite');
return aemOk ? <Badge tone="ok">AEM OK</Badge> : <Badge tone="warn">À traiter</Badge>;
})()} />
</div>
))
) : (
<span className="text-slate-400">Aucune fiche de paie disponible.</span>
)}
</Section>
<Section title="Temps de travail réel" icon={Calendar}>
<Field
label="Jours travaillés"
value={
data.categorie_prof === "Technicien" && data.jours_travail_non_artiste
? data.jours_travail_non_artiste
: (data.jours_travailles ?? 0)
}
/>
<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>
</div>
{/* Script DocuSeal */}
<Script src="https://cdn.docuseal.com/js/form.js" strategy="lazyOnload" />
{/* Modale d'erreur DocuSeal */}
{showErrorModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl max-w-md mx-4 p-6 shadow-xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<h2 className="text-lg font-semibold text-slate-900">Signature non disponible</h2>
</div>
<div className="text-slate-600 mb-6">
<p className="mb-3">Nous nous excusons pour la gêne occasionnée.</p>
<p>La signature électronique n'est pas encore prête pour ce contrat. Nos équipes travaillent activement sur la préparation des documents.</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowErrorModal(false)}
className="flex-1"
>
Fermer
</Button>
<Button
onClick={() => {
window.location.href = '/support';
}}
className="flex-1 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300"
>
Nous contacter
</Button>
</div>
</div>
</div>
)}
{/* Modale de signature DocuSeal */}
<dialog id="dlg-signature" className="rounded-lg border max-w-5xl w-[96vw] p-0">
<div className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b bg-white">
<strong>{modalTitle || 'Signature électronique'}</strong>
<button
onClick={() => {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) dlg.close();
}}
className="p-1.5 rounded hover:bg-slate-50"
aria-label="Fermer"
title="Fermer"
>
<XCircle className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-0" style={{ height: '80vh', minHeight: 520, overflowY: 'auto' }}>
{embedSrc ? (
<div dangerouslySetInnerHTML={{
__html: (() => {
console.log('🎨 [SIGNATURE RENDER] Génération du HTML docuseal-form');
console.log('🎨 [SIGNATURE RENDER] embedSrc:', embedSrc);
// Utiliser la signature depuis l'etat React au lieu de sessionStorage
const signatureB64 = signatureB64ForDocuSeal;
console.log('🎨 [SIGNATURE RENDER] Signature depuis l\'etat React:', {
exists: !!signatureB64,
length: signatureB64?.length,
preview: signatureB64?.substring(0, 50)
});
const signatureAttr = signatureB64 ? `data-signature="${signatureB64.replace(/"/g, '&quot;')}"` : '';
console.log('🎨 [SIGNATURE RENDER] signatureAttr généré:', {
hasAttr: !!signatureAttr,
attrLength: signatureAttr.length,
attrPreview: signatureAttr.substring(0, 100)
});
const html = `<docuseal-form
data-src="${embedSrc}"
data-language="fr"
data-with-title="false"
data-background-color="#fff"
data-allow-typed-signature="false"
${signatureAttr}>
</docuseal-form>`;
console.log('🎨 [SIGNATURE RENDER] HTML final généré');
console.log('🎨 [SIGNATURE RENDER] HTML length:', html.length);
console.log('🎨 [SIGNATURE RENDER] HTML contient data-signature:', html.includes('data-signature='));
return html;
})()
}} />
) : (
<div className="p-4 text-slate-500">Préparation du formulaire</div>
)}
</div>
</dialog>
{/* Modale de chargement */}
<LoadingModal
isOpen={isLoadingSignature}
title="Préparation de la signature"
description="Chargement de l'interface de signature électronique..."
onClose={() => setIsLoadingSignature(false)}
/>
{/* Modale de confirmation de paiement */}
<ConfirmationModal
isOpen={showPaymentModal}
title={selectedPayslipStatus ? "Confirmer le paiement" : "Confirmer l'annulation"}
description={
selectedPayslipStatus
? "Êtes-vous sûr de vouloir marquer cette paie comme payée ? Cette action mettra à jour le statut de virement dans le système."
: "Êtes-vous sûr de vouloir marquer cette paie comme non payée ? Cette action annulera le statut de virement dans le système."
}
confirmText={selectedPayslipStatus ? "Valider" : "Valider"}
cancelText="Annuler"
onConfirm={confirmTogglePayment}
onCancel={() => {
setShowPaymentModal(false);
setSelectedPayslipId("");
setSelectedPayslipStatus(false);
}}
confirmButtonVariant="gradient"
isLoading={markAsPaidMutation.isPending}
/>
</div>
);
}