Checkpoint: Fenêtre administrateur Salaires finalisée : import multipart limité, chiffrement AES-256-GCM avant stockage persistant hors conteneur, métadonnées/index métier minimisés en base, consultation PDF déchiffrée côté serveur avec no-store, filtres année/mois et comparaison M-1 expliquée. Réindexation sûre des archives partielles sans écrasement du PDF. Validation réelle sur deux liasses mensuelles : index partiel signalé sans invention de données, comparaison affichée et lecture après redémarrage. 55 tests Vitest, TypeScript et build de production validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s

This commit is contained in:
Manus
2026-08-29 22:40:59 +00:00
parent 8a58f2c2bd
commit 82de3181df
29 changed files with 6179 additions and 1 deletions

View File

@@ -20,6 +20,7 @@ import SoinsSante from "./pages/SoinsSante";
import StExupery from "./pages/StExupery";
import TableauBordFinance from "./pages/TableauBordFinance";
import MasseSalariale from "./pages/MasseSalariale";
import Salaires from "./pages/Salaires";
import { useAuth } from "./_core/hooks/useAuth";
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
@@ -96,6 +97,9 @@ function Router() {
<Route path="/masse-salariale">
<AdminRoute component={MasseSalariale} />
</Route>
<Route path="/salaires">
<AdminRoute component={Salaires} />
</Route>
<Route path="/404" component={NotFound} />
<Route component={NotFound} />

View File

@@ -135,6 +135,7 @@ const NAV_SECTIONS: NavSection[] = [
children: [
{ id: 'santinova', label: 'Vue SANTINOVA', icon: Heart, path: '/santinova' },
{ id: 'masse-salariale', label: 'Masse salariale', icon: Banknote, path: '/masse-salariale', adminOnly: true },
{ id: 'salaires', label: 'Salaires', icon: Banknote, path: '/salaires', adminOnly: true },
],
},
{

View File

@@ -0,0 +1,226 @@
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Archive,
Banknote,
CalendarDays,
CircleAlert,
FileText,
Loader2,
LockKeyhole,
ShieldCheck,
TrendingDown,
TrendingUp,
Upload,
Users,
} from "lucide-react";
import { AppSidebar } from "../components/AppSidebar";
import { trpc } from "../lib/trpc";
const MONTHS = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
];
function formatEuros(cents?: number | null) {
if (cents === null || cents === undefined) return "—";
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatPercent(percent?: number | null) {
if (percent === null || percent === undefined) return "—";
return `${percent.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} %`;
}
function extractionStatusLabel(status: string) {
if (status === "ready") return { label: "Indexé", className: "bg-emerald-100 text-emerald-700" };
if (status === "partiel") return { label: "Partiel", className: "bg-amber-100 text-amber-800" };
return { label: "À contrôler", className: "bg-rose-100 text-rose-700" };
}
/**
* Module administrateur : les données visibles sont l'index métier réduit des
* bulletins. Les PDFs restent chiffrés dans le stockage persistant et ne sont
* servis qu'après un nouveau contrôle serveur de la session.
*/
export default function Salaires() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const now = useMemo(() => new Date(), []);
const [annee, setAnnee] = useState(now.getFullYear());
const [mois, setMois] = useState(now.getMonth() + 1);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [reindexingId, setReindexingId] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const utils = trpc.useUtils();
const filters = useMemo(() => ({ annee, mois }), [annee, mois]);
const { data, isLoading, isError } = trpc.salaires.list.useQuery(filters);
const { data: periods = [] } = trpc.salaires.periods.useQuery();
const { data: security } = trpc.salaires.getSecurityStatus.useQuery();
const years = useMemo(() => {
const options = new Set([now.getFullYear(), ...periods.map((period) => period.annee)]);
return Array.from(options).sort((a, b) => b - a);
}, [now, periods]);
const uploadLiasse = async () => {
if (!selectedFile) {
toast.error("Sélectionnez une liasse PDF avant l'import.");
return;
}
if (selectedFile.type !== "application/pdf" && !selectedFile.name.toLowerCase().endsWith(".pdf")) {
toast.error("Le fichier sélectionné doit être un PDF.");
return;
}
if (!security?.ready) {
toast.error("Le chiffrement sécurisé est indisponible : l'import est bloqué.");
return;
}
const form = new FormData();
form.append("annee", String(annee));
form.append("mois", String(mois));
form.append("file", selectedFile);
setIsUploading(true);
try {
const response = await fetch("/api/salaires/liasses", { method: "POST", body: form, credentials: "include" });
const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string };
if (!response.ok) throw new Error(payload.error ?? "L'import n'a pas pu être effectué.");
toast.success(`Liasse archivée de façon chiffrée — ${payload.bulletins ?? 0} bulletin(s) indexé(s).`);
if (payload.avertissement) toast.warning(payload.avertissement);
setSelectedFile(null);
if (inputRef.current) inputRef.current.value = "";
await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "L'import sécurisé a échoué.");
} finally {
setIsUploading(false);
}
};
const reindexLiasse = async (liasseId: number) => {
setReindexingId(liasseId);
try {
const response = await fetch(`/api/salaires/liasses/${liasseId}/reindex`, { method: "POST", credentials: "include" });
const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string };
if (!response.ok) throw new Error(payload.error ?? "La réindexation a échoué.");
toast.success(`Index de la liasse actualisé — ${payload.bulletins ?? 0} bulletin(s) reconnu(s).`);
if (payload.avertissement) toast.warning(payload.avertissement);
await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "La réindexation a échoué.");
} finally {
setReindexingId(null);
}
};
const hasExistingPeriod = periods.some((period) => period.annee === annee && period.mois === mois);
return (
<div className="min-h-screen flex bg-background">
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} />
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
<header className="bg-card border-b border-border px-6 py-4 shrink-0">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center shrink-0">
<Banknote className="w-5 h-5 text-emerald-600" />
</div>
<div className="min-w-0">
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: "Sora, sans-serif" }}>Salaires SANTINOVA</h1>
<p className="text-sm text-muted-foreground mt-0.5">Liasses mensuelles chiffrées, index métier et comparatif M-1</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-3 py-1.5 whitespace-nowrap">
<ShieldCheck className="w-3.5 h-3.5" /> Accès administrateur
</div>
</div>
</header>
<div className="flex-1 overflow-auto px-6 py-5 space-y-5">
{!security?.ready && (
<section className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 flex gap-3 text-sm text-rose-900">
<CircleAlert className="w-4 h-4 mt-0.5 shrink-0 text-rose-600" />
<p><strong>Import bloqué.</strong> Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.</p>
</section>
)}
<section className="bg-card border border-border rounded-xl shadow-sm p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground mr-2">
<CalendarDays className="w-4 h-4 text-emerald-600" /> Période affichée
</div>
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
Année
<select value={annee} onChange={(event) => setAnnee(Number(event.target.value))} className="h-9 min-w-28 rounded-lg border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-emerald-300">
{years.map((year) => <option key={year} value={year}>{year}</option>)}
</select>
</label>
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
Mois
<select value={mois} onChange={(event) => setMois(Number(event.target.value))} className="h-9 min-w-36 rounded-lg border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-emerald-300">
{MONTHS.map((month, index) => <option key={month} value={index + 1}>{month}</option>)}
</select>
</label>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">Les montants proviennent uniquement de la liasse importée.</span>
</div>
</section>
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center"><Upload className="w-4 h-4 text-emerald-600" /></div>
<div><h2 className="font-semibold text-foreground">Archiver la liasse de {MONTHS[mois - 1]} {annee}</h2><p className="text-xs text-muted-foreground">Le PDF est chiffré avant envoi vers le stockage persistant hors conteneur.</p></div>
</div>
{hasExistingPeriod && <span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">Période déjà archivée</span>}
</div>
<div className="p-5 flex flex-col lg:flex-row lg:items-center gap-4">
<div className="flex-1 min-h-20 rounded-xl border border-dashed border-emerald-300 bg-emerald-50/50 px-4 flex items-center gap-3">
<FileText className="w-6 h-6 text-emerald-600 shrink-0" />
<span className="min-w-0 flex-1"><strong className="block text-sm text-foreground truncate">{selectedFile?.name ?? "Choisir la liasse PDF"}</strong><span className="text-xs text-muted-foreground">PDF uniquement, 25 Mo maximum</span><input ref={inputRef} type="file" accept="application/pdf,.pdf" aria-label="Choisir la liasse PDF" onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)} className="mt-1.5 block max-w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-emerald-600 file:px-2.5 file:py-1 file:text-xs file:font-semibold file:text-white hover:file:bg-emerald-700" /></span>
</div>
<button onClick={uploadLiasse} disabled={isUploading || hasExistingPeriod || !selectedFile || !security?.ready} className="h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors active:scale-[0.97]">
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LockKeyhole className="w-4 h-4" />} {isUploading ? "Archivage…" : "Importer et chiffrer"}
</button>
</div>
</section>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<StatCard icon={Users} label="Salariés indexés" value={String(data?.indicateurs.nombreSalaries ?? 0)} accent="text-emerald-600" />
<StatCard icon={Banknote} label="Brut total du mois" value={formatEuros(data?.indicateurs.totalBrutCents)} accent="text-emerald-600" />
<StatCard icon={(data?.indicateurs.totalEcartCents ?? 0) < 0 ? TrendingDown : TrendingUp} label="Écart brut global M-1" value={formatEuros(data?.indicateurs.totalEcartCents)} accent={(data?.indicateurs.totalEcartCents ?? 0) < 0 ? "text-rose-600" : "text-emerald-600"} />
</div>
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-border flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center"><Users className="w-4 h-4 text-emerald-600" /></div>
<div><h2 className="font-semibold text-foreground">Salariés et évolution mensuelle</h2><p className="text-xs text-muted-foreground">Comparaison avec le même salarié de la liasse M-1 ; les variations sont explicitées à partir des rubriques extraites.</p></div>
</div>
{isLoading ? <div className="py-14 flex justify-center"><Loader2 className="w-5 h-5 text-emerald-600 animate-spin" /></div> : isError ? <DataNotice message="Les salaires de cette période ne peuvent pas être chargés." /> : data?.bulletins.length ? (
<div className="overflow-auto"><table className="w-full min-w-[1250px] text-sm"><thead className="bg-muted/80 text-[10px] uppercase tracking-wide text-muted-foreground"><tr><th className="text-left px-4 py-3">Salarié</th><th className="text-left px-4 py-3">Poste</th><th className="text-right px-4 py-3">Base mensuelle</th><th className="text-right px-4 py-3">Brut courant</th><th className="text-right px-4 py-3">Brut M-1</th><th className="text-right px-4 py-3">Écart</th><th className="text-left px-4 py-3">Explication</th><th className="text-center px-4 py-3">PDF</th></tr></thead><tbody>{data.bulletins.map((bulletin) => <tr key={`${bulletin.liasseId}-${bulletin.matricule}`} className="border-t border-border/70 hover:bg-muted/30"><td className="px-4 py-3"><p className="font-semibold text-foreground">{bulletin.nom} {bulletin.prenom}</p><p className="font-mono text-[11px] text-muted-foreground">{bulletin.matricule}</p></td><td className="px-4 py-3 text-xs text-foreground">{bulletin.poste}</td><td className="px-4 py-3 text-right whitespace-nowrap">{formatEuros(bulletin.brutMensuelCents)}</td><td className="px-4 py-3 text-right font-semibold whitespace-nowrap">{formatEuros(bulletin.brutAvecPrimesCents)}</td><td className="px-4 py-3 text-right whitespace-nowrap">{formatEuros(bulletin.brutPrecedentCents)}</td><td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${(bulletin.ecartBrutCents ?? 0) < 0 ? "text-rose-700" : (bulletin.ecartBrutCents ?? 0) > 0 ? "text-emerald-700" : "text-muted-foreground"}`}>{formatEuros(bulletin.ecartBrutCents)}<span className="block text-[11px] font-normal">{formatPercent(bulletin.ecartBrutPourcentage)}</span></td><td className="px-4 py-3 max-w-80 text-xs text-muted-foreground">{bulletin.explication}</td><td className="px-4 py-3 text-center"><a href={`/api/salaires/liasses/${bulletin.liasseId}/pdf#page=${bulletin.numeroPage ?? 1}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 hover:text-emerald-900"><FileText className="w-3.5 h-3.5" />Voir</a></td></tr>)}</tbody></table></div>
) : <DataNotice message={`Aucun bulletin n'est indexé pour ${MONTHS[mois - 1]} ${annee}.`} />}
</section>
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-border flex items-center gap-3"><div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center"><Archive className="w-4 h-4 text-emerald-600" /></div><div><h2 className="font-semibold text-foreground">Liasses archivées</h2><p className="text-xs text-muted-foreground">Chaque archive reste disponible après reconstruction ou changement de version de l'application.</p></div></div>
{periods.length === 0 ? <DataNotice message="Aucune liasse de bulletins n'a encore é archivée." /> : <div className="divide-y divide-border">{periods.map((period) => { const status = extractionStatusLabel(period.statutExtraction); return <div key={period.id} className="px-5 py-3 flex flex-wrap gap-3 items-center"><div className="min-w-40"><p className="font-semibold text-sm text-foreground">{MONTHS[period.mois - 1]} {period.annee}</p><p className="text-xs text-muted-foreground truncate max-w-72">{period.nomFichier}</p></div><span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${status.className}`}>{status.label}</span><div className="flex-1 text-xs text-muted-foreground">{period.erreurExtraction ?? "Index complet."}</div>{period.statutExtraction !== "ready" && <button onClick={() => reindexLiasse(period.id)} disabled={reindexingId === period.id} className="inline-flex items-center gap-1 text-xs font-semibold text-amber-800 hover:text-amber-950 disabled:opacity-50"><Loader2 className={`w-3.5 h-3.5 ${reindexingId === period.id ? "animate-spin" : ""}`} />{reindexingId === period.id ? "Réindexation" : "Réindexer"}</button>}<a href={`/api/salaires/liasses/${period.id}/pdf`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 hover:text-emerald-900"><FileText className="w-3.5 h-3.5" />Voir la liasse</a></div>; })}</div>}
</section>
</div>
</main>
</div>
);
}
function StatCard({ icon: Icon, label, value, accent }: { icon: React.ElementType; label: string; value: string; accent: string }) {
return <section className="bg-card border border-border rounded-xl shadow-sm p-4"><div className={`flex items-center gap-2 text-sm font-semibold ${accent}`}><Icon className="w-4 h-4" />{label}</div><p className="mt-3 text-2xl font-bold text-foreground">{value}</p></section>;
}
function DataNotice({ message }: { message: string }) {
return <div className="py-12 text-center text-sm text-muted-foreground"><CircleAlert className="w-5 h-5 mx-auto mb-2 text-muted-foreground/60" />{message}</div>;
}