espace-paie-odentas/app/(app)/cotisations/page.tsx
2025-10-15 00:40:57 +02:00

661 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

"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";
/* =========================
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();
// Vérification staff
const { data: staffCheck } = useStaffCheck();
const { data: organizations = [] } = useOrganizations();
// État pour l'org sélectionnée (staff uniquement)
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
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 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 seffectuent 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) => setSelectedOrgId(e.target.value)}
>
<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 lanné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 (janvmars)</option>
<option value="trimestre_2">T2 (avrjuin)</option>
<option value="trimestre_3">T3 (juilsept)</option>
<option value="trimestre_4">T4 (octdé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 (1530 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>
);
}