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
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
226
client/src/pages/Salaires.tsx
Normal file
226
client/src/pages/Salaires.tsx
Normal 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 été 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user