678 lines
No EOL
28 KiB
TypeScript
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>
|
|
);
|
|
} |