espace-paie-odentas/components/staff/SalariesGrid.tsx
2025-10-12 17:05:46 +02:00

678 lines
No EOL
28 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import Link from "next/link";
type Salarie = {
id: string;
code_salarie?: string | null;
salarie?: string | null;
nom?: string | null;
nom_de_naissance?: string | null;
prenom?: string | null;
civilite?: string | null;
pseudonyme?: string | null;
compte_transat?: string | null;
topaze?: string | null;
justificatifs_personnels?: string | null;
rf_au_sens_fiscal?: boolean | null;
intermittent_mineur_16?: boolean | null;
adresse_mail?: string | null;
nir?: string | null;
conges_spectacles?: string | null;
tel?: string | null;
adresse?: string | null;
date_naissance?: string | null;
lieu_de_naissance?: string | null;
iban?: string | null;
bic?: string | null;
abattement_2024?: string | null;
infos_caisses_organismes?: string | null;
num_salarie?: string | null;
notif_nouveau_salarie?: string | null;
notif_employeur?: string | null;
derniere_profession?: string | null;
created_at?: string | null;
updated_at?: string | null;
employer_id?: string | null;
};
type SalarieDisplay = {
id: string;
matricule: string;
nom_complet: string;
nom?: string | null;
nom_de_naissance?: string | null;
prenom?: string | null;
civilite?: string | null;
pseudonyme?: string | null;
email?: string | null;
nir?: string | null;
tel?: string | null;
adresse?: string | null;
date_naissance?: string | null;
lieu_de_naissance?: string | null;
iban?: string | null;
bic?: string | null;
compte_transat?: string | null;
transat_connecte: boolean;
topaze?: string | null;
justificatifs_personnels?: string | null;
rf_au_sens_fiscal?: boolean | null;
intermittent_mineur_16?: boolean | null;
conges_spectacles?: string | null;
abattement_2024?: string | null;
notif_nouveau_salarie?: string | null;
notif_employeur?: string | null;
dernier_emploi?: string | null;
org_name?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
export default function SalariesGrid({ initialData, activeOrgId }: { initialData: Salarie[]; activeOrgId?: string | null }) {
const [rows, setRows] = useState<SalarieDisplay[]>([]);
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
// filters / sorting / pagination
const [q, setQ] = useState("");
const [civiliteFilter, setCiviliteFilter] = useState<string | null>(null);
const [transatFilter, setTransatFilter] = useState<string | null>(null);
const [orgFilter, setOrgFilter] = useState<string | null>(null);
const [sortField, setSortField] = useState<string>("nom");
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>('asc');
const [page, setPage] = useState(0);
const [limit, setLimit] = useState(50);
const totalCountRef = useRef<number | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editing, setEditing] = useState<{ id: string; field: string } | null>(null);
const [draftValue, setDraftValue] = useState<string>("");
// Define all possible options for select fields
const transatOptions = ["En cours", "Connecté", "Non connecté", "N/A"];
const topazeOptions = ["NC", "OK", "En cours", "N/A"];
const justificatifsOptions = ["En attente de réception", "Reçu", "Complet", "Incomplet", "N/A"];
const abattementOptions = ["Sans objet", "10%", "20%", "30%", "N/A"];
const notificationOptions = ["Fait", "En attente", "N/A"];
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
// Derive filter options from data
const civilites = useMemo(() =>
Array.from(new Set((initialData || []).map((r) => r.civilite).filter(Boolean) as string[])).slice(0, 20),
[initialData]
);
const organizations = useMemo(() =>
Array.from(new Set((initialData || []).map((r) => r.infos_caisses_organismes).filter(Boolean) as string[])).slice(0, 20),
[initialData]
);
// Transform raw salarie data to display format
const transformSalarie = (salarie: Salarie): SalarieDisplay => {
const fullName = salarie.salarie || [salarie.nom, salarie.prenom].filter(Boolean).join(" ").trim() || salarie.nom || "";
const matricule = salarie.code_salarie || (salarie.num_salarie ? String(salarie.num_salarie) : salarie.id);
const comp = (salarie.compte_transat || "").toString().toLowerCase();
const transat = comp.includes("connect") && !comp.includes("non");
return {
id: salarie.id,
matricule,
nom_complet: fullName,
nom: salarie.nom,
nom_de_naissance: salarie.nom_de_naissance,
prenom: salarie.prenom,
civilite: salarie.civilite,
pseudonyme: salarie.pseudonyme,
email: salarie.adresse_mail ?? null,
nir: salarie.nir,
tel: salarie.tel,
adresse: salarie.adresse,
date_naissance: salarie.date_naissance,
lieu_de_naissance: salarie.lieu_de_naissance,
iban: salarie.iban,
bic: salarie.bic,
compte_transat: salarie.compte_transat,
transat_connecte: transat,
topaze: salarie.topaze,
justificatifs_personnels: salarie.justificatifs_personnels,
rf_au_sens_fiscal: salarie.rf_au_sens_fiscal,
intermittent_mineur_16: salarie.intermittent_mineur_16,
conges_spectacles: salarie.conges_spectacles,
abattement_2024: salarie.abattement_2024,
notif_nouveau_salarie: salarie.notif_nouveau_salarie,
notif_employeur: salarie.notif_employeur,
dernier_emploi: salarie.derniere_profession ?? null,
org_name: salarie.infos_caisses_organismes ?? null,
created_at: salarie.created_at ?? null,
updated_at: salarie.updated_at ?? null,
};
};
// Initialize rows from initialData
useEffect(() => {
const transformedData = (initialData || []).map(transformSalarie);
setRows(transformedData);
}, [initialData]);
// optimistic update helper
async function saveCell(id: string, field: string, value: string | boolean) {
// optimistic local update
setRows((r) => r.map((x) => (x.id === id ? { ...x, [field]: value } : x)));
try {
const body: any = { id };
body[field] = value;
const res = await fetch(`/api/staff/salaries/update`, {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j?.error || "Update failed");
}
const result = await res.json();
console.log("✅ Field updated successfully:", { field, value, result });
} catch (err: any) {
console.error("Update failed", err);
// Revert optimistic update on error
const original = (initialData || []).find((s: Salarie) => s.id === id);
if (original) {
const reverted = transformSalarie(original);
setRows((r) => r.map((x) => (x.id === id ? reverted : x)));
}
alert(`Erreur lors de la mise à jour: ${err.message}`);
}
}
// Render editable cell
const renderEditableCell = (row: SalarieDisplay, field: keyof SalarieDisplay, displayValue?: string) => {
const currentValue = String(row[field] || "");
const value = displayValue || currentValue;
if (editing?.id === row.id && editing.field === field) {
const handleSave = () => {
const actualField = field === 'matricule' ? 'code_salarie' :
field === 'nom_complet' ? 'salarie' :
field === 'email' ? 'adresse_mail' :
field === 'dernier_emploi' ? 'derniere_profession' :
field === 'org_name' ? 'infos_caisses_organismes' :
field;
saveCell(row.id, actualField as string, draftValue);
setEditing(null);
};
return (
<input
className="w-full rounded border px-2 py-1 text-sm"
value={draftValue}
onChange={(e) => setDraftValue(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSave();
} else if (e.key === "Escape") {
setEditing(null);
}
}}
autoFocus
/>
);
}
return (
<div className="flex items-center gap-2 group">
<span className="flex-1 min-w-0">{value || "—"}</span>
<button
className="text-xs text-slate-400 hover:text-slate-600 px-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => {
setEditing({ id: row.id, field: field as string });
setDraftValue(currentValue);
}}
title="Modifier"
>
</button>
</div>
);
};
// Render boolean cell
const renderBooleanCell = (row: SalarieDisplay, field: keyof SalarieDisplay) => {
const value = row[field];
const isChecked = Boolean(value);
return (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const actualField = field === 'rf_au_sens_fiscal' ? 'rf_au_sens_fiscal' :
field === 'intermittent_mineur_16' ? 'intermittent_mineur_16' :
field;
saveCell(row.id, actualField as string, e.target.checked);
}}
className="rounded border-slate-300"
/>
<span className="text-xs text-slate-500">
{isChecked ? 'Oui' : 'Non'}
</span>
</div>
);
};
// Render select cell for specific fields
const renderSelectCell = (row: SalarieDisplay, field: keyof SalarieDisplay, options: string[]) => {
const value = String(row[field] || "");
return (
<select
value={value}
onChange={(e) => {
const actualField = field === 'compte_transat' ? 'compte_transat' :
field === 'topaze' ? 'topaze' :
field === 'justificatifs_personnels' ? 'justificatifs_personnels' :
field === 'abattement_2024' ? 'abattement_2024' :
field === 'notif_nouveau_salarie' ? 'notif_nouveau_salarie' :
field === 'notif_employeur' ? 'notif_employeur' :
field;
saveCell(row.id, actualField as string, e.target.value);
}}
className="w-full rounded border px-2 py-1 text-sm bg-white"
>
<option value=""></option>
{options.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
);
};
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) ?? null, [rows, selectedId]);
// Realtime subscription: listen to INSERT / UPDATE / DELETE on salaries
useEffect(() => {
try {
console.log("SalariesGrid initialData (client):", Array.isArray(initialData) ? initialData.length : typeof initialData, initialData?.slice?.(0, 5));
} catch (err) {
console.log("SalariesGrid initialData (client) - could not log:", err);
}
let channel: any = null;
let mounted = true;
(async () => {
try {
channel = supabase.channel("public:salaries");
channel.on(
"postgres_changes",
{ event: "*", schema: "public", table: "salaries" },
(payload: any) => {
try {
const event = payload.event || payload.eventType || payload.type;
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
if (event === "INSERT") {
const newRec = transformSalarie(record as Salarie);
setRows((rs) => {
if (rs.find((r) => r.id === newRec.id)) return rs;
return [newRec, ...rs];
});
} else if (event === "UPDATE") {
const updatedRec = transformSalarie(record as Salarie);
setRows((rs) => rs.map((r) => (r.id === updatedRec.id ? updatedRec : r)));
} else if (event === "DELETE") {
const id = record?.id ?? payload.old?.id;
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
}
} catch (err) {
console.error("Realtime handler error", err);
}
}
);
const sub = await channel.subscribe();
if (!mounted) return;
if (sub && (sub.status === "timed_out" || sub.status === "closed" || sub?.error)) {
console.warn("Realtime subscribe returned unexpected status", sub);
}
} catch (err: any) {
console.warn("Realtime subscription failed for public.salaries — falling back to polling.", err?.message ?? err);
}
})();
return () => {
mounted = false;
try {
if (channel) {
// @ts-ignore
if (supabase.removeChannel) supabase.removeChannel(channel);
else channel.unsubscribe && channel.unsubscribe();
}
} catch (err) {
console.warn("Error unsubscribing realtime channel", err);
}
};
}, []);
// Helper: fetch server-side with current filters
async function fetchServer(pageIndex = 0) {
setLoading(true);
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (civiliteFilter) params.set('civilite', civiliteFilter);
if (transatFilter) params.set('transat', transatFilter);
if (orgFilter) params.set('organization', orgFilter);
params.set('sort', sortField);
params.set('order', sortOrder === 'asc' ? 'asc' : 'desc');
params.set('limit', String(limit));
params.set('offset', String(pageIndex * limit));
const res = await fetch(`/api/staff/salaries/search?${params.toString()}`);
if (!res.ok) throw new Error('Search failed');
const j = await res.json();
totalCountRef.current = j.count ?? (j.rows ? j.rows.length : 0);
setRows(j.rows ?? []);
setPage(pageIndex);
} catch (err) {
console.error('Search error', err);
} finally {
setLoading(false);
}
}
// Debounce searches when filters change
useEffect(() => {
const noFilters = !q && !civiliteFilter && !transatFilter && !orgFilter && sortField === 'nom' && sortOrder === 'asc';
if (noFilters && initialData && initialData.length > 0) {
const transformedData = initialData.map(transformSalarie);
setRows(transformedData);
return;
}
const t = setTimeout(() => fetchServer(0), 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, civiliteFilter, transatFilter, orgFilter, sortField, sortOrder, limit]);
return (
<div className="relative">
{/* View Mode Toggle & Filters */}
<div className="mb-3 flex flex-col sm:flex-row sm:items-center sm:gap-3">
<div className="flex items-center gap-3 mb-2 sm:mb-0">
<div className="flex rounded border overflow-hidden">
<button
className={`px-3 py-1 text-sm ${viewMode === 'compact' ? 'bg-blue-100 text-blue-800' : 'bg-white text-slate-600 hover:bg-slate-50'}`}
onClick={() => setViewMode('compact')}
>
Vue compacte
</button>
<button
className={`px-3 py-1 text-sm ${viewMode === 'detailed' ? 'bg-blue-100 text-blue-800' : 'bg-white text-slate-600 hover:bg-slate-50'}`}
onClick={() => setViewMode('detailed')}
>
Vue détaillée
</button>
</div>
</div>
<div className="flex-1">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Recherche (nom, email, matricule, téléphone, NIR...)"
className="w-full rounded border px-2 py-1 text-sm"
/>
</div>
</div>
{/* Secondary Filters */}
<div className="mb-3 flex flex-wrap gap-2">
<select
value={civiliteFilter ?? ""}
onChange={(e) => setCiviliteFilter(e.target.value || null)}
className="rounded border px-2 py-1 text-sm"
>
<option value="">Toutes civilités</option>
{civilites.map((c) => (<option key={c} value={c}>{c}</option>))}
</select>
<select
value={transatFilter ?? ""}
onChange={(e) => setTransatFilter(e.target.value || null)}
className="rounded border px-2 py-1 text-sm"
>
<option value="">Transat (tous)</option>
<option value="connected">Connecté</option>
<option value="not_connected">Non connecté</option>
</select>
{organizations.length > 0 && (
<select
value={orgFilter ?? ""}
onChange={(e) => setOrgFilter(e.target.value || null)}
className="rounded border px-2 py-1 text-sm"
>
<option value="">Toutes organisations</option>
{organizations.slice(0, 10).map((o) => (<option key={o} value={o}>{o}</option>))}
</select>
)}
<button
className="rounded border px-3 py-1 text-sm hover:bg-slate-50"
onClick={() => {
setQ('');
setCiviliteFilter(null);
setTransatFilter(null);
setOrgFilter(null);
setSortField('nom');
setSortOrder('asc');
}}
>
Réinitialiser
</button>
</div>
{/* Scrollable table container */}
<div className="overflow-auto">
{viewMode === 'compact' ? (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('code_salarie'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Matricule {sortField === 'code_salarie' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('nom'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Nom complet {sortField === 'nom' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Civilité</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('adresse_mail'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Email {sortField === 'adresse_mail' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Téléphone</th>
<th className="text-left px-3 py-2">Transat</th>
<th className="text-left px-3 py-2">Organisation</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('updated_at'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Modifié {sortField === 'updated_at' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t hover:bg-slate-50/50">
<td className="px-3 py-2">
{renderEditableCell(r, 'matricule')}
</td>
<td className="px-3 py-2">
<Link
className="text-left w-full hover:text-blue-600 hover:underline font-medium"
href={`/staff/salaries/${r.id}`}
>
{r.nom_complet || "—"}
</Link>
</td>
<td className="px-3 py-2">{renderEditableCell(r, 'civilite')}</td>
<td className="px-3 py-2">
{r.email ? (
<a href={`mailto:${r.email}`} className="text-blue-600 hover:underline">
{renderEditableCell(r, 'email', r.email)}
</a>
) : (
renderEditableCell(r, 'email')
)}
</td>
<td className="px-3 py-2">
{r.tel ? (
<a href={`tel:${r.tel}`} className="text-blue-600 hover:underline">
{renderEditableCell(r, 'tel', r.tel)}
</a>
) : (
renderEditableCell(r, 'tel')
)}
</td>
<td className="px-3 py-2">
{renderSelectCell(r, 'compte_transat', transatOptions)}
</td>
<td className="px-3 py-2">{renderEditableCell(r, 'org_name')}</td>
<td className="px-3 py-2 text-xs text-slate-500">
{r.updated_at ? new Date(r.updated_at).toLocaleDateString('fr-FR') : '—'}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs min-w-[2000px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-2 py-2 cursor-pointer" onClick={() => { setSortField('code_salarie'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Matricule {sortField === 'code_salarie' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-2 py-2 cursor-pointer" onClick={() => { setSortField('nom'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Nom {sortField === 'nom' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-2 py-2">Nom naissance</th>
<th className="text-left px-2 py-2">Prénom</th>
<th className="text-left px-2 py-2">Civilité</th>
<th className="text-left px-2 py-2">Pseudonyme</th>
<th className="text-left px-2 py-2">Email</th>
<th className="text-left px-2 py-2">NIR</th>
<th className="text-left px-2 py-2">Tél</th>
<th className="text-left px-2 py-2">Adresse</th>
<th className="text-left px-2 py-2">Date naiss.</th>
<th className="text-left px-2 py-2">Lieu naiss.</th>
<th className="text-left px-2 py-2">IBAN</th>
<th className="text-left px-2 py-2">BIC</th>
<th className="text-left px-2 py-2">Transat</th>
<th className="text-left px-2 py-2">Topaze</th>
<th className="text-left px-2 py-2">Justificatifs</th>
<th className="text-left px-2 py-2">RF fiscal</th>
<th className="text-left px-2 py-2">Mineur -16</th>
<th className="text-left px-2 py-2">Congés spectacles</th>
<th className="text-left px-2 py-2">Abattement 2024</th>
<th className="text-left px-2 py-2">Notif nouveau</th>
<th className="text-left px-2 py-2">Notif employeur</th>
<th className="text-left px-2 py-2">Dernier emploi</th>
<th className="text-left px-2 py-2">Organisation</th>
<th className="text-left px-2 py-2">Modifié</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t hover:bg-slate-50/50">
<td className="px-2 py-2">{renderEditableCell(r, 'matricule')}</td>
<td className="px-2 py-2">
<Link className="text-blue-600 hover:underline font-medium" href={`/staff/salaries/${r.id}`}>
{renderEditableCell(r, 'nom', r.nom || "")}
</Link>
</td>
<td className="px-2 py-2">{renderEditableCell(r, 'nom_de_naissance')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'prenom')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'civilite')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'pseudonyme')}</td>
<td className="px-2 py-2">
{r.email ? <a href={`mailto:${r.email}`} className="text-blue-600 hover:underline">{renderEditableCell(r, 'email', r.email)}</a> : renderEditableCell(r, 'email')}
</td>
<td className="px-2 py-2">{renderEditableCell(r, 'nir')}</td>
<td className="px-2 py-2">
{r.tel ? <a href={`tel:${r.tel}`} className="text-blue-600 hover:underline">{renderEditableCell(r, 'tel', r.tel)}</a> : renderEditableCell(r, 'tel')}
</td>
<td className="px-2 py-2">{renderEditableCell(r, 'adresse')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'date_naissance')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'lieu_de_naissance')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'iban')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'bic')}</td>
<td className="px-2 py-2">{renderSelectCell(r, 'compte_transat', transatOptions)}</td>
<td className="px-2 py-2">{renderSelectCell(r, 'topaze', topazeOptions)}</td>
<td className="px-2 py-2">{renderSelectCell(r, 'justificatifs_personnels', justificatifsOptions)}</td>
<td className="px-2 py-2">{renderBooleanCell(r, 'rf_au_sens_fiscal')}</td>
<td className="px-2 py-2">{renderBooleanCell(r, 'intermittent_mineur_16')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'conges_spectacles')}</td>
<td className="px-2 py-2">{renderSelectCell(r, 'abattement_2024', abattementOptions)}</td>
<td className="px-2 py-2">{renderSelectCell(r, 'notif_nouveau_salarie', notificationOptions)}</td>
<td className="px-2 py-2">{renderSelectCell(r, 'notif_employeur', notificationOptions)}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'dernier_emploi')}</td>
<td className="px-2 py-2">{renderEditableCell(r, 'org_name')}</td>
<td className="px-2 py-2 text-xs text-slate-500">
{r.updated_at ? new Date(r.updated_at).toLocaleString('fr-FR') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{rows.length === 0 && (
<div className="p-4 text-sm text-slate-600">
<div>Aucun salarié trouvé.</div>
<div className="mt-2">
<button
className="text-xs underline"
onClick={() => setShowRaw((s) => !s)}
>
{showRaw ? "Cacher le payload" : "Voir le payload reçu"}
</button>
</div>
{showRaw && (
<pre className="mt-2 max-h-48 overflow-auto text-xs bg-slate-50 p-2 rounded border">
{JSON.stringify(initialData, null, 2)}
</pre>
)}
</div>
)}
</div>
{/* Pagination / info */}
<div className="mt-3 flex items-center justify-between text-xs text-slate-600">
<div>
{loading ? 'Chargement…' : `Affichage ${rows.length}${totalCountRef.current ? ` / ${totalCountRef.current}` : ''}`}
</div>
<div className="flex items-center gap-2">
<button
className="text-xs px-2 py-1 rounded border disabled:opacity-50"
onClick={() => { if (page > 0) fetchServer(page - 1); }}
disabled={page === 0}
>
Préc
</button>
<span className="text-xs px-2">{page + 1}</span>
<button
className="text-xs px-2 py-1 rounded border"
onClick={() => { fetchServer(page + 1); }}
>
Suiv
</button>
</div>
</div>
</div>
);
}