espace-paie-odentas/app/(app)/contrats/page.tsx
2025-10-15 00:40:57 +02:00

617 lines
No EOL
26 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 { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { ChevronLeft, ChevronRight, Loader2, Search, Plus, Pencil, Copy, Table } from "lucide-react";
// --- Types
export type Contrat = {
id: string;
reference: string;
salarie_nom: string; // "LOMPO Mariam"
production: string; // "Les Vies Dansent" / "KB HARMONY"
profession: string; // code + libellé
date_debut: string; // ISO yyyy-mm-dd
date_fin: string; // ISO
etat: "pre-demande" | "Reçue" | "envoye" | "signe" | "modification" | "traitee" | "en_cours";
is_multi_mois?: boolean; // drapeau listé par l'API
regime?: "CDDU_MONO" | "CDDU_MULTI" | "RG" | string; // si l'API renvoie directement le régime
};
type ClientInfo = {
id: string;
name: string;
api_name?: string;
} | null;
// --- Hook d'accès API - MODIFIÉ pour récupérer clientInfo dynamiquement
function useContrats(params: { regime: "CDDU" | "RG"; status: "en_cours" | "termines"; page: number; limit: number; q?: string; month?: number; year?: number; period?: string; org?: string | null }){
const { regime, status, page, limit, q, month, year, period, org } = params;
// Récupération dynamique des infos client via /api/me
const { data: clientInfo } = useQuery({
queryKey: ["client-info"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include",
});
if (!res.ok) return null;
const me = await res.json();
return {
id: me.active_org_id || null,
name: me.active_org_name || "Organisation",
api_name: me.active_org_api_name,
} as ClientInfo;
} catch {
return null;
}
},
staleTime: 30_000, // Cache 30s
});
return useQuery({
queryKey: [
"contrats",
regime,
status,
page,
limit,
q,
status === "termines" ? (period || "Y") : undefined,
status === "termines" ? year : undefined,
clientInfo?.id,
org,
],
queryFn: () => {
const base = `/contrats?regime=${encodeURIComponent(regime)}&status=${status}&page=${page}&limit=${limit}`;
const parts: string[] = [];
// If the UI requested an explicit org filter, pass it as `org_id` query param
if (org) {
parts.push(`org_id=${encodeURIComponent(org)}`);
}
if (q) parts.push(`q=${encodeURIComponent(q)}`);
if (status === "termines" && year) {
parts.push(`year=${year}`);
const p = period || "Y";
if (p.startsWith("M")) {
const m = parseInt(p.slice(1), 10);
if (!Number.isNaN(m)) parts.push(`month=${m}`);
} else if (p.startsWith("Q")) {
const qv = parseInt(p.slice(1), 10);
if (!Number.isNaN(qv)) parts.push(`quarter=${qv}`);
} else if (p.startsWith("S")) {
const sv = parseInt(p.slice(1), 10);
if (!Number.isNaN(sv)) parts.push(`semester=${sv}`);
}
}
const qs = parts.length ? `&${parts.join("&")}` : "";
// Build final clientInfo to pass to api(): if UI provided explicit org filter, override id
const finalClientInfo = clientInfo ? { ...clientInfo, id: org ?? clientInfo.id } : (org ? { id: org, name: "Organisation", api_name: undefined } as ClientInfo : null);
return api<{ items: Contrat[]; page: number; limit: number; hasMore: boolean; total?: number; totalPages?: number }>(base + qs, {}, finalClientInfo);
},
staleTime: 15_000,
placeholderData: (prev) => prev,
enabled: !!clientInfo || typeof org === 'string', // allow when we have an explicit org or clientInfo
});
}
// --- Mapping état → couleur/texte
const ETATS: Record<Contrat["etat"], { label: string; className: string }> = {
"pre-demande": { label: "Pré-demande", className: "bg-slate-100 text-slate-700" },
"Reçue": { label: "Reçue", className: "bg-blue-100 text-blue-800" },
"envoye": { label: "Envoyé", className: "bg-blue-100 text-blue-800" },
"signe": { label: "Contrat signé", className: "bg-emerald-100 text-emerald-800" },
"modification": { label: "Modifier la demande", className: "bg-rose-100 text-rose-800" },
"traitee": { label: "Traitée", className: "bg-emerald-100 text-emerald-800" },
"en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800" },
};
function humanizeEtat(raw?: string){
if (!raw) return undefined as unknown as string;
const r = raw.toLowerCase();
if (r.includes("cours") || r.includes("traitement")) return "en_cours";
if (r.includes("reç") || r.includes("rec")) return "Reçue";
if (r.includes("envoy")) return "envoye";
if (r.includes("modif")) return "modification";
if (r.includes("sign")) return "signe";
if (r.includes("pre")) return "pre-demande";
if (r.includes("traitée") || r.includes("traitee")) return "traitee";
return undefined as unknown as string;
}
// --- Composant de pagination
type PaginationProps = {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
isFetching?: boolean;
itemsCount: number;
position?: 'top' | 'bottom';
};
function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChange, isFetching, itemsCount, position = 'bottom' }: PaginationProps) {
return (
<div className={`p-3 flex flex-col sm:flex-row items-center gap-3 ${position === 'bottom' ? 'border-t' : ''}`}>
{/* Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronLeft className="w-4 h-4"/>
</button>
<div className="text-sm">
Page <strong>{page}</strong> sur <strong>{totalPages || 1}</strong>
</div>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-2 py-1 rounded-lg border disabled:opacity-40 hover:bg-slate-50 disabled:hover:bg-transparent"
>
<ChevronRight className="w-4 h-4"/>
</button>
</div>
{/* Sélecteur de limite */}
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-600">Afficher :</span>
<select
value={limit}
onChange={(e) => onLimitChange(parseInt(e.target.value, 10))}
className="px-2 py-1 rounded-lg border bg-white text-sm"
>
<option value={10}>10</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* Informations */}
<div className="sm:ml-auto text-sm text-slate-600">
{isFetching ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Mise à jour
</span>
) : (
<span>
{total > 0 ? (
<>
<strong>{total}</strong> contrat{total > 1 ? 's' : ''} au total
{itemsCount > 0 && `${itemsCount} affiché${itemsCount > 1 ? 's' : ''}`}
</>
) : (
'Aucun contrat'
)}
</span>
)}
</div>
</div>
);
}
function safeEtat(etat?: string){
const key = humanizeEtat(etat) as keyof typeof ETATS | undefined;
if (key && ETATS[key]) return ETATS[key];
const label = etat ? etat.charAt(0).toUpperCase() + etat.slice(1) : "—";
return {
label,
className: "bg-slate-100 text-slate-700",
} as { label: string; className: string };
}
export default function PageContrats(){
const [status, setStatus] = useState<"en_cours" | "termines">("en_cours");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const [q, setQ] = useState("");
const [regime, setRegime] = useState<"CDDU" | "RG">("CDDU");
const router = useRouter();
const detailHref = (c: Contrat) => {
// Si l'utilisateur est sur l'onglet Régime général, on envoie vers la page RG
if (regime === "RG") {
return `/contrats-rg/${c.id}`;
}
// Sinon, on applique la logique CDDU / multi-mois
const regimeUpper = (c.regime || "").toUpperCase();
if (c.is_multi_mois === true || regimeUpper === "CDDU_MULTI") {
return `/contrats-multi/${c.id}`;
}
return `/contrats/${c.id}`;
};
const now = new Date();
const [period, setPeriod] = useState<string>(() => "Y");
const [year, setYear] = useState<number>(now.getFullYear());
const yearOptions = useMemo(() => {
const current = now.getFullYear();
const start = 2021;
const count = Math.max(0, current - start + 1);
return Array.from({ length: count }, (_, i) => start + i);
}, [now]);
// We'll call useContrats after selectedOrg is declared below
function switchTab(s: "en_cours" | "termines"){
setStatus(s); setPage(1);
}
// Fetch /api/me to determine staff and organizations list for filter
const { data: meData } = useQuery({
queryKey: ["me-info"],
queryFn: async () => {
try {
const res = await fetch('/api/me', { cache: 'no-store', credentials: 'include' });
if (!res.ok) return null;
return await res.json();
} catch { return null; }
},
});
const [orgs, setOrgs] = useState<Array<{ id: string; name: string }>>([]);
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const { data, isLoading, isError, error, isFetching } = useContrats({
regime,
status,
page,
limit,
q: q || undefined,
month: status === "termines" && period.startsWith("M") ? parseInt(period.slice(1), 10) : undefined,
year: status === "termines" ? year : undefined,
period: status === "termines" ? period : undefined,
org: selectedOrg || null,
});
const items = data?.items ?? [];
const hasMore = data?.hasMore ?? false;
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 0;
useEffect(() => {
// Load organizations for the selector
let mounted = true;
(async () => {
try {
const r = await fetch('/api/organizations', { cache: 'no-store', credentials: 'include' });
if (!r.ok) return;
const json = await r.json();
if (!mounted) return;
const items = (json && json.items) ? json.items : (Array.isArray(json) ? json : []);
setOrgs((items || []).map((o: any) => ({ id: o.id, name: o.name })));
} catch (e) {
// ignore
}
})();
return () => { mounted = false; };
}, []);
return (
<div className="space-y-5">
{/* En-tête + Recherche */}
<section className="relative overflow-visible rounded-2xl border bg-white p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<h1 className="text-xl font-semibold">Contrats & Paies</h1>
<div className="sm:ml-auto flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border w-full sm:w-80">
<Search className="w-4 h-4"/>
<input value={q} onChange={(e)=>{ setQ(e.target.value); setPage(1); }} placeholder="Référence, nom, production…" className="bg-transparent outline-none text-sm flex-1"/>
</div>
{/* Organization filter visible only for staff users */}
{meData?.is_staff && (
<select
value={selectedOrg || ""}
onChange={(e) => { setSelectedOrg(e.target.value || null); setPage(1); }}
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">Toutes les structures</option>
{orgs.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
)}
</div>
</div>
{/* Régime tabs */}
<div className="mt-3 inline-flex rounded-xl border p-1 bg-slate-50">
<button onClick={()=>{ setRegime("CDDU"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='CDDU' ? 'bg-white shadow border' : 'opacity-80'}`}>CDDU</button>
<button onClick={()=>{ setRegime("RG"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='RG' ? 'bg-white shadow border' : 'opacity-80'}`}>Régime général</button>
</div>
{/* Onglets + action */}
<div className="mt-4 flex flex-col gap-3">
<div className="inline-flex rounded-xl border p-1 bg-slate-50 w-fit">
<button onClick={()=>switchTab("en_cours")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='en_cours' ? 'bg-white shadow border' : 'opacity-80'}`}>En cours</button>
<button onClick={()=>switchTab("termines")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='termines' ? 'bg-white shadow border' : 'opacity-80'}`}>Terminés</button>
</div>
<div className="flex items-center gap-2">
<a
href={regime === 'CDDU' ? '/contrats/nouveau' : '/contrats-rg/nouveau'}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 whitespace-nowrap"
>
<Plus className="w-4 h-4" /> Nouveau contrat {regime === 'RG' ? 'RG' : 'CDDU'}
</a>
{regime === 'CDDU' && (
(() => {
const isStaff = Boolean(meData?.is_staff);
if (isStaff) {
return (
<a
href="/contrats/nouveau/saisie-tableau"
className="hidden sm:inline-flex items-center gap-2 px-3 py-2 rounded-lg border bg-white hover:bg-slate-50 whitespace-nowrap"
>
<Table className="w-4 h-4" /> Saisie en tableau
</a>
);
}
return (
<div className="group relative hidden sm:inline-block">
<button
type="button"
aria-disabled="true"
disabled
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border bg-white text-slate-400 opacity-60 cursor-not-allowed whitespace-nowrap"
>
<Table className="w-4 h-4" /> Saisie en tableau
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-0 top-full mt-2 z-10 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
>
Cette fonction sera bientôt disponible.
<div className="absolute left-4 -top-1 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
);
})()
)}
</div>
</div>
{status === "termines" && (
<div className="mt-3 flex flex-col sm:flex-row gap-2 sm:items-center">
<div className="text-sm text-slate-600">Filtrer par période :</div>
<div className="flex gap-2">
<select
id="period-select"
value={period}
onChange={(e)=>{ setPeriod(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
<optgroup label="Année">
<option value="Y">Toute l'année</option>
</optgroup>
<optgroup label="Semestres">
<option value="S1">1er semestre (janvjuin)</option>
<option value="S2">2e semestre (juildéc)</option>
</optgroup>
<optgroup label="Trimestres">
<option value="Q1">T1 (janvmars)</option>
<option value="Q2">T2 (avrjuin)</option>
<option value="Q3">T3 (juilsept)</option>
<option value="Q4">T4 (octdéc)</option>
</optgroup>
<optgroup label="Mois">
<option value="M1">Janvier</option>
<option value="M2">Février</option>
<option value="M3">Mars</option>
<option value="M4">Avril</option>
<option value="M5">Mai</option>
<option value="M6">Juin</option>
<option value="M7">Juillet</option>
<option value="M8">Août</option>
<option value="M9">Septembre</option>
<option value="M10">Octobre</option>
<option value="M11">Novembre</option>
<option value="M12">Décembre</option>
</optgroup>
</select>
<select
value={year}
onChange={(e)=>{ setYear(parseInt(e.target.value,10)); setPage(1); }}
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
{yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
</div>
)}
</section>
{/* Pagination supérieure */}
{items.length > 0 && (
<section className="rounded-2xl border bg-white">
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={items.length}
position="top"
/>
</section>
)}
{/* Tableau */}
<section className="rounded-2xl border bg-white overflow-hidden">
<div className="overflow-x-auto pb-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<Th>État</Th>
<Th>Référence</Th>
<Th>Salarié</Th>
{/* Structure column visible only to staff: we rely on server /api/me to set clientInfo.isStaff via cookie/session. */}
<Th className="hidden sm:table-cell">Structure</Th>
<Th>{regime === 'RG' ? 'Analytique' : 'Production'}</Th>
<Th>Profession</Th>
<Th>Début</Th>
<Th>Fin</Th>
<Th className="text-right pr-4">Actions</Th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={8} className="py-12 text-center text-slate-500"><Loader2 className="w-4 h-4 inline animate-spin mr-2"/> Chargement…</td></tr>
) : isError ? (
<tr><td colSpan={8} className="py-12 text-center text-rose-500">Erreur : {(error as any)?.message || 'imprévue'}</td></tr>
) : items.length === 0 ? (
<tr><td colSpan={8} className="py-12 text-center text-slate-500">{status==='en_cours' ? 'Aucun contrat en cours.' : 'Aucun contrat terminé.'}</td></tr>
) : (
items.map((c)=> (
<tr key={c.id} className="border-b last:border-b-0">
<Td>
{(() => { const e = safeEtat(c.etat as any); return (
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${e.className}`}>{e.label}</span>
); })()}
</Td>
<Td>
<div className="flex flex-col">
<a href={detailHref(c)} className="underline font-medium">{c.reference}</a>
{(c.is_multi_mois === true || (c.regime && c.regime.toUpperCase() === "CDDU_MULTI")) && (
<span className="mt-1 inline-flex w-fit text-[11px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-800">Multimois</span>
)}
</div>
</Td>
<Td>{c.salarie_nom}</Td>
<Td className="hidden sm:table-cell">{(c as any).org_name || ''}</Td>
<Td>{c.production}</Td>
<Td>{c.profession}</Td>
<Td>{formatFR(c.date_debut)}</Td>
<Td>{formatFR(c.date_fin)}</Td>
<Td className="text-right pr-4">
<div className="inline-flex items-center gap-2">
{
// Détecter si le contrat est en Régime général
}
{(() => {
const regimeUpper = (c.regime || "").toUpperCase();
const isRG = regimeUpper === "RG";
// Bouton Modifier : si RG => afficher variante désactivée avec tooltip
if (isRG) {
return (
<div className="group relative inline-block">
<button
type="button"
aria-disabled="true"
disabled
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border opacity-60 cursor-not-allowed"
>
<Pencil className="w-4 h-4" />
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-full top-1/2 -translate-y-1/2 z-10 mr-2 w-72 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
>
La modification et la duplication d'un contrat Régime général n'est pas encore possible depuis l'Espace Paie, veuillez nous contacter.
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
);
}
// sinon lien normal vers l'édition
return (
<a
href={`/contrats/${c.id}/edit`}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border hover:bg-slate-50"
aria-label="Modifier le contrat"
title="Modifier le contrat"
>
<Pencil className="w-4 h-4" />
</a>
);
})()}
{(() => {
const regimeUpper = (c.regime || "").toUpperCase();
const isRG = regimeUpper === "RG";
if (isRG) {
return (
<div className="group relative inline-block">
<button
type="button"
disabled
aria-disabled="true"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border opacity-60 cursor-not-allowed"
>
<Copy className="w-4 h-4" />
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-full top-1/2 -translate-y-1/2 z-10 mr-2 w-72 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 transition"
>
La modification et la duplication d'un contrat Régime général n'est pas encore possible depuis l'Espace Paie, veuillez nous contacter.
<div className="absolute -right-1 top-1/2 -translate-y-1/2 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
);
}
return (
<button
type="button"
onClick={() => router.push(`/contrats/nouveau?dupe_id=${encodeURIComponent(c.id)}`)}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border hover:bg-slate-50"
aria-label="Dupliquer le contrat"
title="Dupliquer le contrat"
>
<Copy className="w-4 h-4" />
</button>
);
})()}
</div>
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination inférieure */}
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={items.length}
position="bottom"
/>
</section>
</div>
);
}
function Th({ children, className="" }: any){
return <th className={`text-left font-medium px-3 py-2 ${className}`}>{children}</th>;
}
function Td({ children, className="" }: any){
return <td className={`px-3 py-3 ${className}`}>{children}</td>;
}
function formatFR(iso: string){
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
if (d.getFullYear() === 2099 && d.getMonth() === 0 && d.getDate() === 1) return "";
const dd = String(d.getDate()).padStart(2,'0');
const mm = String(d.getMonth()+1).padStart(2,'0');
const yyyy = d.getFullYear();
return `${dd}/${mm}/${yyyy}`;
}