363 lines
No EOL
13 KiB
TypeScript
363 lines
No EOL
13 KiB
TypeScript
"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 l’avancement
|
||
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° d’objet {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>
|
||
);
|
||
} |