espace-paie-odentas/app/(app)/virements-salaires/page.tsx

962 lines
40 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// app/(app)/virements-salaires/page.tsx
"use client";
import React, { useMemo, useState, useEffect } from "react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Search, Download, ExternalLink, Info, Copy, Check } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// --- Types ---
type PeriodKey =
| "toute_annee"
| "premier_semestre"
| "second_semestre"
| "trimestre_1"
| "trimestre_2"
| "trimestre_3"
| "trimestre_4"
| `mois_${1|2|3|4|5|6|7|8|9|10|11|12}`;
type Filters = {
year: number;
period: PeriodKey;
from?: string; // YYYY-MM-DD
to?: string; // YYYY-MM-DD
};
type VirementItem = {
id: string;
periode_label?: string;
periode?: string;
callsheet?: string;
num_appel?: string;
date_mois?: string;
date?: string;
total_salaries_eur?: number;
total?: number;
virement_recu?: boolean | string;
virement_recu_date?: string | null; // Date de réception du virement
salaires_payes?: boolean | string;
pdf_url?: string;
};
type OrgSummary = {
structure_api: string | null;
virements_salaires: string | null;
iban: string | null;
bic: string | null;
gocardless?: string | null;
} | null;
// Vue client (virements gérés par le client)
type ClientVirementItem = {
id: string;
kind: 'CDDU_MONO' | 'CDDU_MULTI' | 'RG';
source: 'contrat' | 'paie_multi' | 'paie_rg' | 'payslip';
contract_id?: string;
salarie?: string | null;
salarie_matricule?: string | null;
reference?: string | null;
profession?: string | null;
date_debut?: string | null;
date_fin?: string | null;
periode?: string | null; // ex: "2025-08-01" ou "08/2025"
net_a_payer?: number | null;
virement_effectue?: 'oui' | 'non' | 'na';
};
type ClientInfo = {
id: string;
name: string;
api_name?: string;
} | null;
// --- Helpers ---
function pad2(n: number) { return String(n).padStart(2, "0"); }
function monthStartISO(y: number, m: number) { return `${y}-${pad2(m)}-01`; }
function monthEndISO(y: number, m: number) {
const d = new Date(y, m, 0); // last day of month
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function quarterRange(y: number, q: 1 | 2 | 3 | 4) {
const startMonth = (q - 1) * 3 + 1;
const endMonth = startMonth + 2;
return { from: monthStartISO(y, startMonth), to: monthEndISO(y, endMonth) };
}
function formatFR(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) 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}`;
}
function maskEndDate(iso?: string | null) {
if (!iso) return '—';
// Masquer la date 2099-01-01 utilisée comme fin indéterminée
if (iso.startsWith('2099-01-01')) return '—';
return formatFR(iso);
}
function capitalize(str: string) {
if (!str) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Affiche une période au format "Mois YYYY" (ex: "Septembre 2025")
function formatPeriode(per?: string | null) {
if (!per) return '—';
const s = String(per).trim();
// ISO: YYYY-MM or YYYY-MM-DD
const mIso = s.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?$/);
if (mIso) {
const y = parseInt(mIso[1], 10);
const m = parseInt(mIso[2], 10);
const d = new Date(y, (isNaN(m) ? 1 : m) - 1, 1);
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
// MM/YYYY
const mMY = s.match(/^(\d{1,2})\/(\d{4})$/);
if (mMY) {
const m = parseInt(mMY[1], 10);
const y = parseInt(mMY[2], 10);
const d = new Date(y, (isNaN(m) ? 1 : m) - 1, 1);
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
// Fallback: essayer Date()
const d = new Date(s);
if (!isNaN(d.getTime())) {
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
return s;
}
function formatCurrency(amount?: number) {
if (amount == null) return "—";
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
function toBoolish(v: any): boolean | undefined {
if (v === true) return true;
if (v === false) return false;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
if (s === 'oui' || s === 'yes' || s === 'true') return true;
if (s === 'non' || s === 'no' || s === 'false') return false;
}
return undefined;
}
// --- Hook pour récupérer les infos utilisateur et les organisations (staff uniquement) ---
function useUserInfo() {
return useQuery({
queryKey: ["user-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 {
isStaff: Boolean(me.is_staff || me.isStaff),
orgId: me.orgId || me.active_org_id || null,
orgName: me.orgName || me.active_org_name || "Organisation",
api_name: me.active_org_api_name
};
} catch {
return null;
}
},
staleTime: 30_000,
});
}
function useOrganizations() {
const { data: userInfo, isSuccess: userInfoLoaded } = useUserInfo();
const query = useQuery({
queryKey: ["organizations"],
queryFn: async () => {
try {
const res = await fetch("/api/staff/organizations", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return [];
const data = await res.json();
return data.organizations || [];
} catch {
return [];
}
},
enabled: false,
staleTime: 60_000,
});
React.useEffect(() => {
if (userInfoLoaded && userInfo?.isStaff && !query.data && !query.isFetching) {
query.refetch();
}
}, [userInfo, userInfoLoaded, query]);
return query;
}
// --- Hook pour récupérer les virements ---
function useVirements(filters: Filters, selectedOrgId?: string) {
// Récupération dynamique des infos utilisateur via /api/me
const { data: userInfo } = useUserInfo();
const qs = new URLSearchParams();
qs.set("year", String(filters.year));
qs.set("period", filters.period);
if (filters.from) qs.set("from", filters.from);
if (filters.to) qs.set("to", filters.to);
// Si c'est un staff qui a sélectionné une organisation spécifique
if (selectedOrgId && userInfo?.isStaff) {
qs.set("org_id", selectedOrgId);
}
return useQuery<{ items: VirementItem[]; enabled?: boolean; org?: OrgSummary; client?: { unpaid: ClientVirementItem[]; recent: ClientVirementItem[] } }>({
queryKey: ["virements-salaires", filters.year, userInfo?.orgId, selectedOrgId],
queryFn: async () => {
// On récupère toutes les données de l'année, le filtrage par période se fera côté client
const simpleQs = new URLSearchParams();
simpleQs.set("year", String(filters.year));
simpleQs.set("period", "toute_annee");
// Si c'est un staff qui a sélectionné une organisation spécifique
if (selectedOrgId && userInfo?.isStaff) {
simpleQs.set("org_id", selectedOrgId);
}
const res = await fetch(`/api/virements-salaires?${simpleQs.toString()}`, {
credentials: "include",
headers: { Accept: "application/json" }
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Erreur ${res.status}`);
}
const data = await res.json();
return {
items: (data?.items || []) as VirementItem[],
enabled: Boolean(data?.enabled),
org: (data?.org ?? null) as OrgSummary,
client: data?.client as { unpaid: ClientVirementItem[]; recent: ClientVirementItem[] } | undefined,
};
},
staleTime: 15_000,
enabled: !!userInfo, // Ne pas exécuter si pas d'infos utilisateur
});
}
export default function VirementsPage() {
usePageTitle("Virements salaires");
const now = new Date();
const [filters, setFilters] = useState<Filters>({
year: now.getFullYear(),
period: "toute_annee"
});
const [searchQuery, setSearchQuery] = useState("");
const [aboutOpen, setAboutOpen] = useState(false);
const [copiedField, setCopiedField] = useState<null | 'iban' | 'bic' | 'benef'>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
const { data: userInfo, isLoading: isLoadingUser } = useUserInfo();
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
const queryClient = useQueryClient();
const years = useMemo(() => {
const base = now.getFullYear();
return Array.from({ length: 7 }, (_, i) => base - i);
}, [now]);
// URL sync
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
const sp = searchParams;
if (!sp) return;
const yParam = sp.get("year");
const pParam = sp.get("period");
const from = sp.get("from") || undefined;
const to = sp.get("to") || undefined;
const next: Filters = { ...filters };
if (yParam) {
const y = parseInt(yParam, 10);
if (!isNaN(y)) next.year = y;
}
if (pParam) {
const allowed = new Set<PeriodKey>([
"toute_annee", "premier_semestre", "second_semestre",
"trimestre_1", "trimestre_2", "trimestre_3", "trimestre_4",
"mois_1", "mois_2", "mois_3", "mois_4", "mois_5", "mois_6",
"mois_7", "mois_8", "mois_9", "mois_10", "mois_11", "mois_12",
] as PeriodKey[]);
if (allowed.has(pParam as PeriodKey)) {
next.period = pParam as PeriodKey;
if (!from && !to) {
const y = next.year;
if (pParam === "premier_semestre") {
next.from = monthStartISO(y, 1);
next.to = monthEndISO(y, 6);
}
else if (pParam === "second_semestre") {
next.from = monthStartISO(y, 7);
next.to = monthEndISO(y, 12);
}
else if (pParam === "trimestre_1") {
const r = quarterRange(y, 1);
next.from = r.from;
next.to = r.to;
}
else if (pParam === "trimestre_2") {
const r = quarterRange(y, 2);
next.from = r.from;
next.to = r.to;
}
else if (pParam === "trimestre_3") {
const r = quarterRange(y, 3);
next.from = r.from;
next.to = r.to;
}
else if (pParam === "trimestre_4") {
const r = quarterRange(y, 4);
next.from = r.from;
next.to = r.to;
}
else if (pParam.startsWith("mois_")) {
const m = parseInt(pParam.split("_")[1]!, 10);
if (!isNaN(m)) {
next.from = monthStartISO(y, m);
next.to = monthEndISO(y, m);
}
}
else if (pParam === "toute_annee") {
next.from = undefined;
next.to = undefined;
}
}
}
}
next.from = (from as any) ?? next.from;
next.to = (to as any) ?? next.to;
const changed = next.year !== filters.year || next.period !== filters.period || next.from !== filters.from || next.to !== filters.to;
if (changed) setFilters(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams?.toString()]);
useEffect(() => {
if (!pathname) return;
const qs = new URLSearchParams();
qs.set("year", String(filters.year));
qs.set("period", filters.period);
if (filters.from) qs.set("from", filters.from);
if (filters.to) qs.set("to", filters.to);
router.replace(`${pathname}?${qs.toString()}`, { scroll: false });
}, [filters, pathname, router]);
const { data, isLoading, isError, error, isFetching } = useVirements(filters, selectedOrgId);
const loadingOrg = isLoading || isFetching || data === undefined;
const org: OrgSummary = (data?.org ?? null) as OrgSummary;
// Amélioration de la détection du mode Odentas
const isOdentas = !loadingOrg && (
Boolean(data?.enabled) ||
(typeof org?.virements_salaires === 'string' && org!.virements_salaires!.toLowerCase().includes('odentas'))
);
const gestionLabel = loadingOrg ? "Chargement…" : (isOdentas ? "Odentas" : "Votre structure");
const items: VirementItem[] = (data?.items ?? []) as VirementItem[];
const clientUnpaidAll: ClientVirementItem[] = (data?.client?.unpaid ?? []) as ClientVirementItem[];
const clientRecentAll: ClientVirementItem[] = (data?.client?.recent ?? []) as ClientVirementItem[];
const clientFilter = (arr: ClientVirementItem[]) => {
if (!searchQuery.trim()) return arr;
const q = searchQuery.toLowerCase();
return arr.filter(it =>
(it.salarie || '').toLowerCase().includes(q) ||
(it.reference || '').toLowerCase().includes(q) ||
(it.profession || '').toLowerCase().includes(q) ||
(it.periode || '').toLowerCase().includes(q)
);
};
const clientUnpaid = clientFilter(clientUnpaidAll);
const clientRecent = clientFilter(clientRecentAll);
// Mutation: marquer un payslip comme viré
async function markPayslipDone(payslipId: string) {
try {
// Optimistic UI: masquer l'élément avant refetch
// (on ne modifie pas le cache TanStack ici, on refetch directement après)
await fetch(`/api/payslips/${payslipId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: true })
});
// Invalider les requêtes liées pour recharger la liste
queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
} catch (e) {
console.error('Erreur marquage payslip:', e);
alert('Erreur lors du marquage du virement.');
}
}
// Filtrage local pour la recherche ET la période
const filteredItems = useMemo((): VirementItem[] => {
let result: VirementItem[] = items;
// 1. Filtrage par année (toujours actif)
result = result.filter((item: VirementItem) => {
if (!item.date_mois) return false;
const [year] = item.date_mois.split('-').map(Number);
return year === filters.year;
});
// 2. Filtrage par période spécifique (si pas "toute_annee")
if (filters.period !== "toute_annee") {
result = result.filter((item: VirementItem) => {
if (!item.date_mois) return false;
const [, month] = item.date_mois.split('-').map(Number);
switch (filters.period) {
case "premier_semestre":
return month >= 1 && month <= 6;
case "second_semestre":
return month >= 7 && month <= 12;
case "trimestre_1":
return month >= 1 && month <= 3;
case "trimestre_2":
return month >= 4 && month <= 6;
case "trimestre_3":
return month >= 7 && month <= 9;
case "trimestre_4":
return month >= 10 && month <= 12;
default:
// Pour les mois individuels (mois_1, mois_2, etc.)
if (filters.period.startsWith("mois_")) {
const targetMonth = parseInt(filters.period.split("_")[1]!, 10);
return month === targetMonth;
}
return true;
}
});
}
// 3. Filtrage par recherche textuelle
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter((item: VirementItem) =>
(item.callsheet || item.num_appel || "").toLowerCase().includes(query) ||
(item.periode_label || item.periode || "").toLowerCase().includes(query)
);
}
return result;
}, [items, searchQuery, filters.period, filters.year]);
return (
<div className="space-y-5">
{/* En-tête + Recherche */}
<section className="rounded-2xl border bg-white p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div>
<h1 className="text-xl font-semibold">Virements de salaires</h1>
<p className="text-sm text-slate-600 mt-1">
Suivez vos appels à virement, l'état des virements reçus et les salaires payés.
</p>
</div>
{isOdentas && (
<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={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="N° appel, période…"
className="bg-transparent outline-none text-sm flex-1"
/>
</div>
</div>
)}
</div>
{/* Filtres de période */}
<div className="mt-4 flex flex-col sm:flex-row sm:items-stretch gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Année :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.year}
onChange={(e) => setFilters(f => ({ ...f, year: parseInt(e.target.value, 10) }))}
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
{userInfo?.isStaff && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Organisation :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
disabled={!organizations || organizations.length === 0}
>
<option value="">
{!organizations || organizations.length === 0
? "Chargement..."
: "Toutes les organisations"}
</option>
{organizations && organizations.map((org: any) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
{organizations && organizations.length > 0 && (
<span className="text-xs text-slate-500">({organizations.length})</span>
)}
</div>
)}
{isOdentas && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Période :</label>
<select
className="px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.period}
onChange={(e) => setFilters(f => ({ ...f, period: e.target.value as PeriodKey }))}
>
<option value="toute_annee">Toute l'année</option>
<optgroup label="Semestres">
<option value="premier_semestre">Premier semestre</option>
<option value="second_semestre">Second semestre</option>
</optgroup>
<optgroup label="Trimestres">
<option value="trimestre_1">Trimestre 1</option>
<option value="trimestre_2">Trimestre 2</option>
<option value="trimestre_3">Trimestre 3</option>
<option value="trimestre_4">Trimestre 4</option>
</optgroup>
<optgroup label="Mois">
{[...Array(12)].map((_, i) => (
<option key={i + 1} value={`mois_${i + 1}` as PeriodKey}>
{new Date(2023, i).toLocaleDateString('fr-FR', { month: 'long' })}
</option>
))}
</optgroup>
</select>
</div>
)}
<div className="sm:ml-auto min-w-[280px] max-w-sm flex-1">
<div className="h-full rounded-xl border bg-white p-3">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold">Gestion des virements</div>
<div className="text-xs text-slate-600">Les virements de salaires sont effectués par</div>
</div>
<div className="flex items-center gap-2">
<div className="relative group inline-block">
<button
type="button"
disabled
aria-disabled="true"
className="text-xs px-2 py-1 rounded-md border opacity-60 cursor-not-allowed"
>
Modifier
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-0 mt-2 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 translate-y-1 group-hover:translate-y-0 transition"
>
Bientôt disponible, veuillez nous contacter.
<div className="absolute -top-1 right-6 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm ${loadingOrg ? 'bg-slate-50 border-slate-200 text-slate-400' : (isOdentas ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-slate-50 border-slate-200 text-slate-700')}`}>
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: isOdentas ? '#10b981' : '#64748b', opacity: loadingOrg ? 0.6 : 1 }} />
<span className="font-medium">{gestionLabel}</span>
</div>
<button
type="button"
onClick={() => !loadingOrg && setAboutOpen(true)}
disabled={loadingOrg}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-colors ${loadingOrg ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-50'}`}
aria-label="En savoir plus sur les virements"
>
<Info className="w-3.5 h-3.5" />
En savoir plus
</button>
</div>
</div>
</div>
</div>
</section>
{/* Tableau principal */}
{isOdentas ? (
<section className="rounded-2xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<Th>Période</Th>
<Th>N° d'appel</Th>
<Th>Date d'appel</Th>
<Th className="text-right">Salaires nets</Th>
<Th className="text-center">Virement reçu</Th>
<Th className="text-center">Salaires payés</Th>
<Th className="text-center">Document</Th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} 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={7} className="py-12 text-center text-rose-500">
Erreur : {(error as any)?.message || 'imprévue'}
</td>
</tr>
) : filteredItems.length === 0 ? (
<tr>
<td colSpan={7} className="py-12 text-center text-slate-500">
{searchQuery ? 'Aucun virement trouvé pour cette recherche.' : 'Aucun virement de salaires trouvé.'}
</td>
</tr>
) : (
filteredItems.map((row: VirementItem) => (
<tr key={row.id} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>{row.periode_label || formatPeriode(row.periode) || "—"}</Td>
<Td>
<span className="font-medium">
{row.num_appel || row.callsheet || "—"}
</span>
</Td>
<Td>{formatFR(row.date_mois || row.date || "")}</Td>
<Td className="text-right font-medium">
{formatCurrency(row.total_salaries_eur ?? row.total)}
</Td>
<Td className="text-center">
<StatusBadge status={row.virement_recu} date={row.virement_recu_date} />
</Td>
<Td className="text-center">
<StatusBadge status={row.salaires_payes} />
</Td>
<Td className="text-center">
{row.pdf_url ? (
<a
href={row.pdf_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-pink-100 text-pink-700 hover:bg-pink-200 transition-colors text-xs"
>
<Download className="w-3 h-3" />
PDF
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-slate-400">—</span>
)}
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer avec indicateur de chargement */}
{filteredItems.length > 0 && (
<div className="p-3 border-t text-right">
<div className="text-sm text-slate-500">
{isFetching ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Mise à jour…
</span>
) : (
`${filteredItems.length} appel${filteredItems.length > 1 ? 's' : ''} à virement${filteredItems.length > 1 ? 's' : ''} affiché${filteredItems.length > 1 ? 's' : ''}`
)}
</div>
</div>
)}
</section>
) : (
<section className="rounded-2xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<Th>Salarié·e</Th>
<Th>Contrat</Th>
<Th>Profession</Th>
<Th>Début contrat</Th>
<Th>Fin contrat</Th>
<Th>Période concernée</Th>
<Th className="text-right">Net à payer</Th>
<Th className="text-center">Marquer comme payé</Th>
</tr>
</thead>
<tbody>
{/* Unpaid first */}
{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>
) : (clientUnpaid.length === 0 && clientRecent.length === 0) ? (
<tr><td colSpan={8} className="py-12 text-center text-slate-500">Aucun virement de salaires trouvé.</td></tr>
) : (
<>
{clientUnpaid.map((it) => (
<tr key={`unpaid-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
{it.salarie || it.salarie_matricule}
</a>
) : (
it.salarie || ''
)}
</Td>
<Td>
{it.contract_id && it.reference ? (
<a
href={it.kind === 'RG' ? `/contrats-rg/${encodeURIComponent(it.contract_id)}` : (it.kind === 'CDDU_MULTI' ? `/contrats-multi/${encodeURIComponent(it.contract_id)}` : `/contrats/${encodeURIComponent(it.contract_id)}`)}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{it.reference}
</a>
) : (
<span className="font-medium">{it.reference || ''}</span>
)}
</Td>
<Td>{it.profession || ''}</Td>
<Td>{formatFR(it.date_debut || '')}</Td>
<Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{formatPeriode(it.periode)}</Td>
<Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : ''}</Td>
<Td className="text-center">
{it.source === 'payslip' ? (
<button
type="button"
onClick={() => markPayslipDone(it.id)}
className="px-2 py-1 text-xs rounded-md border hover:bg-emerald-50 hover:border-emerald-300 text-emerald-700"
title="Marquer le virement comme effectué"
>
Marquer
</button>
) : (
<button type="button" disabled className="px-2 py-1 text-xs rounded-md border opacity-60 cursor-not-allowed" title="Non disponible">Marquer</button>
)}
</Td>
</tr>
))}
{clientRecent.length > 0 && (
<tr className="bg-slate-50/50">
<td colSpan={8} className="px-3 py-2 text-xs text-slate-500">Récemment virés (≤ 30 jours)</td>
</tr>
)}
{clientRecent.map((it) => (
<tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
{it.salarie || it.salarie_matricule}
</a>
) : (
it.salarie || ''
)}
</Td>
<Td>
{it.contract_id && it.reference ? (
<a
href={it.kind === 'RG' ? `/contrats-rg/${encodeURIComponent(it.contract_id)}` : (it.kind === 'CDDU_MULTI' ? `/contrats-multi/${encodeURIComponent(it.contract_id)}` : `/contrats/${encodeURIComponent(it.contract_id)}`)}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{it.reference}
</a>
) : (
<span className="font-medium">{it.reference || ''}</span>
)}
</Td>
<Td>{it.profession || ''}</Td>
<Td>{formatFR(it.date_debut || '')}</Td>
<Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{formatPeriode(it.periode)}</Td>
<Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : ''}</Td>
<Td className="text-center">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">Oui</span>
</Td>
</tr>
))}
</>
)}
</tbody>
</table>
</div>
</section>
)}
{aboutOpen && (
<div className="fixed inset-0 z-[1000]">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setAboutOpen(false)}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" aria-labelledby="about-virements-title" className="w-full max-w-lg rounded-2xl border bg-white shadow-xl">
<div className="p-5 border-b flex items-start justify-between">
<div>
<h2 id="about-virements-title" className="text-base font-semibold">Gestion des virements de salaires</h2>
</div>
<button onClick={() => setAboutOpen(false)} className="text-slate-500 hover:text-slate-900">✕</button>
</div>
<div className="p-5 space-y-4 text-sm">
<p>
En fin de mois, nous vous envoyons un <strong>appel à virement</strong> avec le <strong>total des salaires nets</strong> de la période concernée.
</p>
<p>
Dès réception de votre virement, nous <strong>redistribuons les salaires à vos salariés</strong>. Vous pouvez suivre létat dans le tableau ci-dessous (colonnes « Virement reçu » et « Salaires payés »).
</p>
<div className="rounded-lg border p-3">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Coordonnées bancaires (salaires)</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">Bénéficiaire</div>
<div className="font-mono text-xs break-all">ODENTAS MEDIA SAS</div>
</div>
<button
type="button"
onClick={() => { navigator.clipboard?.writeText('ODENTAS MEDIA SAS').then(()=>{ setCopiedField('benef'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier le bénéficiaire"
>
{copiedField === 'benef' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
</div>
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">IBAN</div>
<div className="font-mono text-xs break-all">FR76 1695 8000 0141 0850 9729 813</div>
</div>
{org?.iban && (
<button
type="button"
onClick={() => { navigator.clipboard?.writeText("FR76 1695 8000 0141 0850 9729 813" as string).then(()=>{ setCopiedField('iban'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier lIBAN"
>
{copiedField === 'iban' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">BIC</div>
<div className="font-mono text-xs break-all">QNTOFRP1XXX</div>
</div>
{org?.bic && (
<button
type="button"
onClick={() => { navigator.clipboard?.writeText("QNTOFRP1XXX" as string).then(()=>{ setCopiedField('bic'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier le BIC"
>
{copiedField === 'bic' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
)}
</div>
<p className="text-xs text-slate-500 mt-2">Ce compte bancaire est réservé à la réception des virements de salaires.</p>
</div>
</div>
</div>
<div className="p-4 border-t flex justify-end">
<button onClick={() => setAboutOpen(false)} className="px-3 py-2 rounded-md border hover:bg-slate-50 text-sm">Fermer</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// --- Composants utilitaires ---
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<th className={`text-left font-medium px-3 py-3 ${className}`}>
{children}
</th>
);
}
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<td className={`px-3 py-3 ${className}`}>
{children}
</td>
);
}
function StatusBadge({ status, date }: { status?: boolean | string; date?: string | null }) {
const val = toBoolish(status);
if (val === true) {
return (
<div className="flex flex-col items-center gap-1">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">
Oui
</span>
{date && (
<span className="text-[10px] text-slate-500">
{formatFR(date)}
</span>
)}
</div>
);
} else if (val === false) {
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-slate-100 text-slate-600">
Non
</span>
);
} else {
return <span className="text-slate-400"></span>;
}
}