566 lines
20 KiB
TypeScript
566 lines
20 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;
|
|
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}`;
|
|
}
|
|
|
|
/* ===== 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,
|
|
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] = useState(25);
|
|
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;
|
|
|
|
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);
|
|
|
|
const headerRight = useMemo(
|
|
() => (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<button
|
|
onClick={() => {
|
|
setPage((p) => {
|
|
const next = Math.max(1, p - 1);
|
|
const sp = new URLSearchParams(searchParams.toString());
|
|
sp.set("page", String(next));
|
|
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
|
|
router.replace(`/salaries?${sp.toString()}`);
|
|
return next;
|
|
});
|
|
}}
|
|
disabled={page === 1 || isFetching}
|
|
className="px-2 py-1 rounded-lg border disabled:opacity-40"
|
|
title="Page précédente"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<div>Page <strong>{page}</strong></div>
|
|
<button
|
|
onClick={() => {
|
|
if (!hasMore || isFetching) return;
|
|
setPage((p) => {
|
|
const next = p + 1;
|
|
const sp = new URLSearchParams(searchParams.toString());
|
|
sp.set("page", String(next));
|
|
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
|
|
router.replace(`/salaries?${sp.toString()}`);
|
|
return next;
|
|
});
|
|
}}
|
|
disabled={!hasMore || isFetching}
|
|
className="px-2 py-1 rounded-lg border disabled:opacity-40"
|
|
title="Page suivante"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
),
|
|
[page, hasMore, isFetching, query, router, searchParams]
|
|
);
|
|
|
|
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>
|
|
{headerRight}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tableau */}
|
|
<Section title="Liste">
|
|
{isLoading ? (
|
|
<div className="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="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>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Pagination bas de page */}
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<button
|
|
onClick={() => {
|
|
setPage((p) => {
|
|
const next = Math.max(1, p - 1);
|
|
const sp = new URLSearchParams(searchParams.toString());
|
|
sp.set("page", String(next));
|
|
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
|
|
router.replace(`/salaries?${sp.toString()}`);
|
|
return next;
|
|
});
|
|
}}
|
|
disabled={page === 1 || isFetching}
|
|
className="px-3 py-2 rounded-lg border disabled:opacity-40"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<div className="text-sm">Page <strong>{page}</strong></div>
|
|
<button
|
|
onClick={() => {
|
|
if (!hasMore || isFetching) return;
|
|
setPage((p) => {
|
|
const next = p + 1;
|
|
const sp = new URLSearchParams(searchParams.toString());
|
|
sp.set("page", String(next));
|
|
if (query.trim()) sp.set("q", query.trim()); else sp.delete("q");
|
|
router.replace(`/salaries?${sp.toString()}`);
|
|
return next;
|
|
});
|
|
}}
|
|
disabled={!hasMore || isFetching}
|
|
className="px-3 py-2 rounded-lg border disabled:opacity-40"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
{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>
|
|
);
|
|
}
|