Compare commits

...

4 Commits

21 changed files with 4401 additions and 9 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>
);
}

67
docs/masse-salariale.md Normal file
View File

@@ -0,0 +1,67 @@
# Module Masse salariale Santinova
## Périmètre et accès
Le module stocke des **données de rémunération à caractère sensible**. L'accès
au menu, à la page et à toutes les procédures de lecture ou d'écriture est
réservé au rôle `admin`. Les sources de paie importées ne sont pas conservées
dans le code source ni dans le stockage applicatif ; seuls les champs métier
nécessaires au tableau sont enregistrés en base.
## Champs du tableau
Chaque salarié est identifié par son matricule paie. Le tableau comprend le
nom, le prénom, le poste et la date d'embauche ainsi que, pour 2025, 2026 et
2027 : salaire annuel brut hors primes, salaire annuel brut avec primes,
salaire mensuel brut hors primes, taux de charge et évolution salariale.
| Champ | Origine | Règle de traitement |
|---|---|---|
| Salaire mensuel brut hors primes | Ligne « Salaire de base » | Montant de la paie source. |
| Salaire annuel brut hors primes | Salaire mensuel brut hors primes | Calcul : montant mensuel × 12. |
| Salaire annuel brut avec primes | Synthèse « Annuel — Brut » | Montant cumulé annuel fourni par la paie. Pour une année non clôturée, le statut indique le mois de référence. |
| Taux de charge | Synthèse « Annuel — Charges patronales » | Calcul : charges patronales ÷ brut annuel avec primes. |
| Évolution salariale | Saisie utilisateur | Pourcentage manuel, stocké séparément des montants importés ; il peut donc être renseigné pour 2027 sans créer de rémunération fictive. |
## Sources analysées
Les bulletins de décembre 2025 fournissent un cumul annuel complet. Les
bulletins 2026 reçus couvrent janvier à août ; le relevé le plus récent par
salarié sera associé à son mois de référence afin de ne pas présenter un cumul
partiel comme une année clôturée (`cumul_provisoire`). Les années ou salariés
sans bulletin ne seront jamais créés avec des données fictives : le tableau
affichera une valeur absente et un statut de donnée à compléter.
## Règles de sécurité et d'intégrité
Le couple `(matricule, année)` est unique. Une importation est idempotente : la
dernière source validée remplace les valeurs extraites du même salarié pour la
même année, sans écraser une évolution salariale saisie manuellement. Les
montants sont stockés en centimes et les pourcentages en points de base pour
éviter toute erreur d'arrondi.
## Contrôle de l'interface
La page de connexion est disponible sur l'environnement de développement. Le
contrôle du tableau Masse salariale est réalisé avec un compte administrateur,
afin de vérifier simultanément l'affichage des données et leur cloisonnement
par rôle.
Un accès direct à `/masse-salariale` sans session est redirigé vers `/login`.
La connexion avec le compte administrateur de test est acceptée et le menu
SANTINOVA devient accessible dans la barre latérale.
Le contrôle visuel confirme que le tableau s'affiche sous SANTINOVA avec les
19 salariés importés, les cumuls 2025 et 2026 correctement signalés, ainsi
qu'une colonne 2027 vide plutôt qu'un total salarial fictif.
Une cellule d'évolution 2027 devient éditable au clic et peut être annulée par
la touche Échap, sans modifier la base de données.
## Modalité d'import
L'import initial des bulletins transmis est réalisé par un outil contrôlé hors
de l'interface. Cette mesure évite d'exposer des documents de paie ou leurs
données brutes dans le navigateur. Les procédures d'import tRPC existent et
sont réservées aux administrateurs pour une future intégration d'import interne
après validation du format de fichier et des règles de conservation. Aucun
fichier de paie n'est versionné dans le dépôt applicatif.

View File

@@ -0,0 +1,30 @@
CREATE TABLE `masse_salariale_remunerations` (
`id` int AUTO_INCREMENT NOT NULL,
`salarieId` int NOT NULL,
`annee` int NOT NULL,
`salaireAnnuelBrutHorsPrimesCents` int NOT NULL,
`salaireAnnuelBrutAvecPrimesCents` int NOT NULL,
`salaireMensuelBrutHorsPrimesCents` int NOT NULL,
`tauxChargeBps` int NOT NULL,
`evolutionSalarialeBps` int,
`periodeReference` varchar(7),
`source` varchar(64) NOT NULL DEFAULT 'bulletin',
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `masse_salariale_remunerations_id` PRIMARY KEY(`id`),
CONSTRAINT `masse_salariale_salarie_annee_idx` UNIQUE(`salarieId`,`annee`)
);
--> statement-breakpoint
CREATE TABLE `masse_salariale_salaries` (
`id` int AUTO_INCREMENT NOT NULL,
`matricule` varchar(32) NOT NULL,
`nom` varchar(150) NOT NULL,
`prenom` varchar(150) NOT NULL,
`poste` varchar(255) NOT NULL,
`dateEmbauche` varchar(10) NOT NULL,
`actif` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `masse_salariale_salaries_id` PRIMARY KEY(`id`),
CONSTRAINT `masse_salariale_salaries_matricule_unique` UNIQUE(`matricule`)
);

View File

@@ -0,0 +1 @@
ALTER TABLE `masse_salariale_remunerations` ADD `statut` varchar(32) DEFAULT 'annuel' NOT NULL;

View File

@@ -0,0 +1,13 @@
CREATE TABLE `masse_salariale_evolutions` (
`id` int AUTO_INCREMENT NOT NULL,
`salarieId` int NOT NULL,
`annee` int NOT NULL,
`evolutionSalarialeBps` int NOT NULL,
`createdBy` int NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `masse_salariale_evolutions_id` PRIMARY KEY(`id`),
CONSTRAINT `masse_salariale_evolution_salarie_annee_idx` UNIQUE(`salarieId`,`annee`)
);
--> statement-breakpoint
ALTER TABLE `masse_salariale_remunerations` DROP COLUMN `evolutionSalarialeBps`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,27 @@
"when": 1786998761879,
"tag": "0004_known_shiva",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1787833569970,
"tag": "0005_nasty_tattoo",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1787833802656,
"tag": "0006_ambitious_shen",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1787833910943,
"tag": "0007_rich_tony_stark",
"breakpoints": true
}
]
}

View File

@@ -201,3 +201,67 @@ export const opexBasesRepartition = mysqlTable("opex_bases_repartition", {
export type OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect;
export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert;
// ─────────────────────────────────────────────────────────────────────────────
// MASSE SALARIALE SANTINOVA — Données sensibles, accès administrateur uniquement
// ─────────────────────────────────────────────────────────────────────────────
export const masseSalarialeSalaries = mysqlTable("masse_salariale_salaries", {
id: int("id").autoincrement().primaryKey(),
/** Identifiant paie stable, distinct des données personnelles superflues. */
matricule: varchar("matricule", { length: 32 }).notNull().unique(),
nom: varchar("nom", { length: 150 }).notNull(),
prenom: varchar("prenom", { length: 150 }).notNull(),
poste: varchar("poste", { length: 255 }).notNull(),
/** Date civile ISO (YYYY-MM-DD), sans fuseau horaire. */
dateEmbauche: varchar("dateEmbauche", { length: 10 }).notNull(),
actif: boolean("actif").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type MasseSalarialeSalarie = typeof masseSalarialeSalaries.$inferSelect;
export type InsertMasseSalarialeSalarie = typeof masseSalarialeSalaries.$inferInsert;
/**
* Une ligne par salarié et exercice. Tous les montants sont en centimes et les
* taux en points de base (ex. 42,75 % = 4 275) pour éviter les erreurs d'arrondi.
*/
export const masseSalarialeRemunerations = mysqlTable("masse_salariale_remunerations", {
id: int("id").autoincrement().primaryKey(),
salarieId: int("salarieId").notNull(),
annee: int("annee").notNull(),
salaireAnnuelBrutHorsPrimesCents: int("salaireAnnuelBrutHorsPrimesCents").notNull(),
salaireAnnuelBrutAvecPrimesCents: int("salaireAnnuelBrutAvecPrimesCents").notNull(),
salaireMensuelBrutHorsPrimesCents: int("salaireMensuelBrutHorsPrimesCents").notNull(),
tauxChargeBps: int("tauxChargeBps").notNull(),
/** Mois de paie ayant servi de référence, ex. 2025-12 ou 2026-08. */
periodeReference: varchar("periodeReference", { length: 7 }),
/** `annuel` pour un exercice clos ; `cumul_provisoire` pour une année en cours. */
statut: varchar("statut", { length: 32 }).notNull().default("annuel"),
source: varchar("source", { length: 64 }).notNull().default("bulletin"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
/** Rend les imports idempotents pour un salarié et un exercice. */
uniqSalarieAnnee: uniqueIndex("masse_salariale_salarie_annee_idx").on(t.salarieId, t.annee),
}));
export type MasseSalarialeRemuneration = typeof masseSalarialeRemunerations.$inferSelect;
export type InsertMasseSalarialeRemuneration = typeof masseSalarialeRemunerations.$inferInsert;
/** Évolution salariale manuelle, stockée même en l'absence de bulletin pour l'année concernée. */
export const masseSalarialeEvolutions = mysqlTable("masse_salariale_evolutions", {
id: int("id").autoincrement().primaryKey(),
salarieId: int("salarieId").notNull(),
annee: int("annee").notNull(),
/** Pourcentage exprimé en points de base, ex. 1,50 % = 150. */
evolutionSalarialeBps: int("evolutionSalarialeBps").notNull(),
createdBy: int("createdBy").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (t) => ({
uniqSalarieAnnee: uniqueIndex("masse_salariale_evolution_salarie_annee_idx").on(t.salarieId, t.annee),
}));
export type MasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferSelect;
export type InsertMasseSalarialeEvolution = typeof masseSalarialeEvolutions.$inferInsert;

View File

@@ -71,6 +71,9 @@ vi.mock("./db", async (importOriginal) => {
upsertOpexBaseRepartition: vi.fn(),
setOpexMontantsEtabBatch: vi.fn(),
deleteOpexPoste: vi.fn(),
getMasseSalariale: vi.fn(),
importMasseSalariale: vi.fn(),
setMasseSalarialeEvolution: vi.fn(),
};
});
@@ -342,3 +345,92 @@ describe("opex.deletePoste", () => {
expect(db.deleteOpexPoste).toHaveBeenCalledWith(2027, 42);
});
});
// ─── Tests Masse salariale (données sensibles, administrateur uniquement) ─────
describe("masseSalariale", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne les données uniquement pour un administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const source = { salaries: [], remunerations: [], evolutions: [] };
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue(source);
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual(source);
});
it("interdit la consultation à un profil standard", async () => {
const { ctx } = createAuthCtx({ role: "standard" });
await expect(appRouter.createCaller(ctx).masseSalariale.get()).rejects.toThrow();
});
it("importe les données validées uniquement pour un administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
(db.importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 });
const { ctx } = createAuthCtx({ role: "admin" });
const salaries = [{
matricule: "000001",
nom: "DUPONT",
prenom: "Alice",
poste: "Responsable administratif",
dateEmbauche: "2024-01-01",
}];
const remunerations = [{
matricule: "000001",
annee: 2026,
salaireAnnuelBrutHorsPrimesCents: 36_000_00,
salaireAnnuelBrutAvecPrimesCents: 40_000_00,
salaireMensuelBrutHorsPrimesCents: 3_000_00,
tauxChargeBps: 5_500,
periodeReference: "2026-08",
statut: "cumul_provisoire" as const,
source: "bulletin",
}];
await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries, remunerations }))
.resolves.toEqual({ salaries: 1, remunerations: 1 });
expect(db.importMasseSalariale).toHaveBeenCalledWith(salaries, remunerations);
});
it("interdit l'import de rémunérations à un profil standard", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "standard" });
await expect(appRouter.createCaller(ctx).masseSalariale.import({ salaries: [], remunerations: [] })).rejects.toThrow();
expect(db.importMasseSalariale).not.toHaveBeenCalled();
});
it("enregistre l'évolution manuelle avec l'identifiant de l'administrateur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ id: 42, role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.setEvolution({
salarieId: 7,
annee: 2027,
evolutionSalarialeBps: 250,
})).resolves.toEqual({ success: true });
expect(db.setMasseSalarialeEvolution).toHaveBeenCalledWith({
salarieId: 7,
annee: 2027,
evolutionSalarialeBps: 250,
createdBy: 42,
});
});
it("rejette un taux d'évolution hors plage avant l'accès à la base", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "admin" });
await expect(appRouter.createCaller(ctx).masseSalariale.setEvolution({
salarieId: 7,
annee: 2027,
evolutionSalarialeBps: 10_001,
})).rejects.toThrow();
expect(db.setMasseSalarialeEvolution).not.toHaveBeenCalled();
});
});

View File

@@ -5,11 +5,17 @@ import {
etablissements,
InsertCapexLigne,
InsertEtablissement,
InsertMasseSalarialeEvolution,
InsertMasseSalarialeRemuneration,
InsertMasseSalarialeSalarie,
InsertOpexMontantEtab,
InsertOpexPoste,
InsertUser,
inventaireMeta,
inventairePostes,
masseSalarialeEvolutions,
masseSalarialeRemunerations,
masseSalarialeSalaries,
opexBasesRepartition,
opexMontantsEtab,
opexPostes,
@@ -448,3 +454,108 @@ export async function importOpexBasesRepartition(
);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// MASSE SALARIALE — accès exclusivement appelés par des procédures admin
// ─────────────────────────────────────────────────────────────────────────────
export type MasseSalarialeImportSalarie = Pick<
InsertMasseSalarialeSalarie,
"matricule" | "nom" | "prenom" | "poste" | "dateEmbauche"
>;
export type MasseSalarialeImportRemuneration = Omit<
InsertMasseSalarialeRemuneration,
"id" | "salarieId" | "createdAt" | "updatedAt"
> & { matricule: string };
/**
* Retourne les trois sources normalisées séparément. Le routeur réalise la
* jointure logique afin d'éviter une matrice SQL qui dupliquerait les années.
*/
export async function getMasseSalariale() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [salaries, remunerations, evolutions] = await Promise.all([
db.select().from(masseSalarialeSalaries).orderBy(asc(masseSalarialeSalaries.nom), asc(masseSalarialeSalaries.prenom)),
db.select().from(masseSalarialeRemunerations),
db.select().from(masseSalarialeEvolutions),
]);
return { salaries, remunerations, evolutions };
}
/**
* Import idempotent : les salaires et leurs rémunérations annuelles sont
* remplacés dans une transaction, sans modifier les évolutions saisies à la
* main qui résident dans une table distincte.
*/
export async function importMasseSalariale(
salaries: MasseSalarialeImportSalarie[],
remunerations: MasseSalarialeImportRemuneration[],
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (salaries.length === 0) return { salaries: 0, remunerations: 0 };
return db.transaction(async (tx) => {
for (const salarie of salaries) {
await tx
.insert(masseSalarialeSalaries)
.values({ ...salarie, actif: true })
.onDuplicateKeyUpdate({
set: {
nom: salarie.nom,
prenom: salarie.prenom,
poste: salarie.poste,
dateEmbauche: salarie.dateEmbauche,
actif: true,
},
});
}
const persistedSalaries = await tx
.select({ id: masseSalarialeSalaries.id, matricule: masseSalarialeSalaries.matricule })
.from(masseSalarialeSalaries);
const idsByMatricule = new Map(persistedSalaries.map((row) => [row.matricule, row.id]));
for (const remuneration of remunerations) {
const salarieId = idsByMatricule.get(remuneration.matricule);
if (!salarieId) {
throw new Error(`Salarié introuvable lors de l'import : ${remuneration.matricule}`);
}
const { matricule: _matricule, ...values } = remuneration;
await tx
.insert(masseSalarialeRemunerations)
.values({ ...values, salarieId })
.onDuplicateKeyUpdate({
set: {
salaireAnnuelBrutHorsPrimesCents: values.salaireAnnuelBrutHorsPrimesCents,
salaireAnnuelBrutAvecPrimesCents: values.salaireAnnuelBrutAvecPrimesCents,
salaireMensuelBrutHorsPrimesCents: values.salaireMensuelBrutHorsPrimesCents,
tauxChargeBps: values.tauxChargeBps,
periodeReference: values.periodeReference,
statut: values.statut,
source: values.source,
},
});
}
return { salaries: salaries.length, remunerations: remunerations.length };
});
}
/** Sauvegarde l'évolution manuelle sans dépendre de l'existence d'un bulletin. */
export async function setMasseSalarialeEvolution(
input: Pick<InsertMasseSalarialeEvolution, "salarieId" | "annee" | "evolutionSalarialeBps" | "createdBy">,
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.insert(masseSalarialeEvolutions)
.values(input)
.onDuplicateKeyUpdate({
set: {
evolutionSalarialeBps: input.evolutionSalarialeBps,
createdBy: input.createdBy,
},
});
}

View File

@@ -248,6 +248,47 @@ export const appRouter = router({
}),
}),
// Les données de rémunération sont sensibles : aucune procédure de ce
// sous-routeur n'est accessible aux profils standard ou lecture seule.
masseSalariale: router({
get: adminProcedure.query(async () => db.getMasseSalariale()),
import: adminProcedure
.input(z.object({
salaries: z.array(z.object({
matricule: z.string().trim().min(1).max(32),
nom: z.string().trim().min(1).max(150),
prenom: z.string().trim().min(1).max(150),
poste: z.string().trim().min(1).max(255),
dateEmbauche: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date d'embauche invalide"),
})).min(1).max(500),
remunerations: z.array(z.object({
matricule: z.string().trim().min(1).max(32),
annee: z.number().int().min(2000).max(2100),
salaireAnnuelBrutHorsPrimesCents: z.number().int().min(0).max(100_000_000),
salaireAnnuelBrutAvecPrimesCents: z.number().int().min(0).max(100_000_000),
salaireMensuelBrutHorsPrimesCents: z.number().int().min(0).max(10_000_000),
tauxChargeBps: z.number().int().min(0).max(100_000),
periodeReference: z.string().regex(/^\d{4}-\d{2}$/).nullable().optional(),
statut: z.enum(["annuel", "cumul_provisoire"]),
source: z.string().trim().min(1).max(64),
})).max(1_500),
}))
.mutation(async ({ input }) => db.importMasseSalariale(input.salaries, input.remunerations)),
setEvolution: adminProcedure
.input(z.object({
salarieId: z.number().int().positive(),
annee: z.number().int().min(2000).max(2100),
// ±100 % maximum : protège contre les erreurs de virgule dans la saisie.
evolutionSalarialeBps: z.number().int().min(-10_000).max(10_000),
}))
.mutation(async ({ input, ctx }) => {
await db.setMasseSalarialeEvolution({ ...input, createdBy: ctx.user.id });
return { success: true };
}),
}),
inventaire: router({
get: protectedProcedure
.input(z.object({ annee: z.number() }))

37
todo.md
View File

@@ -100,8 +100,35 @@
## Déploiement de recette via Gitea
- [x] Vérifier la version à pousser et la cible Gitea de recette
- [x] Pousser la version validée et déclencher le déploiement de recette
- [ ] Vérifier l'application déployée sur l'URL de recette
- [ ] Diagnostiquer et corriger le rejet de signature du webhook CI/CD
- [ ] Confirmer le redéploiement effectif du commit Gitea 3b52527
- [ ] Diagnostiquer et corriger le routage du webhook vers le déploiement Budget SI
- [ ] Déclencher et valider le workflow CI requis avant le redéploiement
- [x] Vérifier l'application déployée sur l'URL de recette
- [x] Diagnostiquer et corriger le rejet de signature du webhook CI/CD
- [x] Constater l'échec du commit 3b52527 puis confirmer le redéploiement du correctif ec597da
- [x] Diagnostiquer et corriger le routage du webhook vers le déploiement Budget SI
- [x] Déclencher et valider le workflow CI requis avant le redéploiement
## Module Masse salariale Santinova
- [x] Analyser les bulletins 2025 et 2026 fournis et relever les champs fiables
- [x] Consolider les bulletins complémentaires de janvier à avril 2026 avec ceux de mai à août
- [x] Résoudre les bulletins multiples d'un même matricule sur une période par une règle métier documentée
- [x] Produire un jeu consolidé 2026 sans doublon par salarié et par année
- [x] Créer le schéma BDD sécurisé pour les salariés et leurs rémunérations annuelles
- [x] Restreindre le module Masse salariale aux administrateurs
- [x] Créer les procédures tRPC de consultation, import et modification de l'évolution salariale
- [x] Ajouter l'entrée de menu et la page Masse salariale avec tableau 20252027
- [x] Importer les données contrôlées depuis les bulletins de salaire
- [x] Tester le rendu réel des états d'interface : chargement, erreur, vide et affichage prêt
- [x] Ajouter des tests d'agrégation : totaux annuels, statut annuel/provisoire et absence de valeur fictive 2027
- [x] Documenter que l'import de bulletins est un import initial contrôlé hors interface, et prévoir son extension future
## Vérification des exportations de factures
- [x] Vérifier le projet Budget SI : aucun flux ou chemin d'export de factures BAP ou d'abonnement n'y est configuré
- [x] Annulé : demande posée dans une autre application
## Reprise du module Masse salariale
- [x] Finaliser les validations et la documentation du module Masse salariale
## Déploiement de recette — Masse salariale
- [x] Vérifier le commit local et la branche Gitea de recette
- [ ] Pousser la version Masse salariale vers Gitea et déclencher la CI/CD
- [ ] Vérifier le déploiement et l'accès HTTPS de recette
- [x] Rétablir l'authentification SSH administrateur vers le serveur de recette

View File

@@ -5,6 +5,11 @@ const templateRoot = path.resolve(import.meta.dirname);
export default defineConfig({
root: templateRoot,
// Le build Vite utilise la transformation JSX automatique de React 19.
// Vitest doit appliquer la même convention aux composants de test TSX.
esbuild: {
jsx: "automatic",
},
resolve: {
alias: {
"@": path.resolve(templateRoot, "client", "src"),
@@ -14,6 +19,12 @@ export default defineConfig({
},
test: {
environment: "node",
include: ["server/**/*.test.ts", "server/**/*.spec.ts", "client/**/*.test.ts", "shared/**/*.test.ts"],
include: [
"server/**/*.test.ts",
"server/**/*.spec.ts",
"client/**/*.test.ts",
"client/**/*.test.tsx",
"shared/**/*.test.ts",
],
},
});