Checkpoint: Ajout du module Masse salariale SANTINOVA : tables de salariés, rémunérations et évolutions manuelles sécurisées par rôle administrateur ; import contrôlé des bulletins 2025 et janvier–août 2026 ; écran tableau 2025–2027 ; tests d'autorisations, d'agrégats et des quatre états UI ; documentation des sources et de la politique d'import.

This commit is contained in:
Manus
2026-08-27 13:10:47 +00:00
parent 2f4b1f2e8e
commit da407db4de
21 changed files with 4390 additions and 4 deletions

View File

@@ -19,6 +19,7 @@ import Santinova from "./pages/Santinova";
import SoinsSante from "./pages/SoinsSante";
import StExupery from "./pages/StExupery";
import TableauBordFinance from "./pages/TableauBordFinance";
import MasseSalariale from "./pages/MasseSalariale";
import { useAuth } from "./_core/hooks/useAuth";
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
@@ -40,6 +41,24 @@ function ProtectedRoute({ component: Component }: { component: React.ComponentTy
return <Component />;
}
/**
* Protection d'interface complémentaire aux contrôles tRPC administrateur. Elle
* redirige les profils non autorisés avant tout rendu de données salariales.
*/
function AdminRoute({ component: Component }: { component: React.ComponentType }) {
const { user, loading } = useAuth();
const [, navigate] = useLocation();
useEffect(() => {
if (!loading && !user) navigate("/login");
if (!loading && user?.role !== "admin") navigate("/");
}, [loading, user, navigate]);
if (loading) return <DashboardLayoutSkeleton />;
if (!user || user.role !== "admin") return null;
return <Component />;
}
function Router() {
return (
<Switch>
@@ -74,6 +93,9 @@ function Router() {
<Route path="/tableau-bord-finance">
<ProtectedRoute component={TableauBordFinance} />
</Route>
<Route path="/masse-salariale">
<AdminRoute component={MasseSalariale} />
</Route>
<Route path="/404" component={NotFound} />
<Route component={NotFound} />

View File

@@ -31,6 +31,7 @@ import {
TableProperties,
Upload,
LayoutDashboard,
Banknote,
} from 'lucide-react';
// Palette de couleurs par section principale
@@ -102,6 +103,8 @@ interface NavLeaf {
label: string;
icon: React.ElementType;
path: string;
/** Les données de paie ne doivent jamais apparaître aux profils non admin. */
adminOnly?: boolean;
}
interface NavSection {
@@ -129,7 +132,10 @@ const NAV_SECTIONS: NavSection[] = [
id: 'santinova',
label: 'SANTINOVA',
icon: Heart,
path: '/santinova',
children: [
{ id: 'santinova', label: 'Vue SANTINOVA', icon: Heart, path: '/santinova' },
{ id: 'masse-salariale', label: 'Masse salariale', icon: Banknote, path: '/masse-salariale', adminOnly: true },
],
},
{
id: 'soins-sante',
@@ -228,6 +234,9 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
const isActive = sectionHasActive(section, location);
const isOpen = openMenus.has(section.id);
const SectionIcon = section.icon;
const visibleChildren = section.children?.filter(
(leaf) => !leaf.adminOnly || user?.role === 'admin',
);
// Section sans sous-menu (feuille directe)
if (!section.children) {
@@ -283,9 +292,9 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
</button>
{/* Items directs — un seul niveau */}
{!collapsed && isOpen && (
{!collapsed && isOpen && visibleChildren && visibleChildren.length > 0 && (
<div className={`mt-1 ml-2 border-l-2 ${c.border} pl-2 space-y-0.5`}>
{section.children.map(leaf => {
{visibleChildren.map(leaf => {
const leafActive = isLeafActive(leaf.path!, location);
const LeafIcon = leaf.icon;
return (

View File

@@ -0,0 +1,27 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { MasseSalarialeDataState } from './MasseSalarialeDataState';
describe('MasseSalarialeDataState', () => {
it.each([
['loading', 'Chargement des données salariales…'],
['error', "Les données de masse salariale n'ont pas pu être chargées."],
['empty', "Aucune donnée salariale n'est encore importée."],
] as const)('rend réellement le message %s', (state, expectedText) => {
const html = renderToStaticMarkup(
<MasseSalarialeDataState state={state}>{'TABLEAU NE DOIT PAS ÊTRE VISIBLE'}</MasseSalarialeDataState>,
);
// Le rendu statique encode les apostrophes en entités HTML ; la
// normalisation permet de tester le texte effectivement affiché.
const visibleText = html.replaceAll('&#x27;', "'");
expect(visibleText).toContain(expectedText);
expect(html).not.toContain('TABLEAU NE DOIT PAS ÊTRE VISIBLE');
});
it('rend réellement le contenu du tableau lorsque les données sont prêtes', () => {
const html = renderToStaticMarkup(
<MasseSalarialeDataState state="ready"><p>TABLEAU PRÊT</p></MasseSalarialeDataState>,
);
expect(html).toContain('TABLEAU PRÊT');
});
});

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import type { MasseSalarialeUiState } from '../lib/masseSalariale';
import { Loader2 } from 'lucide-react';
/**
* Rend les états de données de la page de paie. Ce composant isolé permet de
* vérifier le rendu effectif de chaque état sans exposer de donnée salariale.
*/
export function MasseSalarialeDataState({
state,
children,
}: {
state: MasseSalarialeUiState;
children: ReactNode;
}) {
if (state === 'loading') {
return (
<div className="py-16 flex items-center justify-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
Chargement des données salariales
</div>
);
}
if (state === 'error') {
return <div className="py-16 text-center text-sm text-destructive" role="alert">Les données de masse salariale n'ont pas pu être chargées.</div>;
}
if (state === 'empty') {
return <div className="py-16 text-center text-sm text-muted-foreground">Aucune donnée salariale n'est encore importée.</div>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { getMasseSalarialeUiState, getMasseSalarialeYearSummary } from './masseSalariale';
describe('getMasseSalarialeUiState', () => {
it('distingue chargement, erreur, absence de données et affichage prêt', () => {
expect(getMasseSalarialeUiState(true, false, 0)).toBe('loading');
expect(getMasseSalarialeUiState(false, true, 0)).toBe('error');
expect(getMasseSalarialeUiState(false, false, 0)).toBe('empty');
expect(getMasseSalarialeUiState(false, false, 1)).toBe('ready');
});
});
describe('getMasseSalarialeYearSummary', () => {
const rows = [
{ annee: 2025, salaireAnnuelBrutAvecPrimesCents: 4_000_000, statut: 'annuel' },
{ annee: 2025, salaireAnnuelBrutAvecPrimesCents: 5_500_000, statut: 'annuel' },
{ annee: 2026, salaireAnnuelBrutAvecPrimesCents: 2_000_000, statut: 'cumul_provisoire' },
];
it('calcule le total et le statut annuel pour un exercice clôturé', () => {
expect(getMasseSalarialeYearSummary(rows, 2025)).toEqual({
salaryCount: 2,
annualGrossWithBonusesCents: 9_500_000,
status: 'annuel',
});
});
it('préserve le statut provisoire pour une année en cours', () => {
expect(getMasseSalarialeYearSummary(rows, 2026)).toMatchObject({
salaryCount: 1,
annualGrossWithBonusesCents: 2_000_000,
status: 'cumul_provisoire',
});
});
it('retourne null et non zéro pour une année sans rémunération importée', () => {
expect(getMasseSalarialeYearSummary(rows, 2027)).toEqual({
salaryCount: 0,
annualGrossWithBonusesCents: null,
status: 'empty',
});
});
});

View File

@@ -0,0 +1,59 @@
/** Fonctions pures partagées par l'interface Masse salariale et ses tests. */
export type RemunerationForSummary = {
annee: number;
salaireAnnuelBrutAvecPrimesCents: number;
statut: string;
};
export type MasseSalarialeUiState = 'loading' | 'error' | 'empty' | 'ready';
export type YearSummary = {
salaryCount: number;
annualGrossWithBonusesCents: number | null;
status: 'annuel' | 'cumul_provisoire' | 'mixed' | 'empty';
};
/**
* Centralise les quatre états visuels afin que le tableau ne puisse jamais
* présenter une absence de données comme un état chargé à zéro.
*/
export function getMasseSalarialeUiState(
isLoading: boolean,
isError: boolean,
salaryCount: number,
): MasseSalarialeUiState {
if (isLoading) return 'loading';
if (isError) return 'error';
return salaryCount === 0 ? 'empty' : 'ready';
}
/**
* Agrège uniquement les rémunérations réellement importées d'un exercice.
* L'absence de ligne renvoie `null` pour distinguer « non renseigné » de 0 €.
*/
export function getMasseSalarialeYearSummary(
remunerations: RemunerationForSummary[],
annee: number,
): YearSummary {
const rows = remunerations.filter((row) => row.annee === annee);
if (rows.length === 0) {
return { salaryCount: 0, annualGrossWithBonusesCents: null, status: 'empty' };
}
const statuses = new Set(rows.map((row) => row.statut));
const status = statuses.size === 1 && statuses.has('annuel')
? 'annuel'
: statuses.size === 1 && statuses.has('cumul_provisoire')
? 'cumul_provisoire'
: 'mixed';
return {
salaryCount: rows.length,
annualGrossWithBonusesCents: rows.reduce(
(sum, row) => sum + row.salaireAnnuelBrutAvecPrimesCents,
0,
),
status,
};
}

View File

@@ -0,0 +1,267 @@
// MasseSalariale.tsx — Données de rémunération SANTINOVA (accès administrateur)
import { Fragment, useMemo, useState } from 'react';
import { AppSidebar } from '../components/AppSidebar';
import { MasseSalarialeDataState } from '../components/MasseSalarialeDataState';
import { trpc } from '../lib/trpc';
import { getMasseSalarialeUiState, getMasseSalarialeYearSummary } from '../lib/masseSalariale';
import { toast } from 'sonner';
import {
Banknote,
CalendarDays,
CircleAlert,
ShieldCheck,
Users,
} from 'lucide-react';
const ANNEES = [2025, 2026, 2027] as const;
function formatEuros(cents?: number | null) {
if (cents === undefined || cents === null) return '—';
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatPercent(bps?: number | null) {
if (bps === undefined || bps === null) return '—';
return `${(bps / 100).toLocaleString('fr-FR', { maximumFractionDigits: 2 })} %`;
}
function formatDate(isoDate?: string | null) {
if (!isoDate) return '—';
const [year, month, day] = isoDate.split('-');
return year && month && day ? `${day}/${month}/${year}` : isoDate;
}
function evolutionKey(salarieId: number, annee: number) {
return `${salarieId}-${annee}`;
}
/**
* Les revenus annuels sont strictement ceux présents dans les bulletins
* importés. L'évolution, elle, reste une hypothèse utilisateur distincte et
* n'engendre donc jamais un montant 2027 artificiel.
*/
export default function MasseSalariale() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [evolutionDraft, setEvolutionDraft] = useState('');
const utils = trpc.useUtils();
const { data, isLoading, isError } = trpc.masseSalariale.get.useQuery();
const setEvolution = trpc.masseSalariale.setEvolution.useMutation({
onSuccess: async () => {
await utils.masseSalariale.get.invalidate();
setEditingKey(null);
setEvolutionDraft('');
toast.success('Évolution salariale enregistrée');
},
onError: () => toast.error("L'évolution salariale n'a pas pu être enregistrée"),
});
const remunerationByKey = useMemo(
() => new Map((data?.remunerations ?? []).map((row) => [evolutionKey(row.salarieId, row.annee), row])),
[data?.remunerations],
);
const evolutionByKey = useMemo(
() => new Map((data?.evolutions ?? []).map((row) => [evolutionKey(row.salarieId, row.annee), row])),
[data?.evolutions],
);
const uiState = getMasseSalarialeUiState(isLoading, isError, data?.salaries.length ?? 0);
const totalsByYear = useMemo(
() => new Map(ANNEES.map((annee) => [
annee,
getMasseSalarialeYearSummary(data?.remunerations ?? [], annee),
])),
[data?.remunerations],
);
const startEvolutionEdit = (salarieId: number, annee: number) => {
const key = evolutionKey(salarieId, annee);
const current = evolutionByKey.get(key)?.evolutionSalarialeBps;
setEditingKey(key);
setEvolutionDraft(current === undefined || current === null ? '' : String(current / 100));
};
const saveEvolution = (salarieId: number, annee: number) => {
const normalized = evolutionDraft.trim().replace(',', '.');
const percentage = Number(normalized);
if (!normalized || !Number.isFinite(percentage) || percentage < -100 || percentage > 100) {
toast.error('Saisissez une évolution comprise entre -100 % et 100 %.');
return;
}
setEvolution.mutate({ salarieId, annee, evolutionSalarialeBps: Math.round(percentage * 100) });
};
return (
<div className="min-h-screen flex bg-background">
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} />
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
<div className="flex 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 flex-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' }}>
Masse salariale SANTINOVA
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Rémunérations 20252027 et évolutions salariales manuelles
</p>
</div>
</div>
<div className="hidden sm: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">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{ANNEES.map((annee) => {
const total = totalsByYear.get(annee);
const isProvisional = total?.status === 'cumul_provisoire';
return (
<section key={annee} className="bg-card border border-border rounded-xl shadow-sm p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<CalendarDays className="w-4 h-4 text-emerald-600" />
{annee}
</div>
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${
annee === 2025
? 'bg-emerald-100 text-emerald-700'
: isProvisional
? 'bg-amber-100 text-amber-700'
: 'bg-muted text-muted-foreground'
}`}>
{total?.status === 'annuel' ? 'Année clôturée' : isProvisional ? 'Cumul au dernier bulletin' : 'À renseigner'}
</span>
</div>
<p className="mt-3 text-2xl font-bold text-foreground">
{formatEuros(total?.annualGrossWithBonusesCents)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Brut avec primes {total?.salaryCount ?? 0} salarié{(total?.salaryCount ?? 0) > 1 ? 's' : ''}
</p>
</section>
);
})}
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 flex gap-3 text-sm text-amber-900">
<CircleAlert className="w-4 h-4 flex-shrink-0 mt-0.5 text-amber-600" />
<p>
<strong>2025</strong> est issu des cumuls annuels de décembre. <strong>2026</strong> est un cumul provisoire au dernier bulletin disponible pour chaque salarié (janvier à août). Aucun salaire 2027 nest calculé automatiquement ; seules les évolutions peuvent être saisies.
</p>
</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">Détail par salarié</h2>
<p className="text-xs text-muted-foreground">Cliquez sur une évolution pour la saisir ou la modifier.</p>
</div>
</div>
<MasseSalarialeDataState state={uiState}>
<div className="overflow-auto max-h-[calc(100vh-335px)]">
<table className="w-full min-w-[1900px] text-sm border-collapse">
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur">
<tr className="border-b border-border">
<th rowSpan={2} className="sticky left-0 z-20 bg-muted/95 text-left px-4 py-3 font-semibold text-foreground">Salarié</th>
<th rowSpan={2} className="text-left px-4 py-3 font-semibold text-foreground">Poste</th>
<th rowSpan={2} className="text-left px-4 py-3 font-semibold text-foreground">Embauche</th>
{ANNEES.map((annee) => <th key={annee} colSpan={5} className="text-center px-3 py-2 font-bold text-foreground border-l border-border">{annee}</th>)}
</tr>
<tr className="border-b border-border text-[10px] uppercase tracking-wide text-muted-foreground">
{ANNEES.flatMap((annee) => [
<th key={`${annee}-annual-base`} className="px-3 py-2 text-right font-medium border-l border-border">Annuel hors primes</th>,
<th key={`${annee}-annual-total`} className="px-3 py-2 text-right font-medium">Annuel avec primes</th>,
<th key={`${annee}-monthly`} className="px-3 py-2 text-right font-medium">Mensuel hors primes</th>,
<th key={`${annee}-charges`} className="px-3 py-2 text-right font-medium">Taux charges</th>,
<th key={`${annee}-evolution`} className="px-3 py-2 text-right font-medium">Évolution</th>,
])}
</tr>
</thead>
<tbody>
{data?.salaries.map((salarie) => (
<tr key={salarie.id} className="border-b border-border/70 hover:bg-muted/30">
<td className="sticky left-0 z-[1] bg-card px-4 py-3 min-w-[220px]">
<p className="font-semibold text-foreground">{salarie.nom} {salarie.prenom}</p>
<p className="text-[11px] text-muted-foreground font-mono">{salarie.matricule}</p>
</td>
<td className="px-4 py-3 text-xs text-foreground min-w-[200px]">{salarie.poste}</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(salarie.dateEmbauche)}</td>
{ANNEES.map((annee) => {
const remuneration = remunerationByKey.get(evolutionKey(salarie.id, annee));
const evolution = evolutionByKey.get(evolutionKey(salarie.id, annee));
const key = evolutionKey(salarie.id, annee);
return (
<Fragment key={key}>
<td key={`${key}-annual-base`} className="px-3 py-3 text-right font-medium text-foreground border-l border-border">{formatEuros(remuneration?.salaireAnnuelBrutHorsPrimesCents)}</td>
<td key={`${key}-annual-total`} className="px-3 py-3 text-right font-medium text-foreground">{formatEuros(remuneration?.salaireAnnuelBrutAvecPrimesCents)}</td>
<td key={`${key}-monthly`} className="px-3 py-3 text-right text-foreground">{formatEuros(remuneration?.salaireMensuelBrutHorsPrimesCents)}</td>
<td key={`${key}-charges`} className="px-3 py-3 text-right text-foreground">{formatPercent(remuneration?.tauxChargeBps)}</td>
<td key={`${key}-evolution`} className="px-3 py-2 text-right">
{editingKey === key ? (
<div className="flex items-center justify-end gap-1">
<input
aria-label={`Évolution ${annee} de ${salarie.nom}`}
type="number"
min="-100"
max="100"
step="0.01"
value={evolutionDraft}
onChange={(event) => setEvolutionDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') saveEvolution(salarie.id, annee);
if (event.key === 'Escape') setEditingKey(null);
}}
className="w-20 rounded border border-emerald-400 bg-white px-2 py-1 text-right text-xs focus:outline-none focus:ring-2 focus:ring-emerald-300"
autoFocus
disabled={setEvolution.isPending}
/>
<button
onClick={() => saveEvolution(salarie.id, annee)}
className="text-[11px] font-semibold text-emerald-700 hover:text-emerald-900 disabled:opacity-50"
disabled={setEvolution.isPending}
>
OK
</button>
</div>
) : (
<button
onClick={() => startEvolutionEdit(salarie.id, annee)}
className="min-w-20 rounded px-2 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-300"
title="Saisir l'évolution salariale"
>
{formatPercent(evolution?.evolutionSalarialeBps)}
</button>
)}
</td>
</Fragment>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</MasseSalarialeDataState>
</section>
</div>
</main>
</div>
);
}