- Créer hook useStaffOrgSelection avec persistence localStorage - Ajouter badge StaffOrgBadge dans Sidebar - Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.) - Fix calcul cachets: utiliser totalQuantities au lieu de dates.length - Fix structure field bug: ne plus écraser avec production_name - Ajouter création note lors modification contrat - Implémenter montants personnalisés pour virements salaires - Migrations SQL: custom_amount + fix_structure_field - Réorganiser boutons ContractEditor en carte flottante droite
696 lines
27 KiB
TypeScript
696 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState, useEffect, useRef } from "react";
|
||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { api } from "@/lib/fetcher";
|
||
import { Calendar, Loader2, Building2, Info } from "lucide-react";
|
||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||
import { createPortal } from "react-dom";
|
||
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
|
||
|
||
/* =========================
|
||
Types attendus du backend
|
||
========================= */
|
||
type LigneCotisation = {
|
||
// mois de l'année demandée (1..12)
|
||
mois: number; // 1 = janvier, …
|
||
annee: number; // 2025
|
||
status?: "avenir" | "en_cours" | "ok"; // pastille
|
||
segment?: string | null; // RG / Intermittents / ...
|
||
total: number;
|
||
urssaf: number;
|
||
fts: number; // France Travail Spectacle (ex Pôle Emploi Spectacle)
|
||
audiens_retraite: number;
|
||
audiens_prevoyance: number;
|
||
conges_spectacles: number;
|
||
prevoyance_rg: number;
|
||
pas: number; // Prélèvement à la source
|
||
};
|
||
|
||
type CotisationsResponse = {
|
||
items: LigneCotisation[]; // 12 mois (ou plage filtrée)
|
||
total: LigneCotisation; // ligne total (mois="Total" côté UI)
|
||
};
|
||
|
||
type Filters = {
|
||
year: number;
|
||
period:
|
||
| "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}`;
|
||
// filtre libre par dates (optionnel)
|
||
from?: string | null; // ISO yyyy-mm-dd
|
||
to?: string | null; // ISO yyyy-mm-dd
|
||
};
|
||
|
||
type ClientInfo = {
|
||
id: string;
|
||
name: string;
|
||
api_name?: string;
|
||
} | null;
|
||
|
||
type Organization = {
|
||
id: string;
|
||
name: string;
|
||
structure_api?: string;
|
||
};
|
||
|
||
/* ================
|
||
Composant InfoTooltip
|
||
================ */
|
||
function InfoTooltip({ message }: { message: string }) {
|
||
const iconRef = useRef<HTMLSpanElement | null>(null);
|
||
const [tipOpen, setTipOpen] = useState(false);
|
||
const [tipPos, setTipPos] = useState<{ top: number; left: number } | null>(null);
|
||
const [isMounted, setIsMounted] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setIsMounted(true);
|
||
}, []);
|
||
|
||
function computePos() {
|
||
const el = iconRef.current;
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
// Position directement depuis viewport (pas besoin d'ajouter scroll)
|
||
setTipPos({
|
||
top: r.top - 8, // Au-dessus de l'élément avec un petit espace
|
||
left: r.left + r.width / 2 // Centré horizontalement
|
||
});
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!tipOpen) return;
|
||
const onScroll = () => computePos();
|
||
const onResize = () => computePos();
|
||
window.addEventListener('scroll', onScroll, true);
|
||
window.addEventListener('resize', onResize);
|
||
return () => {
|
||
window.removeEventListener('scroll', onScroll, true);
|
||
window.removeEventListener('resize', onResize);
|
||
};
|
||
}, [tipOpen]);
|
||
|
||
return (
|
||
<>
|
||
<span
|
||
ref={iconRef}
|
||
onMouseEnter={() => { computePos(); setTipOpen(true); }}
|
||
onMouseLeave={() => setTipOpen(false)}
|
||
className="inline-flex cursor-help"
|
||
>
|
||
<Info className="w-3.5 h-3.5 text-slate-400" />
|
||
</span>
|
||
{isMounted && tipOpen && tipPos && createPortal(
|
||
<div
|
||
className="z-[1200] fixed pointer-events-none"
|
||
style={{
|
||
top: `${tipPos.top}px`,
|
||
left: `${tipPos.left}px`,
|
||
transform: 'translate(-50%, -100%)'
|
||
}}
|
||
>
|
||
<div className="flex flex-col items-center">
|
||
<div className="inline-block max-w-[280px] rounded-lg bg-gray-900 text-white text-xs px-3 py-2 shadow-xl mb-1">
|
||
{message}
|
||
</div>
|
||
{/* Flèche pointant vers le bas */}
|
||
<div
|
||
className="w-0 h-0"
|
||
style={{
|
||
borderLeft: '6px solid transparent',
|
||
borderRight: '6px solid transparent',
|
||
borderTop: '6px solid rgb(17, 24, 39)' // gray-900
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ==============
|
||
Data fetching
|
||
============== */
|
||
|
||
// Hook pour vérifier si l'utilisateur est staff
|
||
function useStaffCheck() {
|
||
return useQuery({
|
||
queryKey: ["staff-check"],
|
||
queryFn: async () => {
|
||
const res = await fetch("/api/me");
|
||
if (!res.ok) return { isStaff: false };
|
||
const data = await res.json();
|
||
return { isStaff: Boolean(data.is_staff || data.isStaff) };
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
}
|
||
|
||
// Hook pour charger les organisations (staff uniquement)
|
||
function useOrganizations() {
|
||
const { data: staffCheck } = useStaffCheck();
|
||
|
||
return useQuery({
|
||
queryKey: ["staff-organizations"],
|
||
queryFn: async () => {
|
||
const res = await fetch("/api/staff/organizations");
|
||
if (!res.ok) return [];
|
||
const data = await res.json();
|
||
return (data.organizations || []) as Organization[];
|
||
},
|
||
enabled: !!staffCheck?.isStaff,
|
||
staleTime: 60_000,
|
||
});
|
||
}
|
||
|
||
function useCotisations(f: Filters, orgIdOverride?: string, organizations?: Organization[]) {
|
||
// 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
|
||
});
|
||
|
||
// Si staff override, utiliser l'org_id fourni avec les infos de l'org sélectionnée
|
||
let effectiveClientInfo = clientInfo;
|
||
if (orgIdOverride && organizations) {
|
||
const selectedOrg = organizations.find(org => org.id === orgIdOverride);
|
||
if (selectedOrg) {
|
||
effectiveClientInfo = {
|
||
id: selectedOrg.id,
|
||
name: selectedOrg.name,
|
||
api_name: selectedOrg.structure_api
|
||
} as ClientInfo;
|
||
}
|
||
}
|
||
|
||
const qs = new URLSearchParams();
|
||
qs.set("year", String(f.year));
|
||
qs.set("period", f.period);
|
||
if (f.from) qs.set("from", f.from);
|
||
if (f.to) qs.set("to", f.to);
|
||
|
||
return useQuery({
|
||
queryKey: ["cotisations-mensuelles", f, effectiveClientInfo?.id], // Inclure l'ID client dans la queryKey
|
||
// Endpoint à implémenter côté Lambda: GET /cotisations/mensuelles?year=YYYY&period=toute_annee&from=&to=
|
||
queryFn: () => api<CotisationsResponse>(`/cotisations/mensuelles?${qs.toString()}`, {}, effectiveClientInfo), // Passer clientInfo au helper api()
|
||
staleTime: 15_000,
|
||
enabled: !!effectiveClientInfo, // Ne pas exécuter si pas d'infos client
|
||
});
|
||
}
|
||
|
||
/* =========
|
||
Helpers
|
||
========= */
|
||
const EURO = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
|
||
|
||
function MonthCell({ m, y }: { m: number; y: number }) {
|
||
const label = new Date(y, m - 1, 1).toLocaleDateString("fr-FR", { month: "long" });
|
||
return <span className="capitalize">{label} {y}</span>;
|
||
}
|
||
|
||
function StatusDot({ s }: { s?: "avenir" | "en_cours" | "ok" }) {
|
||
const map = {
|
||
avenir: "bg-sky-500", // futur → bleu
|
||
en_cours: "bg-amber-400", // en cours → jaune
|
||
ok: "bg-emerald-500",
|
||
} as const;
|
||
const cls = s ? map[s] : "bg-slate-300";
|
||
return <span className={`inline-block w-2.5 h-2.5 rounded-full ${cls}`} aria-hidden />;
|
||
}
|
||
|
||
function pad2(n: number){ return n < 10 ? `0${n}` : String(n); }
|
||
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 in local tz
|
||
const yyyy = d.getFullYear();
|
||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||
const dd = String(d.getDate()).padStart(2, '0');
|
||
return `${yyyy}-${mm}-${dd}`;
|
||
}
|
||
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(d: Date) {
|
||
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 paymentWindowLabel(annee: number, mois: number) {
|
||
const nextMonth = mois === 12 ? 1 : mois + 1;
|
||
const nextYear = mois === 12 ? annee + 1 : annee;
|
||
const start = new Date(nextYear, nextMonth - 1, 15);
|
||
const lastDay = new Date(nextYear, nextMonth, 0).getDate();
|
||
const endDay = Math.min(30, lastDay);
|
||
const end = new Date(nextYear, nextMonth - 1, endDay);
|
||
return `${formatFR(start)} → ${formatFR(end)}`;
|
||
}
|
||
|
||
function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) {
|
||
return (
|
||
<section className="rounded-2xl border bg-white">
|
||
<div className="px-4 py-3 border-b bg-slate-50/60 flex items-center justify-between">
|
||
<div className="font-medium text-slate-700">{title}</div>
|
||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||
</div>
|
||
<div className="p-4">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/* =====
|
||
Page
|
||
===== */
|
||
export default function CotisationsMensuellesPage() {
|
||
usePageTitle("Cotisations mensuelles");
|
||
|
||
const now = new Date();
|
||
|
||
// Helper pour valider les UUIDs
|
||
const isValidUUID = (str: string | null): boolean => {
|
||
if (!str) return false;
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
||
};
|
||
|
||
// Zustand store pour la sélection d'organisation (staff)
|
||
const {
|
||
selectedOrgId: globalSelectedOrgId,
|
||
setSelectedOrg: setGlobalSelectedOrg
|
||
} = useStaffOrgSelection();
|
||
|
||
// Vérification staff
|
||
const { data: staffCheck } = useStaffCheck();
|
||
const { data: organizations = [] } = useOrganizations();
|
||
|
||
// État local initialisé avec la valeur globale si elle est un UUID valide
|
||
const [selectedOrgId, setSelectedOrgId] = useState<string>(
|
||
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
|
||
);
|
||
|
||
const handlePeriodChange = (value: Filters["period"]) => {
|
||
setFilters((f) => {
|
||
// base: reset from/to unless we set them specifically below
|
||
let next: Filters = { ...f, period: value, from: undefined, to: undefined };
|
||
const y = f.year;
|
||
if (value === "premier_semestre") {
|
||
next.from = monthStartISO(y, 1);
|
||
next.to = monthEndISO(y, 6);
|
||
} else if (value === "second_semestre") {
|
||
next.from = monthStartISO(y, 7);
|
||
next.to = monthEndISO(y, 12);
|
||
} else if (value === "trimestre_1") {
|
||
const r = quarterRange(y, 1); next.from = r.from; next.to = r.to;
|
||
} else if (value === "trimestre_2") {
|
||
const r = quarterRange(y, 2); next.from = r.from; next.to = r.to;
|
||
} else if (value === "trimestre_3") {
|
||
const r = quarterRange(y, 3); next.from = r.from; next.to = r.to;
|
||
} else if (value === "trimestre_4") {
|
||
const r = quarterRange(y, 4); next.from = r.from; next.to = r.to;
|
||
} else if (value.startsWith("mois_")) {
|
||
const m = parseInt(value.split("_")[1], 10);
|
||
if (!isNaN(m)) { next.from = monthStartISO(y, m); next.to = monthEndISO(y, m); }
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleReset = () => {
|
||
const y = now.getFullYear();
|
||
setFilters({ year: y, period: "toute_annee", from: undefined, to: undefined });
|
||
};
|
||
|
||
const [filters, setFilters] = useState<Filters>({
|
||
year: now.getFullYear(),
|
||
period: "toute_annee",
|
||
from: undefined,
|
||
to: undefined,
|
||
});
|
||
|
||
const years = useMemo(() => {
|
||
const base = now.getFullYear();
|
||
return Array.from({ length: 7 }, (_, i) => base - i);
|
||
}, [now]);
|
||
|
||
// Synchronisation bidirectionnelle : global → local
|
||
useEffect(() => {
|
||
if (staffCheck?.isStaff && isValidUUID(globalSelectedOrgId)) {
|
||
setSelectedOrgId(globalSelectedOrgId);
|
||
}
|
||
}, [globalSelectedOrgId, staffCheck?.isStaff]);
|
||
|
||
// --- Synchronisation URL <-> état des filtres ---
|
||
const searchParams = useSearchParams();
|
||
const pathname = usePathname();
|
||
const router = useRouter();
|
||
|
||
// Appliquer les filtres depuis l'URL quand elle change
|
||
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;
|
||
|
||
let next = { ...filters } as Filters;
|
||
|
||
if (yParam) {
|
||
const y = parseInt(yParam, 10);
|
||
if (!isNaN(y)) next.year = y;
|
||
}
|
||
|
||
if (pParam) {
|
||
const allowed = new Set([
|
||
"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",
|
||
]);
|
||
if (allowed.has(pParam)) {
|
||
next.period = pParam as Filters["period"];
|
||
// Si l'URL ne fournit pas from/to, on les déduit de period
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// from/to : si fournis dans l'URL, ils priment ; sinon on garde ceux calculés ci-dessus
|
||
next.from = (from as any) ?? next.from;
|
||
next.to = (to as any) ?? next.to;
|
||
|
||
// Éviter les boucles : n'appliquer que si ça diffère
|
||
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()]);
|
||
|
||
// Pousser les filtres dans l'URL quand l'état change (shareable / deeplink)
|
||
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 } = useCotisations(filters, selectedOrgId || undefined, organizations);
|
||
|
||
const items = data?.items ?? [];
|
||
const total = data?.total;
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* Bandeau titre + aide */}
|
||
<div className="rounded-2xl border bg-white p-4">
|
||
<div className="text-lg font-semibold">Vos cotisations mensuelles</div>
|
||
<p className="mt-2 text-sm text-slate-600">
|
||
Les télépaiements s’effectuent entre le 15 et le 30 du mois suivant les salaires concernés.
|
||
Par exemple, pour les salaires de septembre, les télépaiements ont lieu entre le 15 et le 30 octobre.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Filtres */}
|
||
<Section title="Filtres">
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
|
||
{/* Sélecteur d'organisation (staff uniquement) */}
|
||
{staffCheck?.isStaff && (
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1 flex items-center gap-1">
|
||
<Building2 className="w-3 h-3" />
|
||
Organisation (Staff)
|
||
</label>
|
||
<select
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
value={selectedOrgId}
|
||
onChange={(e) => {
|
||
const newOrgId = e.target.value;
|
||
setSelectedOrgId(newOrgId);
|
||
|
||
// Synchronisation bidirectionnelle : local → global
|
||
if (newOrgId) {
|
||
const selectedOrg = organizations.find(org => org.id === newOrgId);
|
||
if (selectedOrg) {
|
||
setGlobalSelectedOrg(newOrgId, selectedOrg.name);
|
||
}
|
||
} else {
|
||
setGlobalSelectedOrg(null, null);
|
||
}
|
||
}}
|
||
>
|
||
<option value="">Mon organisation</option>
|
||
{organizations.map((org) => (
|
||
<option key={org.id} value={org.id}>
|
||
{org.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Filtrer par année</label>
|
||
<select
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
value={filters.year}
|
||
onChange={(e) => {
|
||
const y = parseInt(e.target.value, 10);
|
||
setFilters((f) => {
|
||
let next = { ...f, year: y };
|
||
// si une période précise est sélectionnée, recalcule from/to pour la nouvelle année
|
||
if (f.period === "premier_semestre") {
|
||
next.from = monthStartISO(y, 1); next.to = monthEndISO(y, 6);
|
||
} else if (f.period === "second_semestre") {
|
||
next.from = monthStartISO(y, 7); next.to = monthEndISO(y, 12);
|
||
} else if ((f.period as string).startsWith("trimestre_")) {
|
||
const q = parseInt((f.period as string).split("_")[1], 10) as 1|2|3|4;
|
||
const r = quarterRange(y, q); next.from = r.from; next.to = r.to;
|
||
} else if ((f.period as string).startsWith("mois_")) {
|
||
const m = parseInt((f.period as string).split("_")[1], 10);
|
||
next.from = monthStartISO(y, m); next.to = monthEndISO(y, m);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Filtrer par période</label>
|
||
<select
|
||
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||
value={filters.period}
|
||
onChange={(e) => handlePeriodChange(e.target.value as Filters["period"])}
|
||
>
|
||
<optgroup label="Année">
|
||
<option value="toute_annee">Toute l’année</option>
|
||
</optgroup>
|
||
<optgroup label="Semestres">
|
||
<option value="premier_semestre">1er semestre (janv-juin)</option>
|
||
<option value="second_semestre">2e semestre (juil-déc)</option>
|
||
</optgroup>
|
||
<optgroup label="Trimestres">
|
||
<option value="trimestre_1">T1 (janv–mars)</option>
|
||
<option value="trimestre_2">T2 (avr–juin)</option>
|
||
<option value="trimestre_3">T3 (juil–sept)</option>
|
||
<option value="trimestre_4">T4 (oct–déc)</option>
|
||
</optgroup>
|
||
<optgroup label="Mois">
|
||
<option value="mois_1">Janvier</option>
|
||
<option value="mois_2">Février</option>
|
||
<option value="mois_3">Mars</option>
|
||
<option value="mois_4">Avril</option>
|
||
<option value="mois_5">Mai</option>
|
||
<option value="mois_6">Juin</option>
|
||
<option value="mois_7">Juillet</option>
|
||
<option value="mois_8">Août</option>
|
||
<option value="mois_9">Septembre</option>
|
||
<option value="mois_10">Octobre</option>
|
||
<option value="mois_11">Novembre</option>
|
||
<option value="mois_12">Décembre</option>
|
||
</optgroup>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex items-end">
|
||
<button
|
||
onClick={handleReset}
|
||
className="text-xs px-3 py-2 rounded border hover:bg-slate-50"
|
||
type="button"
|
||
>
|
||
Réinitialiser
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Tableau */}
|
||
<Section title="Récapitulatif mensuel">
|
||
{/* Légende */}
|
||
<div className="text-xs text-slate-500 mb-3 flex items-center gap-4">
|
||
<div className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-sky-500 inline-block"/> Futur (fenêtre non ouverte)</div>
|
||
<div className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-amber-400 inline-block"/> En cours (15–30 du mois suivant)</div>
|
||
<div className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-emerald-500 inline-block"/> OK (fenêtre passée)</div>
|
||
</div>
|
||
{isLoading ? (
|
||
<div className="py-12 text-center text-slate-500">
|
||
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
|
||
Chargement des cotisations…
|
||
</div>
|
||
) : isError ? (
|
||
<div className="py-8 text-center text-rose-600">Impossible de charger les cotisations.</div>
|
||
) : (
|
||
<div className="overflow-x-auto overflow-y-visible">
|
||
<table className="w-full text-sm">
|
||
<thead className="relative">
|
||
<tr className="border-b bg-slate-50/80">
|
||
<th className="sticky left-0 z-10 bg-slate-50/80 text-left font-medium px-3 py-2 border-r">Période</th>
|
||
<th className="text-right font-medium px-3 py-2">Total</th>
|
||
<th className="text-right font-medium px-3 py-2">URSSAF</th>
|
||
<th className="text-right font-medium px-3 py-2">France Travail Spectacle</th>
|
||
<th className="text-right font-medium px-3 py-2">Audiens retraite</th>
|
||
<th className="text-right font-medium px-3 py-2">Audiens prévoyance</th>
|
||
<th className="text-right font-medium px-3 py-2">Congés Spectacles</th>
|
||
<th className="text-right font-medium px-3 py-2">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<span>Prévoyance RG</span>
|
||
<InfoTooltip message="Uniquement si autre que AUDIENS" />
|
||
</div>
|
||
</th>
|
||
<th className="text-right font-medium px-3 py-2">PAS</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{/* Ligne Total */}
|
||
{total && (
|
||
<tr className="border-b font-medium">
|
||
<td className="sticky left-0 z-10 bg-white px-3 py-2 border-r">
|
||
<div className="flex items-center gap-2">
|
||
<StatusDot s={total.status} />
|
||
Total
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.total)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.urssaf)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.fts)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.audiens_retraite)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.audiens_prevoyance)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.conges_spectacles)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.prevoyance_rg)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(total.pas)}</td>
|
||
</tr>
|
||
)}
|
||
|
||
{/* Mois */}
|
||
{items.length === 0 ? (
|
||
<tr><td colSpan={9} className="px-3 py-10 text-center text-slate-500">Aucune donnée.</td></tr>
|
||
) : (
|
||
<>
|
||
{items.map((row) => (
|
||
<tr key={`${row.annee}-${row.mois}-${row.segment || 'def'}`} className="border-b last:border-b-0">
|
||
<td className="sticky left-0 z-10 bg-white px-3 py-2 border-r">
|
||
<div className="flex items-center gap-2 group relative">
|
||
<StatusDot s={row.status} />
|
||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||
<MonthCell m={row.mois} y={row.annee} />
|
||
{row.segment && (
|
||
<span className="text-xs text-slate-500">· {row.segment}</span>
|
||
)}
|
||
</div>
|
||
{/* Tooltip custom */}
|
||
<div
|
||
role="tooltip"
|
||
className="pointer-events-none absolute left-full top-1/2 z-10 ml-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/2 group-hover:translate-y-0 transition flex items-center"
|
||
style={{ top: '50%', transform: 'translateY(-50%)' }}
|
||
>
|
||
Fenêtre de paiement : {paymentWindowLabel(row.annee, row.mois)}
|
||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rotate-45 bg-slate-900" />
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.total)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.urssaf)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.fts)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.audiens_retraite)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.audiens_prevoyance)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.conges_spectacles)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.prevoyance_rg)}</td>
|
||
<td className="px-3 py-2 text-right">{EURO.format(row.pas)}</td>
|
||
</tr>
|
||
))}
|
||
{/* Padding sous la dernière ligne */}
|
||
<tr><td colSpan={9} className="py-6" /></tr>
|
||
</>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</Section>
|
||
</div>
|
||
);
|
||
}
|