800 lines
32 KiB
TypeScript
800 lines
32 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useParams } from "next/navigation";
|
||
import { useMemo, useState } from "react";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { ArrowLeft, Download, Info, Loader2, Clock, CheckCircle, Euro } from "lucide-react";
|
||
import { NotesSection } from "@/components/NotesSection";
|
||
import { Button } from "@/components/ui/button";
|
||
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
|
||
import { toast } from "sonner";
|
||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||
|
||
/* =========================
|
||
Types attendus du backend
|
||
========================= */
|
||
type StatutSimple = "oui" | "non" | "na" | "en_attente";
|
||
type EtatDemande =
|
||
| "pre-demande"
|
||
| "demande-recue"
|
||
| "envoye"
|
||
| "signe"
|
||
| "modification"
|
||
| "traitee"
|
||
| "non_commence";
|
||
|
||
type ContratMultiDetail = {
|
||
id: string;
|
||
numero: string; // ex YW2KSC85
|
||
regime: "CDDU_MULTI";
|
||
is_multi_mois: true; // drapeau utile
|
||
salarie: { nom: string; email?: string };
|
||
salarie_matricule?: string; // Matricule API du/de la salarié·e pour lien fiche
|
||
production: string;
|
||
objet?: string;
|
||
profession: string;
|
||
categorie_prof?: string;
|
||
type_salaire?: string;
|
||
salaire_demande?: string;
|
||
date_debut: string; // ISO
|
||
date_fin: string; // ISO
|
||
panier_repas?: StatutSimple;
|
||
|
||
// État & signatures
|
||
etat_demande: EtatDemande;
|
||
contrat_signe_employeur: StatutSimple;
|
||
contrat_signe_salarie: StatutSimple;
|
||
etat_contrat?: "non_commence" | "en_cours" | "termine";
|
||
|
||
// PDFs rattachés à la demande
|
||
pdf_contrat?: { available: boolean; url?: string };
|
||
pdf_avenant?: { available: boolean; url?: string };
|
||
|
||
// Déclarations
|
||
dpae?: "a_traiter" | "envoyee" | "refusee" | "retour_ok";
|
||
|
||
// Temps de travail cumulé
|
||
jours_travailles?: string | number;
|
||
nb_representations?: number;
|
||
nb_services_repetitions?: number;
|
||
nb_heures_repetitions?: number;
|
||
nb_heures_annexes?: number;
|
||
nb_cachets_aem?: number;
|
||
nb_heures_aem?: number;
|
||
};
|
||
|
||
type PaieMulti = {
|
||
id: string;
|
||
ordre: number; // 1,2,3… (Numéro de paie)
|
||
mois: number; // 1..12
|
||
annee: number; // 2024
|
||
aem_statut?: "a_traiter" | "ok" | string | null;
|
||
paie_pdf?: string | null; // URL
|
||
net_a_payer?: string | null;
|
||
net_avant_pas?: string | null;
|
||
brut?: string | null;
|
||
cout_total?: string | null;
|
||
transfer_done?: boolean | null; // Champ pour le statut de paiement (même que payslips)
|
||
traite?: "oui" | "non" | string | null;
|
||
paie_traitee?: string | null; // Période de paie (ex: "01/08/2025 – 31/08/2025")
|
||
};
|
||
|
||
/* ==============
|
||
Data fetching
|
||
============== */
|
||
function useContrat(id: string) {
|
||
return useQuery({
|
||
queryKey: ["contrat-multi", id],
|
||
queryFn: async () => {
|
||
const res = await fetch(`/api/contrats/${id}`, {
|
||
credentials: "include",
|
||
headers: {
|
||
Accept: "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
if (res.status === 403) {
|
||
const msg = await res.text().catch(() => "");
|
||
throw new Error(msg || "access_denied");
|
||
}
|
||
if (res.status === 404) {
|
||
throw new Error("not_found");
|
||
}
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => "");
|
||
throw new Error(t || `http_${res.status}`);
|
||
}
|
||
const data = (await res.json()) as ContratMultiDetail;
|
||
return data;
|
||
},
|
||
staleTime: 15_000,
|
||
enabled: Boolean(id),
|
||
retry: (failureCount, error: any) => {
|
||
const msg = String(error?.message || "");
|
||
if (msg.includes("access_denied") || msg.includes("not_found")) return false;
|
||
return failureCount < 3;
|
||
},
|
||
});
|
||
}
|
||
|
||
function usePaies(id: string) {
|
||
return useQuery({
|
||
queryKey: ["contrat-multi-paies", id],
|
||
queryFn: async () => {
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
console.log("🔍 Fetching paies for contract:", id);
|
||
}
|
||
const res = await fetch(`/api/contrats/${id}/paies?regime=CDDU_MULTI`, {
|
||
credentials: "include",
|
||
headers: {
|
||
Accept: "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
console.log("📥 Paies response:", { status: res.status, statusText: res.statusText, contractId: id });
|
||
}
|
||
|
||
if (res.status === 403) {
|
||
const msg = await res.text().catch(() => "");
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
console.error("❌ Paies access denied:", msg);
|
||
}
|
||
throw new Error(msg || "access_denied");
|
||
}
|
||
if (res.status === 404) {
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
console.log("ℹ️ Paies not found (404), returning empty list");
|
||
}
|
||
// Pas bloquant pour la page : retourne une liste vide
|
||
return { items: [] as PaieMulti[] };
|
||
}
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => "");
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
console.error("❌ Paies error:", { status: res.status, error: t, contractId: id });
|
||
}
|
||
throw new Error(t || `http_${res.status}`);
|
||
}
|
||
const data = (await res.json()) as { items: PaieMulti[] };
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
console.log("✅ Paies data received:", { contractId: id, itemsCount: data.items?.length || 0, data });
|
||
}
|
||
return data;
|
||
},
|
||
staleTime: 15_000,
|
||
enabled: Boolean(id),
|
||
retry: (failureCount, error: any) => {
|
||
const msg = String(error?.message || "");
|
||
if (msg.includes("access_denied")) return false;
|
||
return failureCount < 3;
|
||
},
|
||
});
|
||
}
|
||
|
||
/* =========
|
||
Helpers
|
||
========= */
|
||
function Section({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
|
||
return (
|
||
<section className="rounded-2xl border bg-white">
|
||
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
|
||
{title}
|
||
</div>
|
||
<div className="p-4">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function formatPeriodDisplay(periodStr?: string | null) {
|
||
if (!periodStr) return null;
|
||
// Expected formats: "YYYY-MM-DD – YYYY-MM-DD" or similar. Split on '–' or '-'
|
||
const parts = periodStr.split(/\s*–\s*|\s*-\s*/);
|
||
if (parts.length === 0) return periodStr;
|
||
const left = parts[0]?.trim();
|
||
const right = parts[1]?.trim();
|
||
try {
|
||
const parseISO = (s?: string | null) => {
|
||
if (!s) return null;
|
||
// If format is YYYY-MM-DD or YYYY-MM, ensure correct parsing
|
||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||
const m2 = s.match(/^(\d{4})-(\d{2})$/);
|
||
if (m2) return new Date(Number(m2[1]), Number(m2[2]) - 1, 1);
|
||
const d = new Date(s);
|
||
return isNaN(d.getTime()) ? null : d;
|
||
};
|
||
const L = parseISO(left);
|
||
const R = parseISO(right);
|
||
if (L && R) return `${formatDateFR(L)} – ${formatDateFR(R)}`;
|
||
if (L) return formatDateFR(L);
|
||
if (R) return formatDateFR(R);
|
||
return periodStr;
|
||
} catch (e) {
|
||
return periodStr;
|
||
}
|
||
}
|
||
|
||
function formatDateFR(iso?: string | Date) {
|
||
if (!iso) return "—";
|
||
const d = iso instanceof Date ? iso : new Date(String(iso));
|
||
if (isNaN(d.getTime())) return String(iso);
|
||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
value,
|
||
action,
|
||
}: {
|
||
label: string;
|
||
value?: React.ReactNode;
|
||
action?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="grid grid-cols-1 sm:grid-cols-[220px_1fr_auto] gap-2 items-center py-2">
|
||
<div className="text-sm text-slate-500">{label}</div>
|
||
<div className="text-sm">{value ?? <span className="text-slate-400">—</span>}</div>
|
||
{action ? <div className="justify-self-end">{action}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Badge({
|
||
children,
|
||
tone = "default" as "default" | "ok" | "warn" | "error" | "info",
|
||
}: {
|
||
children: React.ReactNode;
|
||
tone?: "default" | "ok" | "warn" | "error" | "info";
|
||
}) {
|
||
const cls =
|
||
tone === "ok"
|
||
? "bg-emerald-100 text-emerald-800"
|
||
: tone === "warn"
|
||
? "bg-amber-100 text-amber-800"
|
||
: tone === "error"
|
||
? "bg-rose-100 text-rose-800"
|
||
: tone === "info"
|
||
? "bg-sky-100 text-sky-800"
|
||
: "bg-slate-100 text-slate-700";
|
||
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
|
||
}
|
||
|
||
function boolBadge(v?: StatutSimple | boolean | null) {
|
||
if (typeof v === "boolean") return v ? <Badge tone="ok">Oui</Badge> : <Badge tone="error">Non</Badge>;
|
||
if (v === "oui") return <Badge tone="ok">Oui</Badge>;
|
||
if (v === "non") return <Badge tone="error">Non</Badge>;
|
||
if (v === "en_attente") return <Badge tone="info">En cours</Badge>;
|
||
return <Badge>n/a</Badge>;
|
||
}
|
||
|
||
function stateBadgeDemande(s?: EtatDemande) {
|
||
const input = String(s || "").trim();
|
||
const normalized = input
|
||
.toLowerCase()
|
||
.normalize("NFD")
|
||
.replace(/\p{Diacritic}/gu, "")
|
||
.replace(/\s+/g, " ")
|
||
.replace(/-/g, " ")
|
||
.replace(/_/g, " ");
|
||
|
||
// Mapping des 4 états principaux avec couleurs adaptées
|
||
type Style = { bg: string; border: string; text: string; label: string };
|
||
|
||
// Détection intelligente des états
|
||
let style: Style;
|
||
|
||
if (normalized.includes("pre") || normalized.includes("demande")) {
|
||
// Pré-demande - Gris (neutre)
|
||
style = {
|
||
bg: "bg-slate-50",
|
||
border: "border-slate-200",
|
||
text: "text-slate-700",
|
||
label: "Pré-demande"
|
||
};
|
||
} else if (normalized.includes("recu") || normalized.includes("recue")) {
|
||
// Reçue - Bleu (information)
|
||
style = {
|
||
bg: "bg-blue-50",
|
||
border: "border-blue-200",
|
||
text: "text-blue-800",
|
||
label: "Reçue"
|
||
};
|
||
} else if (normalized.includes("cours") || normalized.includes("traitement")) {
|
||
// En cours de traitement - Orange (en attente)
|
||
style = {
|
||
bg: "bg-orange-50",
|
||
border: "border-orange-200",
|
||
text: "text-orange-800",
|
||
label: "En cours de traitement"
|
||
};
|
||
} else if (normalized.includes("traitee") || normalized.includes("traite")) {
|
||
// Traitée - Vert (succès)
|
||
style = {
|
||
bg: "bg-emerald-50",
|
||
border: "border-emerald-200",
|
||
text: "text-emerald-800",
|
||
label: "Traitée"
|
||
};
|
||
} else {
|
||
// Fallback - Affichage de la valeur brute avec style neutre
|
||
style = {
|
||
bg: "bg-slate-50",
|
||
border: "border-slate-200",
|
||
text: "text-slate-700",
|
||
label: input || "—"
|
||
};
|
||
}
|
||
|
||
return (
|
||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs ${style.bg} ${style.border} ${style.text}`}>
|
||
{style.label}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function formatEUR(v?: string | number | null) {
|
||
if (v === null || v === undefined || v === "") return "—";
|
||
// Autorise nombres sous forme de chaîne avec séparateurs
|
||
const num = typeof v === "number" ? v : Number(String(v).replace(/[^0-9,.-]/g, "").replace(",", "."));
|
||
if (!Number.isFinite(num)) return String(v);
|
||
return num.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
|
||
}
|
||
|
||
function monthLabelSafe(m?: number) {
|
||
if (typeof m !== "number" || !Number.isFinite(m) || m < 1 || m > 12) return null;
|
||
return new Date(2025, m - 1, 1).toLocaleDateString("fr-FR", { month: "long" });
|
||
}
|
||
|
||
function payLabel(p: PaieMulti) {
|
||
const ml = monthLabelSafe(p.mois);
|
||
if (ml && p.annee) return `${ml} ${p.annee}`;
|
||
if (ml) return ml;
|
||
if (p.annee) return String(p.annee);
|
||
return "";
|
||
}
|
||
|
||
/* =====
|
||
Page
|
||
===== */
|
||
export default function ContratMultiPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
|
||
const { data, isLoading, isError, error } = useContrat(id);
|
||
|
||
// Titre dynamique basé sur le numéro du contrat
|
||
const contractTitle = data?.numero ? `Contrat ${data.numero}` : `Contrat multi-mois`;
|
||
usePageTitle(contractTitle);
|
||
|
||
const { data: paiesData, isLoading: paiesLoading, isError: paiesError, error: paiesErrorObj } = usePaies(id);
|
||
|
||
// State pour la modale de confirmation de paiement
|
||
const [showPaymentModal, setShowPaymentModal] = useState<boolean>(false);
|
||
const [selectedPaieId, setSelectedPaieId] = useState<string>("");
|
||
const [selectedPaieStatus, setSelectedPaieStatus] = useState<boolean>(false);
|
||
|
||
// Query client pour la mise à jour du cache
|
||
const queryClient = useQueryClient();
|
||
|
||
// Mutation pour marquer une paie multi comme payée/non payée
|
||
const markAsPaidMutation = useMutation({
|
||
mutationFn: async ({ payslipId, transferDone }: { payslipId: string; transferDone: boolean }) => {
|
||
const response = await fetch(`/api/payslips/${payslipId}`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ transfer_done: transferDone }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Erreur lors de la mise à jour');
|
||
}
|
||
|
||
return response.json();
|
||
},
|
||
onSuccess: (_, variables) => {
|
||
// Recharger les données des paies
|
||
queryClient.invalidateQueries({ queryKey: ["contrat-multi-paies", id] });
|
||
toast.success(variables.transferDone ? "Paiement marqué comme effectué !" : "Paiement marqué comme non effectué !");
|
||
setShowPaymentModal(false);
|
||
setSelectedPaieId("");
|
||
setSelectedPaieStatus(false);
|
||
},
|
||
onError: (error: Error) => {
|
||
toast.error("Erreur : " + error.message);
|
||
},
|
||
});
|
||
|
||
// Fonction pour ouvrir le modal de confirmation
|
||
const handleTogglePayment = (paieId: string, currentStatus: boolean) => {
|
||
setSelectedPaieId(paieId);
|
||
setSelectedPaieStatus(!currentStatus);
|
||
setShowPaymentModal(true);
|
||
};
|
||
|
||
// Fonction pour confirmer le changement de statut
|
||
const confirmTogglePayment = () => {
|
||
if (selectedPaieId) {
|
||
markAsPaidMutation.mutate({
|
||
payslipId: selectedPaieId,
|
||
transferDone: selectedPaieStatus
|
||
});
|
||
}
|
||
};
|
||
|
||
// Calcule l'état du contrat en fonction des dates et de l'état de la demande (même logique que contrats/[id])
|
||
const etatContratCalcule = useMemo(() => {
|
||
if (!data) return undefined;
|
||
|
||
// Si l'état de la demande est Annulée → Annulé
|
||
const etatDemandeNorm = String(data.etat_demande || "")
|
||
.toLowerCase()
|
||
.normalize("NFD")
|
||
.replace(/\p{Diacritic}/gu, "");
|
||
if (etatDemandeNorm.includes("annule")) {
|
||
return "Annulé" as const;
|
||
}
|
||
|
||
// Si dates manquantes, on retombe sur l'ancien fallback
|
||
if (!data.date_debut || !data.date_fin) return undefined;
|
||
|
||
const toLocalDateOnly = (iso: string) => {
|
||
const d = new Date(iso);
|
||
// Normalise à minuit local pour une comparaison en J/J inclusif
|
||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
};
|
||
|
||
const start = toLocalDateOnly(data.date_debut);
|
||
const end = toLocalDateOnly(data.date_fin);
|
||
const today = new Date();
|
||
const current = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||
|
||
if (current < start) return "Non commencé" as const;
|
||
if (current > end) return "Terminé" as const;
|
||
return "En cours" as const; // inclusif entre début et fin
|
||
}, [data]);
|
||
|
||
const title = useMemo(() => (data ? `Contrat n° ${data.numero}` : "Contrat"), [data]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="rounded-2xl border bg-white p-10 text-center text-slate-500">
|
||
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
|
||
Chargement du contrat…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isError || !data) {
|
||
return (
|
||
<div className="rounded-2xl border bg-white p-6">
|
||
<div className="text-rose-600 font-medium mb-2">Impossible de charger ce contrat.</div>
|
||
<div className="text-sm text-slate-500">{(error as any)?.message || "Erreur inconnue"}</div>
|
||
<div className="mt-4">
|
||
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border" href="/contrats">
|
||
<ArrowLeft className="w-4 h-4" /> Retour aux contrats
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const paies = paiesData?.items ?? [];
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* Titre + breadcrumb */}
|
||
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||
<Link href="/contrats" className="inline-flex items-center gap-1 text-sm underline">
|
||
<ArrowLeft className="w-4 h-4" /> Retour contrats terminés
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border bg-white p-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-lg font-semibold">{title}</div>
|
||
<div className="h-4 w-px bg-slate-200 mx-1" />
|
||
<div className="text-sm text-slate-500">CDDU · Multi-mois</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Disposition 2 colonnes (colonnes indépendantes en hauteur) */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||
{/* Colonne gauche : Demande puis Temps de travail réel */}
|
||
<div className="flex flex-col gap-5">
|
||
<Section title="Demande">
|
||
<Field
|
||
label="Salarié"
|
||
value={
|
||
data.salarie_matricule ? (
|
||
<Link href={`/salaries/${encodeURIComponent(data.salarie_matricule)}`} className="underline">
|
||
{data.salarie?.nom || data.salarie_matricule}
|
||
</Link>
|
||
) : (
|
||
data.salarie?.nom
|
||
)
|
||
}
|
||
/>
|
||
<Field
|
||
label="Contrat de travail PDF"
|
||
value={
|
||
data.pdf_contrat?.available ? (
|
||
<a className="inline-flex items-center gap-2 underline" href={data.pdf_contrat.url} target="_blank" rel="noreferrer">
|
||
<Download className="w-4 h-4" /> Télécharger
|
||
</a>
|
||
) : (
|
||
<span className="text-slate-400">Bientôt disponible…</span>
|
||
)
|
||
}
|
||
/>
|
||
<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} />
|
||
<Field label="Salaire demandé" value={data.salaire_demande} />
|
||
<Field label="Début contrat" value={formatDateFR(data.date_debut)} />
|
||
<Field label="Fin contrat" value={formatDateFR(data.date_fin)} />
|
||
<Field label="Panier repas" value={boolBadge(data.panier_repas)} />
|
||
</Section>
|
||
|
||
<Section title="Temps de travail réel">
|
||
<Field label="Jours travaillés" value={data.jours_travailles ?? "—"} />
|
||
<Field label="Nbre de représentations" value={data.nb_representations ?? 0} />
|
||
<Field label="Nbre de services répétitions" value={data.nb_services_repetitions ?? 0} />
|
||
<Field label="Nbre d'heures répétitions" value={data.nb_heures_repetitions ?? 0} />
|
||
<Field label="Nbre d'heures Annexes 8" value={data.nb_heures_annexes ?? 0} />
|
||
<Field label="Nombre de cachets AEM" value={data.nb_cachets_aem ?? 0} />
|
||
<Field label="Nombre d'heures AEM" value={data.nb_heures_aem ?? 0} />
|
||
</Section>
|
||
</div>
|
||
|
||
{/* Colonne droite : Déclarations au-dessus des Paies */}
|
||
<div className="flex flex-col gap-5">
|
||
<Section title="Déclarations">
|
||
{(() => {
|
||
const raw = String(data.dpae || "");
|
||
const norm = raw.normalize("NFD").replace(/\p{Diacritic}/gu, "").trim().toLowerCase();
|
||
if (norm === "ok" || norm === "envoyee" || norm === "retourok" || norm === "retour_ok") {
|
||
return (
|
||
<Field
|
||
label="DPAE"
|
||
value={(
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
|
||
<CheckCircle className="w-3 h-3" /> Effectuée
|
||
</span>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
if (norm === "a traiter" || norm === "a_traiter" || norm === "a_traiter") {
|
||
return (
|
||
<Field
|
||
label="DPAE"
|
||
value={(
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
|
||
<Clock className="w-3 h-3" /> En cours
|
||
</span>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
return <Field label="DPAE" value={<span className="text-xs text-slate-400">—</span>} />;
|
||
})()}
|
||
</Section>
|
||
|
||
<Section title={<><span>Paies</span><span className="ml-2 text-xs italic text-slate-500">- Cliquez sur la carte d'une paie pour afficher ses documents de paie et sur le symbole € pour la marquer comme payée ou non payée.</span></>}>
|
||
{paiesLoading ? (
|
||
<div className="px-3 py-8 text-center text-slate-500">
|
||
<Loader2 className="w-4 h-4 inline animate-spin mr-2" /> Chargement des paies…
|
||
</div>
|
||
) : paiesError ? (
|
||
<div className="px-3 py-8 text-center text-rose-600">
|
||
Erreur lors du chargement des paies.
|
||
{process.env.NODE_ENV !== 'production' && (
|
||
<div className="mt-2 text-xs text-slate-400">{(paiesErrorObj as any)?.message || 'Erreur inconnue'}</div>
|
||
)}
|
||
</div>
|
||
) : paies.length === 0 ? (
|
||
<div className="px-3 py-8 text-center text-slate-500">
|
||
Aucune paie trouvée.
|
||
{process.env.NODE_ENV === 'development' && (
|
||
<div className="mt-2 text-xs text-slate-400 space-y-1">
|
||
<div>Debug: Contract ID = {id}</div>
|
||
<div>Debug: Contract Reference = {data?.numero || 'non définie'}</div>
|
||
<div className="text-yellow-600">
|
||
Vérifiez que le champ "Référence API" dans la table "Paies CDDU Multi-Mois" contient la valeur "{data?.numero || '???'}".
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{paies
|
||
.slice()
|
||
.sort((a,b) => (b.ordre ?? 0) - (a.ordre ?? 0))
|
||
.map((p) => {
|
||
const label = payLabel(p) || (p.ordre ? `Paie ${p.ordre}` : 'Paie');
|
||
// Normalize aem_statut for robust checks (API can return 'OK', 'ok', etc.)
|
||
const aemNorm = String((p as any).aem_statut || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').trim().toLowerCase();
|
||
const aemOk = aemNorm === 'ok' || aemNorm === 'oui' || aemNorm.includes('ok');
|
||
const aemPending = aemNorm.includes('a_traiter') || aemNorm.includes('a traiter') || aemNorm.includes('traiter');
|
||
|
||
const CardInner = (
|
||
<div className="h-full rounded-2xl border bg-white p-4 hover:shadow-md transition-shadow relative">
|
||
{/* Bouton de paiement en position absolue */}
|
||
{p.traite === 'oui' && (
|
||
<Button
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
handleTogglePayment(p.id, p.transfer_done || false);
|
||
}}
|
||
className="absolute bottom-2 right-2 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300 text-xs px-2 py-1 h-auto z-10"
|
||
disabled={markAsPaidMutation.isPending}
|
||
title={p.transfer_done ? "Valider" : "Valider"}
|
||
>
|
||
<Euro className="w-3 h-3" />
|
||
</Button>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
||
<div className="flex items-center gap-2">
|
||
{/* Numéro de paie */}
|
||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800 border border-indigo-200/60">
|
||
# {p.ordre ?? '—'}
|
||
</span>
|
||
{/* Période (Paie traitée) */}
|
||
{
|
||
// Prefer explicit period_start/period_end from the API when present
|
||
(p as any).period_start && (p as any).period_end ? (
|
||
<span className="text-[11px] px-2 py-1 rounded-lg bg-gradient-to-br from-sky-50 to-blue-100 text-blue-800 border border-blue-200/70 shadow-[0_1px_0_rgba(255,255,255,0.6)]">
|
||
{formatDateFR((p as any).period_start)} – {formatDateFR((p as any).period_end)}
|
||
</span>
|
||
) : p.paie_traitee ? (
|
||
<span className="text-[11px] px-2 py-1 rounded-lg bg-gradient-to-br from-sky-50 to-blue-100 text-blue-800 border border-blue-200/70 shadow-[0_1px_0_rgba(255,255,255,0.6)]">
|
||
{formatPeriodDisplay(p.paie_traitee)}
|
||
</span>
|
||
) : null
|
||
}
|
||
</div>
|
||
<div className="sm:ml-auto flex items-center gap-2">
|
||
{/* 1. Traitée */}
|
||
{p.traite === 'non' ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
|
||
<Clock className="w-3 h-3" /> À traiter
|
||
</span>
|
||
) : p.traite === 'oui' ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
|
||
<CheckCircle className="w-3 h-3" /> Traitée
|
||
</span>
|
||
) : null}
|
||
|
||
{/* 2. AEM */}
|
||
{aemPending ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
|
||
<Clock className="w-3 h-3" /> AEM
|
||
</span>
|
||
) : aemOk ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
|
||
<CheckCircle className="w-3 h-3" /> AEM OK
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-slate-400">AEM —</span>
|
||
)}
|
||
|
||
{/* 3. Payée avec même style que les autres */}
|
||
{p.transfer_done ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
|
||
<CheckCircle className="w-3 h-3" /> Payée
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
|
||
<Clock className="w-3 h-3" /> Non payée
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-4 gap-3 text-sm">
|
||
<div>
|
||
<div className="text-slate-500">Net avant PAS</div>
|
||
<div className="font-medium">{formatEUR((p as any).net_avant_pas ?? p.net_avant_pas)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-slate-500">Net à payer</div>
|
||
<div className="font-medium">{formatEUR(p.net_a_payer)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-slate-500">Brut</div>
|
||
<div>{formatEUR(p.brut)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-slate-500">Coût total</div>
|
||
<div>{formatEUR(p.cout_total)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return p.paie_pdf ? (
|
||
<a key={p.id} href={p.paie_pdf} target="_blank" rel="noreferrer" className="group block">
|
||
{CardInner}
|
||
</a>
|
||
) : (
|
||
<div key={p.id} className="opacity-70 cursor-not-allowed group block" title="PDF de paie indisponible">
|
||
{CardInner}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Section>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Notes */}
|
||
<NotesSection contractId={id} contractRef={data?.numero} />
|
||
|
||
{/* Modale de confirmation de paiement */}
|
||
<ConfirmationModal
|
||
isOpen={showPaymentModal}
|
||
title={selectedPaieStatus ? "Confirmer le paiement" : "Confirmer l'annulation"}
|
||
description={
|
||
selectedPaieStatus
|
||
? "Êtes-vous sûr de vouloir marquer cette paie comme payée ? Cette action mettra à jour le statut de paiement dans le système."
|
||
: "Êtes-vous sûr de vouloir marquer cette paie comme non payée ? Cette action annulera le statut de paiement dans le système."
|
||
}
|
||
confirmText={selectedPaieStatus ? "Valider" : "Valider"}
|
||
cancelText="Annuler"
|
||
onConfirm={confirmTogglePayment}
|
||
onCancel={() => {
|
||
setShowPaymentModal(false);
|
||
setSelectedPaieId("");
|
||
setSelectedPaieStatus(false);
|
||
}}
|
||
confirmButtonVariant="gradient"
|
||
isLoading={markAsPaidMutation.isPending}
|
||
/>
|
||
|
||
</div>
|
||
);
|
||
}
|