espace-paie-odentas/app/(app)/salaries/page.tsx

598 lines
21 KiB
TypeScript

"use client";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useMemo, useState, useEffect } from "react";
import { api } from "@/lib/fetcher";
import { Loader2, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
/* ===== Types ===== */
type SalarieRow = {
matricule: string; // "Code salarié"
nom: string;
email?: string | null;
transat_connecte?: boolean;
dernier_emploi?: string | null;
dernier_contrat?: {
id: string;
reference: string;
is_multi_mois?: boolean;
regime?: "CDDU_MONO" | "CDDU_MULTI" | "RG" | string;
} | null;
};
type SalariesResponse = {
items: SalarieRow[];
page: number;
limit: number;
total?: number;
totalPages?: number;
hasMore: boolean;
};
type ClientInfo = {
id: string;
name: string;
api_name?: string;
} | null;
/* ===== Helpers ===== */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
</section>
);
}
function lastContractHref(c?: SalarieRow["dernier_contrat"]) {
if (!c) return null;
const isMulti = c.is_multi_mois === true || (c.regime && c.regime.toUpperCase() === "CDDU_MULTI");
return isMulti ? `/contrats-multi/${c.id}` : `/contrats/${c.id}`;
}
/* ===== 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> salarié{total > 1 ? 's' : ''} au total
{itemsCount > 0 && `${itemsCount} affiché${itemsCount > 1 ? 's' : ''}`}
</>
) : (
'Aucun salarié'
)}
</span>
)}
</div>
</div>
);
}
/* ===== Data hook ===== */
function useSalaries(page: number, limit: number, search: string, org?: string | null) {
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 useSalaries debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
page,
search
});
// 🎭 Mode démo : utiliser les données fictives DIRECTEMENT
if (isDemoMode) {
console.log('🎭 Demo mode detected, loading demo salaries...');
// Données fictives de salariés
const DEMO_SALARIES: SalarieRow[] = [
{
matricule: "demo-sal-001",
nom: "MARTIN Alice",
email: "alice.martin@demo.fr",
transat_connecte: true,
dernier_emploi: "Comédien",
dernier_contrat: {
id: "demo-cont-001",
reference: "DEMO-2024-001",
is_multi_mois: true,
regime: "CDDU_MULTI"
}
},
{
matricule: "demo-sal-002",
nom: "DUBOIS Pierre",
email: "pierre.dubois@demo.fr",
transat_connecte: false,
dernier_emploi: "Metteur en scène",
dernier_contrat: {
id: "demo-cont-002",
reference: "DEMO-2024-002",
is_multi_mois: false,
regime: "CDDU_MONO"
}
},
{
matricule: "demo-sal-003",
nom: "LEROY Sophie",
email: "sophie.leroy@demo.fr",
transat_connecte: true,
dernier_emploi: "Danseur",
dernier_contrat: {
id: "demo-cont-003",
reference: "DEMO-2024-003",
is_multi_mois: true,
regime: "CDDU_MULTI"
}
},
{
matricule: "demo-sal-004",
nom: "BERNARD Marc",
email: "marc.bernard@demo.fr",
transat_connecte: false,
dernier_emploi: "Technicien son",
dernier_contrat: {
id: "demo-cont-004",
reference: "DEMO-2024-004",
is_multi_mois: false,
regime: "CDDU_MONO"
}
},
{
matricule: "demo-sal-005",
nom: "GARCIA Elena",
email: "elena.garcia@demo.fr",
transat_connecte: true,
dernier_emploi: "Costumière",
dernier_contrat: {
id: "demo-cont-005",
reference: "DEMO-2024-005",
is_multi_mois: true,
regime: "CDDU_MULTI"
}
}
];
// Filtrer par recherche si nécessaire
const filteredSalaries = DEMO_SALARIES.filter(salarie => {
if (search.trim()) {
const searchTerm = search.toLowerCase();
return salarie.nom.toLowerCase().includes(searchTerm) ||
salarie.matricule.toLowerCase().includes(searchTerm) ||
salarie.email?.toLowerCase().includes(searchTerm) ||
salarie.dernier_emploi?.toLowerCase().includes(searchTerm);
}
return true;
});
// Pagination simple
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedSalaries = filteredSalaries.slice(startIndex, endIndex);
console.log('✅ Filtered demo salaries:', filteredSalaries.length, 'total,', paginatedSalaries.length, 'on page', page);
return {
data: {
items: paginatedSalaries,
page,
limit,
total: filteredSalaries.length,
totalPages: Math.ceil(filteredSalaries.length / limit),
hasMore: endIndex < filteredSalaries.length,
},
isLoading: false,
error: null,
isError: false,
isFetching: false
};
}
// Mode normal : récupération via API
// 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
});
const params = new URLSearchParams();
params.set("page", String(page));
params.set("limit", String(limit));
if (search.trim()) params.set("search", search.trim());
// We'll return a tuple: a query function that accepts an optional org override
return useQuery<SalariesResponse>({
queryKey: ["salaries", page, limit, search, clientInfo?.id, org],
queryFn: () => {
// If an explicit org override is provided (staff filtering), pass it via clientInfo override
const finalClientInfo = clientInfo ? { ...clientInfo, id: org ?? clientInfo.id } : (org ? { id: org, name: "Organisation", api_name: undefined } as ClientInfo : null);
return api<SalariesResponse>(`/salaries?${params.toString()}`, {}, finalClientInfo);
},
staleTime: 10_000,
gcTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
initialData: undefined,
enabled: !!clientInfo,
});
}
/* ===== Page ===== */
export default function SalariesPage() {
usePageTitle("Salariés");
const router = useRouter();
const searchParams = useSearchParams();
const initialPage = Number(searchParams.get("page") || 1);
const initialSearch = searchParams.get("q") || "";
const [page, setPage] = useState(initialPage);
const [limit, setLimit] = useState(10);
const [query, setQuery] = useState(initialSearch);
const debouncedSearch = useDebouncedCallback((v: string) => {
setPage(1);
const sp = new URLSearchParams(searchParams.toString());
if (v.trim()) sp.set("q", v.trim()); else sp.delete("q");
sp.set("page", "1");
router.replace(`/salaries?${sp.toString()}`);
}, 300);
// (moved below, after selectedOrg declaration)
// Fetch /api/me and organizations for staff 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, isFetching } = useSalaries(page, limit, query, selectedOrg);
const rows: SalarieRow[] = data?.items ?? [];
const hasMore: boolean = data?.hasMore ?? false;
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 0;
useEffect(() => {
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 {}
})();
return () => { mounted = false; };
}, []);
// Modal "Nouveau contrat" déclenché depuis la liste
const [newContratOpen, setNewContratOpen] = useState(false);
const [selectedMatricule, setSelectedMatricule] = useState<string | null>(null);
const [selectedNom, setSelectedNom] = useState<string | null>(null);
return (
<div className="space-y-5">
{/* Barre de titre + actions */}
<div className="rounded-2xl border bg-white p-4">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<div className="text-lg font-semibold">Vos salariés</div>
<div className="md:ml-auto w-full md:w-auto">
<input
type="search"
placeholder="Rechercher (nom, email, matricule)…"
value={query}
onChange={(e) => {
const v = e.target.value;
setQuery(v);
debouncedSearch(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const v = (e.target as HTMLInputElement).value;
setQuery(v);
setPage(1);
const sp = new URLSearchParams(searchParams.toString());
if (v.trim()) sp.set("q", v.trim()); else sp.delete("q");
sp.set("page", "1");
router.replace(`/salaries?${sp.toString()}`);
}
}}
className="w-full md:w-80 px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
{/* Organization filter for staff */}
{meData?.is_staff && (
<select
value={selectedOrg || ""}
onChange={(e) => { setSelectedOrg(e.target.value || null); setPage(1); }}
className="ml-3 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>
)}
<Link
href="/salaries/nouveau"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700"
>
<Plus className="w-4 h-4" /> Nouveau salarié
</Link>
</div>
</div>
{/* Pagination supérieure */}
{rows.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={rows.length}
position="top"
/>
</section>
)}
{/* Tableau */}
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
Liste
</div>
{isLoading ? (
<div className="p-4 py-10 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement des salarié·e·s
</div>
) : (
<>
<div className="p-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<th className="text-left font-medium px-3 py-2">Salarié</th>
<th className="text-left font-medium px-3 py-2 hidden md:table-cell">Structure</th>
<th className="text-left font-medium px-3 py-2">Matricule</th>
<th className="text-left font-medium px-3 py-2">Espace Transat</th>
<th className="text-left font-medium px-3 py-2">Adresse mail</th>
<th className="text-left font-medium px-3 py-2">Dernier emploi</th>
<th className="text-left font-medium px-3 py-2">Dernier contrat</th>
<th className="text-right font-medium px-3 py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-3 py-8 text-center text-slate-500">
Aucun résultat.
</td>
</tr>
) : (
rows.map((r: SalarieRow) => {
const contratHref = lastContractHref(r.dernier_contrat);
return (
<tr key={r.matricule} className="border-b last:border-0">
<td className="px-3 py-2">
<Link href={`/salaries/${r.matricule}`} className="underline font-medium">
{r.nom}
</Link>
</td>
<td className="px-3 py-2 hidden md:table-cell">{(r as any).org_name || ''}</td>
<td className="px-3 py-2">{r.matricule}</td>
<td className="px-3 py-2">
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${
r.transat_connecte ? "bg-emerald-100 text-emerald-800"
: "bg-rose-100 text-rose-800"
}`}>
{r.transat_connecte ? "Connecté" : "Non connecté"}
</span>
</td>
<td className="px-3 py-2">{r.email || <span className="text-slate-400"></span>}</td>
<td className="px-3 py-2">{r.dernier_emploi || <span className="text-slate-400"></span>}</td>
<td className="px-3 py-2">
{r.dernier_contrat && contratHref ? (
<Link href={contratHref} className="underline">{r.dernier_contrat.reference}</Link>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="px-3 py-2 text-right pr-4">
<button
type="button"
onClick={() => {
setSelectedMatricule(r.matricule);
setSelectedNom(r.nom || r.matricule);
setNewContratOpen(true);
}}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border hover:bg-slate-50"
aria-label="Créer un contrat pour ce salarié"
title="Créer un contrat"
>
<Plus className="w-4 h-4" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination inférieure dans la Section */}
<Pagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(newPage) => setPage(newPage)}
onLimitChange={(newLimit) => { setLimit(newLimit); setPage(1); }}
isFetching={isFetching}
itemsCount={rows.length}
position="bottom"
/>
</>
)}
</section>
{newContratOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={() => setNewContratOpen(false)}
/>
{/* Modal */}
<div className="relative z-10 w-full max-w-md rounded-2xl border bg-white p-5 shadow-xl">
<div className="text-base font-medium mb-1">Créer un nouveau contrat</div>
{selectedNom && (
<div className="text-sm text-slate-600 mb-1">
Pour: <span className="font-medium">{selectedNom}</span>
</div>
)}
<p className="text-sm text-slate-500 mb-4">
Pour quel type de contrat souhaitez-vous procéder ?
</p>
<div className="grid grid-cols-1 gap-3">
<button
type="button"
onClick={() => {
setNewContratOpen(false);
if (selectedMatricule) {
router.push(`/contrats/nouveau?salarie=${encodeURIComponent(selectedMatricule)}`);
}
}}
className="w-full px-3 py-2 rounded-lg border text-sm hover:bg-slate-50 text-left"
>
CDDU
<div className="text-xs text-slate-500">Contrat à durée déterminée d'usage</div>
</button>
<button
type="button"
onClick={() => {
// Non fonctionnel pour l'instant
setNewContratOpen(false);
}}
className="w-full px-3 py-2 rounded-lg border text-sm opacity-70 cursor-not-allowed text-left"
title="Bientôt disponible"
disabled
>
Régime général
<div className="text-xs text-slate-500">Bientôt disponible</div>
</button>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => setNewContratOpen(false)}
className="text-sm px-3 py-2 rounded-lg border"
>
Annuler
</button>
</div>
</div>
</div>
)}
</div>
);
}