Checkpoint: Ajout du module Masse salariale SANTINOVA : tables de salariés, rémunérations et évolutions manuelles sécurisées par rôle administrateur ; import contrôlé des bulletins 2025 et janvier–août 2026 ; écran tableau 2025–2027 ; tests d'autorisations, d'agrégats et des quatre états UI ; documentation des sources et de la politique d'import.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user