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 SoinsSante from "./pages/SoinsSante";
|
||||||
import StExupery from "./pages/StExupery";
|
import StExupery from "./pages/StExupery";
|
||||||
import TableauBordFinance from "./pages/TableauBordFinance";
|
import TableauBordFinance from "./pages/TableauBordFinance";
|
||||||
|
import MasseSalariale from "./pages/MasseSalariale";
|
||||||
import { useAuth } from "./_core/hooks/useAuth";
|
import { useAuth } from "./_core/hooks/useAuth";
|
||||||
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
||||||
|
|
||||||
@@ -40,6 +41,24 @@ function ProtectedRoute({ component: Component }: { component: React.ComponentTy
|
|||||||
return <Component />;
|
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() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -74,6 +93,9 @@ function Router() {
|
|||||||
<Route path="/tableau-bord-finance">
|
<Route path="/tableau-bord-finance">
|
||||||
<ProtectedRoute component={TableauBordFinance} />
|
<ProtectedRoute component={TableauBordFinance} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/masse-salariale">
|
||||||
|
<AdminRoute component={MasseSalariale} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
TableProperties,
|
TableProperties,
|
||||||
Upload,
|
Upload,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Banknote,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Palette de couleurs par section principale
|
// Palette de couleurs par section principale
|
||||||
@@ -102,6 +103,8 @@ interface NavLeaf {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
path: string;
|
path: string;
|
||||||
|
/** Les données de paie ne doivent jamais apparaître aux profils non admin. */
|
||||||
|
adminOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavSection {
|
interface NavSection {
|
||||||
@@ -129,7 +132,10 @@ const NAV_SECTIONS: NavSection[] = [
|
|||||||
id: 'santinova',
|
id: 'santinova',
|
||||||
label: 'SANTINOVA',
|
label: 'SANTINOVA',
|
||||||
icon: Heart,
|
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',
|
id: 'soins-sante',
|
||||||
@@ -228,6 +234,9 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
|||||||
const isActive = sectionHasActive(section, location);
|
const isActive = sectionHasActive(section, location);
|
||||||
const isOpen = openMenus.has(section.id);
|
const isOpen = openMenus.has(section.id);
|
||||||
const SectionIcon = section.icon;
|
const SectionIcon = section.icon;
|
||||||
|
const visibleChildren = section.children?.filter(
|
||||||
|
(leaf) => !leaf.adminOnly || user?.role === 'admin',
|
||||||
|
);
|
||||||
|
|
||||||
// Section sans sous-menu (feuille directe)
|
// Section sans sous-menu (feuille directe)
|
||||||
if (!section.children) {
|
if (!section.children) {
|
||||||
@@ -283,9 +292,9 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Items directs — un seul niveau */}
|
{/* 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`}>
|
<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 leafActive = isLeafActive(leaf.path!, location);
|
||||||
const LeafIcon = leaf.icon;
|
const LeafIcon = leaf.icon;
|
||||||
return (
|
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,
|
"when": 1786998761879,
|
||||||
"tag": "0004_known_shiva",
|
"tag": "0004_known_shiva",
|
||||||
"breakpoints": true
|
"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 OpexBaseRepartition = typeof opexBasesRepartition.$inferSelect;
|
||||||
export type InsertOpexBaseRepartition = typeof opexBasesRepartition.$inferInsert;
|
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(),
|
upsertOpexBaseRepartition: vi.fn(),
|
||||||
setOpexMontantsEtabBatch: vi.fn(),
|
setOpexMontantsEtabBatch: vi.fn(),
|
||||||
deleteOpexPoste: 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);
|
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,
|
etablissements,
|
||||||
InsertCapexLigne,
|
InsertCapexLigne,
|
||||||
InsertEtablissement,
|
InsertEtablissement,
|
||||||
|
InsertMasseSalarialeEvolution,
|
||||||
|
InsertMasseSalarialeRemuneration,
|
||||||
|
InsertMasseSalarialeSalarie,
|
||||||
InsertOpexMontantEtab,
|
InsertOpexMontantEtab,
|
||||||
InsertOpexPoste,
|
InsertOpexPoste,
|
||||||
InsertUser,
|
InsertUser,
|
||||||
inventaireMeta,
|
inventaireMeta,
|
||||||
inventairePostes,
|
inventairePostes,
|
||||||
|
masseSalarialeEvolutions,
|
||||||
|
masseSalarialeRemunerations,
|
||||||
|
masseSalarialeSalaries,
|
||||||
opexBasesRepartition,
|
opexBasesRepartition,
|
||||||
opexMontantsEtab,
|
opexMontantsEtab,
|
||||||
opexPostes,
|
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({
|
inventaire: router({
|
||||||
get: protectedProcedure
|
get: protectedProcedure
|
||||||
.input(z.object({ annee: z.number() }))
|
.input(z.object({ annee: z.number() }))
|
||||||
|
|||||||
37
todo.md
37
todo.md
@@ -100,8 +100,35 @@
|
|||||||
## Déploiement de recette via Gitea
|
## Déploiement de recette via Gitea
|
||||||
- [x] Vérifier la version à pousser et la cible Gitea de recette
|
- [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
|
- [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
|
- [x] Vérifier l'application déployée sur l'URL de recette
|
||||||
- [ ] Diagnostiquer et corriger le rejet de signature du webhook CI/CD
|
- [x] Diagnostiquer et corriger le rejet de signature du webhook CI/CD
|
||||||
- [ ] Confirmer le redéploiement effectif du commit Gitea 3b52527
|
- [x] Constater l'échec du commit 3b52527 puis confirmer le redéploiement du correctif ec597da
|
||||||
- [ ] Diagnostiquer et corriger le routage du webhook vers le déploiement Budget SI
|
- [x] 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] 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({
|
export default defineConfig({
|
||||||
root: templateRoot,
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(templateRoot, "client", "src"),
|
"@": path.resolve(templateRoot, "client", "src"),
|
||||||
@@ -14,6 +19,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
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