Compare commits
4 Commits
ec597da4a2
...
fe2a266d2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe2a266d2d | ||
|
|
15d67924ca | ||
|
|
da407db4de | ||
|
|
2f4b1f2e8e |
@@ -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} />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
27
client/src/components/MasseSalarialeDataState.test.tsx
Normal file
27
client/src/components/MasseSalarialeDataState.test.tsx
Normal 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(''', "'");
|
||||
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');
|
||||
});
|
||||
});
|
||||
31
client/src/components/MasseSalarialeDataState.tsx
Normal file
31
client/src/components/MasseSalarialeDataState.tsx
Normal 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}</>;
|
||||
}
|
||||
43
client/src/lib/masseSalariale.test.ts
Normal file
43
client/src/lib/masseSalariale.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
59
client/src/lib/masseSalariale.ts
Normal file
59
client/src/lib/masseSalariale.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
267
client/src/pages/MasseSalariale.tsx
Normal file
267
client/src/pages/MasseSalariale.tsx
Normal 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 2025–2027 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 n’est 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
67
docs/masse-salariale.md
Normal 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.
|
||||
30
drizzle/0005_nasty_tattoo.sql
Normal file
30
drizzle/0005_nasty_tattoo.sql
Normal 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`)
|
||||
);
|
||||
1
drizzle/0006_ambitious_shen.sql
Normal file
1
drizzle/0006_ambitious_shen.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `statut` varchar(32) DEFAULT 'annuel' NOT NULL;
|
||||
13
drizzle/0007_rich_tony_stark.sql
Normal file
13
drizzle/0007_rich_tony_stark.sql
Normal 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`;
|
||||
1123
drizzle/meta/0005_snapshot.json
Normal file
1123
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1131
drizzle/meta/0006_snapshot.json
Normal file
1131
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1202
drizzle/meta/0007_snapshot.json
Normal file
1202
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
111
server/db.ts
111
server/db.ts
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
37
todo.md
@@ -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 2025–2027
|
||||
- [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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user