espace-paie-odentas/app/(app)/cotisations/page.tsx
2025-10-12 17:05:46 +02:00

497 lines
20 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 } from "react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Calendar, Loader2 } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
/* =========================
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;
/* ==============
Data fetching
============== */
function useCotisations(f: Filters) {
// Récupération dynamique des infos client via /api/me
const { data: clientInfo } = useQuery({
queryKey: ["client-info"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return null;
const me = await res.json();
return {
id: me.active_org_id || null,
name: me.active_org_name || "Organisation",
api_name: me.active_org_api_name
} as ClientInfo;
} catch {
return null;
}
},
staleTime: 30_000, // Cache 30s
});
const 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, clientInfo?.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()}`, {}, clientInfo), // Passer clientInfo au helper api()
staleTime: 15_000,
enabled: !!clientInfo, // 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();
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);
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">
<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">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50/80">
<th className="text-left font-medium px-3 py-2">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">Prévoyance RG</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="px-3 py-2 flex items-center gap-2">
<StatusDot s={total.status} />
Total
</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="px-3 py-2">
<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>
);
}