- Création de la page /staff/contrats/saisie-temps-reel avec tableau éditable - Ajout des colonnes jours_representations et jours_repetitions dans l'API - Construction intelligente du TT Contractuel (concaténation des sources) - Ajout de la colonne temps_reel_traite pour marquer les contrats traités - Interface avec filtres (année, mois, organisation, recherche) - Tri par date/salarié - Édition inline avec auto-save via API - Checkbox pour marquer comme traité (masque automatiquement la ligne) - Toggle pour afficher/masquer les contrats traités - Migration SQL pour la colonne temps_reel_traite - Ajout du menu 'Temps de travail réel' dans la sidebar - Logs de débogage pour le suivi des sauvegardes
1833 lines
No EOL
75 KiB
TypeScript
1833 lines
No EOL
75 KiB
TypeScript
"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, HelpCircle, Repeat, Timer, Wrench, Theater, TrendingUp } 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 : "",
|
||
|
||
// Dates détaillées (texte) pour les représentations et répétitions
|
||
dates_representations: data.dates_representations || "",
|
||
dates_repetitions: data.dates_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}>
|
||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-5">
|
||
{/* Jours travaillés */}
|
||
<div className="md:col-span-2 pb-4 border-b border-blue-200">
|
||
<div className="flex items-start gap-3">
|
||
<div className="mt-1 w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||
<Calendar className="w-5 h-5 text-blue-600" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="text-xs font-medium text-blue-600 uppercase tracking-wide mb-1">
|
||
Jours travaillés
|
||
</div>
|
||
<div className="text-base font-semibold text-slate-900">
|
||
{data.jours_travail_reel || "—"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Représentations */}
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||
Représentations
|
||
</div>
|
||
</div>
|
||
<div className="text-2xl font-bold text-slate-900">
|
||
{data.nb_representations_reel ?? 0}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">cachets</div>
|
||
</div>
|
||
|
||
{/* Services répétitions */}
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||
Services répétitions
|
||
</div>
|
||
<div className="group relative">
|
||
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
|
||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
|
||
Indique le nombre de services de répétitions réels, indépendamment du type de rémunération (au cachet ou à l'heure).
|
||
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-2xl font-bold text-slate-900">
|
||
{data.nb_services_repetitions_reel ?? 0}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">services</div>
|
||
</div>
|
||
|
||
{/* Heures répétitions */}
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||
Heures répétitions
|
||
</div>
|
||
<div className="group relative">
|
||
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
|
||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
|
||
Indique le nombre d'heures de répétitions réel, indépendamment du type de rémunération (au cachet ou à l'heure).
|
||
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-2xl font-bold text-slate-900">
|
||
{data.nb_heures_repetitions_reel ?? 0}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">heures</div>
|
||
</div>
|
||
|
||
{/* Heures Annexe 8 */}
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||
Heures Annexe 8
|
||
</div>
|
||
<div className="group relative">
|
||
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
|
||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
|
||
Concerne uniquement les techniciens (Annexe 8). Les techniciens ne peuvent être rémunérés qu'à l'heure.
|
||
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-2xl font-bold text-slate-900">
|
||
{data.nb_heures_annexes_reel ?? 0}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">heures</div>
|
||
</div>
|
||
|
||
{/* Cachets AEM */}
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||
Cachets AEM
|
||
</div>
|
||
<div className="group relative">
|
||
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
|
||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
|
||
Indique le nombre de cachets de représentation et/ou de répétitions (uniquement pour les répétitions pouvant être rémunérées au cachet) ayant été déclaré dans l'AEM.
|
||
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-2xl font-bold text-slate-900">
|
||
{data.nb_cachets_aem_reel ?? 0}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">cachets</div>
|
||
</div>
|
||
|
||
{/* Heures AEM */}
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||
Heures AEM
|
||
</div>
|
||
<div className="group relative">
|
||
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
|
||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 bg-slate-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10">
|
||
Indique les heures déclarées dans l'AEM (contrat technicien, metteur en scène ou répétitions ne pouvant être rémunérées en cachet).
|
||
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-2xl font-bold text-slate-900">
|
||
{data.nb_heures_aem_reel ?? 0}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">heures</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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, '"')}"` : '';
|
||
|
||
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>
|
||
);
|
||
} |