espace-paie-odentas/app/(app)/contrats/[id]/edit/page.tsx
2025-10-12 17:05:46 +02:00

363 lines
No EOL
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Loader2, RefreshCw, Info } from "lucide-react";
import { api } from "@/lib/fetcher";
import { usePageTitle } from "@/hooks/usePageTitle";
/** Types minimalistes — adapte si besoin à ta réponse API */
type ContratDetail = {
id: string;
numero?: string | null;
production?: string | null;
numero_objet?: string | null;
regime?: string | null;
etat?: string | null; // parfois l'API renvoie "etat"
etat_demande?: string | null; // parfois l'API renvoie "etat_demande"
date_debut?: string | null;
date_fin?: string | null;
updated_at?: string | null;
profession?: string | null;
salarie?: { nom?: string | string[] | null } | null;
};
// ——— util date FR (YYYY-MM-DD -> DD/MM/YYYY) ———
function formatDateFr(iso: string | null | undefined) {
if (!iso) return "—";
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso.trim());
if (!m) return iso;
const [, y, mo, d] = m;
return `${d}/${mo}/${y}`;
}
/** ---- Logger simple, horodaté ---- */
const DEBUG = true;
function tsNow() {
try {
const d = new Date();
const t = d.toLocaleTimeString?.() ?? d.toISOString();
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${t}.${ms}`;
} catch { return ""; }
}
function logStep(step: string, details?: any) {
if (!DEBUG) return;
const head = `%c[ContratEtat][${tsNow()}] ${step}`;
try {
if (details !== undefined) {
console.groupCollapsed(head, "color:#0ea5e9");
console.log(details);
console.groupEnd();
} else {
console.log(head, "color:#0ea5e9");
}
} catch {
console.log(`[ContratEtat][${tsNow()}] ${step}`, details);
}
}
/** ---- Badge détat : couleurs basiques ---- */
function StatusBadge({ value }: { value: string }) {
const v = value.toLowerCase();
const color =
v.includes("traite") || v.includes("valid")
? "bg-emerald-100 text-emerald-800"
: v.includes("cours") || v.includes("processing")
? "bg-amber-100 text-amber-800"
: v.includes("attente") || v.includes("recu") || v.includes("reçue")
? "bg-sky-100 text-sky-800"
: "bg-slate-100 text-slate-800";
return (
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${color}`}>
{value}
</span>
);
}
/** ---- Page ---- */
export default function ContratEtatPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
usePageTitle("Édition de contrat");
const [showCancelModal, setShowCancelModal] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
logStep("Mount", { id });
// Fonction pour annuler le contrat
const handleCancelContract = async () => {
if (!id) return;
setIsCancelling(true);
try {
logStep("Cancel contract start", { contractId: id });
const response = await fetch(`/api/contrats/${id}`, {
method: "DELETE",
credentials: "include",
headers: { Accept: "application/json" },
});
if (response.status === 401) {
throw new Error("access_denied");
}
if (response.status === 404) {
throw new Error("not_found");
}
if (response.status === 400) {
const errorData = await response.json();
throw new Error(errorData.message || "Contrat non annulable");
}
if (!response.ok) {
throw new Error(`http_${response.status}`);
}
const result = await response.json();
logStep("Cancel contract success", result);
// Fermer la modale et rediriger vers la liste des contrats
setShowCancelModal(false);
router.push("/contrats");
} catch (error: any) {
logStep("Cancel contract error", error);
alert(`Erreur lors de l'annulation: ${error.message}`);
} finally {
setIsCancelling(false);
}
};
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery<ContratDetail>({
queryKey: ["contrat-detail", id],
queryFn: async () => {
const url = `/api/contrats/${id}`;
logStep("GET(detail) start", { url });
try {
const resp = await fetch(url, {
method: "GET",
credentials: "include", // indispensable pour que la route serveur lise la session Supabase
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
cache: "no-store",
});
if (resp.status === 401) {
const err = new Error("access_denied");
(err as any).code = 401;
throw err;
}
if (resp.status === 404) {
const err = new Error("not_found");
(err as any).code = 404;
throw err;
}
if (!resp.ok) {
const err = new Error(`http_${resp.status}`);
(err as any).code = resp.status;
(err as any).body = await resp.text().catch(() => "");
throw err;
}
const json = (await resp.json()) as ContratDetail;
logStep("GET(detail) success", json);
return json;
} catch (e: any) {
logStep("GET(detail) error", { url, message: e?.message, code: e?.code, body: e?.body, stack: e?.stack });
throw e;
}
},
// Auto-refresh toutes les 15 secondes pour suivre lavancement
refetchInterval: 15_000,
refetchOnWindowFocus: true,
staleTime: 5_000,
onError: (e: any) => logStep("RQ onError", { message: e?.message }),
onSuccess: (d: ContratDetail | undefined) => logStep("RQ onSuccess", { id: d?.id, etat: d?.etat ?? d?.etat_demande }),
} as any);
const etat = useMemo(() => {
const raw = (data?.etat ?? data?.etat_demande ?? "").trim();
return raw || "Inconnu";
}, [data?.etat, data?.etat_demande]);
const etatDemandeRaw = (data?.etat_demande ?? data?.etat ?? "").trim();
const isRecue = /reçue|recue/i.test(etatDemandeRaw);
if (isLoading) {
logStep("UI loading");
return (
<main className="max-w-3xl mx-auto p-4">
<div className="rounded-2xl border bg-white p-6 text-center">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
<div className="text-sm text-slate-600">Chargement du contrat</div>
</div>
</main>
);
}
if (isError || !data) {
logStep("UI error", { error });
return (
<main className="max-w-3xl mx-auto p-4">
<div className="rounded-2xl border bg-white p-6">
<div className="text-base font-semibold mb-2">Impossible de récupérer le contrat</div>
<p className="text-sm text-slate-600">
Merci de réessayer un peu plus tard. Si le problème persiste, contacte le support.
</p>
{error && (
<pre className="mt-3 text-xs overflow-auto p-3 rounded bg-slate-50">
{String((error as any)?.message ?? error)}
</pre>
)}
<div className="mt-4">
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? "animate-spin" : ""}`} />
Réessayer
</button>
</div>
</div>
</main>
);
}
return (
<main className="max-w-3xl mx-auto p-4 space-y-5">
{/* En-tête */}
<div className="rounded-2xl border bg-white p-5 flex items-start justify-between gap-4">
<div>
<div className="text-lg font-semibold">Modification de contrat</div>
{data.numero ? (
<div className="mt-1 text-sm text-slate-600">
Référence : <strong>{data.numero}</strong>
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? "animate-spin" : ""}`} />
Rafraîchir
</button>
</div>
</div>
{/* Carte état */}
<div className="rounded-2xl border bg-white p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="rounded-xl border p-4">
<div className="text-slate-500">Salarié</div>
<div className="mt-1 font-medium">
{Array.isArray((data as any).salarie?.nom) ? (data as any).salarie.nom.join(", ") : (data as any).salarie?.nom ?? "—"}
</div>
</div>
<div className="rounded-xl border p-4">
<div className="text-slate-500">Production</div>
<div className="mt-1 font-medium">
{data.production ?? "—"}
{data.numero_objet ? <span className="text-slate-500"> n° dobjet {data.numero_objet}</span> : null}
</div>
</div>
<div className="rounded-xl border p-4">
<div className="text-slate-500">Profession</div>
<div className="mt-1 font-medium">{data.profession ?? "—"}</div>
</div>
<div className="rounded-xl border p-4">
<div className="text-slate-500">Période</div>
<div className="mt-1 font-medium">
{formatDateFr(data.date_debut)} {data.date_fin ? `${formatDateFr(data.date_fin)}` : ""}
</div>
</div>
</div>
{isRecue ? (
<div className="rounded-2xl border border-emerald-300 bg-emerald-50 p-5">
<div className="text-sm text-emerald-800">
Le traitement de cette demande par nos services n'ayant pas encore commencé, vous pouvez directement la modifier depuis l'Espace Paie.
Cliquez ci-dessous pour accéder au formulaire de modification.
</div>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
onClick={() => router.push(`/contrats/${id}/edit/formulaire`)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700"
>
Modifier la demande
</button>
<button
type="button"
onClick={() => setShowCancelModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700"
>
Annuler la demande
</button>
</div>
</div>
) : (
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-5">
<div className="text-sm text-amber-800">
Cette demande n'est plus modifiable directement depuis l'Espace Paie car son traitement par nos services a commencé.
Nous vous invitons à demander une modification manuelle via le bouton ci-dessous.
</div>
<div className="mt-4">
<button
type="button"
onClick={() => router.push(`/contrats/${id}/modification`)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-600 text-white hover:bg-amber-700"
>
Demander une modification
</button>
</div>
</div>
)}
</div>
{showCancelModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => setShowCancelModal(false)} />
<div className="relative w-full max-w-md rounded-2xl border bg-white p-5 shadow-2xl">
<div className="text-base font-semibold">Confirmer l'annulation ?</div>
<p className="text-sm text-slate-600 mt-2">
Vous êtes sur le point d'annuler cette demande. Le contrat sera supprimé de votre Espace Paie et ne sera pas facturé.
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setShowCancelModal(false)}
disabled={isCancelling}
className="px-3 py-2 rounded-lg border disabled:opacity-50"
>
Fermer
</button>
<button
type="button"
onClick={handleCancelContract}
disabled={isCancelling}
className="px-3 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 flex items-center gap-2"
>
{isCancelling && <Loader2 className="w-4 h-4 animate-spin" />}
{isCancelling ? "Annulation..." : "Annuler la demande"}
</button>
</div>
</div>
</div>
)}
</main>
);
}