espace-paie-odentas/app/(app)/contrats/page.tsx
odentas 64299cd9a5 fix: Affichage des CDI et activation Super Staff
- Support complet des contrats CDI dans l'onglet Régime général
  - Correction du filtrage pour inclure type_de_contrat = 'CDI'
  - Gestion des CDI sans date de fin (end_date null)
  - Les CDI apparaissent maintenant dans 'En cours'
  - Redirection vers /contrats-rg/[id] pour les CDI
  - Ajout du type 'CDI' dans les types TypeScript

- Correction du lien d'activation pour les Super Staff
  - Ajout de /activate aux pages publiques dans middleware.ts
  - Les nouveaux Super Staff peuvent maintenant activer leur compte sans erreur 404

- Nettoyage du code
  - Retrait des logs de debug temporaires
2025-12-02 13:32:44 +01:00

718 lines
No EOL
31 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, HelpCircle } from "lucide-react";
import { useDemoMode } from "@/hooks/useDemoMode";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// --- 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" | "CDI" | 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; sortField?: 'date_debut' | 'date_fin'; sortOrder?: 'asc' | 'desc' }){
const { regime, status, page, limit, q, month, year, period, org, sortField = 'date_fin', sortOrder = 'desc' } = 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,
sortField,
sortOrder,
],
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}`);
}
}
// Ajouter les paramètres de tri
if (sortField) parts.push(`sort=${encodeURIComponent(sortField)}`);
if (sortOrder) parts.push(`order=${encodeURIComponent(sortOrder)}`);
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 [sortField, setSortField] = useState<'date_debut' | 'date_fin'>('date_fin'); // Tri par défaut: date de fin
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); // Ordre par défaut: décroissant
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
const router = useRouter();
// 🎭 Détection du mode démo
const { isDemoMode } = useDemoMode();
const detailHref = (c: Contrat) => {
// 🎭 En mode démo, toujours rediriger vers la page de démo unique
if (isDemoMode) {
return `/contrats/demo`;
}
// Si c'est un contrat RG ou CDI, on envoie vers la page RG
const regimeUpper = (c.regime || "").toUpperCase();
if (regime === "RG" || regimeUpper === "RG" || regimeUpper === "CDI") {
return `/contrats-rg/${c.id}`;
}
// Sinon, on applique la logique CDDU / multi-mois
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; }
},
});
// Zustand store pour la sélection d'organisation (uniquement pour le staff)
const { selectedOrgId, setSelectedOrg: setGlobalSelectedOrg } = useStaffOrgSelection();
// Helper: vérifier si une string est un UUID valide
const isValidUUID = (str: string | null): boolean => {
if (!str) return false;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
const [orgs, setOrgs] = useState<Array<{ id: string; name: string }>>([]);
// N'utiliser selectedOrgId que si c'est un UUID valide (pas un nom de structure)
const [selectedOrg, setSelectedOrg] = useState<string | null>(
isValidUUID(selectedOrgId) ? selectedOrgId : null
);
// Synchroniser le filtre local avec le store global quand selectedOrgId change
useEffect(() => {
if (meData?.is_staff && isValidUUID(selectedOrgId)) {
setSelectedOrg(selectedOrgId);
}
}, [selectedOrgId, meData?.is_staff]);
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,
sortField,
sortOrder,
});
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) => {
const value = e.target.value || null;
setSelectedOrg(value);
setPage(1);
// Synchroniser avec le store global
if (value) {
const org = orgs.find(o => o.id === value);
setGlobalSelectedOrg(value, org?.name || null);
} else {
setGlobalSelectedOrg(null, null);
}
}}
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 sm:flex-row sm:items-center sm:justify-between 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 left-1/2 -translate-x-1/2 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-1/2 -translate-x-1/2 -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>
<div className="flex items-center gap-1.5">
État
{regime === 'CDDU' && (
<div
className="relative inline-block"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setTooltipPos({
top: rect.top + rect.height / 2,
left: rect.right
});
setShowTooltip(true);
}}
onMouseLeave={() => setShowTooltip(false)}
>
<HelpCircle className="w-3.5 h-3.5 text-slate-400 cursor-help" />
</div>
)}
</div>
</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 className="cursor-pointer" onClick={() => { setSortField('date_debut'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Début {sortField === 'date_debut' ? (sortOrder === 'asc' ? '' : '') : ''}
</Th>
<Th className="cursor-pointer" onClick={() => { setSortField('date_fin'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Fin {sortField === 'date_fin' ? (sortOrder === 'asc' ? '' : '') : ''}
</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>
{/* Tooltip fixe pour l'icône État */}
{showTooltip && tooltipPos && (
<div
className="fixed z-[1000] pointer-events-none"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
transform: 'translateY(-50%)'
}}
>
<div className="flex items-center">
<div className="w-0 h-0" style={{
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
borderRight: '6px solid rgb(15, 23, 42)' // slate-900
}} />
<div className="w-72 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs leading-relaxed shadow-xl">
Sur la vue CDDU &gt; En cours, indique l'état de traitement du contrat.
<br /><br />
Sur la vue CDDU &gt; Terminés, indique l'état de traitement de la paie.
</div>
</div>
</div>
)}
</div>
);
}
function Th({ children, className="", onClick }: any){
return <th className={`text-left font-medium px-3 py-2 ${className}`} onClick={onClick}>{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}`;
}