962 lines
40 KiB
TypeScript
962 lines
40 KiB
TypeScript
// 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 l’IBAN"
|
||
>
|
||
{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>;
|
||
}
|
||
}
|