Compare commits
29 Commits
2422784aeb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8312cf25fc | ||
|
|
28570234d0 | ||
|
|
5c9618b204 | ||
|
|
f759032793 | ||
|
|
b2c8580382 | ||
|
|
6447389cb8 | ||
|
|
7ee4e848d2 | ||
|
|
eeac2de481 | ||
|
|
0e59dac29f | ||
|
|
193f7ce32a | ||
|
|
7698e9f680 | ||
|
|
558582d358 | ||
|
|
82de3181df | ||
|
|
8a58f2c2bd | ||
|
|
0a00e70a9f | ||
|
|
fe2a266d2d | ||
|
|
15d67924ca | ||
|
|
da407db4de | ||
|
|
2f4b1f2e8e | ||
|
|
ec597da4a2 | ||
|
|
79411a6801 | ||
|
|
c5ba703165 | ||
|
|
df208f2b02 | ||
|
|
3b52527fd0 | ||
|
|
eec1c4e9f7 | ||
|
|
9a1b9f0ac6 | ||
|
|
06afee0d0b | ||
|
|
07b96c03c1 | ||
|
|
615dd46059 |
38
.gitea/workflows/validate.yml
Normal file
38
.gitea/workflows/validate.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Validation applicative
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: TypeScript, tests et build
|
||||
# Image locale avec Node 22, pnpm verrouillé et Bash déjà installés.
|
||||
# Le runner de recette n'a pas d'accès DNS fiable vers GitHub : le checkout
|
||||
# s'effectue donc directement depuis le service Gitea interne.
|
||||
runs-on: ci-node22
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Récupérer le commit déclencheur depuis Gitea interne
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "http://gitea:3000/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout --detach FETCH_HEAD
|
||||
|
||||
- name: Installer les dépendances verrouillées
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Vérifier TypeScript, tests et build
|
||||
run: pnpm verify
|
||||
@@ -2,14 +2,17 @@ FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copier les fichiers de dépendances (y compris les patches)
|
||||
# Corepack lit packageManager et installe exactement pnpm 10.4.1, version
|
||||
# compatible avec le lockfile. L'installation globale de pnpm récupérait une
|
||||
# version plus récente, incompatible avec les métadonnées figées du lockfile.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN npm install -g pnpm && pnpm install --frozen-lockfile
|
||||
RUN npm install -g corepack@0.31.0 \
|
||||
&& corepack pnpm install --frozen-lockfile
|
||||
|
||||
# Copier le reste du code et builder
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
RUN corepack pnpm build
|
||||
|
||||
# ── Image de production ──────────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
11
app.json
11
app.json
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"id": "itinova-budget-si",
|
||||
"name": "Gestion Budget Informatique",
|
||||
"name": "Gestion budget informatique",
|
||||
"category": "SANTINOVA",
|
||||
"urls": {
|
||||
"recette": "https://budget-si.recette.santinova-soft.org",
|
||||
"prod": "https://budget-si.santinova-soft.org"
|
||||
},
|
||||
"containerName": "itinova-budget-si-app",
|
||||
"image": "images/budget-si.png",
|
||||
"giteaRepo": "itinova-budget-si",
|
||||
"giteaOwner": "manus-admin"
|
||||
"image": "images/budget-si.jpg",
|
||||
"giteaRepo": "manus-admin/itinova-budget-si",
|
||||
"giteaOwner": "manus-admin",
|
||||
"ci": {
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ 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 Salaires from "./pages/Salaires";
|
||||
import { useAuth } from "./_core/hooks/useAuth";
|
||||
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
||||
|
||||
@@ -40,6 +42,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 +94,12 @@ function Router() {
|
||||
<Route path="/tableau-bord-finance">
|
||||
<ProtectedRoute component={TableauBordFinance} />
|
||||
</Route>
|
||||
<Route path="/masse-salariale">
|
||||
<AdminRoute component={MasseSalariale} />
|
||||
</Route>
|
||||
<Route path="/salaires">
|
||||
<AdminRoute component={Salaires} />
|
||||
</Route>
|
||||
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2, Send, User, Sparkles } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
/**
|
||||
* Message type matching server-side LLM Message interface
|
||||
*/
|
||||
export type Message = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AIChatBoxProps = {
|
||||
/**
|
||||
* Messages array to display in the chat.
|
||||
* Should match the format used by invokeLLM on the server.
|
||||
*/
|
||||
messages: Message[];
|
||||
|
||||
/**
|
||||
* Callback when user sends a message.
|
||||
* Typically you'll call a tRPC mutation here to invoke the LLM.
|
||||
*/
|
||||
onSendMessage: (content: string) => void;
|
||||
|
||||
/**
|
||||
* Whether the AI is currently generating a response
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
|
||||
/**
|
||||
* Placeholder text for the input field
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* Custom className for the container
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Height of the chat box (default: 600px)
|
||||
*/
|
||||
height?: string | number;
|
||||
|
||||
/**
|
||||
* Empty state message to display when no messages
|
||||
*/
|
||||
emptyStateMessage?: string;
|
||||
|
||||
/**
|
||||
* Suggested prompts to display in empty state
|
||||
* Click to send directly
|
||||
*/
|
||||
suggestedPrompts?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A ready-to-use AI chat box component that integrates with the LLM system.
|
||||
*
|
||||
* Features:
|
||||
* - Matches server-side Message interface for seamless integration
|
||||
* - Markdown rendering with Streamdown
|
||||
* - Auto-scrolls to latest message
|
||||
* - Loading states
|
||||
* - Uses global theme colors from index.css
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const ChatPage = () => {
|
||||
* const [messages, setMessages] = useState<Message[]>([
|
||||
* { role: "system", content: "You are a helpful assistant." }
|
||||
* ]);
|
||||
*
|
||||
* const chatMutation = trpc.ai.chat.useMutation({
|
||||
* onSuccess: (response) => {
|
||||
* // Assuming your tRPC endpoint returns the AI response as a string
|
||||
* setMessages(prev => [...prev, {
|
||||
* role: "assistant",
|
||||
* content: response
|
||||
* }]);
|
||||
* },
|
||||
* onError: (error) => {
|
||||
* console.error("Chat error:", error);
|
||||
* // Optionally show error message to user
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* const handleSend = (content: string) => {
|
||||
* const newMessages = [...messages, { role: "user", content }];
|
||||
* setMessages(newMessages);
|
||||
* chatMutation.mutate({ messages: newMessages });
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <AIChatBox
|
||||
* messages={messages}
|
||||
* onSendMessage={handleSend}
|
||||
* isLoading={chatMutation.isPending}
|
||||
* suggestedPrompts={[
|
||||
* "Explain quantum computing",
|
||||
* "Write a hello world in Python"
|
||||
* ]}
|
||||
* />
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function AIChatBox({
|
||||
messages,
|
||||
onSendMessage,
|
||||
isLoading = false,
|
||||
placeholder = "Type your message...",
|
||||
className,
|
||||
height = "600px",
|
||||
emptyStateMessage = "Start a conversation with AI",
|
||||
suggestedPrompts,
|
||||
}: AIChatBoxProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputAreaRef = useRef<HTMLFormElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Filter out system messages
|
||||
const displayMessages = messages.filter((msg) => msg.role !== "system");
|
||||
|
||||
// Calculate min-height for last assistant message to push user message to top
|
||||
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && inputAreaRef.current) {
|
||||
const containerHeight = containerRef.current.offsetHeight;
|
||||
const inputHeight = inputAreaRef.current.offsetHeight;
|
||||
const scrollAreaHeight = containerHeight - inputHeight;
|
||||
|
||||
// Reserve space for:
|
||||
// - padding (p-4 = 32px top+bottom)
|
||||
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
|
||||
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
|
||||
const userMessageReservedHeight = 56;
|
||||
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
|
||||
|
||||
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom helper function with smooth animation
|
||||
const scrollToBottom = () => {
|
||||
const viewport = scrollAreaRef.current?.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLDivElement;
|
||||
|
||||
if (viewport) {
|
||||
requestAnimationFrame(() => {
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedInput = input.trim();
|
||||
if (!trimmedInput || isLoading) return;
|
||||
|
||||
onSendMessage(trimmedInput);
|
||||
setInput("");
|
||||
|
||||
// Scroll immediately after sending
|
||||
scrollToBottom();
|
||||
|
||||
// Keep focus on input
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
|
||||
className
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Messages Area */}
|
||||
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
|
||||
{displayMessages.length === 0 ? (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Sparkles className="size-12 opacity-20" />
|
||||
<p className="text-sm">{emptyStateMessage}</p>
|
||||
</div>
|
||||
|
||||
{suggestedPrompts && suggestedPrompts.length > 0 && (
|
||||
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
|
||||
{suggestedPrompts.map((prompt, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onSendMessage(prompt)}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col space-y-4 p-4">
|
||||
{displayMessages.map((message, index) => {
|
||||
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
|
||||
const isLastMessage = index === displayMessages.length - 1;
|
||||
const shouldApplyMinHeight =
|
||||
isLastMessage && !isLoading && minHeightForLastMessage > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
message.role === "user"
|
||||
? "justify-end items-start"
|
||||
: "justify-start items-start"
|
||||
)}
|
||||
style={
|
||||
shouldApplyMinHeight
|
||||
? { minHeight: `${minHeightForLastMessage}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{message.role === "assistant" && (
|
||||
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-lg px-4 py-2.5",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<Streamdown>{message.content}</Streamdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm">
|
||||
{message.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message.role === "user" && (
|
||||
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
|
||||
<User className="size-4 text-secondary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
style={
|
||||
minHeightForLastMessage > 0
|
||||
? { minHeight: `${minHeightForLastMessage}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted px-4 py-2.5">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<form
|
||||
ref={inputAreaRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-2 p-4 border-t bg-background/50 items-end"
|
||||
>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 max-h-32 resize-none min-h-9"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="shrink-0 h-[38px] w-[38px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,17 +22,16 @@ import {
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Heart,
|
||||
Stethoscope,
|
||||
Layers,
|
||||
BookOpen,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
TableProperties,
|
||||
Upload,
|
||||
LayoutDashboard,
|
||||
Banknote,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Palette de couleurs par section principale
|
||||
@@ -104,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 {
|
||||
@@ -131,7 +132,11 @@ 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: 'salaires', label: 'Salaires', icon: Banknote, path: '/salaires', adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'soins-sante',
|
||||
@@ -193,19 +198,6 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSubMenu = (id: string) => {
|
||||
setOpenMenus(prev => {
|
||||
const next = new Set(prev);
|
||||
// Les sous-menus s'ouvrent/ferment indépendamment (pas d'accordéon entre eux)
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const colors = (sectionId: string) => SECTION_COLORS[sectionId] ?? SECTION_COLORS['itinova'];
|
||||
|
||||
return (
|
||||
@@ -243,6 +235,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) {
|
||||
@@ -298,9 +293,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 (
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
||||
];
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||
const DEFAULT_WIDTH = 280;
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 480;
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
||||
});
|
||||
const { loading, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
||||
}, [sidebarWidth]);
|
||||
|
||||
if (loading) {
|
||||
return <DashboardLayoutSkeleton />
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-center">
|
||||
Sign in to continue
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Access to this dashboard requires authentication. Continue to launch the login flow.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = getLoginUrl();
|
||||
}}
|
||||
size="lg"
|
||||
className="w-full shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": `${sidebarWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
|
||||
{children}
|
||||
</DashboardLayoutContent>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardLayoutContentProps = {
|
||||
children: React.ReactNode;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
};
|
||||
|
||||
function DashboardLayoutContent({
|
||||
children,
|
||||
setSidebarWidth,
|
||||
}: DashboardLayoutContentProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const [location, setLocation] = useLocation();
|
||||
const { state, toggleSidebar } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const activeMenuItem = menuItems.find(item => item.path === location);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed) {
|
||||
setIsResizing(false);
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
||||
const newWidth = e.clientX - sidebarLeft;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [isResizing, setSidebarWidth]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative" ref={sidebarRef}>
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className="border-r-0"
|
||||
disableTransition={isResizing}
|
||||
>
|
||||
<SidebarHeader className="h-16 justify-center">
|
||||
<div className="flex items-center gap-3 px-2 transition-all w-full">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-semibold tracking-tight truncate">
|
||||
Navigation
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="gap-0">
|
||||
<SidebarMenu className="px-2 py-1">
|
||||
{menuItems.map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => setLocation(item.path)}
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon
|
||||
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Avatar className="h-9 w-9 border shrink-0">
|
||||
<AvatarFallback className="text-xs font-medium">
|
||||
{(user?.firstName || user?.login)?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
||||
<p className="text-sm font-medium truncate leading-none">
|
||||
{user?.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user?.login || "-"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
||||
{user?.email || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={logout}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
||||
onMouseDown={() => {
|
||||
if (isCollapsed) return;
|
||||
setIsResizing(true);
|
||||
}}
|
||||
style={{ zIndex: 50 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarInset>
|
||||
{isMobile && (
|
||||
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="tracking-tight text-foreground">
|
||||
{activeMenuItem?.label ?? "Menu"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main className="flex-1 p-4">{children}</main>
|
||||
</SidebarInset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function SortIcon({ field, sortField, sortOrder }: { field: SortField; sortField
|
||||
: <ArrowDown className="w-3.5 h-3.5 text-primary" />;
|
||||
}
|
||||
|
||||
function VetusteBar({ value, total, color }: { value: number; total: number; color: string }) {
|
||||
function VetusteBar({ value, total }: { value: number; total: number }) {
|
||||
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
const barColor =
|
||||
pct >= 80 ? 'bg-red-500' :
|
||||
@@ -133,12 +133,12 @@ export function EtablissementListView({ etablissements, onSelect, sortField, sor
|
||||
|
||||
{/* PC Fixes */}
|
||||
<td className="px-3 py-3">
|
||||
<VetusteBar value={nbFixesR} total={nbFixesTotal} color="blue" />
|
||||
<VetusteBar value={nbFixesR} total={nbFixesTotal} />
|
||||
</td>
|
||||
|
||||
{/* PC Portables */}
|
||||
<td className="px-3 py-3">
|
||||
<VetusteBar value={nbPortablesR} total={nbPortablesTotal} color="orange" />
|
||||
<VetusteBar value={nbPortablesR} total={nbPortablesTotal} />
|
||||
</td>
|
||||
|
||||
{/* Total à renouveler */}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs)
|
||||
// Design: Corporate Modernism — Itinova Budget SI
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAnnee, getInventaireStorageKey } from '../contexts/AnneeContext';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAnnee } from '../contexts/AnneeContext';
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
@@ -28,6 +27,9 @@ import { toast } from 'sonner';
|
||||
async function fileToRows(file: File): Promise<string[][]> {
|
||||
const isXlsx = file.name.match(/\.(xlsx|xls)$/i);
|
||||
if (isXlsx) {
|
||||
// XLSX pèse plusieurs centaines de Ko : il n'est requis qu'à l'ouverture
|
||||
// d'un fichier d'import et ne doit pas ralentir l'affichage initial.
|
||||
const XLSX = await import('xlsx');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
@@ -418,10 +420,6 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||
h === 'fabricant' || h === 'marque' || h === 'brand' ||
|
||||
h.includes('fabricant') || h.includes('marque')
|
||||
);
|
||||
const statutIdx = rawHeaders.findIndex(h =>
|
||||
h === 'statut' || h === 'status' || h.includes('statut')
|
||||
);
|
||||
|
||||
// Compteurs pour le résumé
|
||||
let nbFixes = 0;
|
||||
let nbPortables = 0;
|
||||
@@ -543,7 +541,7 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||
});
|
||||
|
||||
// ── Envoyer à la base de données via tRPC ──────────────────────────────────
|
||||
const result = await importInventaireMutation.mutateAsync({ annee, filename: file.name, postes: postesPayload });
|
||||
await importInventaireMutation.mutateAsync({ annee, filename: file.name, postes: postesPayload });
|
||||
|
||||
// ── Upsert établissements dans la BDD ──────────────────────────────────────
|
||||
for (const [code, info] of Array.from(etabData.entries())) {
|
||||
@@ -552,7 +550,6 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||
} catch { /* ignore les erreurs d'upsert établissement */ }
|
||||
}
|
||||
|
||||
const nbNouveaux = result.nbEtablissements;
|
||||
const details = [
|
||||
`${rows.length - 1} poste${rows.length - 1 > 1 ? 's' : ''}`,
|
||||
`${etabData.size} établissement${etabData.size > 1 ? 's' : ''} détectés`,
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ManusDialogProps {
|
||||
title?: string;
|
||||
logo?: string;
|
||||
open?: boolean;
|
||||
onLogin: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ManusDialog({
|
||||
title,
|
||||
logo,
|
||||
open = false,
|
||||
onLogin,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
}: ManusDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onOpenChange) {
|
||||
setInternalOpen(open);
|
||||
}
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (onOpenChange) {
|
||||
onOpenChange(nextOpen);
|
||||
} else {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
|
||||
if (!nextOpen) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={onOpenChange ? open : internalOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DialogContent className="py-5 bg-[#f8f8f7] rounded-[20px] w-[400px] shadow-[0px_4px_11px_0px_rgba(0,0,0,0.08)] border border-[rgba(0,0,0,0.08)] backdrop-blur-2xl p-0 gap-0 text-center">
|
||||
<div className="flex flex-col items-center gap-2 p-5 pt-12">
|
||||
{logo ? (
|
||||
<div className="w-16 h-16 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] flex items-center justify-center">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Dialog graphic"
|
||||
className="w-10 h-10 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Title and subtitle */}
|
||||
{title ? (
|
||||
<DialogTitle className="text-xl font-semibold text-[#34322d] leading-[26px] tracking-[-0.44px]">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
) : null}
|
||||
<DialogDescription className="text-sm text-[#858481] leading-5 tracking-[-0.154px]">
|
||||
Please login with Manus to continue
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-5">
|
||||
{/* Login button */}
|
||||
<Button
|
||||
onClick={onLogin}
|
||||
className="w-full h-10 bg-[#1a1a19] hover:bg-[#1a1a19]/90 text-white rounded-[10px] text-sm font-medium leading-5 tracking-[-0.154px]"
|
||||
>
|
||||
Login with Manus
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
|
||||
*
|
||||
* USAGE FROM PARENT COMPONENT:
|
||||
* ======
|
||||
*
|
||||
* const mapRef = useRef<google.maps.Map | null>(null);
|
||||
*
|
||||
* <MapView
|
||||
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
|
||||
* initialZoom={15}
|
||||
* onMapReady={(map) => {
|
||||
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
|
||||
* </MapView>
|
||||
*
|
||||
* ======
|
||||
* Available Libraries and Core Features:
|
||||
* -------------------------------
|
||||
* 📍 MARKER (from `marker` library)
|
||||
* - Attaches to map using { map, position }
|
||||
* new google.maps.marker.AdvancedMarkerElement({
|
||||
* map,
|
||||
* position: { lat: 37.7749, lng: -122.4194 },
|
||||
* title: "San Francisco",
|
||||
* });
|
||||
*
|
||||
* -------------------------------
|
||||
* 🏢 PLACES (from `places` library)
|
||||
* - Does not attach directly to map; use data with your map manually.
|
||||
* const place = new google.maps.places.Place({ id: PLACE_ID });
|
||||
* await place.fetchFields({ fields: ["displayName", "location"] });
|
||||
* map.setCenter(place.location);
|
||||
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
|
||||
*
|
||||
* -------------------------------
|
||||
* 🧭 GEOCODER (from `geocoding` library)
|
||||
* - Standalone service; manually apply results to map.
|
||||
* const geocoder = new google.maps.Geocoder();
|
||||
* geocoder.geocode({ address: "New York" }, (results, status) => {
|
||||
* if (status === "OK" && results[0]) {
|
||||
* map.setCenter(results[0].geometry.location);
|
||||
* new google.maps.marker.AdvancedMarkerElement({
|
||||
* map,
|
||||
* position: results[0].geometry.location,
|
||||
* });
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* -------------------------------
|
||||
* 📐 GEOMETRY (from `geometry` library)
|
||||
* - Pure utility functions; not attached to map.
|
||||
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
|
||||
*
|
||||
* -------------------------------
|
||||
* 🛣️ ROUTES (from `routes` library)
|
||||
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
|
||||
* const directionsService = new google.maps.DirectionsService();
|
||||
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
|
||||
* directionsService.route(
|
||||
* { origin, destination, travelMode: "DRIVING" },
|
||||
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
|
||||
* );
|
||||
*
|
||||
* -------------------------------
|
||||
* 🌦️ MAP LAYERS (attach directly to map)
|
||||
* - new google.maps.TrafficLayer().setMap(map);
|
||||
* - new google.maps.TransitLayer().setMap(map);
|
||||
* - new google.maps.BicyclingLayer().setMap(map);
|
||||
*
|
||||
* -------------------------------
|
||||
* ✅ SUMMARY
|
||||
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
|
||||
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
|
||||
* - “data-only” → Place, Geometry utilities.
|
||||
*/
|
||||
|
||||
/// <reference types="@types/google.maps" />
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePersistFn } from "@/hooks/usePersistFn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: typeof google;
|
||||
}
|
||||
}
|
||||
|
||||
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
|
||||
const FORGE_BASE_URL =
|
||||
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
|
||||
"https://forge.butterfly-effect.dev";
|
||||
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
|
||||
|
||||
function loadMapScript() {
|
||||
return new Promise(resolve => {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
|
||||
script.async = true;
|
||||
script.crossOrigin = "anonymous";
|
||||
script.onload = () => {
|
||||
resolve(null);
|
||||
script.remove(); // Clean up immediately
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error("Failed to load Google Maps script");
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
interface MapViewProps {
|
||||
className?: string;
|
||||
initialCenter?: google.maps.LatLngLiteral;
|
||||
initialZoom?: number;
|
||||
onMapReady?: (map: google.maps.Map) => void;
|
||||
}
|
||||
|
||||
export function MapView({
|
||||
className,
|
||||
initialCenter = { lat: 37.7749, lng: -122.4194 },
|
||||
initialZoom = 12,
|
||||
onMapReady,
|
||||
}: MapViewProps) {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<google.maps.Map | null>(null);
|
||||
|
||||
const init = usePersistFn(async () => {
|
||||
await loadMapScript();
|
||||
if (!mapContainer.current) {
|
||||
console.error("Map container not found");
|
||||
return;
|
||||
}
|
||||
map.current = new window.google.maps.Map(mapContainer.current, {
|
||||
zoom: initialZoom,
|
||||
center: initialCenter,
|
||||
mapTypeControl: true,
|
||||
fullscreenControl: true,
|
||||
zoomControl: true,
|
||||
streetViewControl: true,
|
||||
mapId: "DEMO_MAP_ID",
|
||||
});
|
||||
if (onMapReady) {
|
||||
onMapReady(map.current);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, [init]);
|
||||
|
||||
return (
|
||||
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
|
||||
);
|
||||
}
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,734 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
disableTransition = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
disableTransition?: boolean;
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent",
|
||||
disableTransition
|
||||
? "transition-none"
|
||||
: "transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||
disableTransition
|
||||
? "transition-none"
|
||||
: "transition-[left,right,width] duration-200 ease-linear",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={event => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
50
client/src/lib/opexTrends.test.ts
Normal file
50
client/src/lib/opexTrends.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateCategoryTrends } from "./opexTrends";
|
||||
|
||||
describe("calculateCategoryTrends", () => {
|
||||
it("compare les montants de l'exercice affiché à ceux de sa vraie année N-1", () => {
|
||||
const trends = calculateCategoryTrends(
|
||||
[
|
||||
{ categorie: "App global", montant: 504_980 },
|
||||
{ categorie: "App HEP", montant: 98_000 },
|
||||
],
|
||||
[
|
||||
{ categorie: "App global", montant: 505_730 },
|
||||
{ categorie: "App HEP", montant: 98_000 },
|
||||
],
|
||||
);
|
||||
|
||||
// Régression : 2027 ne doit jamais réutiliser le +26 % (comparaison 2026/2025).
|
||||
expect(trends["App global"]).toEqual({
|
||||
montantN1: 505_730,
|
||||
variationPct: -0.1,
|
||||
tendance: "stable",
|
||||
});
|
||||
expect(trends["App HEP"]).toEqual({
|
||||
montantN1: 98_000,
|
||||
variationPct: 0,
|
||||
tendance: "stable",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifie une nouvelle catégorie et une variation significative", () => {
|
||||
const trends = calculateCategoryTrends(
|
||||
[
|
||||
{ categorie: "App global", montant: 1_050 },
|
||||
{ categorie: "Sécurité", montant: 20 },
|
||||
],
|
||||
[{ categorie: "App global", montant: 1_000 }],
|
||||
);
|
||||
|
||||
expect(trends["App global"]).toEqual({
|
||||
montantN1: 1_000,
|
||||
variationPct: 5,
|
||||
tendance: "hausse",
|
||||
});
|
||||
expect(trends["Sécurité"]).toEqual({
|
||||
montantN1: null,
|
||||
variationPct: null,
|
||||
tendance: "new",
|
||||
});
|
||||
});
|
||||
});
|
||||
73
client/src/lib/opexTrends.ts
Normal file
73
client/src/lib/opexTrends.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Calculs purs des tendances OPEX utilisées par les vignettes de catégories.
|
||||
*
|
||||
* Cette fonction ne dépend volontairement ni de React, ni des fichiers JSON
|
||||
* historiques : la comparaison est toujours faite avec les montants réellement
|
||||
* chargés pour l'exercice précédent.
|
||||
*/
|
||||
export type OpexCategoryLine = {
|
||||
categorie: string | null | undefined;
|
||||
montant: number;
|
||||
};
|
||||
|
||||
export type OpexCategoryTrend = {
|
||||
montantN1: number | null;
|
||||
variationPct: number | null;
|
||||
tendance: "hausse" | "baisse" | "stable" | "new";
|
||||
};
|
||||
|
||||
/** Toute variation strictement inférieure à ce seuil est considérée stable. */
|
||||
export const STABLE_VARIATION_THRESHOLD_PERCENT = 2;
|
||||
|
||||
function toCategoryTotals(lines: OpexCategoryLine[]): Record<string, number> {
|
||||
return lines.reduce<Record<string, number>>((totals, line) => {
|
||||
const category = line.categorie?.trim() || "Autre";
|
||||
totals[category] = (totals[category] ?? 0) + line.montant;
|
||||
return totals;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les tendances par catégorie pour l'exercice courant.
|
||||
*
|
||||
* Les pourcentages sont arrondis à une décimale pour correspondre à l'affichage
|
||||
* et éviter des différences entre le badge et les données fournies au composant.
|
||||
*/
|
||||
export function calculateCategoryTrends(
|
||||
currentLines: OpexCategoryLine[],
|
||||
previousLines: OpexCategoryLine[],
|
||||
): Record<string, OpexCategoryTrend> {
|
||||
const currentTotals = toCategoryTotals(currentLines);
|
||||
const previousTotals = toCategoryTotals(previousLines);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(currentTotals).map(([category, currentAmount]) => {
|
||||
const previousAmount = previousTotals[category] ?? 0;
|
||||
|
||||
if (previousAmount === 0) {
|
||||
return [
|
||||
category,
|
||||
{
|
||||
montantN1: null,
|
||||
variationPct: null,
|
||||
tendance: "new" as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const rawVariation = ((currentAmount - previousAmount) / previousAmount) * 100;
|
||||
const variationPct = Math.round(rawVariation * 10) / 10;
|
||||
const tendance =
|
||||
Math.abs(variationPct) < STABLE_VARIATION_THRESHOLD_PERCENT
|
||||
? "stable"
|
||||
: variationPct > 0
|
||||
? "hausse"
|
||||
: "baisse";
|
||||
|
||||
return [
|
||||
category,
|
||||
{ montantN1: previousAmount, variationPct, tendance },
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,11 +83,6 @@ export default function DsiCapex() {
|
||||
{ retry: false, refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
// Charger toutes les lignes CAPEX pour tous les établissements de l'année
|
||||
// On fait une requête par établissement (ou on utilise une route agrégée si disponible)
|
||||
// Pour l'instant, on charge les données pour chaque établissement connu
|
||||
const etabCodes = useMemo(() => (etabsData ?? []).map(e => e.code), [etabsData]);
|
||||
|
||||
// Construire les lignes du tableau depuis les données BDD
|
||||
const lignes = useMemo(() => {
|
||||
if (!etabsData) return [];
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUpDown,
|
||||
Save,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
PencilLine,
|
||||
@@ -35,6 +34,8 @@ import { useAnnee } from '../contexts/AnneeContext';
|
||||
import opexRaw from '../data_opex.json';
|
||||
import opex2025Raw from '../data_opex_2025.json';
|
||||
import { formatEuros } from '../lib/format';
|
||||
import { calculateCategoryTrends } from '../lib/opexTrends';
|
||||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from '@shared/opexValidation';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -81,20 +82,12 @@ interface EtabSource {
|
||||
base_repartition: number;
|
||||
}
|
||||
|
||||
interface TendanceCategorie {
|
||||
montant_n1: number | null;
|
||||
montant_n: number;
|
||||
variation_pct: number | null;
|
||||
tendance: 'hausse' | 'baisse' | 'stable' | 'new';
|
||||
}
|
||||
|
||||
interface OpexData {
|
||||
annee: number;
|
||||
total_global: number;
|
||||
postes: Poste[];
|
||||
etablissements: EtabSource[];
|
||||
categories_totaux: Record<string, number>;
|
||||
tendances_categories?: Record<string, TendanceCategorie>;
|
||||
meta: { nb_postes: number; nb_etablissements?: number };
|
||||
}
|
||||
|
||||
@@ -117,6 +110,25 @@ interface LigneOpex {
|
||||
colIdx: number;
|
||||
}
|
||||
|
||||
/** Sous-ensemble commun aux lignes brutes OPEX retournées par la BDD. */
|
||||
interface OpexPosteRow {
|
||||
id: number;
|
||||
libelle: string;
|
||||
fournisseur: string | null;
|
||||
categorie: string | null;
|
||||
type: string | null;
|
||||
facturation: string | null;
|
||||
compte: string | null;
|
||||
detail: string | null;
|
||||
budgetN1: string | null;
|
||||
montant: string | null;
|
||||
isCustom: boolean | null;
|
||||
modeVentilation: string | null;
|
||||
libelleCourt: string | null;
|
||||
libelleDetail: string | null;
|
||||
colIdx: number;
|
||||
}
|
||||
|
||||
// ─── Constantes ───────────────────────────────────────────────────────────────
|
||||
|
||||
const opexData2026 = opexRaw as OpexData;
|
||||
@@ -185,6 +197,29 @@ function buildPostesFromSource(annee: number) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Convertit les lignes Drizzle en modèle de vue, en écartant les synthèses importées. */
|
||||
function mapDbPostesToLignes(postes: OpexPosteRow[]): LigneOpex[] {
|
||||
return postes
|
||||
.filter(p => !isLibelleParasite(p.libelle))
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
libelle: p.libelle,
|
||||
fournisseur: p.fournisseur ?? '',
|
||||
categorie: p.categorie ?? 'Autre',
|
||||
type: p.type ?? '',
|
||||
facturation: p.facturation ?? '',
|
||||
compte: p.compte ?? '',
|
||||
detail: p.detail ?? '',
|
||||
budget_n1: parseFloat(p.budgetN1 ?? '0') || 0,
|
||||
montant: parseFloat(p.montant ?? '0') || 0,
|
||||
isCustom: p.isCustom ?? false,
|
||||
mode_ventilation: p.modeVentilation ?? null,
|
||||
libelleCourt: p.libelleCourt ?? null,
|
||||
libelleDetail: p.libelleDetail ?? null,
|
||||
colIdx: p.colIdx,
|
||||
}));
|
||||
}
|
||||
|
||||
const EMPTY_LIGNE_FORM = {
|
||||
libelle: '',
|
||||
fournisseur: '',
|
||||
@@ -554,19 +589,13 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
}
|
||||
|
||||
// ── Construction des lignes à importer ───────────────────────────────
|
||||
// Mots-clés qui indiquent une ligne parasite (catégorie ou synthèse, pas un établissement)
|
||||
const PARASITE_PATTERNS = /^(infog|s.curit|t.l.phonie|app global|app hep|app smr|applicatifs|total|ventilation|r.partition|nouveaut)/i;
|
||||
const importRows = data.slice(headerIdx + 1)
|
||||
.filter(row => {
|
||||
const code = String(row[codeIdx] ?? '').trim();
|
||||
if (!code) return false;
|
||||
// Rejeter les lignes dont le code ressemble à un libellé de catégorie
|
||||
const codeNorm = code.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
if (PARASITE_PATTERNS.test(codeNorm)) return false;
|
||||
return true;
|
||||
return isOpexEtablissementCode(code);
|
||||
})
|
||||
.map(row => ({
|
||||
etablissementCode: String(row[codeIdx]).trim(),
|
||||
etablissementCode: normalizeOpexEtablissementCode(String(row[codeIdx])),
|
||||
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
|
||||
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0,
|
||||
baseRepartitionHep: hepIdx >= 0
|
||||
@@ -817,6 +846,9 @@ export default function DsiOpex() {
|
||||
|
||||
// ── Requêtes tRPC ──────────────────────────────────────────────────────────
|
||||
const { data: postesRaw, isLoading: loadingPostes } = trpc.opex.getPostes.useQuery({ annee });
|
||||
// Les vignettes doivent comparer l'exercice affiché avec ses données BDD N-1,
|
||||
// et non avec les tendances figées du fichier source 2026.
|
||||
const { data: postesN1Raw, isLoading: loadingPostesN1 } = trpc.opex.getPostes.useQuery({ annee: annee - 1 });
|
||||
const { data: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee });
|
||||
const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee });
|
||||
// Bases de répartition depuis la BDD (charges classe 6 par établissement)
|
||||
@@ -827,6 +859,14 @@ export default function DsiOpex() {
|
||||
onSuccess: () => utils.opex.getPostes.invalidate({ annee }),
|
||||
onError: (err) => toast.error('Erreur sauvegarde', { description: err.message }),
|
||||
});
|
||||
const deletePoste = trpc.opex.deletePoste.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.opex.getPostes.invalidate({ annee });
|
||||
utils.opex.getMontantsEtab.invalidate({ annee });
|
||||
toast.success('Poste OPEX supprimé');
|
||||
},
|
||||
onError: (err) => toast.error('Erreur suppression', { description: err.message }),
|
||||
});
|
||||
const initFromSource = trpc.opex.initFromSource.useMutation({
|
||||
onSuccess: () => { utils.opex.getPostes.invalidate({ annee }); toast.success(`OPEX ${annee} initialisé`); },
|
||||
onError: (err) => toast.error('Erreur initialisation', { description: err.message }),
|
||||
@@ -859,27 +899,40 @@ export default function DsiOpex() {
|
||||
// ── Mapper les données BDD vers le format interne ──────────────────────────
|
||||
const lignes: LigneOpex[] = useMemo(() => {
|
||||
if (!postesRaw) return [];
|
||||
return postesRaw
|
||||
.filter(p => !isLibelleParasite(p.libelle))
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
libelle: p.libelle,
|
||||
fournisseur: p.fournisseur ?? '',
|
||||
categorie: p.categorie ?? 'Autre',
|
||||
type: p.type ?? '',
|
||||
facturation: p.facturation ?? '',
|
||||
compte: p.compte ?? '',
|
||||
detail: p.detail ?? '',
|
||||
budget_n1: parseFloat(p.budgetN1 ?? '0') || 0,
|
||||
montant: parseFloat(p.montant ?? '0') || 0,
|
||||
isCustom: p.isCustom ?? false,
|
||||
mode_ventilation: p.modeVentilation ?? null,
|
||||
libelleCourt: p.libelleCourt ?? null,
|
||||
libelleDetail: p.libelleDetail ?? null,
|
||||
colIdx: p.colIdx,
|
||||
}));
|
||||
return mapDbPostesToLignes(postesRaw);
|
||||
}, [postesRaw]);
|
||||
|
||||
const lignesN1 = useMemo(() => {
|
||||
if (postesN1Raw && postesN1Raw.length > 0) {
|
||||
return mapDbPostesToLignes(postesN1Raw);
|
||||
}
|
||||
|
||||
// Seuls les fichiers 2025 et 2026 sont des sources historiques fiables.
|
||||
// Pour les années sans source ni données BDD, l'interface indique simplement
|
||||
// qu'il n'existe pas de référence N-1 au lieu de réutiliser 2026 par défaut.
|
||||
if (annee - 1 === 2025 || annee - 1 === 2026) {
|
||||
return buildPostesFromSource(annee - 1).map((poste, index) => ({
|
||||
id: -(index + 1),
|
||||
libelle: poste.libelle,
|
||||
fournisseur: poste.fournisseur ?? '',
|
||||
categorie: poste.categorie ?? 'Autre',
|
||||
type: poste.type ?? '',
|
||||
facturation: poste.facturation ?? '',
|
||||
compte: poste.compte ?? '',
|
||||
detail: poste.detail ?? '',
|
||||
budget_n1: parseFloat(poste.budgetN1 ?? '0') || 0,
|
||||
montant: parseFloat(poste.montant ?? '0') || 0,
|
||||
isCustom: poste.isCustom ?? false,
|
||||
mode_ventilation: poste.modeVentilation ?? null,
|
||||
libelleCourt: poste.libelleCourt ?? null,
|
||||
libelleDetail: poste.libelleDetail ?? null,
|
||||
colIdx: poste.colIdx,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [annee, postesN1Raw]);
|
||||
|
||||
// Overrides par établissement : { codeEtab: { libellePoste: montant } }
|
||||
const montantsManuelEtab = useMemo(() => {
|
||||
if (!montantsEtabRaw) return {} as Record<string, Record<string, number>>;
|
||||
@@ -1011,15 +1064,11 @@ export default function DsiOpex() {
|
||||
});
|
||||
}, [editingLigne, upsertPoste, annee]);
|
||||
|
||||
const handleDeleteLigne = useCallback((id: number) => {
|
||||
// On ne peut pas vraiment "supprimer" via upsert — on utilise deleteAll + réinsertion
|
||||
// Pour simplifier, on marque le poste avec montant 0 et libelle préfixé [SUPPRIMÉ]
|
||||
// Mais la meilleure approche est d'ajouter une procédure deletePoste
|
||||
// Pour l'instant, on utilise une mutation directe via deleteAll n'est pas approprié
|
||||
// On va ajouter une procédure deletePoste dans le routeur
|
||||
toast.error('Suppression non disponible — rechargez la page après avoir ajouté la procédure deletePoste');
|
||||
const handleDeleteLigne = useCallback(() => {
|
||||
if (!deleteConfirmId) return;
|
||||
deletePoste.mutate({ annee, id: deleteConfirmId });
|
||||
setDeleteConfirmId(null);
|
||||
}, []);
|
||||
}, [annee, deleteConfirmId, deletePoste]);
|
||||
|
||||
const handleInlineEditStart = useCallback((codeEtab: string, libelle: string, currentVal: number) => {
|
||||
if (isValidated) return;
|
||||
@@ -1091,6 +1140,11 @@ export default function DsiOpex() {
|
||||
return totaux;
|
||||
}, [lignes]);
|
||||
|
||||
const tendancesParCategorie = useMemo(
|
||||
() => calculateCategoryTrends(lignes, lignesN1),
|
||||
[lignes, lignesN1],
|
||||
);
|
||||
|
||||
const filteredLignes = useMemo(() => {
|
||||
return lignes.filter(l => {
|
||||
if (selectedCategorie !== 'Toutes' && l.categorie !== selectedCategorie) return false;
|
||||
@@ -1332,11 +1386,12 @@ export default function DsiOpex() {
|
||||
const montant = totalParCategorie[cat] || 0;
|
||||
const pct = totalGlobal > 0 ? (montant / totalGlobal * 100).toFixed(1) : '0';
|
||||
const colors = getCatColors(cat);
|
||||
const srcData = getOpexDataForAnnee(annee);
|
||||
const tendanceInfo: TendanceCategorie | undefined = srcData.tendances_categories?.[cat];
|
||||
const tendance = tendanceInfo?.tendance;
|
||||
const variationPct = tendanceInfo?.variation_pct;
|
||||
const montantN1 = tendanceInfo?.montant_n1;
|
||||
const tendanceInfo = tendancesParCategorie[cat];
|
||||
// Tant que la requête N-1 est active, ne pas présenter de badge
|
||||
// temporairement erroné (par exemple « Nouveau »).
|
||||
const tendance = loadingPostesN1 ? undefined : tendanceInfo?.tendance;
|
||||
const variationPct = tendanceInfo?.variationPct;
|
||||
const montantN1 = tendanceInfo?.montantN1;
|
||||
return (
|
||||
<div key={cat} className="bg-card border border-border rounded-xl p-4 min-w-[140px] flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -2000,7 +2055,7 @@ export default function DsiOpex() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteConfirmId && handleDeleteLigne(deleteConfirmId)}
|
||||
onClick={handleDeleteLigne}
|
||||
className="bg-destructive hover:bg-destructive/90 text-white"
|
||||
>
|
||||
Supprimer
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Building2,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
LayoutGrid,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Page de connexion locale — Budget SI Itinova
|
||||
* Branding : logo Itinova en haut, "powered by Santinova" en bas (partie droite)
|
||||
* Branding : wordmarks Itinova et Santinova intégrés pour rester fiables en recette.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
@@ -10,9 +10,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Eye, EyeOff, Lock, User } from "lucide-react";
|
||||
|
||||
const ITINOVA_LOGO = "/manus-storage/Itinova_Logo_Couleurs_f87c931b.jpg";
|
||||
const SANTINOVA_LOGO = "/manus-storage/logo_santinova_fond_blanc_746ca8e1.webp";
|
||||
|
||||
export default function Login() {
|
||||
const [, navigate] = useLocation();
|
||||
const [login, setLogin] = useState("");
|
||||
@@ -101,13 +98,16 @@ export default function Login() {
|
||||
|
||||
{/* Panneau droit — formulaire */}
|
||||
<div className="flex-1 lg:max-w-md flex flex-col justify-between bg-white shadow-2xl">
|
||||
{/* Logo Itinova en haut */}
|
||||
{/* Wordmark Itinova : ne dépend pas d'un stockage externe à la recette. */}
|
||||
<div className="flex justify-center pt-10 px-8">
|
||||
<img
|
||||
src={ITINOVA_LOGO}
|
||||
alt="Itinova"
|
||||
className="h-16 object-contain"
|
||||
/>
|
||||
<div className="flex items-center gap-2.5 text-[#1a3a6e]" aria-label="Itinova">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-xl bg-[#1a3a6e] text-lg font-black text-white shadow-sm">
|
||||
i
|
||||
</span>
|
||||
<span className="text-3xl font-bold tracking-tight" style={{ fontFamily: "Sora, sans-serif" }}>
|
||||
itinova
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulaire centré */}
|
||||
@@ -202,15 +202,13 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Logo Santinova en bas */}
|
||||
{/* Wordmark Santinova : ne dépend pas d'un stockage externe à la recette. */}
|
||||
<div className="flex flex-col items-center pb-8 px-8 gap-2">
|
||||
<div className="w-full h-px bg-gray-100 mb-4" />
|
||||
<span className="text-xs text-gray-400">powered by</span>
|
||||
<img
|
||||
src={SANTINOVA_LOGO}
|
||||
alt="Santinova Soft"
|
||||
className="h-8 object-contain opacity-80"
|
||||
/>
|
||||
<span className="text-sm font-semibold tracking-wide text-[#1a3a6e] opacity-80">
|
||||
Santinova Soft
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
218
client/src/pages/MasseSalariale.tsx
Normal file
218
client/src/pages/MasseSalariale.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
// MasseSalariale.tsx — Prévisionnel et réel 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 } from "../lib/masseSalariale";
|
||||
import { toast } from "sonner";
|
||||
import { Banknote, CalendarDays, CircleAlert, FileSpreadsheet, LockKeyhole, ShieldCheck, Upload, 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",
|
||||
minimumFractionDigits: cents % 100 === 0 ? 0 : 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).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 keyFor(salarieId: number, annee: number) {
|
||||
return `${salarieId}-${annee}`;
|
||||
}
|
||||
|
||||
function formatMonths(months: number[] | undefined) {
|
||||
if (!months?.length) return "—";
|
||||
return months.length === 12 ? "12 mois" : `${months.length} mois`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les prévisions viennent du classeur de référence. Le réel n'est affiché que
|
||||
* lorsqu'il provient d'une clôture ou d'une liasse PDF effectivement archivée.
|
||||
*/
|
||||
export default function MasseSalariale() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [primeDraft, setPrimeDraft] = useState("");
|
||||
const [evolutionDraft, setEvolutionDraft] = useState("");
|
||||
const [workbookFile, setWorkbookFile] = useState<File | null>(null);
|
||||
const [isImportingWorkbook, setIsImportingWorkbook] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
const { data, isLoading, isError } = trpc.masseSalariale.get.useQuery();
|
||||
const saveAdjustments = trpc.masseSalariale.setForecastAdjustments.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.masseSalariale.get.invalidate();
|
||||
setEditingKey(null);
|
||||
toast.success("Prévision mise à jour");
|
||||
},
|
||||
onError: (error) => toast.error(error.message || "La prévision n'a pas pu être enregistrée"),
|
||||
});
|
||||
|
||||
const forecastByKey = useMemo(
|
||||
() => new Map((data?.forecasts ?? []).map((row) => [keyFor(row.salarieId, row.annee), row])),
|
||||
[data?.forecasts],
|
||||
);
|
||||
const actualByKey = useMemo(
|
||||
() => new Map((data?.actuals ?? []).map((row) => [keyFor(row.salarieId, row.annee), row])),
|
||||
[data?.actuals],
|
||||
);
|
||||
const totals = useMemo(() => new Map(ANNEES.map((annee) => {
|
||||
const forecasts = (data?.forecasts ?? []).filter((row) => row.annee === annee);
|
||||
const actuals = (data?.actuals ?? []).filter((row) => row.annee === annee);
|
||||
return [annee, {
|
||||
forecast: forecasts.reduce((sum, row) => sum + row.brutAvecPrimesCents, 0),
|
||||
actual: actuals.reduce((sum, row) => sum + row.brutAvecPrimesCents, 0),
|
||||
forecastCount: forecasts.length,
|
||||
actualCount: actuals.length,
|
||||
actualMonths: Math.max(0, ...actuals.map((row) => row.moisDisponibles.length)),
|
||||
}];
|
||||
})), [data?.forecasts, data?.actuals]);
|
||||
|
||||
const uiState = getMasseSalarialeUiState(isLoading, isError, data?.salaries.length ?? 0);
|
||||
|
||||
const startEdit = (salarieId: number, annee: number) => {
|
||||
const forecast = forecastByKey.get(keyFor(salarieId, annee));
|
||||
if (!forecast?.editable) return;
|
||||
setEditingKey(keyFor(salarieId, annee));
|
||||
setPrimeDraft((forecast.primeCents / 100).toFixed(2));
|
||||
setEvolutionDraft((forecast.evolutionCents / 100).toFixed(2));
|
||||
};
|
||||
|
||||
const saveEdit = (salarieId: number, annee: number) => {
|
||||
const normalize = (value: string) => Number(value.trim().replace(",", "."));
|
||||
const prime = normalize(primeDraft);
|
||||
const evolution = normalize(evolutionDraft);
|
||||
if (!Number.isFinite(prime) || !Number.isFinite(evolution) || Math.abs(prime) > 1_000_000 || Math.abs(evolution) > 1_000_000) {
|
||||
toast.error("Saisissez des montants compris entre -1 000 000 € et 1 000 000 €.");
|
||||
return;
|
||||
}
|
||||
saveAdjustments.mutate({
|
||||
salarieId,
|
||||
annee,
|
||||
primeCents: Math.round(prime * 100),
|
||||
evolutionCents: Math.round(evolution * 100),
|
||||
});
|
||||
};
|
||||
|
||||
const importWorkbook = async () => {
|
||||
if (!workbookFile) {
|
||||
toast.error("Choisissez le fichier Excel de référence avant de lancer l'import.");
|
||||
return;
|
||||
}
|
||||
if (!workbookFile.name.toLowerCase().endsWith(".xlsx") || workbookFile.size > 5 * 1024 * 1024) {
|
||||
toast.error("Le fichier doit être un .xlsx de 5 Mo maximum.");
|
||||
return;
|
||||
}
|
||||
setIsImportingWorkbook(true);
|
||||
try {
|
||||
const payload = new FormData();
|
||||
payload.append("file", workbookFile);
|
||||
const response = await fetch("/api/masse-salariale/previsionnels", { method: "POST", body: payload, credentials: "include" });
|
||||
const result = await response.json() as { salaries?: number; remunerations?: number; error?: string };
|
||||
if (!response.ok) throw new Error(result.error ?? "L'import a échoué.");
|
||||
await utils.masseSalariale.get.invalidate();
|
||||
setWorkbookFile(null);
|
||||
toast.success(`Fichier de référence importé : ${result.salaries ?? 0} salarié(s), ${result.remunerations ?? 0} prévision(s).`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "L'import a échoué.");
|
||||
} finally {
|
||||
setIsImportingWorkbook(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">Prévisionnel et réel annuel, issus du fichier de référence et des liasses archivées.</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">
|
||||
<section className="bg-card border border-border rounded-xl shadow-sm px-5 py-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3"><div className="w-9 h-9 rounded-lg bg-emerald-500/15 flex items-center justify-center flex-shrink-0"><FileSpreadsheet className="w-4 h-4 text-emerald-600" /></div><div><h2 className="font-semibold text-foreground">Fichier prévisionnel de référence</h2><p className="text-xs text-muted-foreground mt-0.5">Administrateur uniquement. L'import remplace les prévisions 2025–2027, sans modifier les liasses de paie ni les réels déjà archivés.</p></div></div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center"><input aria-label="Fichier Excel de masse salariale" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => setWorkbookFile(event.target.files?.[0] ?? null)} disabled={isImportingWorkbook} className="max-w-72 text-xs file:mr-3 file:rounded-md file:border-0 file:bg-emerald-50 file:px-3 file:py-2 file:text-xs file:font-semibold file:text-emerald-700 hover:file:bg-emerald-100" /><button onClick={importWorkbook} disabled={!workbookFile || isImportingWorkbook} className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"><Upload className="w-3.5 h-3.5" />{isImportingWorkbook ? "Import en cours…" : "Importer le fichier"}</button></div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
|
||||
{ANNEES.map((annee) => {
|
||||
const total = totals.get(annee);
|
||||
const hasActual = (total?.actualCount ?? 0) > 0;
|
||||
return <section key={annee} className="bg-card border border-border rounded-xl shadow-sm p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground"><CalendarDays className="w-4 h-4 text-emerald-600" />{annee}</div>
|
||||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||
<div><p className="text-[10px] font-semibold uppercase tracking-wide text-emerald-700">Prévisionnel</p><p className="text-xl font-bold text-foreground mt-1">{formatEuros(total?.forecast)}</p><p className="text-xs text-muted-foreground mt-1">{total?.forecastCount ?? 0} salarié(s)</p></div>
|
||||
<div className="border-l border-border pl-4"><p className="text-[10px] font-semibold uppercase tracking-wide text-sky-700">Réel</p><p className="text-xl font-bold text-foreground mt-1">{hasActual ? formatEuros(total?.actual) : "—"}</p><p className="text-xs text-muted-foreground mt-1">{hasActual ? `${total?.actualCount} salarié(s) · ${formatMonths(total?.actualMonths ? Array.from({ length: total.actualMonths }, (_, index) => index) : [])}` : "Aucune liasse"}</p></div>
|
||||
</div>
|
||||
</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>Réel 2026 :</strong> cumul strict des liasses mensuelles disponibles, sans annualisation. Le brut hors astreinte retire uniquement les primes d’astreinte. Les cellules vertes <strong>Prime</strong> et <strong>Évolution</strong> sont modifiables seulement pour {data?.currentYear ?? "l’exercice courant"} et les années suivantes.</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">Prévisionnel issu du fichier modèle ; réel issu des clôtures et liasses PDF archivées.</p></div></div>
|
||||
<MasseSalarialeDataState state={uiState}>
|
||||
<div className="overflow-auto max-h-[calc(100vh-350px)]">
|
||||
<table className="w-full min-w-[4900px] text-sm border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur">
|
||||
<tr className="border-b border-border"><th rowSpan={3} className="sticky left-0 z-20 bg-muted/95 text-left px-4 py-3 font-semibold text-foreground">Salarié</th><th rowSpan={3} className="text-left px-4 py-3 font-semibold text-foreground">Poste</th><th rowSpan={3} className="text-left px-4 py-3 font-semibold text-foreground">Embauche</th>{ANNEES.map((annee) => <th key={annee} colSpan={12} 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"><th colSpan={7} className="px-3 py-2 text-center text-emerald-700 border-l border-border">Prévisionnel</th><th colSpan={5} className="px-3 py-2 text-center text-sky-700 border-l border-border">Réel</th>{ANNEES.slice(1).flatMap((annee) => [<th key={`${annee}-p`} colSpan={7} className="px-3 py-2 text-center text-emerald-700 border-l border-border">Prévisionnel</th>, <th key={`${annee}-r`} colSpan={5} className="px-3 py-2 text-center text-sky-700 border-l border-border">Réel</th>])}</tr>
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wide text-muted-foreground">{ANNEES.flatMap((annee) => [
|
||||
<th key={`${annee}-f-base`} className="px-3 py-2 text-right font-medium border-l border-border">Annuel hors astreinte</th>,
|
||||
<th key={`${annee}-f-total`} className="px-3 py-2 text-right font-medium">Annuel avec primes</th>,
|
||||
<th key={`${annee}-f-monthly`} className="px-3 py-2 text-right font-medium">Mensuel hors astreinte</th>,
|
||||
<th key={`${annee}-f-astreinte`} className="px-3 py-2 text-right font-medium">Astreintes</th>,
|
||||
<th key={`${annee}-f-charges`} className="px-3 py-2 text-right font-medium">Taux charges</th>,
|
||||
<th key={`${annee}-f-evolution`} className="px-3 py-2 text-right font-medium">Évolution</th>,
|
||||
<th key={`${annee}-f-prime`} className="px-3 py-2 text-right font-medium">Prime</th>,
|
||||
<th key={`${annee}-a-base`} className="px-3 py-2 text-right font-medium border-l border-border">Cumul hors astreinte</th>,
|
||||
<th key={`${annee}-a-total`} className="px-3 py-2 text-right font-medium">Cumul avec primes</th>,
|
||||
<th key={`${annee}-a-monthly`} className="px-3 py-2 text-right font-medium">Moy. mensuelle</th>,
|
||||
<th key={`${annee}-a-astreinte`} className="px-3 py-2 text-right font-medium">Astreintes</th>,
|
||||
<th key={`${annee}-a-months`} className="px-3 py-2 text-right font-medium">Mois</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 forecast = forecastByKey.get(keyFor(salarie.id, annee));
|
||||
const actual = actualByKey.get(keyFor(salarie.id, annee));
|
||||
const editing = editingKey === keyFor(salarie.id, annee);
|
||||
return <Fragment key={keyFor(salarie.id, annee)}>
|
||||
<td className="px-3 py-3 text-right font-medium text-foreground border-l border-border">{formatEuros(forecast?.brutHorsAstreinteCents)}</td><td className="px-3 py-3 text-right font-medium text-foreground">{formatEuros(forecast?.brutAvecPrimesCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(forecast?.brutMensuelHorsAstreinteCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(forecast?.primeAstreinteCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatPercent(forecast?.tauxChargeBps)}</td>
|
||||
<td className="px-3 py-2 text-right">{editing ? <input aria-label={`Évolution ${annee} de ${salarie.nom}`} type="number" step="0.01" value={evolutionDraft} onChange={(event) => setEvolutionDraft(event.target.value)} className="w-24 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" disabled={saveAdjustments.isPending} /> : forecast?.editable ? <button onClick={() => startEdit(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">{formatEuros(forecast.evolutionCents)}</button> : <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><LockKeyhole className="w-3 h-3" />{formatEuros(forecast?.evolutionCents)}</span>}</td>
|
||||
<td className="px-3 py-2 text-right">{editing ? <div className="flex items-center justify-end gap-1"><input aria-label={`Prime ${annee} de ${salarie.nom}`} type="number" step="0.01" value={primeDraft} onChange={(event) => setPrimeDraft(event.target.value)} className="w-24 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" disabled={saveAdjustments.isPending} /><button onClick={() => saveEdit(salarie.id, annee)} className="text-[11px] font-semibold text-emerald-700 hover:text-emerald-900" disabled={saveAdjustments.isPending}>OK</button></div> : forecast?.editable ? <button onClick={() => startEdit(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">{formatEuros(forecast.primeCents)}</button> : <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><LockKeyhole className="w-3 h-3" />{formatEuros(forecast?.primeCents)}</span>}</td>
|
||||
<td className="px-3 py-3 text-right font-medium text-foreground border-l border-border">{formatEuros(actual?.brutHorsAstreinteCents)}</td><td className="px-3 py-3 text-right font-medium text-foreground">{formatEuros(actual?.brutAvecPrimesCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(actual?.brutMensuelHorsAstreinteCents)}</td><td className="px-3 py-3 text-right text-foreground">{formatEuros(actual?.primeAstreinteCents)}</td><td className="px-3 py-3 text-right text-muted-foreground">{formatMonths(actual?.moisDisponibles)}</td>
|
||||
</Fragment>;
|
||||
})}</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</MasseSalarialeDataState>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
client/src/pages/Salaires.test.tsx
Normal file
18
client/src/pages/Salaires.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PayrollSecurityNotice } from "./Salaires";
|
||||
|
||||
describe("PayrollSecurityNotice", () => {
|
||||
it("affiche un état neutre de vérification, et non une alerte, tant que la sonde de chiffrement charge", () => {
|
||||
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading ready={undefined} />);
|
||||
|
||||
expect(html).toContain("Vérification du chiffrement sécurisé…");
|
||||
expect(html).not.toContain("Import bloqué");
|
||||
});
|
||||
|
||||
it("n'affiche l'alerte de blocage que lorsque la sonde a répondu indisponible", () => {
|
||||
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading={false} ready={false} />);
|
||||
|
||||
expect(html).toContain("Import bloqué");
|
||||
});
|
||||
});
|
||||
229
client/src/pages/Salaires.tsx
Normal file
229
client/src/pages/Salaires.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Archive,
|
||||
Banknote,
|
||||
CalendarDays,
|
||||
CircleAlert,
|
||||
FileText,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { AppSidebar } from "../components/AppSidebar";
|
||||
import { trpc } from "../lib/trpc";
|
||||
|
||||
const MONTHS = [
|
||||
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||||
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
||||
];
|
||||
|
||||
function formatEuros(cents?: number | null) {
|
||||
if (cents === null || cents === undefined) return "—";
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatPercent(percent?: number | null) {
|
||||
if (percent === null || percent === undefined) return "—";
|
||||
return `${percent.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} %`;
|
||||
}
|
||||
|
||||
function extractionStatusLabel(status: string) {
|
||||
if (status === "ready") return { label: "Indexé", className: "bg-emerald-100 text-emerald-700" };
|
||||
if (status === "partiel") return { label: "Partiel", className: "bg-amber-100 text-amber-800" };
|
||||
return { label: "À contrôler", className: "bg-rose-100 text-rose-700" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Module administrateur : les données visibles sont l'index métier réduit des
|
||||
* bulletins. Les PDFs restent chiffrés dans le stockage persistant et ne sont
|
||||
* servis qu'après un nouveau contrôle serveur de la session.
|
||||
*/
|
||||
export default function Salaires() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const now = useMemo(() => new Date(), []);
|
||||
const [annee, setAnnee] = useState(now.getFullYear());
|
||||
const [mois, setMois] = useState(now.getMonth() + 1);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [reindexingId, setReindexingId] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
const filters = useMemo(() => ({ annee, mois }), [annee, mois]);
|
||||
const { data, isLoading, isError } = trpc.salaires.list.useQuery(filters);
|
||||
const { data: periods = [] } = trpc.salaires.periods.useQuery();
|
||||
const { data: security, isLoading: isSecurityLoading } = trpc.salaires.getSecurityStatus.useQuery();
|
||||
|
||||
const years = useMemo(() => {
|
||||
const options = new Set([now.getFullYear(), ...periods.map((period) => period.annee)]);
|
||||
return Array.from(options).sort((a, b) => b - a);
|
||||
}, [now, periods]);
|
||||
|
||||
const uploadLiasse = async () => {
|
||||
if (!selectedFile) {
|
||||
toast.error("Sélectionnez une liasse PDF avant l'import.");
|
||||
return;
|
||||
}
|
||||
if (selectedFile.type !== "application/pdf" && !selectedFile.name.toLowerCase().endsWith(".pdf")) {
|
||||
toast.error("Le fichier sélectionné doit être un PDF.");
|
||||
return;
|
||||
}
|
||||
if (security?.ready !== true) {
|
||||
toast.error("Le chiffrement sécurisé est indisponible : l'import est bloqué.");
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("annee", String(annee));
|
||||
form.append("mois", String(mois));
|
||||
form.append("file", selectedFile);
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const response = await fetch("/api/salaires/liasses", { method: "POST", body: form, credentials: "include" });
|
||||
const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string };
|
||||
if (!response.ok) throw new Error(payload.error ?? "L'import n'a pas pu être effectué.");
|
||||
toast.success(`Liasse archivée de façon chiffrée — ${payload.bulletins ?? 0} bulletin(s) indexé(s).`);
|
||||
if (payload.avertissement) toast.warning(payload.avertissement);
|
||||
setSelectedFile(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "L'import sécurisé a échoué.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reindexLiasse = async (liasseId: number) => {
|
||||
setReindexingId(liasseId);
|
||||
try {
|
||||
const response = await fetch(`/api/salaires/liasses/${liasseId}/reindex`, { method: "POST", credentials: "include" });
|
||||
const payload = await response.json().catch(() => ({})) as { bulletins?: number; avertissement?: string | null; error?: string };
|
||||
if (!response.ok) throw new Error(payload.error ?? "La réindexation a échoué.");
|
||||
toast.success(`Index de la liasse actualisé — ${payload.bulletins ?? 0} bulletin(s) reconnu(s).`);
|
||||
if (payload.avertissement) toast.warning(payload.avertissement);
|
||||
await Promise.all([utils.salaires.list.invalidate(), utils.salaires.periods.invalidate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "La réindexation a échoué.");
|
||||
} finally {
|
||||
setReindexingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hasExistingPeriod = periods.some((period) => period.annee === annee && period.mois === mois);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed((value) => !value)} />
|
||||
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<header className="bg-card border-b border-border px-6 py-4 shrink-0">
|
||||
<div className="flex flex-wrap 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 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" }}>Salaires — SANTINOVA</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Liasses mensuelles chiffrées, index métier et comparatif M-1</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="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">
|
||||
<PayrollSecurityNotice isLoading={isSecurityLoading} ready={security?.ready} />
|
||||
|
||||
<section className="bg-card border border-border rounded-xl shadow-sm p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground mr-2">
|
||||
<CalendarDays className="w-4 h-4 text-emerald-600" /> Période affichée
|
||||
</div>
|
||||
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
|
||||
Année
|
||||
<select value={annee} onChange={(event) => setAnnee(Number(event.target.value))} className="h-9 min-w-28 rounded-lg border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-emerald-300">
|
||||
{years.map((year) => <option key={year} value={year}>{year}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
|
||||
Mois
|
||||
<select value={mois} onChange={(event) => setMois(Number(event.target.value))} className="h-9 min-w-36 rounded-lg border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-emerald-300">
|
||||
{MONTHS.map((month, index) => <option key={month} value={index + 1}>{month}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">Les montants proviennent uniquement de la liasse importée.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center"><Upload className="w-4 h-4 text-emerald-600" /></div>
|
||||
<div><h2 className="font-semibold text-foreground">Archiver la liasse de {MONTHS[mois - 1]} {annee}</h2><p className="text-xs text-muted-foreground">Le PDF est chiffré avant envoi vers le stockage persistant hors conteneur.</p></div>
|
||||
</div>
|
||||
{hasExistingPeriod && <span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">Période déjà archivée</span>}
|
||||
</div>
|
||||
<div className="p-5 flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
<div className="flex-1 min-h-20 rounded-xl border border-dashed border-emerald-300 bg-emerald-50/50 px-4 flex items-center gap-3">
|
||||
<FileText className="w-6 h-6 text-emerald-600 shrink-0" />
|
||||
<span className="min-w-0 flex-1"><strong className="block text-sm text-foreground truncate">{selectedFile?.name ?? "Choisir la liasse PDF"}</strong><span className="text-xs text-muted-foreground">PDF uniquement, 25 Mo maximum</span><input ref={inputRef} type="file" accept="application/pdf,.pdf" aria-label="Choisir la liasse PDF" onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)} className="mt-1.5 block max-w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-emerald-600 file:px-2.5 file:py-1 file:text-xs file:font-semibold file:text-white hover:file:bg-emerald-700" /></span>
|
||||
</div>
|
||||
<button onClick={uploadLiasse} disabled={isUploading || isSecurityLoading || hasExistingPeriod || !selectedFile || security?.ready !== true} className="h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors active:scale-[0.97]">
|
||||
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LockKeyhole className="w-4 h-4" />} {isUploading ? "Archivage…" : "Importer et chiffrer"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<StatCard icon={Users} label="Salariés indexés" value={String(data?.indicateurs.nombreSalaries ?? 0)} accent="text-emerald-600" />
|
||||
<StatCard icon={Banknote} label="Brut total du mois" value={formatEuros(data?.indicateurs.totalBrutCents)} accent="text-emerald-600" />
|
||||
<StatCard icon={(data?.indicateurs.totalEcartCents ?? 0) < 0 ? TrendingDown : TrendingUp} label="Écart brut global M-1" value={formatEuros(data?.indicateurs.totalEcartCents)} accent={(data?.indicateurs.totalEcartCents ?? 0) < 0 ? "text-rose-600" : "text-emerald-600"} />
|
||||
</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">Salariés et évolution mensuelle</h2><p className="text-xs text-muted-foreground">Comparaison avec le même salarié de la liasse M-1 ; les variations sont explicitées à partir des rubriques extraites.</p></div>
|
||||
</div>
|
||||
{isLoading ? <div className="py-14 flex justify-center"><Loader2 className="w-5 h-5 text-emerald-600 animate-spin" /></div> : isError ? <DataNotice message="Les salaires de cette période ne peuvent pas être chargés." /> : data?.bulletins.length ? (
|
||||
<div className="overflow-auto"><table className="w-full min-w-[1250px] text-sm"><thead className="bg-muted/80 text-[10px] uppercase tracking-wide text-muted-foreground"><tr><th className="text-left px-4 py-3">Salarié</th><th className="text-left px-4 py-3">Poste</th><th className="text-right px-4 py-3">Base mensuelle</th><th className="text-right px-4 py-3">Brut courant</th><th className="text-right px-4 py-3">Brut M-1</th><th className="text-right px-4 py-3">Écart</th><th className="text-left px-4 py-3">Explication</th><th className="text-center px-4 py-3">PDF</th></tr></thead><tbody>{data.bulletins.map((bulletin) => <tr key={`${bulletin.liasseId}-${bulletin.matricule}`} className="border-t border-border/70 hover:bg-muted/30"><td className="px-4 py-3"><p className="font-semibold text-foreground">{bulletin.nom} {bulletin.prenom}</p><p className="font-mono text-[11px] text-muted-foreground">{bulletin.matricule}</p></td><td className="px-4 py-3 text-xs text-foreground">{bulletin.poste}</td><td className="px-4 py-3 text-right whitespace-nowrap">{formatEuros(bulletin.brutMensuelCents)}</td><td className="px-4 py-3 text-right font-semibold whitespace-nowrap">{formatEuros(bulletin.brutAvecPrimesCents)}</td><td className="px-4 py-3 text-right whitespace-nowrap">{formatEuros(bulletin.brutPrecedentCents)}</td><td className={`px-4 py-3 text-right font-semibold whitespace-nowrap ${(bulletin.ecartBrutCents ?? 0) < 0 ? "text-rose-700" : (bulletin.ecartBrutCents ?? 0) > 0 ? "text-emerald-700" : "text-muted-foreground"}`}>{formatEuros(bulletin.ecartBrutCents)}<span className="block text-[11px] font-normal">{formatPercent(bulletin.ecartBrutPourcentage)}</span></td><td className="px-4 py-3 max-w-80 text-xs text-muted-foreground">{bulletin.explication}</td><td className="px-4 py-3 text-center"><a href={`/api/salaires/liasses/${bulletin.liasseId}/pdf#page=${bulletin.numeroPage ?? 1}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 hover:text-emerald-900"><FileText className="w-3.5 h-3.5" />Voir</a></td></tr>)}</tbody></table></div>
|
||||
) : <DataNotice message={`Aucun bulletin n'est indexé pour ${MONTHS[mois - 1]} ${annee}.`} />}
|
||||
</section>
|
||||
|
||||
<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"><Archive className="w-4 h-4 text-emerald-600" /></div><div><h2 className="font-semibold text-foreground">Liasses archivées</h2><p className="text-xs text-muted-foreground">Chaque archive reste disponible après reconstruction ou changement de version de l'application.</p></div></div>
|
||||
{periods.length === 0 ? <DataNotice message="Aucune liasse de bulletins n'a encore été archivée." /> : <div className="divide-y divide-border">{periods.map((period) => { const status = extractionStatusLabel(period.statutExtraction); return <div key={period.id} className="px-5 py-3 flex flex-wrap gap-3 items-center"><div className="min-w-40"><p className="font-semibold text-sm text-foreground">{MONTHS[period.mois - 1]} {period.annee}</p><p className="text-xs text-muted-foreground truncate max-w-72">{period.nomFichier}</p></div><span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${status.className}`}>{status.label}</span><div className="flex-1 text-xs text-muted-foreground">{period.erreurExtraction ?? "Index complet."}</div>{period.statutExtraction !== "ready" && <button onClick={() => reindexLiasse(period.id)} disabled={reindexingId === period.id} className="inline-flex items-center gap-1 text-xs font-semibold text-amber-800 hover:text-amber-950 disabled:opacity-50"><Loader2 className={`w-3.5 h-3.5 ${reindexingId === period.id ? "animate-spin" : ""}`} />{reindexingId === period.id ? "Réindexation…" : "Réindexer"}</button>}<a href={`/api/salaires/liasses/${period.id}/pdf`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 hover:text-emerald-900"><FileText className="w-3.5 h-3.5" />Voir la liasse</a></div>; })}</div>}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, accent }: { icon: React.ElementType; label: string; value: string; accent: string }) {
|
||||
return <section className="bg-card border border-border rounded-xl shadow-sm p-4"><div className={`flex items-center gap-2 text-sm font-semibold ${accent}`}><Icon className="w-4 h-4" />{label}</div><p className="mt-3 text-2xl font-bold text-foreground">{value}</p></section>;
|
||||
}
|
||||
|
||||
function DataNotice({ message }: { message: string }) {
|
||||
return <div className="py-12 text-center text-sm text-muted-foreground"><CircleAlert className="w-5 h-5 mx-auto mb-2 text-muted-foreground/60" />{message}</div>;
|
||||
}
|
||||
|
||||
export function PayrollSecurityNotice({ isLoading, ready }: { isLoading: boolean; ready: boolean | undefined }) {
|
||||
if (isLoading) {
|
||||
return <section className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 flex gap-3 text-sm text-sky-900"><Loader2 className="w-4 h-4 mt-0.5 shrink-0 animate-spin text-sky-700" /><p><strong>Vérification du chiffrement sécurisé…</strong> L'import reste temporairement désactivé.</p></section>;
|
||||
}
|
||||
if (ready === true) return null;
|
||||
return <section className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 flex gap-3 text-sm text-rose-900"><CircleAlert className="w-4 h-4 mt-0.5 shrink-0 text-rose-600" /><p><strong>Import bloqué.</strong> Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.</p></section>;
|
||||
}
|
||||
@@ -4,31 +4,35 @@ services:
|
||||
container_name: itinova-budget-si-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si
|
||||
- JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027}
|
||||
- VITE_APP_ID=${VITE_APP_ID:-}
|
||||
- OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-}
|
||||
- VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-}
|
||||
- OWNER_OPEN_ID=${OWNER_OPEN_ID:-}
|
||||
- OWNER_NAME=${OWNER_NAME:-Administrateur}
|
||||
- BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-}
|
||||
- BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-}
|
||||
- VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-}
|
||||
- VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-}
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://budget_user:BudgetSI2027!@itinova-budget-si-db:3306/budget_si
|
||||
- JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027}
|
||||
- VITE_APP_ID=${VITE_APP_ID:-}
|
||||
- OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-}
|
||||
- VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-}
|
||||
- OWNER_OPEN_ID=${OWNER_OPEN_ID:-}
|
||||
- OWNER_NAME=${OWNER_NAME:-Administrateur}
|
||||
- BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-}
|
||||
- BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-}
|
||||
- VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-}
|
||||
- VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-}
|
||||
- VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-}
|
||||
- PAYROLL_ENCRYPTION_KEY=${PAYROLL_ENCRYPTION_KEY:-}
|
||||
- PAYROLL_STORAGE_DIR=/var/lib/itinova-budget-si/payroll
|
||||
volumes:
|
||||
- ${PAYROLL_STORAGE_HOST_DIR:-/opt/manus-deploy/data/itinova-budget-si/payroll}:/var/lib/itinova-budget-si/payroll
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- web
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.budget-si.rule=Host(`budget-si.recette.santinova-soft.org`)"
|
||||
- "traefik.http.routers.budget-si.entrypoints=websecure"
|
||||
- "traefik.http.routers.budget-si.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.budget-si.loadbalancer.server.port=3000"
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.budget-si.rule=Host(`${BUDGET_SI_DOMAIN:-budget-si.recette.santinova-soft.org}`)"
|
||||
- "traefik.http.routers.budget-si.entrypoints=websecure"
|
||||
- "traefik.http.routers.budget-si.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.budget-si.loadbalancer.server.port=3000"
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
@@ -40,14 +44,25 @@ services:
|
||||
MYSQL_USER: budget_user
|
||||
MYSQL_PASSWORD: BudgetSI2027!
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pBudgetSI2027!"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"mysqladmin",
|
||||
"ping",
|
||||
"-h",
|
||||
"localhost",
|
||||
"-u",
|
||||
"root",
|
||||
"-pBudgetSI2027!",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- web
|
||||
- web
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
|
||||
37
docs/masse-salariale-previsionnel-reel.md
Normal file
37
docs/masse-salariale-previsionnel-reel.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Masse salariale SANTINOVA — Prévisionnel et réel
|
||||
|
||||
## Source et périmètre
|
||||
|
||||
Le fichier `TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx`, fourni le 31 août 2026, sert de référence pour les prévisionnels 2025, 2026 et 2027. Il comporte une feuille **Masse salariale** et une feuille **Méthode**. Le détail reste exclusivement accessible aux administrateurs.
|
||||
|
||||
## Règles de restitution
|
||||
|
||||
| Année | Prévisionnel | Réel |
|
||||
|---|---|---|
|
||||
| 2025 | Valeurs du fichier | Mêmes valeurs que le prévisionnel, année clôturée |
|
||||
| 2026 | Valeurs du fichier | Cumul des seules liasses mensuelles archivées dans Salaires, sans extrapolation |
|
||||
| 2027 | Valeurs du fichier | Non disponible tant qu'aucune liasse mensuelle n'est importée |
|
||||
|
||||
Le brut hors astreinte correspond au brut avec primes diminué **uniquement** des primes d'astreinte. Les autres éléments inclus au brut, notamment les primes d'activité, restent dans le montant.
|
||||
|
||||
## Édition et calcul
|
||||
|
||||
Les valeurs de référence importées sont conservées. Pour l'année courante et les années futures, un administrateur peut modifier seulement les colonnes **Prime** et **Évolution** du prévisionnel. Les totaux prévisionnels sont recalculés à partir de la référence, en ajoutant ou retirant l'écart entre la valeur modifiée et la valeur initiale. Les exercices antérieurs sont verrouillés.
|
||||
|
||||
Le réel 2026 affiche le nombre de mois effectivement disponibles et ne transforme jamais un cumul partiel en prévision annuelle.
|
||||
|
||||
La vérification visuelle de développement a confirmé les trois années, les blocs prévisionnel-réel, le cumul réel 2026 limité aux deux liasses disponibles, et la présence des colonnes éditables uniquement sur les exercices autorisés. Les montants doivent néanmoins conserver leurs centimes à l'affichage afin d'éviter tout arrondi de restitution.
|
||||
|
||||
Après correction, l'interface restitue les centimes issus du fichier modèle. La table détaille, pour chacune des années 2025, 2026 et 2027, les colonnes prévisionnelles et réelles séparées. Les valeurs réelles non disponibles restent indiquées comme absentes plutôt que remplacées par une estimation.
|
||||
|
||||
La liasse de janvier 2026 a été archivée depuis la fenêtre Salaires, chiffrée avant stockage et indexée complètement. Elle alimente désormais le cumul réel 2026 sans réécriture d'une valeur prévisionnelle.
|
||||
|
||||
La liasse de février 2026 a également été archivée et indexée. Le comparatif M-1 de Salaires est disponible à partir de février ; les variations restent reliées aux rubriques extraites et non à une estimation annuelle.
|
||||
|
||||
La liasse de mars 2026 est archivée avec un statut partiel : les fiches non reconnues restent signalées et le PDF conservé, tandis que le cumul réel utilise uniquement les bulletins effectivement indexés.
|
||||
|
||||
La liasse d'avril 2026 est également archivée avec un statut partiel. Le réel annuel conserve donc explicitement sa couverture mensuelle et ne masque pas les extractions incomplètes.
|
||||
|
||||
Les contrôles automatisés finaux ont réussi : 72 tests couvrant notamment l'accès administrateur, les projections, l'import du modèle, la confidentialité des bulletins et les règles de persistance. Le contrôle TypeScript et le build de production ont également réussi.
|
||||
|
||||
Le téléversement administrateur du fichier modèle a été validé de bout en bout. Le serveur lit le classeur, remplace de manière atomique les seules prévisions 2025–2027 et renvoie uniquement un décompte ; les lignes salariales ne quittent pas l'environnement serveur dans la réponse d'import.
|
||||
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.
|
||||
67
docs/salaires-securite.md
Normal file
67
docs/salaires-securite.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Module Salaires — sécurité et conservation
|
||||
|
||||
Le module **Salaires** est réservé aux comptes de profil `admin`, à la fois dans la navigation, dans les routes clientes, dans les procédures métier et sur les deux routes binaires serveur. Les profils `standard` et `readonly` ne peuvent ni lister les données, ni importer, ni consulter une liasse.
|
||||
|
||||
Les fichiers PDF sont chiffrés côté serveur avec AES-256-GCM avant leur stockage. Lorsque Forge est configuré, l'archive chiffrée est envoyée au stockage persistant du projet. En recette, où Forge n'est pas disponible, elle est stockée dans un volume Docker monté depuis `/opt/manus-deploy/data/itinova-budget-si/payroll` sur l'hôte : ce volume n'est ni intégré à l'image, ni supprimé par la reconstruction du conteneur. La base de données contient seulement les métadonnées indispensables, les paramètres cryptographiques par fichier et l'index métier minimal : matricule, identité professionnelle, poste et montants bruts. Elle ne contient ni adresse, ni IBAN, ni numéro de sécurité sociale, ni net à payer.
|
||||
|
||||
La consultation récupère le contenu chiffré exclusivement côté serveur, le déchiffre après contrôle de la session administrateur et retourne le PDF avec les entêtes `Cache-Control: no-store` et `X-Content-Type-Options: nosniff`. Le navigateur ne reçoit aucune URL de stockage ni paramètre de chiffrement.
|
||||
|
||||
La page a été vérifiée dans une session administrateur le 29 août 2026 : la navigation SANTINOVA affiche l'entrée Salaires, le statut de chiffrement est opérationnel, les contrôles année/mois, la zone d'import PDF, les indicateurs, l'état vide et les archives sont rendus sans erreur visible.
|
||||
|
||||
La validation bout en bout utilise une liasse mensuelle fournie. Elle portera sur l'archivage du fichier chiffré, l'index métier restreint, le filtrage par période, l'explication M-1 et la réouverture après redémarrage du serveur.
|
||||
|
||||
Lors du premier import réel de mai 2026, l'archive chiffrée a été créée et est restée consultable, mais l'index a été signalé « à contrôler ». Le diagnostic a confirmé que l'extracteur PDF compactait la mise en page : 22 en-têtes de bulletin ont été détectés, mais les matricules étaient présentés sous la forme « Matricule : M 000… » et l'espace avant la civilité était réduit. Le parseur a été corrigé à partir de ces seules caractéristiques de structure, puis une réindexation sans remplacement du PDF a été ajoutée.
|
||||
|
||||
Après correction, l'analyse de la liasse réelle a reconnu 18 bulletins sur 22, sans qu'aucun contenu d'adresse, d'IBAN ou de numéro de sécurité sociale ne soit détecté dans le poste. L'archive doit donc rester au statut `partiel` et signaler quatre fiches à contrôler, plutôt que d'inventer leurs montants.
|
||||
|
||||
La réindexation de l'archive de mai 2026 a ensuite été exécutée dans une session administrateur sans nouveau téléversement ni remplacement du PDF. Elle a persisté l'index partiel de 18 bulletins et le statut d'avertissement adéquat.
|
||||
|
||||
Le filtre sur mai 2026 a été vérifié dans l'interface administrateur : la liste affiche les 18 salariés indexés avec le poste, la base mensuelle, le brut courant, l'absence justifiée de référence M-1 et le lien vers la liasse contrôlée. Les quatre bulletins non indexés demeurent explicitement signalés par le statut partiel.
|
||||
|
||||
Une seconde liasse réelle, pour juin 2026, a été archivée et indexée au même statut partiel. Le filtre de juin affiche les 18 enregistrements indexés et compare chaque brut à celui de mai : les écarts et leurs explications reposent sur la variation des seules astreintes et des autres éléments inclus dans le brut. Les variations non reconnues restent explicitement signalées, sans valeur inventée.
|
||||
|
||||
La consultation réelle de la liasse de juin a retourné un document dont la signature est `%PDF-`, avec `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Aucune URL de stockage n'a été renvoyée au navigateur.
|
||||
|
||||
Après un redémarrage complet du serveur de développement, les deux archives mensuelles et leurs statuts d'indexation étaient toujours présents dans l'interface. La relecture de la liasse de juin a de nouveau retourné un PDF valide, avec l'entête `no-store`. Cette vérification confirme que le fichier chiffré est lu depuis le stockage persistant extérieur au processus applicatif, et non depuis le système de fichiers d'un conteneur.
|
||||
|
||||
La CI Gitea de recette et le déploiement Blue-Green ont été validés pour le commit qui transmet explicitement la clé de paie au conteneur applicatif. Le déploiement sauvegarde la base avant reconstruction, met à jour le code depuis Gitea, reconstruit l'image sans cache, puis recrée l'application une fois la base saine.
|
||||
|
||||
Après ce déploiement, une vérification de recette a créé un marqueur de contrôle dans le volume de paie, recréé uniquement le conteneur applicatif, puis relu le marqueur avec la même empreinte SHA-256. Le marqueur a été supprimé immédiatement après le contrôle. Cette vérification confirme la persistance effective du volume à travers une recréation Docker, sans y déposer de donnée salariale de test.
|
||||
|
||||
Le contrôle final de recette a ensuite importé une liasse réelle de mai 2026. L'import a créé une archive chiffrée et un index de 18 bulletins au statut `partiel`, sans fabrication des quatre fiches non reconnues. La liste tRPC filtrée retourne les 18 entrées et n'expose ni clé de stockage, ni IV, ni tag d'authentification, ni empreinte. La route PDF a retourné un document de 79 752 octets commençant par `%PDF-`, avec les en-têtes `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Après une recréation Docker de l'application, la même archive était encore lisible, avec la même taille et la même signature PDF.
|
||||
|
||||
La vérification visuelle administrateur de recette a confirmé la présence de l'entrée **Salaires**, des filtres, de l'archive de mai et de son statut partiel. Une alerte frontale incohérente indiquant que le chiffrement était indisponible a toutefois été observée alors que la sonde serveur retournait bien `AES-256-GCM` opérationnel ; cette divergence doit être corrigée avant clôture.
|
||||
|
||||
Après le chargement des requêtes, le filtre de mai 2026 a affiché 18 bulletins indexés, un total brut mensuel et le statut partiel de la liasse, sans alerte de chiffrement. La divergence initiale correspond au rendu transitoire avant réception de la sonde de sécurité ; ce rendu doit être remplacé par un état de chargement neutre, jamais par une alerte de blocage.
|
||||
|
||||
Depuis la page authentifiée de recette, le bouton **Voir la liasse** a ouvert le visualiseur PDF du navigateur sur la route protégée de consultation. Le parcours interface utilise uniquement cette route applicative ; il ne présente aucune URL de stockage au navigateur.
|
||||
|
||||
Un diagnostic de recette a révélé qu'après une recréation Docker, l'alias réseau générique `db` pouvait résoudre vers une autre base présente sur le réseau partagé, provoquant un refus d'accès et empêchant temporairement toute connexion. La configuration utilise désormais le nom unique `itinova-budget-si-db`. Une sonde de régression vérifie ce point avant chaque déploiement.
|
||||
|
||||
La CI Gitea `validate.yml` associée au correctif de résolution MySQL a terminé avec succès. Le contrôle de déploiement final reste centré sur la reconnexion administrateur et la page Salaires en HTTPS après le redémarrage applicatif.
|
||||
|
||||
Après le déploiement du correctif, la connexion avec le compte administrateur de recette a de nouveau abouti et l'interface générale s'affiche sans erreur de base de données.
|
||||
|
||||
La validation finale en HTTPS a confirmé le menu **Santinova > Salaires**, l'absence de fausse alerte de chiffrement, l'affichage de la liasse archivée et l'ouverture de cette liasse exclusivement via le bouton de consultation protégé. Aucun lien de stockage n'est exposé dans l'interface.
|
||||
|
||||
Le portail de recette et le dashboard ont été synchronisés à partir du manifeste applicatif. La carte **Gestion budget informatique** est présente dans l'onglet Santinova du portail, utilise son visuel dédié et ouvre l'URL HTTPS de recette.
|
||||
|
||||
La promotion vers la production utilise le domaine HTTPS `budget-si.santinova-soft.org`, un secret de session et une clé de chiffrement distincts de la recette, ainsi qu'un volume persistant de paie dédié. La page de connexion de production a répondu après le déploiement initial.
|
||||
|
||||
Le dashboard de production a découvert Budget SI et le signale en ligne. Le portail de production présente désormais la carte **Gestion budget informatique** dans l'onglet Santinova, avec son visuel dédié et le lien HTTPS de production. La promotion a été réalisée depuis le dépôt Gitea production par le flux Santinova de déploiement sécurisé ; la CI Gitea reste validée sur la recette avant promotion.
|
||||
|
||||
La validation de connexion administrateur de production a mis en évidence une initialisation de schéma incomplète : la table des utilisateurs n'est pas encore disponible. Cette correction est requise avant de déclarer la production utilisable.
|
||||
|
||||
Le serveur de production a été redéployé depuis le dépôt Gitea après ajout du bootstrap de migrations et du compte administrateur initial. La page HTTPS de connexion est de nouveau accessible ; la validation authentifiée suit.
|
||||
|
||||
La connexion avec le compte administrateur initial a abouti après le redéploiement. Le menu d'administration et les modules Budget SI sont accessibles en production sur une base désormais initialisée.
|
||||
|
||||
Le parcours administrateur de production permet d'ouvrir **Santinova > Salaires**. La page présente les filtres mensuels, l'import PDF limité et les zones d'archives ; l'état du chiffrement est encore en vérification au premier affichage et fait l'objet du contrôle final.
|
||||
|
||||
La page HTTPS de connexion de recette est accessible après la synchronisation du bootstrap ; la validation administrateur de recette reste en cours.
|
||||
|
||||
La tentative de connexion administrateur de recette est restée en attente après le redéploiement. Le diagnostic de cette divergence, alors que la production est fonctionnelle, est en cours avant clôture.
|
||||
|
||||
Après redémarrage contrôlé du portail et du dashboard, puis relance du déploiement une fois la CI de recette terminée avec succès, la connexion administrateur de recette est de nouveau opérationnelle.
|
||||
|
||||
La validation finale de recette confirme l'accès administrateur à **Santinova > Salaires**, le statut de chiffrement opérationnel, l'archive mensuelle existante, son statut partiel explicite, ainsi que les commandes de réindexation et de consultation protégée.
|
||||
3
drizzle/0004_known_shiva.sql
Normal file
3
drizzle/0004_known_shiva.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `capex_lignes` ADD CONSTRAINT `capex_lignes_annee_etab_cle_idx` UNIQUE(`annee`,`etablissementCode`,`cle`);--> statement-breakpoint
|
||||
ALTER TABLE `opex_montants_etab` ADD CONSTRAINT `opex_montants_annee_etab_poste_idx` UNIQUE(`annee`,`etablissementCode`,`libellePoste`);--> statement-breakpoint
|
||||
ALTER TABLE `opex_postes` ADD CONSTRAINT `opex_postes_annee_libelle_idx` UNIQUE(`annee`,`libelle`);
|
||||
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`;
|
||||
36
drizzle/0008_living_silver_surfer.sql
Normal file
36
drizzle/0008_living_silver_surfer.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE `salaires_bulletins` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`liasseId` int NOT NULL,
|
||||
`matricule` varchar(32) NOT NULL,
|
||||
`nom` varchar(150) NOT NULL,
|
||||
`prenom` varchar(150) NOT NULL,
|
||||
`poste` varchar(255) NOT NULL,
|
||||
`brutMensuelCents` int NOT NULL,
|
||||
`brutAvecPrimesCents` int NOT NULL,
|
||||
`primeAstreinteCents` int NOT NULL DEFAULT 0,
|
||||
`explicationEcart` text,
|
||||
`numeroPage` int,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `salaires_bulletins_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `salaires_bulletins_liasse_matricule_idx` UNIQUE(`liasseId`,`matricule`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `salaires_liasses` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`annee` int NOT NULL,
|
||||
`mois` int NOT NULL,
|
||||
`nomFichier` varchar(255) NOT NULL,
|
||||
`stockageKey` varchar(512) NOT NULL,
|
||||
`empreinteSha256` varchar(64) NOT NULL,
|
||||
`tailleOctets` int NOT NULL,
|
||||
`ivBase64` varchar(32) NOT NULL,
|
||||
`authTagBase64` varchar(32) NOT NULL,
|
||||
`versionCle` int NOT NULL DEFAULT 1,
|
||||
`importePar` int NOT NULL,
|
||||
`importeLe` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `salaires_liasses_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `salaires_liasses_stockageKey_unique` UNIQUE(`stockageKey`),
|
||||
CONSTRAINT `salaires_liasses_empreinteSha256_unique` UNIQUE(`empreinteSha256`),
|
||||
CONSTRAINT `salaires_liasses_periode_empreinte_idx` UNIQUE(`annee`,`mois`,`empreinteSha256`)
|
||||
);
|
||||
1
drizzle/0009_white_stardust.sql
Normal file
1
drizzle/0009_white_stardust.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `salaires_liasses` ADD CONSTRAINT `salaires_liasses_periode_idx` UNIQUE(`annee`,`mois`);
|
||||
2
drizzle/0010_brainy_oracle.sql
Normal file
2
drizzle/0010_brainy_oracle.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `salaires_liasses` ADD `statutExtraction` varchar(32) DEFAULT 'ready' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `salaires_liasses` ADD `erreurExtraction` text;
|
||||
9
drizzle/0011_dashing_grey_gargoyle.sql
Normal file
9
drizzle/0011_dashing_grey_gargoyle.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE `masse_salariale_remunerations` DROP INDEX `masse_salariale_salarie_annee_idx`;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` MODIFY COLUMN `tauxChargeBps` int;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `nature` enum('previsionnel') DEFAULT 'previsionnel' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `primeAstreinteCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `primeCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `evolutionCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `primeReferenceCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD `evolutionReferenceCents` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `masse_salariale_remunerations` ADD CONSTRAINT `masse_salariale_salarie_annee_nature_idx` UNIQUE(`salarieId`,`annee`,`nature`);
|
||||
918
drizzle/meta/0004_snapshot.json
Normal file
918
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,918 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "fcb67a8c-d69f-4eab-b81e-e3256730a27b",
|
||||
"prevId": "44abeed3-e7bf-47cd-a29c-59a27bf57f1a",
|
||||
"tables": {
|
||||
"capex_lignes": {
|
||||
"name": "capex_lignes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"etablissementCode": {
|
||||
"name": "etablissementCode",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cle": {
|
||||
"name": "cle",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"montant": {
|
||||
"name": "montant",
|
||||
"type": "decimal(12,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"capex_lignes_annee_etab_cle_idx": {
|
||||
"name": "capex_lignes_annee_etab_cle_idx",
|
||||
"columns": [
|
||||
"annee",
|
||||
"etablissementCode",
|
||||
"cle"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"capex_lignes_id": {
|
||||
"name": "capex_lignes_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"etablissements": {
|
||||
"name": "etablissements",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"nom": {
|
||||
"name": "nom",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"groupe": {
|
||||
"name": "groupe",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ville": {
|
||||
"name": "ville",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"actif": {
|
||||
"name": "actif",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"etablissements_id": {
|
||||
"name": "etablissements_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"etablissements_code_unique": {
|
||||
"name": "etablissements_code_unique",
|
||||
"columns": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"inventaire_meta": {
|
||||
"name": "inventaire_meta",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dateImport": {
|
||||
"name": "dateImport",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"nbEtablissements": {
|
||||
"name": "nbEtablissements",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"nbFixes": {
|
||||
"name": "nbFixes",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"nbPortables": {
|
||||
"name": "nbPortables",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"inventaire_meta_id": {
|
||||
"name": "inventaire_meta_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"inventaire_meta_annee_unique": {
|
||||
"name": "inventaire_meta_annee_unique",
|
||||
"columns": [
|
||||
"annee"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"inventaire_postes": {
|
||||
"name": "inventaire_postes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"etablissementCode": {
|
||||
"name": "etablissementCode",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"libelle": {
|
||||
"name": "libelle",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"typePoste": {
|
||||
"name": "typePoste",
|
||||
"type": "enum('fixe','portable')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dateRef": {
|
||||
"name": "dateRef",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ageAns": {
|
||||
"name": "ageAns",
|
||||
"type": "decimal(5,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"modele": {
|
||||
"name": "modele",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fabricant": {
|
||||
"name": "fabricant",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"inventaire_postes_id": {
|
||||
"name": "inventaire_postes_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"opex_bases_repartition": {
|
||||
"name": "opex_bases_repartition",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"etablissementCode": {
|
||||
"name": "etablissementCode",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"etablissementNom": {
|
||||
"name": "etablissementNom",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"baseRepartition": {
|
||||
"name": "baseRepartition",
|
||||
"type": "decimal(20,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"baseRepartitionHep": {
|
||||
"name": "baseRepartitionHep",
|
||||
"type": "decimal(20,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'0'"
|
||||
},
|
||||
"modeManuel": {
|
||||
"name": "modeManuel",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"opex_bases_repartition_annee_etab_idx": {
|
||||
"name": "opex_bases_repartition_annee_etab_idx",
|
||||
"columns": [
|
||||
"annee",
|
||||
"etablissementCode"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"opex_bases_repartition_id": {
|
||||
"name": "opex_bases_repartition_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"opex_montants_etab": {
|
||||
"name": "opex_montants_etab",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"etablissementCode": {
|
||||
"name": "etablissementCode",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"libellePoste": {
|
||||
"name": "libellePoste",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"montant": {
|
||||
"name": "montant",
|
||||
"type": "decimal(12,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"opex_montants_annee_etab_poste_idx": {
|
||||
"name": "opex_montants_annee_etab_poste_idx",
|
||||
"columns": [
|
||||
"annee",
|
||||
"etablissementCode",
|
||||
"libellePoste"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"opex_montants_etab_id": {
|
||||
"name": "opex_montants_etab_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"opex_postes": {
|
||||
"name": "opex_postes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"colIdx": {
|
||||
"name": "colIdx",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"libelle": {
|
||||
"name": "libelle",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"libelleCourt": {
|
||||
"name": "libelleCourt",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"libelleDetail": {
|
||||
"name": "libelleDetail",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fournisseur": {
|
||||
"name": "fournisseur",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categorie": {
|
||||
"name": "categorie",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"facturation": {
|
||||
"name": "facturation",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"modeVentilation": {
|
||||
"name": "modeVentilation",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'Prorata C 6'"
|
||||
},
|
||||
"compte": {
|
||||
"name": "compte",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detail": {
|
||||
"name": "detail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"budgetN1": {
|
||||
"name": "budgetN1",
|
||||
"type": "decimal(12,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"montant": {
|
||||
"name": "montant",
|
||||
"type": "decimal(12,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isCustom": {
|
||||
"name": "isCustom",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"opex_postes_annee_libelle_idx": {
|
||||
"name": "opex_postes_annee_libelle_idx",
|
||||
"columns": [
|
||||
"annee",
|
||||
"libelle"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"opex_postes_id": {
|
||||
"name": "opex_postes_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"opex_validated": {
|
||||
"name": "opex_validated",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"validatedAt": {
|
||||
"name": "validatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"validatedBy": {
|
||||
"name": "validatedBy",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"opex_validated_id": {
|
||||
"name": "opex_validated_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"opex_validated_annee_unique": {
|
||||
"name": "opex_validated_annee_unique",
|
||||
"columns": [
|
||||
"annee"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"parametres_app": {
|
||||
"name": "parametres_app",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"cle": {
|
||||
"name": "cle",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"valeur": {
|
||||
"name": "valeur",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"parametres_app_id": {
|
||||
"name": "parametres_app_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"parametres_app_cle_unique": {
|
||||
"name": "parametres_app_cle_unique",
|
||||
"columns": [
|
||||
"cle"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"user_etablissements": {
|
||||
"name": "user_etablissements",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"etablissementCode": {
|
||||
"name": "etablissementCode",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"user_etablissements_id": {
|
||||
"name": "user_etablissements_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"login": {
|
||||
"name": "login",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"passwordHash": {
|
||||
"name": "passwordHash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"firstName": {
|
||||
"name": "firstName",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lastName": {
|
||||
"name": "lastName",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('admin','standard','readonly')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'standard'"
|
||||
},
|
||||
"isActive": {
|
||||
"name": "isActive",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_login_unique": {
|
||||
"name": "users_login_unique",
|
||||
"columns": [
|
||||
"login"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
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
1449
drizzle/meta/0008_snapshot.json
Normal file
1449
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1457
drizzle/meta/0009_snapshot.json
Normal file
1457
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1472
drizzle/meta/0010_snapshot.json
Normal file
1472
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1521
drizzle/meta/0011_snapshot.json
Normal file
1521
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,62 @@
|
||||
"when": 1781163262493,
|
||||
"tag": "0003_tense_havok",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1788040817404,
|
||||
"tag": "0008_living_silver_surfer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1788041511204,
|
||||
"tag": "0009_white_stardust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1788041740554,
|
||||
"tag": "0010_brainy_oracle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1788172234921,
|
||||
"tag": "0011_dashing_grey_gargoyle",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -87,21 +87,31 @@ export const opexPostes = mysqlTable("opex_postes", {
|
||||
isCustom: boolean("isCustom").default(false),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
}, (t) => ({
|
||||
/** Empêche une initialisation répétée de créer le même poste pour une année. */
|
||||
uniqAnneeLibelle: uniqueIndex("opex_postes_annee_libelle_idx").on(t.annee, t.libelle),
|
||||
}));
|
||||
|
||||
export type OpexPoste = typeof opexPostes.$inferSelect;
|
||||
export type InsertOpexPoste = typeof opexPostes.$inferInsert;
|
||||
|
||||
// Overrides manuels par établissement × poste × année
|
||||
export const opexMontantsEtab = mysqlTable("opex_montants_etab", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
annee: int("annee").notNull(),
|
||||
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
|
||||
libellePoste: varchar("libellePoste", { length: 255 }).notNull(),
|
||||
montant: decimal("montant", { precision: 12, scale: 2 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
annee: int("annee").notNull(),
|
||||
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
|
||||
libellePoste: varchar("libellePoste", { length: 255 }).notNull(),
|
||||
montant: decimal("montant", { precision: 12, scale: 2 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (t) => ({
|
||||
/** Une seule surcharge manuelle par année, établissement et poste. */
|
||||
uniqAnneeEtabPoste: uniqueIndex("opex_montants_annee_etab_poste_idx").on(
|
||||
t.annee,
|
||||
t.etablissementCode,
|
||||
t.libellePoste,
|
||||
),
|
||||
}));
|
||||
|
||||
export type OpexMontantEtab = typeof opexMontantsEtab.$inferSelect;
|
||||
export type InsertOpexMontantEtab = typeof opexMontantsEtab.$inferInsert;
|
||||
@@ -148,16 +158,23 @@ export const inventaireMeta = mysqlTable("inventaire_meta", {
|
||||
// CAPEX — Lignes budgétaires par établissement
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const capexLignes = mysqlTable("capex_lignes", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
annee: int("annee").notNull(),
|
||||
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
|
||||
cle: varchar("cle", { length: 100 }).notNull(),
|
||||
// cles possibles: renouvellement_2027 | machines_supplementaires | appel_malade
|
||||
// | telephonie | wifi | video_surveillance | copieurs | visio | autres | commentaires
|
||||
montant: decimal("montant", { precision: 12, scale: 2 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
annee: int("annee").notNull(),
|
||||
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
|
||||
cle: varchar("cle", { length: 100 }).notNull(),
|
||||
// cles possibles: renouvellement_2027 | machines_supplementaires | appel_malade
|
||||
// | telephonie | wifi | video_surveillance | copieurs | visio | autres | commentaires
|
||||
montant: decimal("montant", { precision: 12, scale: 2 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (t) => ({
|
||||
/** La sauvegarde CAPEX reste idempotente pour chaque clé métier. */
|
||||
uniqAnneeEtabCle: uniqueIndex("capex_lignes_annee_etab_cle_idx").on(
|
||||
t.annee,
|
||||
t.etablissementCode,
|
||||
t.cle,
|
||||
),
|
||||
}));
|
||||
|
||||
export type CapexLigne = typeof capexLignes.$inferSelect;
|
||||
export type InsertCapexLigne = typeof capexLignes.$inferInsert;
|
||||
@@ -184,3 +201,137 @@ 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(),
|
||||
/** Les lignes stockées constituent le prévisionnel ; le réel est dérivé des liasses mensuelles. */
|
||||
nature: mysqlEnum("nature", ["previsionnel"]).notNull().default("previsionnel"),
|
||||
salaireAnnuelBrutHorsPrimesCents: int("salaireAnnuelBrutHorsPrimesCents").notNull(),
|
||||
salaireAnnuelBrutAvecPrimesCents: int("salaireAnnuelBrutAvecPrimesCents").notNull(),
|
||||
salaireMensuelBrutHorsPrimesCents: int("salaireMensuelBrutHorsPrimesCents").notNull(),
|
||||
tauxChargeBps: int("tauxChargeBps"),
|
||||
/** Seules ces deux composantes de prévision sont éditables pour l'exercice courant ou futur. */
|
||||
primeAstreinteCents: int("primeAstreinteCents").notNull().default(0),
|
||||
primeCents: int("primeCents").notNull().default(0),
|
||||
evolutionCents: int("evolutionCents").notNull().default(0),
|
||||
/** Valeurs à l'import, utilisées pour recalculer le prévisionnel par différence lors d'une édition. */
|
||||
primeReferenceCents: int("primeReferenceCents").notNull().default(0),
|
||||
evolutionReferenceCents: int("evolutionReferenceCents").notNull().default(0),
|
||||
/** 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é, exercice et nature de donnée. */
|
||||
uniqSalarieAnneeNature: uniqueIndex("masse_salariale_salarie_annee_nature_idx").on(t.salarieId, t.annee, t.nature),
|
||||
}));
|
||||
|
||||
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;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SALAIRES MENSUELS — Liasses PDF chiffrées et données extraites
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une liasse représente un dépôt mensuel. Le fichier est chiffré avant envoi
|
||||
* vers le stockage persistant ; la base ne conserve ni octet de PDF ni clé.
|
||||
*/
|
||||
export const salairesLiasses = mysqlTable("salaires_liasses", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
annee: int("annee").notNull(),
|
||||
mois: int("mois").notNull(),
|
||||
nomFichier: varchar("nomFichier", { length: 255 }).notNull(),
|
||||
stockageKey: varchar("stockageKey", { length: 512 }).notNull().unique(),
|
||||
empreinteSha256: varchar("empreinteSha256", { length: 64 }).notNull().unique(),
|
||||
tailleOctets: int("tailleOctets").notNull(),
|
||||
/** Paramètres AES-256-GCM nécessaires au déchiffrement côté serveur. */
|
||||
ivBase64: varchar("ivBase64", { length: 32 }).notNull(),
|
||||
authTagBase64: varchar("authTagBase64", { length: 32 }).notNull(),
|
||||
versionCle: int("versionCle").notNull().default(1),
|
||||
/** `ready`, `partiel` ou `a_controler` : une liasse est conservée même si l'extraction est incomplète. */
|
||||
statutExtraction: varchar("statutExtraction", { length: 32 }).notNull().default("ready"),
|
||||
erreurExtraction: text("erreurExtraction"),
|
||||
importePar: int("importePar").notNull(),
|
||||
importeLe: timestamp("importeLe").defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
/** Une liasse unique par mois : toutes les fiches mensuelles restent ainsi cohérentes. */
|
||||
uniqPeriode: uniqueIndex("salaires_liasses_periode_idx").on(t.annee, t.mois),
|
||||
/** Empêche de traiter deux fois le même fichier sous une période distincte. */
|
||||
uniqPeriodeEmpreinte: uniqueIndex("salaires_liasses_periode_empreinte_idx").on(t.annee, t.mois, t.empreinteSha256),
|
||||
}));
|
||||
|
||||
export type SalaireLiasse = typeof salairesLiasses.$inferSelect;
|
||||
export type InsertSalaireLiasse = typeof salairesLiasses.$inferInsert;
|
||||
|
||||
/**
|
||||
* Données utiles par bulletin. `explicationEcart` conserve uniquement les
|
||||
* rubriques de rémunération nécessaires à expliquer une variation mensuelle.
|
||||
*/
|
||||
export const salairesBulletins = mysqlTable("salaires_bulletins", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
liasseId: int("liasseId").notNull(),
|
||||
matricule: varchar("matricule", { length: 32 }).notNull(),
|
||||
nom: varchar("nom", { length: 150 }).notNull(),
|
||||
prenom: varchar("prenom", { length: 150 }).notNull(),
|
||||
poste: varchar("poste", { length: 255 }).notNull(),
|
||||
brutMensuelCents: int("brutMensuelCents").notNull(),
|
||||
brutAvecPrimesCents: int("brutAvecPrimesCents").notNull(),
|
||||
primeAstreinteCents: int("primeAstreinteCents").notNull().default(0),
|
||||
explicationEcart: text("explicationEcart"),
|
||||
numeroPage: int("numeroPage"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (t) => ({
|
||||
/** Un seul bulletin indexé par matricule dans une liasse donnée. */
|
||||
uniqLiasseMatricule: uniqueIndex("salaires_bulletins_liasse_matricule_idx").on(t.liasseId, t.matricule),
|
||||
}));
|
||||
|
||||
export type SalaireBulletin = typeof salairesBulletins.$inferSelect;
|
||||
export type InsertSalaireBulletin = typeof salairesBulletins.$inferInsert;
|
||||
|
||||
10
package.json
10
package.json
@@ -10,7 +10,8 @@
|
||||
"check": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"db:push": "drizzle-kit generate && drizzle-kit migrate"
|
||||
"db:push": "drizzle-kit generate && drizzle-kit migrate",
|
||||
"verify": "pnpm check && pnpm test && pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.693.0",
|
||||
@@ -61,9 +62,11 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"jose": "6.1.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
"multer": "2.0.2",
|
||||
"mysql2": "^3.15.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-parse": "1.1.1",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.1",
|
||||
@@ -87,9 +90,11 @@
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "^24.7.0",
|
||||
"@types/react": "^19.2.1",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"add": "^2.0.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -98,6 +103,7 @@
|
||||
"pnpm": "^10.15.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.6.2",
|
||||
"supertest": "7.1.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.19.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -115,4 +121,4 @@
|
||||
"tailwindcss>nanoid": "3.3.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
312
pnpm-lock.yaml
generated
312
pnpm-lock.yaml
generated
@@ -160,6 +160,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.453.0
|
||||
version: 0.453.0(react@19.2.1)
|
||||
multer:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
mysql2:
|
||||
specifier: ^3.15.0
|
||||
version: 3.22.5(@types/node@24.7.0)
|
||||
@@ -169,6 +172,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
pdf-parse:
|
||||
specifier: 1.1.1
|
||||
version: 1.1.1
|
||||
react:
|
||||
specifier: ^19.2.1
|
||||
version: 19.2.1
|
||||
@@ -233,6 +239,9 @@ importers:
|
||||
'@types/google.maps':
|
||||
specifier: ^3.58.1
|
||||
version: 3.58.1
|
||||
'@types/multer':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^24.7.0
|
||||
version: 24.7.0
|
||||
@@ -242,6 +251,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.1
|
||||
version: 19.2.1(@types/react@19.2.1)
|
||||
'@types/supertest':
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
||||
@@ -266,6 +278,9 @@ importers:
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2
|
||||
supertest:
|
||||
specifier: 7.1.4
|
||||
version: 7.1.4
|
||||
tailwindcss:
|
||||
specifier: ^4.1.14
|
||||
version: 4.1.14
|
||||
@@ -1164,9 +1179,16 @@ packages:
|
||||
'@mermaid-js/parser@0.6.3':
|
||||
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodable/entities@2.1.1':
|
||||
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -2099,6 +2121,9 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -2225,12 +2250,18 @@ packages:
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/multer@2.0.0':
|
||||
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
|
||||
|
||||
'@types/node@24.7.0':
|
||||
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
|
||||
|
||||
@@ -2257,6 +2288,12 @@ packages:
|
||||
'@types/serve-static@1.15.9':
|
||||
resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==}
|
||||
|
||||
'@types/superagent@8.1.11':
|
||||
resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==}
|
||||
|
||||
'@types/supertest@6.0.3':
|
||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -2323,6 +2360,9 @@ packages:
|
||||
anynum@1.0.0:
|
||||
resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==}
|
||||
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2330,6 +2370,9 @@ packages:
|
||||
array-flatten@1.1.1:
|
||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2377,6 +2420,10 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2467,6 +2514,13 @@ packages:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
@@ -2495,6 +2549,9 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookiejar@2.1.4:
|
||||
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
@@ -2691,6 +2748,14 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2743,6 +2808,9 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
@@ -2972,6 +3040,9 @@ packages:
|
||||
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-xml-builder@1.2.0:
|
||||
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
|
||||
|
||||
@@ -3005,6 +3076,14 @@ packages:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formidable@3.5.4:
|
||||
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3090,6 +3169,10 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-from-dom@5.0.1:
|
||||
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
|
||||
|
||||
@@ -3518,6 +3601,14 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@7.1.2:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -3529,6 +3620,10 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
|
||||
@@ -3547,6 +3642,10 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
multer@2.0.2:
|
||||
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
||||
mysql2@3.22.5:
|
||||
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
|
||||
engines: {node: '>= 8.0'}
|
||||
@@ -3577,6 +3676,9 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
node-ensure@0.0.0:
|
||||
resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==}
|
||||
|
||||
node-releases@2.0.23:
|
||||
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
|
||||
|
||||
@@ -3596,6 +3698,9 @@ packages:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
oniguruma-parser@0.12.1:
|
||||
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
||||
|
||||
@@ -3635,6 +3740,10 @@ packages:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
pdf-parse@1.1.1:
|
||||
resolution: {integrity: sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==}
|
||||
engines: {node: '>=6.8.1'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3695,6 +3804,10 @@ packages:
|
||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
qs@6.15.3:
|
||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -3791,6 +3904,10 @@ packages:
|
||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||
|
||||
@@ -3886,6 +4003,10 @@ packages:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3898,6 +4019,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.1:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -3944,6 +4069,13 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
||||
streamsearch@1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
@@ -3959,10 +4091,18 @@ packages:
|
||||
stylis@4.3.6:
|
||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
||||
|
||||
superagent@10.3.0:
|
||||
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
superjson@1.13.3:
|
||||
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
supertest@7.1.4:
|
||||
resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
@@ -4044,6 +4184,9 @@ packages:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -4292,6 +4435,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -4301,6 +4447,10 @@ packages:
|
||||
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -5077,8 +5227,14 @@ snapshots:
|
||||
dependencies:
|
||||
langium: 3.3.1
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodable/entities@2.1.1': {}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -6017,6 +6173,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.7.0
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
@@ -6174,10 +6332,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/multer@2.0.0':
|
||||
dependencies:
|
||||
'@types/express': 4.17.21
|
||||
|
||||
'@types/node@24.7.0':
|
||||
dependencies:
|
||||
undici-types: 7.14.0
|
||||
@@ -6209,6 +6373,18 @@ snapshots:
|
||||
'@types/node': 24.7.0
|
||||
'@types/send': 0.17.5
|
||||
|
||||
'@types/superagent@8.1.11':
|
||||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 24.7.0
|
||||
form-data: 4.0.4
|
||||
|
||||
'@types/supertest@6.0.3':
|
||||
dependencies:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.11
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
@@ -6283,12 +6459,16 @@ snapshots:
|
||||
|
||||
anynum@1.0.0: {}
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
array-flatten@1.1.1: {}
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -6348,6 +6528,10 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
busboy@1.6.0:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
@@ -6435,6 +6619,15 @@ snapshots:
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
confbox@0.2.2: {}
|
||||
@@ -6453,6 +6646,8 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cookiejar@2.1.4: {}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
@@ -6665,6 +6860,10 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -6699,6 +6898,11 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dezalgo@1.0.4:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
@@ -6942,6 +7146,8 @@ snapshots:
|
||||
|
||||
fast-equals@5.3.2: {}
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-xml-builder@1.2.0:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.5.0
|
||||
@@ -6980,6 +7186,20 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
formidable@3.5.4:
|
||||
dependencies:
|
||||
'@paralleldrive/cuid2': 2.3.1
|
||||
dezalgo: 1.0.4
|
||||
once: 1.4.0
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
@@ -7050,6 +7270,10 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-from-dom@5.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -7750,6 +7974,10 @@ snapshots:
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
minizlib@3.1.0:
|
||||
@@ -7758,6 +7986,10 @@ snapshots:
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
mlly@1.8.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@@ -7777,6 +8009,16 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
multer@2.0.2:
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
busboy: 1.6.0
|
||||
concat-stream: 2.0.0
|
||||
mkdirp: 0.5.6
|
||||
object-assign: 4.1.1
|
||||
type-is: 1.6.18
|
||||
xtend: 4.0.2
|
||||
|
||||
mysql2@3.22.5(@types/node@24.7.0):
|
||||
dependencies:
|
||||
'@types/node': 24.7.0
|
||||
@@ -7804,6 +8046,8 @@ snapshots:
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
|
||||
node-ensure@0.0.0: {}
|
||||
|
||||
node-releases@2.0.23: {}
|
||||
|
||||
normalize-range@0.1.2: {}
|
||||
@@ -7816,6 +8060,10 @@ snapshots:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
oniguruma-parser@0.12.1: {}
|
||||
|
||||
oniguruma-to-es@4.3.3:
|
||||
@@ -7854,6 +8102,13 @@ snapshots:
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
pdf-parse@1.1.1:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
node-ensure: 0.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@@ -7915,6 +8170,11 @@ snapshots:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
qs@6.15.3:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
@@ -8017,6 +8277,12 @@ snapshots:
|
||||
|
||||
react@19.2.1: {}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
dependencies:
|
||||
decimal.js-light: 2.5.1
|
||||
@@ -8201,6 +8467,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -8224,6 +8495,14 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
side-channel@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
@@ -8274,6 +8553,12 @@ snapshots:
|
||||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
@@ -8293,10 +8578,31 @@ snapshots:
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
superagent@10.3.0:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.6
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.15.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
superjson@1.13.3:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
|
||||
supertest@7.1.4:
|
||||
dependencies:
|
||||
methods: 1.1.2
|
||||
superagent: 10.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
|
||||
@@ -8364,6 +8670,8 @@ snapshots:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
@@ -8612,6 +8920,8 @@ snapshots:
|
||||
regexparam: 3.0.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.1)
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
@@ -8624,6 +8934,8 @@ snapshots:
|
||||
|
||||
xml-naming@0.1.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
68
scripts/analyzeMasseSalarialeWorkbook.py
Normal file
68
scripts/analyzeMasseSalarialeWorkbook.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Analyse la structure du classeur de masse salariale sans afficher d'identité ni de montant."""
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
workbook_path = Path("/home/ubuntu/upload/TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx")
|
||||
workbook = load_workbook(workbook_path, data_only=False)
|
||||
values_workbook = load_workbook(workbook_path, data_only=True)
|
||||
|
||||
for sheet in workbook.worksheets:
|
||||
print(f"sheet={sheet.title}|rows={sheet.max_row}|cols={sheet.max_column}")
|
||||
for row_index in range(1, min(sheet.max_row, 8) + 1):
|
||||
values = []
|
||||
for column_index in range(1, sheet.max_column + 1):
|
||||
value = sheet.cell(row_index, column_index).value
|
||||
if isinstance(value, str) and value.startswith("="):
|
||||
value = f"FORMULA:{value}"
|
||||
if value is not None:
|
||||
values.append(f"C{column_index}={value}")
|
||||
if values:
|
||||
print(f"row={row_index}|{' ; '.join(values)}")
|
||||
formula_count = sum(
|
||||
1
|
||||
for row in sheet.iter_rows()
|
||||
for cell in row
|
||||
if isinstance(cell.value, str) and cell.value.startswith("=")
|
||||
)
|
||||
print(f"formula_count={formula_count}")
|
||||
|
||||
salary_sheet = workbook["Masse salariale"]
|
||||
data_rows = [row for row in range(6, salary_sheet.max_row + 1) if salary_sheet.cell(row, 2).value]
|
||||
print(f"salaries_with_identifier={len(data_rows)}")
|
||||
for column, label in {
|
||||
8: "2025_brut_avec_primes",
|
||||
9: "2025_astreintes",
|
||||
11: "2025_taux_charges",
|
||||
12: "2025_evolution",
|
||||
15: "2026_astreintes",
|
||||
17: "2026_taux_charges",
|
||||
18: "2026_evolution",
|
||||
21: "2027_astreintes",
|
||||
23: "2027_evolution",
|
||||
24: "2027_prime",
|
||||
}.items():
|
||||
populated = sum(salary_sheet.cell(row, column).value is not None for row in data_rows)
|
||||
formulas = sum(
|
||||
isinstance(salary_sheet.cell(row, column).value, str)
|
||||
and salary_sheet.cell(row, column).value.startswith("=")
|
||||
for row in data_rows
|
||||
)
|
||||
print(f"field={label}|populated={populated}|formulas={formulas}")
|
||||
|
||||
for column, label in {7: "2025_hors_astreinte", 14: "2026_avec_primes", 20: "2027_avec_primes"}.items():
|
||||
formulas = Counter()
|
||||
for row in data_rows:
|
||||
value = salary_sheet.cell(row, column).value
|
||||
if isinstance(value, str) and value.startswith("="):
|
||||
formulas[re.sub(r"\d+", "#", value)] += 1
|
||||
for formula, count in formulas.items():
|
||||
print(f"formula_pattern={label}|count={count}|formula={formula}")
|
||||
|
||||
values_sheet = values_workbook["Masse salariale"]
|
||||
for column, label in {7: "2025_hors_astreinte", 10: "2025_mensuel", 13: "2026_hors_astreinte", 14: "2026_avec_primes", 16: "2026_mensuel", 19: "2027_hors_astreinte", 20: "2027_avec_primes", 22: "2027_mensuel"}.items():
|
||||
calculated = sum(values_sheet.cell(row, column).value is not None for row in data_rows)
|
||||
print(f"cached_value={label}|populated={calculated}")
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createConnection } from 'mysql2/promise';
|
||||
import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
const conn = await createConnection(process.env.DATABASE_URL);
|
||||
|
||||
// Libellés parasites connus (noms de catégories ou lignes de synthèse)
|
||||
const PARASITES_KEYWORDS = [
|
||||
'Infogérance', 'Infogerance', 'Sécurité', 'Securite',
|
||||
'Téléphonie', 'Telephonie',
|
||||
'App global', 'App HEP', 'App SMR',
|
||||
'Applicatifs', // commence par
|
||||
'TOTAL', 'Total',
|
||||
'Ventilation', 'Répartition', 'Repartition',
|
||||
'Nouveautés', 'Nouveautes',
|
||||
];
|
||||
|
||||
// 1. Lister les lignes parasites
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT id, annee, etablissementCode, etablissementNom FROM opex_bases_repartition ORDER BY annee, etablissementCode`
|
||||
);
|
||||
|
||||
console.log('=== Toutes les lignes opex_bases_repartition ===');
|
||||
for (const r of rows) {
|
||||
const code = r.etablissementCode ?? '';
|
||||
const nom = r.etablissementNom ?? '';
|
||||
const isParasite = PARASITES_KEYWORDS.some(k =>
|
||||
code.toLowerCase().includes(k.toLowerCase()) ||
|
||||
nom.toLowerCase().includes(k.toLowerCase())
|
||||
) || /^(total|ventilation|répartition|applicatifs)/i.test(code.trim());
|
||||
if (isParasite) {
|
||||
console.log(` PARASITE id=${r.id} annee=${r.annee} code="${code}" nom="${nom}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Supprimer les parasites
|
||||
const [delResult] = await conn.execute(
|
||||
`DELETE FROM opex_bases_repartition
|
||||
WHERE etablissementCode REGEXP '^(Infog|S.curit|T.l.phonie|App global|App HEP|App SMR|Applicatifs|TOTAL|Total|Ventilation|R.partition|Nouveaut)'
|
||||
OR etablissementNom REGEXP '^(Infog|S.curit|T.l.phonie|App global|App HEP|App SMR|Applicatifs|TOTAL|Total|Ventilation|R.partition|Nouveaut)'`
|
||||
);
|
||||
console.log(`\nSupprimé ${delResult.affectedRows} lignes parasites.`);
|
||||
|
||||
// 3. Vérification finale
|
||||
const [remaining] = await conn.execute(
|
||||
`SELECT annee, COUNT(*) as cnt FROM opex_bases_repartition GROUP BY annee ORDER BY annee`
|
||||
);
|
||||
console.log('=== Lignes restantes par année ===');
|
||||
console.table(remaining);
|
||||
|
||||
await conn.end();
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createConnection } from 'mysql2/promise';
|
||||
import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
const conn = await createConnection(process.env.DATABASE_URL);
|
||||
|
||||
// 1. Voir les doublons
|
||||
const [doublons] = await conn.execute(
|
||||
'SELECT annee, libelle, COUNT(*) as cnt FROM opex_postes GROUP BY annee, libelle HAVING cnt > 1 ORDER BY cnt DESC LIMIT 30'
|
||||
);
|
||||
console.log('=== Doublons (annee, libelle) ===');
|
||||
console.table(doublons);
|
||||
|
||||
// 2. Total par année
|
||||
const [totaux] = await conn.execute(
|
||||
'SELECT annee, COUNT(*) as cnt FROM opex_postes GROUP BY annee ORDER BY annee'
|
||||
);
|
||||
console.log('=== Total postes par année ===');
|
||||
console.table(totaux);
|
||||
|
||||
// 3. Libellés qui ressemblent à des catégories (parasites)
|
||||
const [parasites] = await conn.execute(
|
||||
`SELECT id, annee, libelle, categorie FROM opex_postes
|
||||
WHERE libelle IN ('Infogérance','Sécurité','Téléphonie','App global','App HEP','App SMR',
|
||||
'Applicatifs communs','Applicatifs PA','Applicatifs HEP','Applicatifs SMR','Nouveautés','TOTAL',
|
||||
'Ventilation cout','Ventilation coût')
|
||||
OR libelle REGEXP '^(total|ventilation|répartition)'
|
||||
ORDER BY annee, libelle`
|
||||
);
|
||||
console.log('=== Libellés parasites en BDD ===');
|
||||
console.table(parasites);
|
||||
|
||||
await conn.end();
|
||||
20
scripts/diagnoseMasseSalarialeWorkbook.ts
Normal file
20
scripts/diagnoseMasseSalarialeWorkbook.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import XLSX from "xlsx";
|
||||
|
||||
const workbook = XLSX.readFile("/home/ubuntu/upload/TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx", { cellDates: true });
|
||||
const sheet = workbook.Sheets["Masse salariale"];
|
||||
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
|
||||
const rows = XLSX.utils.sheet_to_json<Array<unknown>>(sheet, { header: 1, raw: true, defval: null });
|
||||
const candidates = rows.slice(5).filter((row) => row[1] !== null && row[1] !== undefined && row[1] !== "");
|
||||
const typeCount = (index: number) => candidates.reduce<Record<string, number>>((counts, row) => {
|
||||
const kind = row[index] === null ? "null" : row[index] instanceof Date ? "date" : typeof row[index];
|
||||
counts[kind] = (counts[kind] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
console.log(JSON.stringify({
|
||||
candidates: candidates.length,
|
||||
matriculeTypes: typeCount(1),
|
||||
nomTypes: typeCount(2),
|
||||
prenomTypes: typeCount(3),
|
||||
posteTypes: typeCount(4),
|
||||
dateEmbaucheTypes: typeCount(5),
|
||||
}));
|
||||
28
scripts/diagnosePayrollExtraction.ts
Normal file
28
scripts/diagnosePayrollExtraction.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import fs from "node:fs";
|
||||
import { extractPayrollBulletins } from "../server/payrollParser";
|
||||
import { extractPayrollPdfText } from "../server/payrollPdfText";
|
||||
|
||||
const filePath = process.argv[2];
|
||||
if (!filePath) throw new Error("Chemin PDF requis");
|
||||
|
||||
const text = await extractPayrollPdfText(fs.readFileSync(filePath));
|
||||
const extraction = extractPayrollBulletins(text);
|
||||
const lines = text.split(/\r?\n/);
|
||||
const redactStructure = (line: string) => line.replace(/[A-Za-zÀ-ÿ]/g, "x").replace(/\d/g, "#").slice(0, 240);
|
||||
const prohibitedPosteContent = extraction.bulletins.some((bulletin) => /iban|rib|n°\s*ss|adresse|fr\d{2}/i.test(bulletin.poste));
|
||||
console.log(JSON.stringify({
|
||||
bulletins: extraction.bulletins.length,
|
||||
statutExtraction: extraction.statutExtraction,
|
||||
hasExtractionWarning: Boolean(extraction.erreurExtraction),
|
||||
containsProhibitedPosteContent: prohibitedPosteContent,
|
||||
preservedPageSeparators: (text.match(/\f/g) ?? []).length,
|
||||
bulletinMarkers: (text.match(/##BULLETIN##/g) ?? []).length,
|
||||
matriculeMatchCount: (text.match(/Matricule\s*:\s*(?:M|N|N°)?\s*\d+/g) ?? []).length,
|
||||
emploiSameLineCount: lines.filter((line) => /Emploi\s*:.*(?:Monsieur|Madame)\b/i.test(line)).length,
|
||||
baseLineCount: lines.filter((line) => line.trim().startsWith("Salaire de base")).length,
|
||||
grossLineCount: lines.filter((line) => line.trim().startsWith("Salaire brut")).length,
|
||||
emploiVicinityStructures: lines
|
||||
.flatMap((line, index) => /Emploi\s*:/i.test(line) ? lines.slice(index, index + 4) : [])
|
||||
.slice(0, 8)
|
||||
.map(redactStructure),
|
||||
}));
|
||||
24
scripts/importMasseSalarialeWorkbook.ts
Normal file
24
scripts/importMasseSalarialeWorkbook.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import XLSX from "xlsx";
|
||||
import { importMasseSalariale } from "../server/db";
|
||||
import { parseMasseSalarialeWorkbookRows } from "../server/masseSalarialeWorkbookImport";
|
||||
|
||||
const workbookPath = "/home/ubuntu/upload/TableaurecapMassesalarialeSANTINOVA-28.08.26.xlsx";
|
||||
const workbook = XLSX.readFile(workbookPath, { cellDates: true });
|
||||
const sheet = workbook.Sheets["Masse salariale"];
|
||||
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
|
||||
|
||||
const rows = XLSX.utils.sheet_to_json<Array<string | number | Date | null>>(sheet, {
|
||||
header: 1,
|
||||
raw: true,
|
||||
defval: null,
|
||||
});
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
const result = await importMasseSalariale(parsed.salaries, parsed.remunerations, {
|
||||
replaceForecastYears: [2025, 2026, 2027],
|
||||
});
|
||||
|
||||
const byYear = parsed.remunerations.reduce<Record<number, number>>((counts, row) => {
|
||||
counts[row.annee] = (counts[row.annee] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
console.log(JSON.stringify({ salaries: result.salaries, remunerationsByYear: byYear }));
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createConnection } from 'mysql2/promise';
|
||||
import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
const conn = await createConnection(process.env.DATABASE_URL);
|
||||
|
||||
// Lister toutes les lignes dont le code n'est pas un code établissement standard (7 chiffres + 2 lettres)
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT id, annee, etablissementCode, etablissementNom, baseRepartition
|
||||
FROM opex_bases_repartition
|
||||
ORDER BY etablissementCode`
|
||||
);
|
||||
|
||||
console.log('=== Codes non-standard (parasites potentiels) ===');
|
||||
for (const r of rows) {
|
||||
const code = r.etablissementCode ?? '';
|
||||
// Code standard = 7 chiffres + 2 lettres majuscules (ex: 1001BPT)
|
||||
const isStandard = /^\d{4,7}[A-Z]{2,3}$/.test(code);
|
||||
if (!isStandard) {
|
||||
console.log(`id=${r.id} annee=${r.annee} code="${code}" nom="${r.etablissementNom}" base=${r.baseRepartition}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal lignes: ${rows.length}`);
|
||||
await conn.end();
|
||||
@@ -6,6 +6,9 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||
import { registerOAuthRoutes } from "./oauth";
|
||||
import { registerStorageProxy } from "./storageProxy";
|
||||
import { appRouter } from "../routers";
|
||||
import { registerSalairesHttpRoutes } from "../salairesHttp";
|
||||
import { registerMasseSalarialeHttpRoutes } from "../masseSalarialeHttp";
|
||||
import { runDatabaseMigrations } from "../databaseMigrations";
|
||||
import { createContext } from "./context";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
|
||||
@@ -29,6 +32,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// Une instance ne devient disponible qu'après la synchronisation du schéma.
|
||||
await runDatabaseMigrations();
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
// Configure body parser with larger size limit for file uploads
|
||||
@@ -36,6 +41,8 @@ async function startServer() {
|
||||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
||||
registerStorageProxy(app);
|
||||
registerOAuthRoutes(app);
|
||||
registerSalairesHttpRoutes(app);
|
||||
registerMasseSalarialeHttpRoutes(app);
|
||||
// tRPC API
|
||||
app.use(
|
||||
"/api/trpc",
|
||||
@@ -63,4 +70,7 @@ async function startServer() {
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch(console.error);
|
||||
// Vitest ne doit jamais démarrer un serveur HTTP.
|
||||
if (!process.env.VITEST) {
|
||||
startServer().catch(console.error);
|
||||
}
|
||||
|
||||
22
server/appManifest.test.ts
Normal file
22
server/appManifest.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("manifeste applicatif Budget SI", () => {
|
||||
it("déclare les environnements, la catégorie et le dépôt Gitea attendus", async () => {
|
||||
const raw = await readFile(resolve(process.cwd(), "app.json"), "utf8");
|
||||
const manifest = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
expect(manifest).toMatchObject({
|
||||
id: "itinova-budget-si",
|
||||
name: "Gestion budget informatique",
|
||||
category: "SANTINOVA",
|
||||
giteaRepo: "manus-admin/itinova-budget-si",
|
||||
image: "images/budget-si.jpg",
|
||||
urls: {
|
||||
recette: "https://budget-si.recette.santinova-soft.org",
|
||||
prod: "https://budget-si.santinova-soft.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,14 @@ vi.mock("./db", async (importOriginal) => {
|
||||
listUsers: vi.fn(),
|
||||
listEtablissements: vi.fn(),
|
||||
getParametres: vi.fn(),
|
||||
upsertOpexBaseRepartition: vi.fn(),
|
||||
setOpexMontantsEtabBatch: vi.fn(),
|
||||
deleteOpexPoste: vi.fn(),
|
||||
getMasseSalariale: vi.fn(),
|
||||
importMasseSalariale: vi.fn(),
|
||||
setMasseSalarialeEvolution: vi.fn(),
|
||||
setMasseSalarialeForecastAdjustments: vi.fn(),
|
||||
getSalairesMensuels: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -268,3 +276,334 @@ describe("parametres.get", () => {
|
||||
expect(result).toEqual({ seuil_fixes_ans: "5", cout_fixe: "850" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests robustesse OPEX ────────────────────────────────────────────────────
|
||||
|
||||
describe("opex.setBaseRepartition", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("normalise le code établissement avant de l'enregistrer", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "standard" });
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await caller.opex.setBaseRepartition({
|
||||
annee: 2026,
|
||||
etablissementCode: " 1001bpt ",
|
||||
etablissementNom: "FAM Saint Joseph",
|
||||
baseRepartition: 100,
|
||||
baseRepartitionHep: 0,
|
||||
modeManuel: false,
|
||||
});
|
||||
|
||||
expect(db.upsertOpexBaseRepartition).toHaveBeenCalledWith(expect.objectContaining({
|
||||
etablissementCode: "1001BPT",
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejette les lignes de synthèse avant tout accès à la base", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "standard" });
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.opex.setBaseRepartition({
|
||||
annee: 2026,
|
||||
etablissementCode: "TOTAL",
|
||||
baseRepartition: 1,
|
||||
baseRepartitionHep: 0,
|
||||
modeManuel: false,
|
||||
})).rejects.toThrow("Code établissement OPEX invalide");
|
||||
expect(db.upsertOpexBaseRepartition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interdit toute écriture au profil readonly", async () => {
|
||||
const { ctx } = createAuthCtx({ role: "readonly" });
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.opex.setBaseRepartition({
|
||||
annee: 2026,
|
||||
etablissementCode: "1001BPT",
|
||||
baseRepartition: 1,
|
||||
baseRepartitionHep: 0,
|
||||
modeManuel: false,
|
||||
})).rejects.toThrow("Accès en lecture seule");
|
||||
});
|
||||
});
|
||||
|
||||
describe("opex.deletePoste", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("supprime un poste et renvoie le résultat de la transaction métier", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
(db.deleteOpexPoste as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
const { ctx } = createAuthCtx({ role: "standard" });
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.opex.deletePoste({ annee: 2027, id: 42 })).resolves.toEqual({ success: true });
|
||||
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: [], bulletinsMensuels: [] };
|
||||
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue(source);
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.get()).resolves.toEqual({
|
||||
salaries: [],
|
||||
forecasts: [],
|
||||
actuals: [],
|
||||
currentYear: new Date().getFullYear(),
|
||||
});
|
||||
});
|
||||
|
||||
it("interdit la consultation à un profil standard", async () => {
|
||||
const { ctx } = createAuthCtx({ role: "standard" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.get()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("construit le réel annuel uniquement depuis les bulletins mensuels archivés", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
(db.getMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
salaries: [{ id: 7, matricule: "000001", nom: "NOM", prenom: "PRENOM", poste: "Poste", dateEmbauche: "2024-01-01" }],
|
||||
remunerations: [{
|
||||
salarieId: 7,
|
||||
annee: 2026,
|
||||
salaireAnnuelBrutHorsPrimesCents: 1_000_000,
|
||||
salaireAnnuelBrutAvecPrimesCents: 1_200_000,
|
||||
salaireMensuelBrutHorsPrimesCents: 83_333,
|
||||
tauxChargeBps: 4_500,
|
||||
primeAstreinteCents: 200_000,
|
||||
primeCents: 0,
|
||||
primeReferenceCents: 0,
|
||||
evolutionCents: 0,
|
||||
evolutionReferenceCents: 0,
|
||||
}],
|
||||
evolutions: [],
|
||||
bulletinsMensuels: [
|
||||
{ annee: 2026, mois: 1, matricule: "000001", brutMensuelCents: 100_000, brutAvecPrimesCents: 120_000, primeAstreinteCents: 20_000 },
|
||||
{ annee: 2026, mois: 2, matricule: "000001", brutMensuelCents: 100_000, brutAvecPrimesCents: 110_000, primeAstreinteCents: 0 },
|
||||
],
|
||||
});
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
|
||||
const result = await appRouter.createCaller(ctx).masseSalariale.get();
|
||||
|
||||
expect(result.actuals).toEqual([expect.objectContaining({
|
||||
salarieId: 7,
|
||||
annee: 2026,
|
||||
brutAvecPrimesCents: 230_000,
|
||||
brutHorsAstreinteCents: 210_000,
|
||||
moisDisponibles: [1, 2],
|
||||
origine: "liasses",
|
||||
})]);
|
||||
});
|
||||
|
||||
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[0],
|
||||
nature: "previsionnel",
|
||||
primeAstreinteCents: 0,
|
||||
primeCents: 0,
|
||||
evolutionCents: 0,
|
||||
primeReferenceCents: 0,
|
||||
evolutionReferenceCents: 0,
|
||||
}]);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("autorise un administrateur à modifier uniquement les primes et évolutions de l'exercice courant", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
const anneeCourante = new Date().getFullYear();
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
|
||||
salarieId: 7,
|
||||
annee: anneeCourante,
|
||||
primeCents: 12_500,
|
||||
evolutionCents: 20_000,
|
||||
})).resolves.toEqual({ success: true });
|
||||
|
||||
expect(db.setMasseSalarialeForecastAdjustments).toHaveBeenCalledWith({
|
||||
salarieId: 7,
|
||||
annee: anneeCourante,
|
||||
primeCents: 12_500,
|
||||
evolutionCents: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("interdit l'édition prévisionnelle d'un exercice antérieur", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
|
||||
salarieId: 7,
|
||||
annee: new Date().getFullYear() - 1,
|
||||
primeCents: 12_500,
|
||||
evolutionCents: 20_000,
|
||||
})).rejects.toThrow();
|
||||
expect(db.setMasseSalarialeForecastAdjustments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interdit l'édition prévisionnelle à un profil standard", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "standard" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).masseSalariale.setForecastAdjustments({
|
||||
salarieId: 7,
|
||||
annee: new Date().getFullYear(),
|
||||
primeCents: 0,
|
||||
evolutionCents: 0,
|
||||
})).rejects.toThrow();
|
||||
expect(db.setMasseSalarialeForecastAdjustments).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests Salaires : sonde de chiffrement et confidentialité ─────────────────
|
||||
|
||||
describe("salaires.getSecurityStatus", () => {
|
||||
it("valide la clé de chiffrement configurée via une procédure administrateur", async () => {
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
const previousKey = process.env.PAYROLL_ENCRYPTION_KEY;
|
||||
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
|
||||
try {
|
||||
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus())
|
||||
.resolves.toEqual({ ready: true, algorithm: "AES-256-GCM" });
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY;
|
||||
else process.env.PAYROLL_ENCRYPTION_KEY = previousKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("interdit la sonde de sécurité aux profils non administrateurs", async () => {
|
||||
const { ctx } = createAuthCtx({ role: "standard" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).salaires.getSecurityStatus()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("salaires.list", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retourne l'index métier et explique la variation uniquement à l'administrateur", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const mayLiasse = {
|
||||
id: 9, annee: 2026, mois: 5, nomFichier: "mai.pdf", stockageKey: "secret-key",
|
||||
empreinteSha256: "a".repeat(64), tailleOctets: 1200, ivBase64: "iv", authTagBase64: "tag",
|
||||
versionCle: 1, statutExtraction: "ready", erreurExtraction: null, importePar: 1, importeLe: new Date(),
|
||||
};
|
||||
const juneLiasse = { ...mayLiasse, id: 10, mois: 6, nomFichier: "juin.pdf", stockageKey: "secret-key-2" };
|
||||
const mayBulletin = {
|
||||
id: 1, liasseId: 9, matricule: "000001", nom: "DUPONT", prenom: "TEST", poste: "Responsable",
|
||||
brutMensuelCents: 300000, brutAvecPrimesCents: 320000, primeAstreinteCents: 20000,
|
||||
explicationEcart: null, numeroPage: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
const juneBulletin = { ...mayBulletin, id: 2, liasseId: 10, brutAvecPrimesCents: 350000, primeAstreinteCents: 40000 };
|
||||
(db.getSalairesMensuels as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
liasses: [juneLiasse, mayLiasse],
|
||||
rows: [{ liasse: juneLiasse, bulletin: juneBulletin }, { liasse: mayLiasse, bulletin: mayBulletin }],
|
||||
});
|
||||
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
const result = await appRouter.createCaller(ctx).salaires.list({ annee: 2026, mois: 6 });
|
||||
|
||||
expect(result.indicateurs).toEqual({ nombreSalaries: 1, totalBrutCents: 350000, totalEcartCents: 30000 });
|
||||
expect(result.bulletins[0]).toMatchObject({ ecartBrutCents: 30000, brutPrecedentCents: 320000 });
|
||||
expect(result.bulletins[0]?.explication).toContain("astreintes");
|
||||
expect(JSON.stringify(result)).not.toContain("secret-key");
|
||||
expect(JSON.stringify(result)).not.toContain("empreinteSha256");
|
||||
expect(result.liasses[0]?.pdfPath).toBe("/api/salaires/liasses/10/pdf");
|
||||
});
|
||||
|
||||
it("interdit l'index salarial aux profils standard et lecture seule", async () => {
|
||||
const standard = createAuthCtx({ role: "standard" });
|
||||
const readonly = createAuthCtx({ role: "readonly" });
|
||||
|
||||
await expect(appRouter.createCaller(standard.ctx).salaires.list({})).rejects.toThrow();
|
||||
await expect(appRouter.createCaller(readonly.ctx).salaires.list({})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejette un mois invalide avant tout accès aux données", async () => {
|
||||
const { db } = await import("./db").then(m => ({ db: m }));
|
||||
const { ctx } = createAuthCtx({ role: "admin" });
|
||||
|
||||
await expect(appRouter.createCaller(ctx).salaires.list({ mois: 13 })).rejects.toThrow();
|
||||
expect(db.getSalairesMensuels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
55
server/databaseMigrations.ts
Normal file
55
server/databaseMigrations.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createPool } from "mysql2/promise";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { users } from "../drizzle/schema";
|
||||
|
||||
const INITIAL_ADMIN_LOGIN = "adminItinova";
|
||||
const INITIAL_ADMIN_PASSWORD = process.env.INITIAL_ADMIN_PASSWORD ?? "Itinova69!";
|
||||
|
||||
async function ensureInitialAdmin(pool: ReturnType<typeof createPool>) {
|
||||
const database = drizzle(pool);
|
||||
const existingAdmin = await database
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.login, INITIAL_ADMIN_LOGIN))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin.length > 0) return;
|
||||
|
||||
const passwordHash = await bcrypt.hash(INITIAL_ADMIN_PASSWORD, 12);
|
||||
await database.insert(users).values({
|
||||
login: INITIAL_ADMIN_LOGIN,
|
||||
email: "adminItinova@santinova-soft.org",
|
||||
firstName: "Admin",
|
||||
lastName: "Itinova",
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
isActive: true,
|
||||
});
|
||||
console.log("[Database] Compte administrateur initial créé.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise la base avec les migrations versionnées avant d'exposer l'API.
|
||||
* Une base sans DATABASE_URL reste utilisable en développement statique, mais
|
||||
* un déploiement connecté échoue explicitement si la migration ne peut aboutir.
|
||||
*/
|
||||
export async function runDatabaseMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.warn("[Database] DATABASE_URL absent : migrations ignorées.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = createPool(databaseUrl);
|
||||
try {
|
||||
const database = drizzle(pool);
|
||||
await migrate(database, { migrationsFolder: "./drizzle" });
|
||||
await ensureInitialAdmin(pool);
|
||||
console.log("[Database] Migrations Drizzle synchronisées.");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
445
server/db.ts
445
server/db.ts
@@ -1,23 +1,31 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import {
|
||||
capexLignes,
|
||||
etablissements,
|
||||
InsertCapexLigne,
|
||||
InsertEtablissement,
|
||||
InsertMasseSalarialeEvolution,
|
||||
InsertMasseSalarialeRemuneration,
|
||||
InsertMasseSalarialeSalarie,
|
||||
InsertOpexMontantEtab,
|
||||
InsertOpexPoste,
|
||||
InsertUser,
|
||||
inventaireMeta,
|
||||
inventairePostes,
|
||||
masseSalarialeEvolutions,
|
||||
masseSalarialeRemunerations,
|
||||
masseSalarialeSalaries,
|
||||
opexBasesRepartition,
|
||||
opexMontantsEtab,
|
||||
opexPostes,
|
||||
opexValidated,
|
||||
parametresApp,
|
||||
userEtablissements,
|
||||
salairesBulletins,
|
||||
salairesLiasses,
|
||||
users,
|
||||
} from "../drizzle/schema";
|
||||
import { isOpexEtablissementCode } from "../shared/opexValidation";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
@@ -97,7 +105,7 @@ export async function updateLastSignedIn(id: number) {
|
||||
export async function listEtablissements() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(etablissements);
|
||||
return db.select().from(etablissements).orderBy(asc(etablissements.code));
|
||||
}
|
||||
|
||||
export async function upsertEtablissement(etab: InsertEtablissement) {
|
||||
@@ -151,7 +159,8 @@ export async function getOpexPostes(annee: number) {
|
||||
return db
|
||||
.select()
|
||||
.from(opexPostes)
|
||||
.where(eq(opexPostes.annee, annee));
|
||||
.where(eq(opexPostes.annee, annee))
|
||||
.orderBy(asc(opexPostes.colIdx));
|
||||
}
|
||||
|
||||
export async function upsertOpexPoste(poste: InsertOpexPoste) {
|
||||
@@ -161,7 +170,9 @@ export async function upsertOpexPoste(poste: InsertOpexPoste) {
|
||||
await db
|
||||
.update(opexPostes)
|
||||
.set(poste)
|
||||
.where(eq(opexPostes.id, poste.id));
|
||||
// L'année fait partie de la clé fonctionnelle : elle empêche un client
|
||||
// de modifier un poste d'un autre exercice avec un identifiant forgé.
|
||||
.where(and(eq(opexPostes.id, poste.id), eq(opexPostes.annee, poste.annee)));
|
||||
} else {
|
||||
await db.insert(opexPostes).values(poste);
|
||||
}
|
||||
@@ -196,10 +207,29 @@ export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
if (rows.length === 0) return;
|
||||
// Insert par batch de 100
|
||||
for (let i = 0; i < rows.length; i += 100) {
|
||||
await db.insert(opexMontantsEtab).values(rows.slice(i, i + 100));
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert par batch de 100. L'index métier garantit que la même cellule ne
|
||||
// peut pas être dupliquée lors d'une reprise d'import.
|
||||
for (let i = 0; i < rows.length; i += 100) {
|
||||
await tx.insert(opexMontantsEtab).values(rows.slice(i, i + 100));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Sauvegarde atomiquement plusieurs valeurs manuelles d'un même établissement. */
|
||||
export async function setOpexMontantsEtabBatch(rows: InsertOpexMontantEtab[]) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
if (rows.length === 0) return;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const row of rows) {
|
||||
await tx
|
||||
.insert(opexMontantsEtab)
|
||||
.values(row)
|
||||
.onDuplicateKeyUpdate({ set: { montant: row.montant } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOpexPostes(annee: number) {
|
||||
@@ -208,6 +238,33 @@ export async function deleteOpexPostes(annee: number) {
|
||||
await db.delete(opexPostes).where(eq(opexPostes.annee, annee));
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un poste OPEX précis et ses surcharges manuelles associées.
|
||||
* La sélection et les deux suppressions se font dans une transaction afin
|
||||
* d'empêcher la persistance de montants orphelins.
|
||||
*/
|
||||
export async function deleteOpexPoste(annee: number, id: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const poste = await tx
|
||||
.select({ libelle: opexPostes.libelle })
|
||||
.from(opexPostes)
|
||||
.where(and(eq(opexPostes.id, id), eq(opexPostes.annee, annee)))
|
||||
.limit(1);
|
||||
|
||||
const libelle = poste[0]?.libelle;
|
||||
if (!libelle) return false;
|
||||
|
||||
await tx
|
||||
.delete(opexMontantsEtab)
|
||||
.where(and(eq(opexMontantsEtab.annee, annee), eq(opexMontantsEtab.libellePoste, libelle)));
|
||||
await tx.delete(opexPostes).where(and(eq(opexPostes.id, id), eq(opexPostes.annee, annee)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOpexMontantsEtab(annee: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
@@ -271,19 +328,20 @@ export async function importInventaire(
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
// Supprimer l'inventaire existant pour cette année
|
||||
await db.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
|
||||
// Insérer les nouveaux postes par batch
|
||||
if (postes.length > 0) {
|
||||
for (let i = 0; i < postes.length; i += 200) {
|
||||
await db.insert(inventairePostes).values(postes.slice(i, i + 200));
|
||||
await db.transaction(async (tx) => {
|
||||
// L'import remplace l'inventaire annuel en une seule transaction : une
|
||||
// erreur de lecture ou d'insertion ne laisse jamais l'année partiellement vide.
|
||||
await tx.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
|
||||
if (postes.length > 0) {
|
||||
for (let i = 0; i < postes.length; i += 200) {
|
||||
await tx.insert(inventairePostes).values(postes.slice(i, i + 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Upsert meta
|
||||
await db
|
||||
.insert(inventaireMeta)
|
||||
.values({ annee, ...meta })
|
||||
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
|
||||
await tx
|
||||
.insert(inventaireMeta)
|
||||
.values({ annee, ...meta })
|
||||
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -311,12 +369,14 @@ export async function saveCapexLignes(
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
for (const ligne of lignes) {
|
||||
await db
|
||||
.insert(capexLignes)
|
||||
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
|
||||
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
for (const ligne of lignes) {
|
||||
await tx
|
||||
.insert(capexLignes)
|
||||
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
|
||||
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function insertCapexLignes(rows: InsertCapexLigne[]) {
|
||||
@@ -332,10 +392,15 @@ export async function insertCapexLignes(rows: InsertCapexLigne[]) {
|
||||
export async function getOpexBasesRepartition(annee: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
return db
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(opexBasesRepartition)
|
||||
.where(eq(opexBasesRepartition.annee, annee));
|
||||
.where(eq(opexBasesRepartition.annee, annee))
|
||||
.orderBy(asc(opexBasesRepartition.etablissementCode));
|
||||
|
||||
// Tolérance de lecture pour les imports historiques : les lignes non
|
||||
// établissement restent conservées en BDD pour audit mais ne polluent pas la vue.
|
||||
return rows.filter((row) => isOpexEtablissementCode(row.etablissementCode));
|
||||
}
|
||||
|
||||
export async function upsertOpexBaseRepartition(input: {
|
||||
@@ -375,17 +440,313 @@ export async function importOpexBasesRepartition(
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
// Supprimer les données existantes pour l'année
|
||||
await db.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee));
|
||||
if (rows.length === 0) return;
|
||||
// Insérer en batch
|
||||
await db.insert(opexBasesRepartition).values(
|
||||
rows.map(r => ({
|
||||
annee,
|
||||
etablissementCode: r.etablissementCode,
|
||||
etablissementNom: r.etablissementNom ?? null,
|
||||
baseRepartition: String(r.baseRepartition),
|
||||
baseRepartitionHep: String(r.baseRepartitionHep ?? 0),
|
||||
}))
|
||||
);
|
||||
await db.transaction(async (tx) => {
|
||||
// Remplacement atomique : l'ancienne base n'est supprimée que si la nouvelle
|
||||
// série complète peut être enregistrée.
|
||||
await tx.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee));
|
||||
if (rows.length === 0) return;
|
||||
await tx.insert(opexBasesRepartition).values(
|
||||
rows.map(r => ({
|
||||
annee,
|
||||
etablissementCode: r.etablissementCode,
|
||||
etablissementNom: r.etablissementNom ?? null,
|
||||
baseRepartition: String(r.baseRepartition),
|
||||
baseRepartitionHep: String(r.baseRepartitionHep ?? 0),
|
||||
}))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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, bulletinsMensuels] = await Promise.all([
|
||||
db.select().from(masseSalarialeSalaries).orderBy(asc(masseSalarialeSalaries.nom), asc(masseSalarialeSalaries.prenom)),
|
||||
db.select().from(masseSalarialeRemunerations),
|
||||
db.select().from(masseSalarialeEvolutions),
|
||||
db.select({
|
||||
annee: salairesLiasses.annee,
|
||||
mois: salairesLiasses.mois,
|
||||
matricule: salairesBulletins.matricule,
|
||||
brutMensuelCents: salairesBulletins.brutMensuelCents,
|
||||
brutAvecPrimesCents: salairesBulletins.brutAvecPrimesCents,
|
||||
primeAstreinteCents: salairesBulletins.primeAstreinteCents,
|
||||
}).from(salairesBulletins).innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id)),
|
||||
]);
|
||||
return { salaries, remunerations, evolutions, bulletinsMensuels };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[],
|
||||
options: { replaceForecastYears?: number[] } = {},
|
||||
) {
|
||||
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]));
|
||||
|
||||
if (options.replaceForecastYears?.length) {
|
||||
await tx.delete(masseSalarialeRemunerations).where(and(
|
||||
inArray(masseSalarialeRemunerations.annee, options.replaceForecastYears),
|
||||
eq(masseSalarialeRemunerations.nature, "previsionnel"),
|
||||
));
|
||||
}
|
||||
|
||||
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,
|
||||
nature: values.nature,
|
||||
primeAstreinteCents: values.primeAstreinteCents,
|
||||
primeCents: values.primeCents,
|
||||
evolutionCents: values.evolutionCents,
|
||||
primeReferenceCents: values.primeReferenceCents,
|
||||
evolutionReferenceCents: values.evolutionReferenceCents,
|
||||
periodeReference: values.periodeReference,
|
||||
statut: values.statut,
|
||||
source: values.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { salaries: salaries.length, remunerations: remunerations.length };
|
||||
});
|
||||
}
|
||||
|
||||
/** Met à jour uniquement les deux hypothèses qui peuvent être modifiées. */
|
||||
export async function setMasseSalarialeForecastAdjustments(input: {
|
||||
salarieId: number;
|
||||
annee: number;
|
||||
primeCents: number;
|
||||
evolutionCents: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(masseSalarialeRemunerations).set({
|
||||
primeCents: input.primeCents,
|
||||
evolutionCents: input.evolutionCents,
|
||||
}).where(and(
|
||||
eq(masseSalarialeRemunerations.salarieId, input.salarieId),
|
||||
eq(masseSalarialeRemunerations.annee, input.annee),
|
||||
eq(masseSalarialeRemunerations.nature, "previsionnel"),
|
||||
));
|
||||
}
|
||||
|
||||
/** 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SALAIRES MENSUELS — Métadonnées de liasses et index de bulletins chiffrés
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SalaireBulletinImport = {
|
||||
matricule: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
poste: string;
|
||||
brutMensuelCents: number;
|
||||
brutAvecPrimesCents: number;
|
||||
primeAstreinteCents: number;
|
||||
explicationEcart?: string | null;
|
||||
numeroPage?: number | null;
|
||||
};
|
||||
|
||||
export type SalaireLiasseCreateInput = {
|
||||
annee: number;
|
||||
mois: number;
|
||||
nomFichier: string;
|
||||
stockageKey: string;
|
||||
empreinteSha256: string;
|
||||
tailleOctets: number;
|
||||
ivBase64: string;
|
||||
authTagBase64: string;
|
||||
importePar: number;
|
||||
statutExtraction: "ready" | "partiel" | "a_controler";
|
||||
erreurExtraction?: string | null;
|
||||
bulletins: SalaireBulletinImport[];
|
||||
};
|
||||
|
||||
/** Retourne une liasse de période pour empêcher une importation mensuelle multiple. */
|
||||
export async function getSalaireLiasseByPeriod(annee: number, mois: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const [liasse] = await db
|
||||
.select()
|
||||
.from(salairesLiasses)
|
||||
.where(and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois)));
|
||||
return liasse ?? null;
|
||||
}
|
||||
|
||||
/** Retourne une liasse par identifiant, sans jamais renvoyer de fichier déchiffré. */
|
||||
export async function getSalaireLiasseById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const [liasse] = await db.select().from(salairesLiasses).where(eq(salairesLiasses.id, id));
|
||||
return liasse ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste de façon atomique la référence chiffrée de la liasse et son index
|
||||
* métier. Les octets du PDF restent exclusivement dans le stockage persistant.
|
||||
*/
|
||||
export async function createSalaireLiasse(input: SalaireLiasseCreateInput) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: salairesLiasses.id })
|
||||
.from(salairesLiasses)
|
||||
.where(and(eq(salairesLiasses.annee, input.annee), eq(salairesLiasses.mois, input.mois)));
|
||||
if (existing) throw new Error("Une liasse est déjà enregistrée pour cette période");
|
||||
|
||||
await tx.insert(salairesLiasses).values({
|
||||
annee: input.annee,
|
||||
mois: input.mois,
|
||||
nomFichier: input.nomFichier,
|
||||
stockageKey: input.stockageKey,
|
||||
empreinteSha256: input.empreinteSha256,
|
||||
tailleOctets: input.tailleOctets,
|
||||
ivBase64: input.ivBase64,
|
||||
authTagBase64: input.authTagBase64,
|
||||
importePar: input.importePar,
|
||||
statutExtraction: input.statutExtraction,
|
||||
erreurExtraction: input.erreurExtraction ?? null,
|
||||
});
|
||||
|
||||
const [liasse] = await tx
|
||||
.select({ id: salairesLiasses.id })
|
||||
.from(salairesLiasses)
|
||||
.where(eq(salairesLiasses.stockageKey, input.stockageKey));
|
||||
if (!liasse) throw new Error("Liasse non retrouvée après enregistrement");
|
||||
|
||||
if (input.bulletins.length > 0) {
|
||||
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({
|
||||
...bulletin,
|
||||
liasseId: liasse.id,
|
||||
})));
|
||||
}
|
||||
return { liasseId: liasse.id, bulletins: input.bulletins.length };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour l'index métier d'une liasse déjà archivée sans modifier sa clé de
|
||||
* stockage, son empreinte ou ses paramètres de chiffrement.
|
||||
*/
|
||||
export async function replaceSalaireLiasseIndex(
|
||||
liasseId: number,
|
||||
input: Pick<SalaireLiasseCreateInput, "bulletins" | "statutExtraction" | "erreurExtraction">,
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.delete(salairesBulletins).where(eq(salairesBulletins.liasseId, liasseId));
|
||||
await tx.update(salairesLiasses).set({
|
||||
statutExtraction: input.statutExtraction,
|
||||
erreurExtraction: input.erreurExtraction ?? null,
|
||||
}).where(eq(salairesLiasses.id, liasseId));
|
||||
if (input.bulletins.length > 0) {
|
||||
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ ...bulletin, liasseId })));
|
||||
}
|
||||
return { bulletins: input.bulletins.length };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les liasses filtrées et les bulletins associés. La comparaison est
|
||||
* réalisée dans le routeur à partir de ces montants normalisés en centimes.
|
||||
*/
|
||||
export async function getSalairesMensuels(annee?: number, mois?: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const filter = annee && mois
|
||||
? and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois))
|
||||
: annee
|
||||
? eq(salairesLiasses.annee, annee)
|
||||
: mois
|
||||
? eq(salairesLiasses.mois, mois)
|
||||
: undefined;
|
||||
|
||||
const liassesQuery = db.select().from(salairesLiasses);
|
||||
const liasses = filter
|
||||
? await liassesQuery.where(filter).orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois))
|
||||
: await liassesQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois));
|
||||
|
||||
const bulletinsQuery = db
|
||||
.select({ liasse: salairesLiasses, bulletin: salairesBulletins })
|
||||
.from(salairesBulletins)
|
||||
.innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id));
|
||||
const rows = filter
|
||||
? await bulletinsQuery.where(filter).orderBy(asc(salairesBulletins.nom), asc(salairesBulletins.prenom))
|
||||
: await bulletinsQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois), asc(salairesBulletins.nom));
|
||||
return { liasses, rows };
|
||||
}
|
||||
|
||||
13
server/dockerCompose.test.ts
Normal file
13
server/dockerCompose.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("docker-compose de recette", () => {
|
||||
it("utilise le nom unique du conteneur MySQL et évite l'alias générique partagé", async () => {
|
||||
const compose = await readFile(resolve(process.cwd(), "docker-compose.yml"), "utf8");
|
||||
|
||||
expect(compose).toContain("DATABASE_URL=mysql://budget_user:BudgetSI2027!@itinova-budget-si-db:3306/budget_si");
|
||||
expect(compose).not.toContain("DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si");
|
||||
expect(compose).toContain("BUDGET_SI_DOMAIN:-budget-si.recette.santinova-soft.org");
|
||||
});
|
||||
});
|
||||
14
server/dockerMigrationBootstrap.test.ts
Normal file
14
server/dockerMigrationBootstrap.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("bootstrap de migrations Docker", () => {
|
||||
it("exécute les migrations Drizzle avant de lancer le serveur", async () => {
|
||||
const server = await readFile(resolve(process.cwd(), "server/_core/index.ts"), "utf8");
|
||||
const migrationBootstrap = await readFile(resolve(process.cwd(), "server/databaseMigrations.ts"), "utf8");
|
||||
|
||||
expect(server).toContain("await runDatabaseMigrations()");
|
||||
expect(migrationBootstrap).toContain("migrate(");
|
||||
expect(migrationBootstrap).toContain("migrationsFolder: \"./drizzle\"");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import express from "express";
|
||||
import { createServer } from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
// Serve static files from dist/public in production
|
||||
const staticPath =
|
||||
process.env.NODE_ENV === "production"
|
||||
? path.resolve(__dirname, "public")
|
||||
: path.resolve(__dirname, "..", "dist", "public");
|
||||
|
||||
app.use(express.static(staticPath));
|
||||
|
||||
// Handle client-side routing - serve index.html for all routes
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(staticPath, "index.html"));
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}/`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch(console.error);
|
||||
14
server/initialAdminBootstrap.test.ts
Normal file
14
server/initialAdminBootstrap.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("bootstrap du compte administrateur", () => {
|
||||
it("crée le compte administrateur requis après migration sans stocker de mot de passe en clair", async () => {
|
||||
const bootstrap = await readFile(resolve(process.cwd(), "server/databaseMigrations.ts"), "utf8");
|
||||
|
||||
expect(bootstrap).toContain('const INITIAL_ADMIN_LOGIN = "adminItinova"');
|
||||
expect(bootstrap).toContain("await ensureInitialAdmin(pool)");
|
||||
expect(bootstrap).toContain("await bcrypt.hash(INITIAL_ADMIN_PASSWORD");
|
||||
expect(bootstrap).not.toContain("passwordHash: INITIAL_ADMIN_PASSWORD,");
|
||||
});
|
||||
});
|
||||
58
server/masseSalarialeHttp.test.ts
Normal file
58
server/masseSalarialeHttp.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_core/sdk", () => ({ sdk: { authenticateRequest: vi.fn() } }));
|
||||
vi.mock("./db", () => ({ importMasseSalariale: vi.fn() }));
|
||||
vi.mock("./masseSalarialeWorkbookImport", () => ({ parseMasseSalarialeWorkbookBuffer: vi.fn() }));
|
||||
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { importMasseSalariale } from "./db";
|
||||
import { registerMasseSalarialeHttpRoutes } from "./masseSalarialeHttp";
|
||||
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
registerMasseSalarialeHttpRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("route HTTP d'import Masse salariale", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("interdit l'import Excel à un profil non administrateur", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 2, role: "standard" });
|
||||
|
||||
await request(makeApp())
|
||||
.post("/api/masse-salariale/previsionnels")
|
||||
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
|
||||
.expect(403);
|
||||
|
||||
expect(importMasseSalariale).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("importe un fichier Excel administrateur sans transmettre son contenu au client", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
|
||||
(parseMasseSalarialeWorkbookBuffer as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
salaries: [{ matricule: "000001", nom: "NOM", prenom: "PRENOM", poste: "Poste", dateEmbauche: "2024-01-01" }],
|
||||
remunerations: [{
|
||||
matricule: "000001", annee: 2026, nature: "previsionnel", salaireAnnuelBrutHorsPrimesCents: 100_000,
|
||||
salaireAnnuelBrutAvecPrimesCents: 110_000, salaireMensuelBrutHorsPrimesCents: 8_333,
|
||||
tauxChargeBps: 4_500, primeAstreinteCents: 10_000, primeCents: 0, primeReferenceCents: 0,
|
||||
evolutionCents: 0, evolutionReferenceCents: 0, periodeReference: "2026-08", statut: "previsionnel", source: "fichier_modele_2026-08-28",
|
||||
}],
|
||||
});
|
||||
(importMasseSalariale as ReturnType<typeof vi.fn>).mockResolvedValue({ salaries: 1, remunerations: 1 });
|
||||
|
||||
const response = await request(makeApp())
|
||||
.post("/api/masse-salariale/previsionnels")
|
||||
.attach("file", Buffer.from("PK\x03\x04 fichier Excel"), { filename: "masse.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
|
||||
.expect(200);
|
||||
|
||||
expect(importMasseSalariale).toHaveBeenCalledWith(expect.any(Array), expect.any(Array), { replaceForecastYears: [2025, 2026, 2027] });
|
||||
expect(response.body).toEqual({ salaries: 1, remunerations: 1, years: [2025, 2026, 2027] });
|
||||
expect(JSON.stringify(response.body)).not.toContain("000001");
|
||||
});
|
||||
});
|
||||
52
server/masseSalarialeHttp.ts
Normal file
52
server/masseSalarialeHttp.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { importMasseSalariale } from "./db";
|
||||
import { parseMasseSalarialeWorkbookBuffer } from "./masseSalarialeWorkbookImport";
|
||||
|
||||
const MAX_WORKBOOK_BYTES = 5 * 1024 * 1024;
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_WORKBOOK_BYTES, files: 1 } });
|
||||
|
||||
async function getAdminUser(req: Request, res: Response) {
|
||||
try {
|
||||
const user = await sdk.authenticateRequest(req);
|
||||
if (user.role === "admin") return user;
|
||||
res.status(403).json({ error: "Accès réservé aux administrateurs." });
|
||||
} catch {
|
||||
res.status(401).json({ error: "Authentification requise." });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isXlsx(file: Express.Multer.File | undefined): file is Express.Multer.File {
|
||||
if (!file) return false;
|
||||
return file.originalname.toLowerCase().endsWith(".xlsx")
|
||||
&& file.buffer.subarray(0, 2).toString("ascii") === "PK";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cette route est volontairement limitée au transfert binaire du classeur.
|
||||
* Elle ne renvoie jamais les lignes salariales : la page relit ensuite son
|
||||
* agrégat tRPC administrateur.
|
||||
*/
|
||||
export function registerMasseSalarialeHttpRoutes(app: Express) {
|
||||
app.post("/api/masse-salariale/previsionnels", upload.single("file"), async (req, res) => {
|
||||
const user = await getAdminUser(req, res);
|
||||
if (!user) return;
|
||||
if (!isXlsx(req.file)) {
|
||||
res.status(400).json({ error: "Transmettez un fichier .xlsx conforme (5 Mo maximum)." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseMasseSalarialeWorkbookBuffer(req.file.buffer);
|
||||
const result = await importMasseSalariale(parsed.salaries, parsed.remunerations, {
|
||||
replaceForecastYears: [2025, 2026, 2027],
|
||||
});
|
||||
res.json({ salaries: result.salaries, remunerations: result.remunerations, years: [2025, 2026, 2027] });
|
||||
} catch (error) {
|
||||
console.error("[Masse salariale] Échec import prévisionnel", error instanceof Error ? error.message : "erreur inconnue");
|
||||
res.status(422).json({ error: "Le fichier n'a pas pu être importé. Les prévisions existantes sont conservées." });
|
||||
}
|
||||
});
|
||||
}
|
||||
75
server/masseSalarialeProjection.test.ts
Normal file
75
server/masseSalarialeProjection.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAnnualForecasts,
|
||||
buildAnnualPayrollActuals,
|
||||
isForecastAdjustmentEditable,
|
||||
} from "./masseSalarialeProjection";
|
||||
|
||||
describe("projection prévisionnelle et réelle de masse salariale", () => {
|
||||
it("duplique les données closes 2025 entre prévisionnel et réel", () => {
|
||||
const forecasts = buildAnnualForecasts([
|
||||
{
|
||||
annee: 2025,
|
||||
brutHorsAstreinteReferenceCents: 108_000,
|
||||
brutAvecPrimesReferenceCents: 120_000,
|
||||
primeAstreinteCents: 12_000,
|
||||
primeCents: 0,
|
||||
primeReferenceCents: 0,
|
||||
evolutionCents: 0,
|
||||
evolutionReferenceCents: 0,
|
||||
tauxChargeBps: 4_500,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(forecasts.get(2025)).toMatchObject({
|
||||
brutHorsAstreinteCents: 108_000,
|
||||
brutAvecPrimesCents: 120_000,
|
||||
brutMensuelHorsAstreinteCents: 9_000,
|
||||
tauxChargeBps: 4_500,
|
||||
});
|
||||
});
|
||||
|
||||
it("recalcule le prévisionnel futur à partir de la référence importée et des seules modifications manuelles", () => {
|
||||
const forecasts = buildAnnualForecasts([
|
||||
{
|
||||
annee: 2026,
|
||||
brutHorsAstreinteReferenceCents: 114_000,
|
||||
brutAvecPrimesReferenceCents: 119_000,
|
||||
primeAstreinteCents: 6_000,
|
||||
primeCents: 1_500,
|
||||
primeReferenceCents: 1_000,
|
||||
evolutionCents: 6_000,
|
||||
evolutionReferenceCents: 5_000,
|
||||
tauxChargeBps: 4_600,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(forecasts.get(2026)).toMatchObject({
|
||||
brutHorsAstreinteCents: 115_500,
|
||||
brutAvecPrimesCents: 120_500,
|
||||
brutMensuelHorsAstreinteCents: 9_625,
|
||||
});
|
||||
});
|
||||
|
||||
it("calcule le réel 2026 à partir des seuls bulletins mensuels archivés, sans extrapolation", () => {
|
||||
const actuals = buildAnnualPayrollActuals([
|
||||
{ annee: 2026, mois: 5, matricule: "test-1", brutMensuelCents: 10_000, brutAvecPrimesCents: 12_000, primeAstreinteCents: 1_000 },
|
||||
{ annee: 2026, mois: 6, matricule: "test-1", brutMensuelCents: 10_000, brutAvecPrimesCents: 11_000, primeAstreinteCents: 0 },
|
||||
]);
|
||||
|
||||
expect(actuals.get("test-1")?.get(2026)).toEqual({
|
||||
brutHorsAstreinteCents: 22_000,
|
||||
brutAvecPrimesCents: 23_000,
|
||||
brutMensuelHorsAstreinteCents: 11_000,
|
||||
primeAstreinteCents: 1_000,
|
||||
primeComplementaireCents: 2_000,
|
||||
moisDisponibles: [5, 6],
|
||||
});
|
||||
});
|
||||
|
||||
it("n'autorise la modification des primes et évolutions qu'à partir de l'année courante", () => {
|
||||
expect(isForecastAdjustmentEditable(2025, 2026)).toBe(false);
|
||||
expect(isForecastAdjustmentEditable(2026, 2026)).toBe(true);
|
||||
expect(isForecastAdjustmentEditable(2027, 2026)).toBe(true);
|
||||
});
|
||||
});
|
||||
112
server/masseSalarialeProjection.ts
Normal file
112
server/masseSalarialeProjection.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Calculs purs de la fenêtre Masse salariale. Les montants sont conservés en
|
||||
* centimes afin que les projections et cumuls ne subissent aucun arrondi
|
||||
* intermédiaire. Les réels ne sont jamais extrapolés : ils cumulent seulement
|
||||
* les liasses mensuelles effectivement archivées.
|
||||
*/
|
||||
|
||||
export type AnnualForecastInput = {
|
||||
annee: number;
|
||||
brutHorsAstreinteReferenceCents: number;
|
||||
brutAvecPrimesReferenceCents: number;
|
||||
primeAstreinteCents: number;
|
||||
primeCents: number;
|
||||
primeReferenceCents: number;
|
||||
evolutionCents: number;
|
||||
evolutionReferenceCents: number;
|
||||
tauxChargeBps: number | null;
|
||||
};
|
||||
|
||||
export type AnnualProjection = {
|
||||
brutHorsAstreinteCents: number;
|
||||
brutAvecPrimesCents: number;
|
||||
brutMensuelHorsAstreinteCents: number;
|
||||
primeAstreinteCents: number;
|
||||
primeComplementaireCents: number;
|
||||
evolutionCents: number;
|
||||
tauxChargeBps: number | null;
|
||||
};
|
||||
|
||||
export type PayrollMonthlyInput = {
|
||||
annee: number;
|
||||
mois: number;
|
||||
matricule: string;
|
||||
brutMensuelCents: number;
|
||||
brutAvecPrimesCents: number;
|
||||
primeAstreinteCents: number;
|
||||
};
|
||||
|
||||
export type PayrollAnnualActual = Omit<AnnualProjection, "evolutionCents" | "tauxChargeBps"> & {
|
||||
moisDisponibles: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Restitue exactement la référence importée, puis applique seulement les
|
||||
* écarts manuels des primes et évolutions. Ainsi, un import Excel conserve ses
|
||||
* calculs initiaux tandis que toute correction autorisée est automatiquement
|
||||
* répercutée sur le brut prévisionnel dérivé.
|
||||
*/
|
||||
export function buildAnnualForecasts(inputs: AnnualForecastInput[]): Map<number, AnnualProjection> {
|
||||
const forecasts = new Map<number, AnnualProjection>();
|
||||
for (const current of [...inputs].sort((a, b) => a.annee - b.annee)) {
|
||||
const adjustmentDelta =
|
||||
(current.primeCents - current.primeReferenceCents)
|
||||
+ (current.evolutionCents - current.evolutionReferenceCents);
|
||||
const brutHorsAstreinteCents = current.brutHorsAstreinteReferenceCents + adjustmentDelta;
|
||||
const brutAvecPrimesCents = current.brutAvecPrimesReferenceCents + adjustmentDelta;
|
||||
forecasts.set(current.annee, {
|
||||
brutHorsAstreinteCents,
|
||||
brutAvecPrimesCents,
|
||||
brutMensuelHorsAstreinteCents: Math.round(brutHorsAstreinteCents / 12),
|
||||
primeAstreinteCents: current.primeAstreinteCents,
|
||||
primeComplementaireCents: current.primeCents,
|
||||
evolutionCents: current.evolutionCents,
|
||||
tauxChargeBps: current.tauxChargeBps,
|
||||
});
|
||||
}
|
||||
return forecasts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrège les bulletins archivés par salarié et année. Le brut hors astreinte
|
||||
* ne retire que les primes d'astreinte : les autres éléments compris dans le
|
||||
* brut (activité, responsabilité, vacances…) sont donc préservés.
|
||||
*/
|
||||
export function buildAnnualPayrollActuals(inputs: PayrollMonthlyInput[]): Map<string, Map<number, PayrollAnnualActual>> {
|
||||
const grouped = new Map<string, Map<number, PayrollMonthlyInput[]>>();
|
||||
for (const input of inputs) {
|
||||
const byYear = grouped.get(input.matricule) ?? new Map<number, PayrollMonthlyInput[]>();
|
||||
byYear.set(input.annee, [...(byYear.get(input.annee) ?? []), input]);
|
||||
grouped.set(input.matricule, byYear);
|
||||
}
|
||||
|
||||
const actuals = new Map<string, Map<number, PayrollAnnualActual>>();
|
||||
for (const [matricule, byYear] of Array.from(grouped.entries())) {
|
||||
const projections = new Map<number, PayrollAnnualActual>();
|
||||
for (const [annee, monthlyRows] of Array.from(byYear.entries())) {
|
||||
const brutAvecPrimesCents = monthlyRows.reduce((sum, row) => sum + row.brutAvecPrimesCents, 0);
|
||||
const primeAstreinteCents = monthlyRows.reduce((sum, row) => sum + row.primeAstreinteCents, 0);
|
||||
const brutHorsAstreinteCents = brutAvecPrimesCents - primeAstreinteCents;
|
||||
const primeComplementaireCents = monthlyRows.reduce(
|
||||
(sum, row) => sum + (row.brutAvecPrimesCents - row.brutMensuelCents - row.primeAstreinteCents),
|
||||
0,
|
||||
);
|
||||
const moisDisponibles = Array.from(new Set<number>(monthlyRows.map((row) => row.mois))).sort((a, b) => a - b);
|
||||
projections.set(annee, {
|
||||
brutHorsAstreinteCents,
|
||||
brutAvecPrimesCents,
|
||||
brutMensuelHorsAstreinteCents: Math.round(brutHorsAstreinteCents / moisDisponibles.length),
|
||||
primeAstreinteCents,
|
||||
primeComplementaireCents,
|
||||
moisDisponibles,
|
||||
});
|
||||
}
|
||||
actuals.set(matricule, projections);
|
||||
}
|
||||
return actuals;
|
||||
}
|
||||
|
||||
/** Les ajustements de prévision sont bloqués sur les exercices clôturés. */
|
||||
export function isForecastAdjustmentEditable(annee: number, currentYear: number): boolean {
|
||||
return annee >= currentYear;
|
||||
}
|
||||
68
server/masseSalarialeWorkbookImport.test.ts
Normal file
68
server/masseSalarialeWorkbookImport.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMasseSalarialeWorkbookRows } from "./masseSalarialeWorkbookImport";
|
||||
|
||||
describe("import du modèle Masse salariale", () => {
|
||||
it("importe les prévisionnels sans confondre taux, primes et évolutions", () => {
|
||||
const rows: Array<Array<string | number | Date | null>> = Array.from({ length: 5 }, () => []);
|
||||
rows.push([
|
||||
null,
|
||||
"000001",
|
||||
"NOM",
|
||||
"PRENOM",
|
||||
"Poste",
|
||||
new Date("2024-01-01T00:00:00.000Z"),
|
||||
35_000,
|
||||
37_000,
|
||||
2_000,
|
||||
2_916.67,
|
||||
0.45,
|
||||
1_000,
|
||||
36_000,
|
||||
39_000,
|
||||
2_500,
|
||||
3_000,
|
||||
0.46,
|
||||
1_500,
|
||||
40_000,
|
||||
43_000,
|
||||
2_000,
|
||||
3_333.33,
|
||||
null,
|
||||
2_000,
|
||||
0.5,
|
||||
]);
|
||||
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
|
||||
expect(parsed.salaries).toHaveLength(1);
|
||||
expect(parsed.remunerations).toHaveLength(3);
|
||||
expect(parsed.remunerations.find((row) => row.annee === 2026)).toMatchObject({
|
||||
salaireAnnuelBrutHorsPrimesCents: 3_600_000,
|
||||
salaireAnnuelBrutAvecPrimesCents: 3_900_000,
|
||||
primeAstreinteCents: 250_000,
|
||||
evolutionCents: 150_000,
|
||||
tauxChargeBps: 4_600,
|
||||
primeCents: 0,
|
||||
});
|
||||
expect(parsed.remunerations.find((row) => row.annee === 2027)).toMatchObject({
|
||||
primeCents: 50,
|
||||
evolutionCents: 200_000,
|
||||
tauxChargeBps: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepte les lignes SheetJS sans cellule A vide et les dates Excel numériques", () => {
|
||||
const rows: Array<Array<string | number | Date | null>> = Array.from({ length: 5 }, () => []);
|
||||
rows.push([
|
||||
"000002", "NOM", "PRENOM", "Poste", 45_292,
|
||||
35_000, 37_000, 2_000, 2_916.67, 0.45, 1_000,
|
||||
36_000, 39_000, 2_500, 3_000, 0.46, 1_500,
|
||||
40_000, 43_000, 2_000, 3_333.33, null, 2_000, 0.5,
|
||||
]);
|
||||
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
|
||||
expect(parsed.salaries).toMatchObject([{ matricule: "000002", dateEmbauche: "2024-01-01" }]);
|
||||
expect(parsed.remunerations).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
118
server/masseSalarialeWorkbookImport.ts
Normal file
118
server/masseSalarialeWorkbookImport.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { MasseSalarialeImportRemuneration, MasseSalarialeImportSalarie } from "./db";
|
||||
import XLSX from "xlsx";
|
||||
|
||||
type SpreadsheetValue = string | number | Date | null | undefined;
|
||||
|
||||
function cents(value: SpreadsheetValue): number {
|
||||
if (typeof value === "number") return Math.round(value * 100);
|
||||
if (typeof value !== "string") return 0;
|
||||
const normalized = value.replace(/[\s\u00a0]/g, "").replace(",", ".");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
function bps(value: SpreadsheetValue): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const numeric = typeof value === "number" ? value : Number(String(value).replace(",", "."));
|
||||
return Number.isFinite(numeric) ? Math.round(numeric * 10_000) : null;
|
||||
}
|
||||
|
||||
function isoDate(value: SpreadsheetValue): string | null {
|
||||
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const excelDate = new Date(Date.UTC(1899, 11, 30) + Math.round(value * 86_400_000));
|
||||
return Number.isNaN(excelDate.valueOf()) ? null : excelDate.toISOString().slice(0, 10);
|
||||
}
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
||||
if (typeof value === "string" && /^\d{2}\/\d{2}\/\d{4}$/.test(value)) {
|
||||
const [day, month, year] = value.split("/");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function text(value: SpreadsheetValue): string {
|
||||
return typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
|
||||
}
|
||||
|
||||
export type MasseSalarialeWorkbookImport = {
|
||||
salaries: MasseSalarialeImportSalarie[];
|
||||
remunerations: MasseSalarialeImportRemuneration[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convertit les lignes de la feuille modèle (index Excel 1, en-tête sur la
|
||||
* ligne 5) sans évaluer de formules côté application : les valeurs calculées
|
||||
* et sauvegardées par le fichier fourni servent de référence importée.
|
||||
*/
|
||||
export function parseMasseSalarialeWorkbookRows(rows: SpreadsheetValue[][]): MasseSalarialeWorkbookImport {
|
||||
const salaries: MasseSalarialeImportSalarie[] = [];
|
||||
const remunerations: MasseSalarialeImportRemuneration[] = [];
|
||||
|
||||
for (const row of rows.slice(5)) {
|
||||
// SheetJS supprime la cellule A vide de certaines lignes. On repère donc
|
||||
// la présence effective de cette cellule plutôt que de supposer un index.
|
||||
const originalColumn = (excelColumn: number) => row[excelColumn + (row[0] === null || row[0] === undefined ? 0 : -1)];
|
||||
const matricule = text(originalColumn(1));
|
||||
const nom = text(originalColumn(2));
|
||||
const prenom = text(originalColumn(3));
|
||||
const poste = text(originalColumn(4));
|
||||
const dateEmbauche = isoDate(originalColumn(5));
|
||||
if (!matricule || !nom || !prenom || !poste || !dateEmbauche) continue;
|
||||
|
||||
salaries.push({ matricule, nom, prenom, poste, dateEmbauche });
|
||||
const addForecast = (annee: number, startIndex: number, tauxChargeBps: number | null, periodeReference: string) => {
|
||||
const brutHorsAstreinte = cents(originalColumn(startIndex));
|
||||
const brutAvecPrimes = cents(originalColumn(startIndex + 1));
|
||||
const primeAstreinte = cents(originalColumn(startIndex + 2));
|
||||
const brutMensuel = cents(originalColumn(startIndex + 3));
|
||||
const evolution = cents(originalColumn(startIndex + 5));
|
||||
const prime = annee === 2027 ? cents(originalColumn(startIndex + 6)) : 0;
|
||||
if (brutHorsAstreinte === 0 && brutAvecPrimes === 0 && brutMensuel === 0) return;
|
||||
|
||||
remunerations.push({
|
||||
matricule,
|
||||
annee,
|
||||
nature: "previsionnel",
|
||||
salaireAnnuelBrutHorsPrimesCents: brutHorsAstreinte,
|
||||
salaireAnnuelBrutAvecPrimesCents: brutAvecPrimes,
|
||||
salaireMensuelBrutHorsPrimesCents: brutMensuel,
|
||||
tauxChargeBps,
|
||||
primeAstreinteCents: primeAstreinte,
|
||||
primeCents: prime,
|
||||
evolutionCents: evolution,
|
||||
primeReferenceCents: prime,
|
||||
evolutionReferenceCents: evolution,
|
||||
periodeReference,
|
||||
statut: annee === 2025 ? "annuel" : "previsionnel",
|
||||
source: "fichier_modele_2026-08-28",
|
||||
});
|
||||
};
|
||||
|
||||
addForecast(2025, 6, bps(originalColumn(10)), "2025-12");
|
||||
addForecast(2026, 12, bps(originalColumn(16)), "2026-08");
|
||||
addForecast(2027, 18, null, "2027-01");
|
||||
}
|
||||
|
||||
return { salaries, remunerations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le fichier exclusivement côté serveur, afin que les données de masse
|
||||
* salariale ne soient ni transformées ni renvoyées par le navigateur.
|
||||
*/
|
||||
export function parseMasseSalarialeWorkbookBuffer(buffer: Buffer): MasseSalarialeWorkbookImport {
|
||||
const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true });
|
||||
const sheet = workbook.Sheets["Masse salariale"];
|
||||
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
|
||||
const rows = XLSX.utils.sheet_to_json<SpreadsheetValue[]>(sheet, {
|
||||
header: 1,
|
||||
raw: true,
|
||||
defval: null,
|
||||
});
|
||||
const parsed = parseMasseSalarialeWorkbookRows(rows);
|
||||
if (parsed.salaries.length === 0 || parsed.remunerations.length === 0) {
|
||||
throw new Error("Le fichier ne contient aucune prévision exploitable");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
37
server/payrollCrypto.test.ts
Normal file
37
server/payrollCrypto.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { decryptPayrollPdf, encryptPayrollPdf, getPayrollEncryptionStatus } from "./payrollCrypto";
|
||||
|
||||
const originalKey = process.env.PAYROLL_ENCRYPTION_KEY;
|
||||
|
||||
describe.sequential("payrollCrypto", () => {
|
||||
beforeEach(() => {
|
||||
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKey === undefined) delete process.env.PAYROLL_ENCRYPTION_KEY;
|
||||
else process.env.PAYROLL_ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
|
||||
it("chiffre puis déchiffre un PDF sans changer son contenu", () => {
|
||||
const original = Buffer.from("%PDF-1.7 contenu de test", "utf8");
|
||||
const encrypted = encryptPayrollPdf(original);
|
||||
|
||||
expect(encrypted.ciphertext).not.toEqual(original);
|
||||
expect(decryptPayrollPdf(encrypted)).toEqual(original);
|
||||
expect(getPayrollEncryptionStatus()).toEqual({ ready: true, algorithm: "AES-256-GCM" });
|
||||
});
|
||||
|
||||
it("refuse un tag d'authentification altéré", () => {
|
||||
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de test", "utf8"));
|
||||
const tamperedTag = Buffer.from(encrypted.authTagBase64, "base64");
|
||||
tamperedTag[0] = tamperedTag[0]! ^ 0xff;
|
||||
|
||||
expect(() => decryptPayrollPdf({ ...encrypted, authTagBase64: tamperedTag.toString("base64") })).toThrow();
|
||||
});
|
||||
|
||||
it("ne révèle aucune erreur détaillée lorsque la clé est invalide", () => {
|
||||
process.env.PAYROLL_ENCRYPTION_KEY = "invalide";
|
||||
expect(getPayrollEncryptionStatus()).toEqual({ ready: false, algorithm: "AES-256-GCM" });
|
||||
});
|
||||
});
|
||||
62
server/payrollCrypto.ts
Normal file
62
server/payrollCrypto.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_BYTES = 12;
|
||||
const AUTH_TAG_BYTES = 16;
|
||||
|
||||
export type EncryptedPayrollFile = {
|
||||
ciphertext: Buffer;
|
||||
ivBase64: string;
|
||||
authTagBase64: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lit la clé dédiée aux bulletins. La clé ne transite jamais vers le client et
|
||||
* doit être une valeur Base64 représentant précisément 32 octets AES-256.
|
||||
*/
|
||||
function getPayrollEncryptionKey(): Buffer {
|
||||
const encodedKey = process.env.PAYROLL_ENCRYPTION_KEY?.trim();
|
||||
if (!encodedKey) throw new Error("Clé de chiffrement des bulletins absente");
|
||||
|
||||
const key = Buffer.from(encodedKey, "base64");
|
||||
if (key.length !== 32) {
|
||||
throw new Error("La clé de chiffrement des bulletins doit contenir 32 octets");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Sondage sans divulgation de la clé, destiné aux contrôles administrateur. */
|
||||
export function getPayrollEncryptionStatus() {
|
||||
try {
|
||||
getPayrollEncryptionKey();
|
||||
return { ready: true as const, algorithm: "AES-256-GCM" };
|
||||
} catch {
|
||||
return { ready: false as const, algorithm: "AES-256-GCM" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Chiffre un PDF avant son envoi vers le stockage persistant. */
|
||||
export function encryptPayrollPdf(plaintext: Buffer): EncryptedPayrollFile {
|
||||
const key = getPayrollEncryptionKey();
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES });
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return {
|
||||
ciphertext,
|
||||
ivBase64: iv.toString("base64"),
|
||||
authTagBase64: cipher.getAuthTag().toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Déchiffre le document uniquement côté serveur, après contrôle des droits. */
|
||||
export function decryptPayrollPdf(encrypted: EncryptedPayrollFile): Buffer {
|
||||
const key = getPayrollEncryptionKey();
|
||||
const iv = Buffer.from(encrypted.ivBase64, "base64");
|
||||
const authTag = Buffer.from(encrypted.authTagBase64, "base64");
|
||||
if (iv.length !== IV_BYTES || authTag.length !== AUTH_TAG_BYTES) {
|
||||
throw new Error("Paramètres de chiffrement de bulletin invalides");
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_BYTES });
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(encrypted.ciphertext), decipher.final()]);
|
||||
}
|
||||
80
server/payrollParser.test.ts
Normal file
80
server/payrollParser.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractPayrollBulletins } from "./payrollParser";
|
||||
|
||||
const SAMPLE_BULLETIN = `
|
||||
##BULLETIN##05-2026##009999##DUPONT##TEST##
|
||||
Matricule : 009999
|
||||
Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT
|
||||
Salaire de base (Forfait 218 jours) 3 650.00
|
||||
Prime d'astreinte 200.00
|
||||
Prime d'astreinte 200.00
|
||||
Prime d'activité 150.00
|
||||
Salaire brut 4 200.00
|
||||
N° SS : ne-pas-extraire
|
||||
Iban / Rib : ne-pas-extraire
|
||||
Net à payer avant impôt sur le revenu 2 000.00
|
||||
`;
|
||||
|
||||
describe("extractPayrollBulletins", () => {
|
||||
it("n'extrait que les champs métier autorisés et additionne les astreintes", () => {
|
||||
const result = extractPayrollBulletins(SAMPLE_BULLETIN);
|
||||
|
||||
expect(result).toMatchObject({ statutExtraction: "ready", erreurExtraction: null });
|
||||
expect(result.bulletins).toEqual([
|
||||
expect.objectContaining({
|
||||
matricule: "009999",
|
||||
nom: "DUPONT",
|
||||
prenom: "TEST",
|
||||
poste: "RESPONSABLE APPLICATIF",
|
||||
brutMensuelCents: 365000,
|
||||
brutAvecPrimesCents: 420000,
|
||||
primeAstreinteCents: 40000,
|
||||
}),
|
||||
]);
|
||||
expect(Object.keys(result.bulletins[0]!)).not.toContain("iban");
|
||||
expect(JSON.stringify(result)).not.toContain("ne-pas-extraire");
|
||||
});
|
||||
|
||||
it("signale une liasse non indexable sans prétendre perdre le PDF", () => {
|
||||
expect(extractPayrollBulletins("contenu PDF non reconnu")).toEqual({
|
||||
bulletins: [],
|
||||
statutExtraction: "a_controler",
|
||||
erreurExtraction: "Aucun bulletin n’a pu être indexé automatiquement. Le PDF reste conservé et consultable.",
|
||||
});
|
||||
});
|
||||
|
||||
it("indexe chaque salarié lorsque le texte PDF concatène les pages sans séparateur", () => {
|
||||
const secondBulletin = SAMPLE_BULLETIN
|
||||
.replace("##009999##DUPONT##TEST##", "##008888##MARTIN##EXEMPLE##")
|
||||
.replaceAll("009999", "008888")
|
||||
.replaceAll("TEST DUPONT", "EXEMPLE MARTIN");
|
||||
|
||||
const result = extractPayrollBulletins(`${SAMPLE_BULLETIN}\n${secondBulletin}`);
|
||||
expect(result.bulletins).toHaveLength(2);
|
||||
expect(result.bulletins.map((bulletin) => bulletin.matricule)).toEqual(["009999", "008888"]);
|
||||
});
|
||||
|
||||
it("accepte un emploi suivi de la civilité par un seul espace, comme certains PDF extraits", () => {
|
||||
const compacted = SAMPLE_BULLETIN.replace(" Monsieur", " Monsieur");
|
||||
|
||||
expect(extractPayrollBulletins(compacted).bulletins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lit un poste sur sa seule ligne lorsque la civilité est rendue à la ligne suivante", () => {
|
||||
const separateCivilite = SAMPLE_BULLETIN.replace(" Monsieur TEST DUPONT", "\nMonsieur TEST DUPONT");
|
||||
|
||||
expect(extractPayrollBulletins(separateCivilite).bulletins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuse une extraction d'emploi répartie sur plusieurs lignes pour ne jamais persister de donnée personnelle", () => {
|
||||
const unsafeLayout = SAMPLE_BULLETIN.replace(
|
||||
/Emploi\s*:[^\n]*/,
|
||||
"Emploi :\nAdresse personnelle à ignorer\nRESPONSABLE APPLICATIF\nMonsieur TEST DUPONT",
|
||||
);
|
||||
|
||||
expect(extractPayrollBulletins(unsafeLayout)).toMatchObject({
|
||||
bulletins: [],
|
||||
statutExtraction: "a_controler",
|
||||
});
|
||||
});
|
||||
});
|
||||
151
server/payrollParser.ts
Normal file
151
server/payrollParser.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Extraction volontairement minimisée des liasses de paie. Aucune coordonnée,
|
||||
* donnée bancaire, numéro de sécurité sociale ou rémunération nette n'est
|
||||
* renvoyé ni persisté par ce module.
|
||||
*/
|
||||
|
||||
export type ParsedPayrollBulletin = {
|
||||
matricule: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
poste: string;
|
||||
brutMensuelCents: number;
|
||||
brutAvecPrimesCents: number;
|
||||
primeAstreinteCents: number;
|
||||
explicationEcart: string | null;
|
||||
numeroPage: number;
|
||||
};
|
||||
|
||||
export type PayrollExtractionResult = {
|
||||
bulletins: ParsedPayrollBulletin[];
|
||||
statutExtraction: "ready" | "partiel" | "a_controler";
|
||||
erreurExtraction: string | null;
|
||||
};
|
||||
|
||||
const MONEY_RE = /(?<![\d.])-?\s*\d{1,3}(?:[ \u00a0]\d{3})*\.\d{2}(?!\d)/g;
|
||||
const HEADER_RE = /##BULLETIN##\d{2}-\d{4}##(\d+)##([^#]+)##([^#]+)##/g;
|
||||
|
||||
function amountsIn(line: string): number[] {
|
||||
return (line.match(MONEY_RE) ?? []).map((raw) => Math.round(Number(raw.replace(/[ \u00a0]/g, "")) * 100));
|
||||
}
|
||||
|
||||
function firstMatchingLine(lines: string[], prefix: string): string | null {
|
||||
return lines.find((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith(prefix)) ?? null;
|
||||
}
|
||||
|
||||
function pagesForHeaders(text: string): Array<{ content: string; page: number }> {
|
||||
const pages = text.split("\f");
|
||||
const grouped: Array<{ content: string; page: number; matricule: string }> = [];
|
||||
let current: { content: string; page: number; matricule: string } | null = null;
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
HEADER_RE.lastIndex = 0;
|
||||
const headers: Array<{ index: number; length: number; matricule: string }> = [];
|
||||
let header: RegExpExecArray | null;
|
||||
while ((header = HEADER_RE.exec(page)) !== null) {
|
||||
headers.push({ index: header.index, length: header[0].length, matricule: header[1] });
|
||||
}
|
||||
if (headers.length === 0 && current) {
|
||||
current.content += `\n${page}`;
|
||||
return;
|
||||
}
|
||||
if (headers.length === 0) return;
|
||||
|
||||
headers.forEach((entry, headerIndex) => {
|
||||
const next = headers[headerIndex + 1];
|
||||
const content = page.slice(entry.index, next?.index);
|
||||
if (headerIndex === 0 && current?.matricule === entry.matricule) {
|
||||
current.content += `\n${content}`;
|
||||
} else {
|
||||
current = { content, page: index + 1, matricule: entry.matricule };
|
||||
grouped.push(current);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Certains PDF ne contiennent pas de saut de page exploitable. On tente alors
|
||||
// une extraction complète, qui sera marquée "à contrôler" sans en-tête fiable.
|
||||
return grouped.length > 0 ? grouped.map(({ content, page }) => ({ content, page })) : [{ content: text, page: 1 }];
|
||||
}
|
||||
|
||||
function getIdentity(text: string): { matricule: string; nom: string; prenom: string; poste: string } | null {
|
||||
HEADER_RE.lastIndex = 0;
|
||||
const header = HEADER_RE.exec(text);
|
||||
const lines = text.split(/\r?\n/);
|
||||
const matricule = /Matricule\s*:\s*(?:M|N|N°)?\s*(\d+)/.exec(text)?.[1] ?? header?.[1];
|
||||
const emploiLine = lines.find((line) => /Emploi\s*:/i.test(line));
|
||||
const poste = /Emploi\s*:\s*(.*)$/i.exec(emploiLine ?? "")?.[1]
|
||||
?.replace(/\s+(?:Monsieur|Madame)\b.*$/i, "")
|
||||
.trim();
|
||||
if (!matricule || !poste) return null;
|
||||
|
||||
if (header?.[2] && header[3]) {
|
||||
return {
|
||||
matricule,
|
||||
nom: header[2].trim(),
|
||||
prenom: header[3].trim(),
|
||||
poste,
|
||||
};
|
||||
}
|
||||
|
||||
const displayName = /Emploi\s*:[\s\S]*?(?:Monsieur|Madame)\s+([A-ZÀ-ÖØ-Ý' -]+)/.exec(text)?.[1]?.trim();
|
||||
const names = displayName?.split(/\s+/).filter(Boolean) ?? [];
|
||||
if (names.length < 2) return null;
|
||||
return { matricule, prenom: names[0], nom: names.slice(1).join(" "), poste };
|
||||
}
|
||||
|
||||
function parseOneBulletin(content: string, page: number): ParsedPayrollBulletin | null {
|
||||
const lines = content.split(/\r?\n/).map((line) => line.trimEnd());
|
||||
const identity = getIdentity(content);
|
||||
const baseAmounts = amountsIn(firstMatchingLine(lines, "salaire de base") ?? "");
|
||||
const grossAmounts = amountsIn(firstMatchingLine(lines, "salaire brut") ?? "");
|
||||
if (!identity || baseAmounts.length === 0 || grossAmounts.length === 0) return null;
|
||||
|
||||
const primeAstreinteCents = lines
|
||||
.filter((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith("prime d'astreinte"))
|
||||
.map((line) => amountsIn(line).at(-1) ?? 0)
|
||||
.reduce((sum, amount) => sum + amount, 0);
|
||||
|
||||
const brutMensuelCents = baseAmounts.at(-1)!;
|
||||
const brutAvecPrimesCents = grossAmounts.at(-1)!;
|
||||
const otherVariableCents = brutAvecPrimesCents - brutMensuelCents - primeAstreinteCents;
|
||||
const parts: string[] = [];
|
||||
if (primeAstreinteCents !== 0) parts.push("prime d’astreinte relevée");
|
||||
if (otherVariableCents !== 0) parts.push("autres éléments de rémunération inclus dans le brut");
|
||||
|
||||
return {
|
||||
...identity,
|
||||
brutMensuelCents,
|
||||
brutAvecPrimesCents,
|
||||
primeAstreinteCents,
|
||||
explicationEcart: parts.length ? parts.join(" ; ") : null,
|
||||
numeroPage: page,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme le texte d'une liasse PDF en index métier restreint. Toute absence
|
||||
* de champ indispensable est signalée sans empêcher la conservation du PDF.
|
||||
*/
|
||||
export function extractPayrollBulletins(text: string): PayrollExtractionResult {
|
||||
const candidates = pagesForHeaders(text);
|
||||
const bulletins = candidates
|
||||
.map(({ content, page }) => parseOneBulletin(content, page))
|
||||
.filter((bulletin): bulletin is ParsedPayrollBulletin => bulletin !== null);
|
||||
|
||||
if (bulletins.length === 0) {
|
||||
return {
|
||||
bulletins: [],
|
||||
statutExtraction: "a_controler",
|
||||
erreurExtraction: "Aucun bulletin n’a pu être indexé automatiquement. Le PDF reste conservé et consultable.",
|
||||
};
|
||||
}
|
||||
if (bulletins.length !== candidates.length) {
|
||||
return {
|
||||
bulletins,
|
||||
statutExtraction: "partiel",
|
||||
erreurExtraction: "Une ou plusieurs fiches n’ont pas pu être indexées automatiquement. Le PDF reste consultable.",
|
||||
};
|
||||
}
|
||||
return { bulletins, statutExtraction: "ready", erreurExtraction: null };
|
||||
}
|
||||
39
server/payrollPdfText.ts
Normal file
39
server/payrollPdfText.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import pdfParse from "pdf-parse/lib/pdf-parse.js";
|
||||
|
||||
type PdfTextItem = { str?: string; transform?: number[] };
|
||||
type PdfPageData = { getTextContent: (options: { normalizeWhitespace: boolean; disableCombineTextItems: boolean }) => Promise<{ items: PdfTextItem[] }> };
|
||||
|
||||
/**
|
||||
* Reconstruit les lignes en ordonnant les fragments de texte par coordonnées.
|
||||
* Cette étape évite que les valeurs de colonnes distinctes (adresse, IBAN, etc.)
|
||||
* soient concaténées dans l'intitulé de poste par l'extracteur PDF standard.
|
||||
*/
|
||||
export async function renderPayrollPage(pageData: unknown): Promise<string> {
|
||||
const pdfPage = pageData as PdfPageData;
|
||||
const textContent = await pdfPage.getTextContent({ normalizeWhitespace: false, disableCombineTextItems: false });
|
||||
const fragments = textContent.items
|
||||
.filter((item) => item.str && item.transform && item.transform.length >= 6)
|
||||
.map((item) => ({ text: item.str!.trim(), x: item.transform![4]!, y: item.transform![5]! }))
|
||||
.filter((item) => item.text.length > 0)
|
||||
.sort((a, b) => Math.abs(b.y - a.y) > 0.75 ? b.y - a.y : a.x - b.x);
|
||||
|
||||
const lines: Array<{ y: number; items: Array<{ text: string; x: number }> }> = [];
|
||||
fragments.forEach((fragment) => {
|
||||
const previous = lines[lines.length - 1];
|
||||
if (previous && Math.abs(previous.y - fragment.y) <= 0.75) {
|
||||
previous.items.push({ text: fragment.text, x: fragment.x });
|
||||
} else {
|
||||
lines.push({ y: fragment.y, items: [{ text: fragment.text, x: fragment.x }] });
|
||||
}
|
||||
});
|
||||
|
||||
return `${lines
|
||||
.map((line) => line.items.sort((a, b) => a.x - b.x).map((item) => item.text).join(" "))
|
||||
.join("\n")}\f`;
|
||||
}
|
||||
|
||||
/** Extrait le texte d'une liasse en préservant les limites de pages. */
|
||||
export async function extractPayrollPdfText(pdf: Buffer): Promise<string> {
|
||||
const parsed = await pdfParse(pdf, { pagerender: renderPayrollPage });
|
||||
return parsed.text;
|
||||
}
|
||||
38
server/payrollStorage.test.ts
Normal file
38
server/payrollStorage.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const originalStorageDir = process.env.PAYROLL_STORAGE_DIR;
|
||||
const originalForgeUrl = process.env.BUILT_IN_FORGE_API_URL;
|
||||
const originalForgeKey = process.env.BUILT_IN_FORGE_API_KEY;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalStorageDir === undefined) delete process.env.PAYROLL_STORAGE_DIR;
|
||||
else process.env.PAYROLL_STORAGE_DIR = originalStorageDir;
|
||||
if (originalForgeUrl === undefined) delete process.env.BUILT_IN_FORGE_API_URL;
|
||||
else process.env.BUILT_IN_FORGE_API_URL = originalForgeUrl;
|
||||
if (originalForgeKey === undefined) delete process.env.BUILT_IN_FORGE_API_KEY;
|
||||
else process.env.BUILT_IN_FORGE_API_KEY = originalForgeKey;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("stockage persistant des bulletins", () => {
|
||||
it("conserve un PDF déjà chiffré dans le répertoire persistant quand Forge est indisponible", async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), "itinova-payroll-storage-"));
|
||||
process.env.PAYROLL_STORAGE_DIR = storageDir;
|
||||
delete process.env.BUILT_IN_FORGE_API_URL;
|
||||
delete process.env.BUILT_IN_FORGE_API_KEY;
|
||||
vi.stubGlobal("fetch", vi.fn(() => { throw new Error("Forge ne doit pas être appelé"); }));
|
||||
|
||||
const { payrollStorageGetBuffer, payrollStoragePut } = await import("./storage");
|
||||
const ciphertext = Buffer.from("octets-chiffres-de-test", "utf8");
|
||||
const stored = await payrollStoragePut("salaires/test.pdf.enc", ciphertext, "application/octet-stream");
|
||||
|
||||
expect(stored.key).toMatch(/^salaires\/test\.pdf_[a-f0-9]{8}\.enc$/);
|
||||
await expect(payrollStorageGetBuffer(stored.key)).resolves.toEqual(ciphertext);
|
||||
|
||||
await rm(storageDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,14 @@ import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
|
||||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
|
||||
import { getPayrollEncryptionStatus } from "./payrollCrypto";
|
||||
import {
|
||||
buildAnnualForecasts,
|
||||
buildAnnualPayrollActuals,
|
||||
isForecastAdjustmentEditable,
|
||||
} from "./masseSalarialeProjection";
|
||||
import type { AnnualProjection } from "./masseSalarialeProjection";
|
||||
|
||||
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.user.role === "readonly") {
|
||||
@@ -15,6 +23,41 @@ const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
/** Normalise et valide la clé métier d'une base de répartition OPEX. */
|
||||
const opexEtablissementCodeSchema = z
|
||||
.string()
|
||||
.transform(normalizeOpexEtablissementCode)
|
||||
.refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" });
|
||||
|
||||
const salaireFiltersSchema = z.object({
|
||||
annee: z.number().int().min(2020).max(2100).optional(),
|
||||
mois: z.number().int().min(1).max(12).optional(),
|
||||
});
|
||||
|
||||
function periodKey(annee: number, mois: number) {
|
||||
return annee * 12 + (mois - 1);
|
||||
}
|
||||
|
||||
function describeMonthlySalaryDifference(
|
||||
current: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number },
|
||||
previous: { brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number } | undefined,
|
||||
) {
|
||||
if (!previous) return "Aucun bulletin de ce salarié disponible au mois précédent.";
|
||||
const difference = current.brutAvecPrimesCents - previous.brutAvecPrimesCents;
|
||||
if (difference === 0) return "Brut stable par rapport au mois précédent.";
|
||||
|
||||
const sign = (amount: number) => amount > 0 ? "hausse" : "baisse";
|
||||
const centsLabel = (amount: number) => `${(Math.abs(amount) / 100).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
||||
const reasons: string[] = [];
|
||||
const baseDifference = current.brutMensuelCents - previous.brutMensuelCents;
|
||||
const astreinteDifference = current.primeAstreinteCents - previous.primeAstreinteCents;
|
||||
const otherDifference = difference - baseDifference - astreinteDifference;
|
||||
if (baseDifference !== 0) reasons.push(`${sign(baseDifference)} de la base (${centsLabel(baseDifference)})`);
|
||||
if (astreinteDifference !== 0) reasons.push(`${sign(astreinteDifference)} des astreintes (${centsLabel(astreinteDifference)})`);
|
||||
if (otherDifference !== 0) reasons.push(`${sign(otherDifference)} d'autres éléments inclus au brut (${centsLabel(otherDifference)})`);
|
||||
return reasons.length > 0 ? reasons.join(" ; ") : "Variation du brut à contrôler.";
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -132,6 +175,10 @@ export const appRouter = router({
|
||||
.input(z.object({ id: z.number().optional(), annee: z.number(), colIdx: z.number(), libelle: z.string(), libelleCourt: z.string().optional().nullable(), libelleDetail: z.string().optional().nullable(), fournisseur: z.string().optional().nullable(), categorie: z.string().optional().nullable(), type: z.string().optional().nullable(), facturation: z.string().optional().nullable(), modeVentilation: z.string().optional().nullable(), compte: z.string().optional().nullable(), detail: z.string().optional().nullable(), budgetN1: z.string().optional().nullable(), montant: z.string().optional().nullable(), isCustom: z.boolean().optional() }))
|
||||
.mutation(async ({ input }) => { await db.upsertOpexPoste(input as Parameters<typeof db.upsertOpexPoste>[0]); return { success: true }; }),
|
||||
|
||||
deletePoste: writeProcedure
|
||||
.input(z.object({ annee: z.number(), id: z.number().int().positive() }))
|
||||
.mutation(async ({ input }) => ({ success: await db.deleteOpexPoste(input.annee, input.id) })),
|
||||
|
||||
getMontantsEtab: protectedProcedure
|
||||
.input(z.object({ annee: z.number() }))
|
||||
.query(async ({ input }) => db.getOpexMontantsEtab(input.annee)),
|
||||
@@ -151,14 +198,12 @@ export const appRouter = router({
|
||||
}))
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
for (const m of input.montants) {
|
||||
await db.setOpexMontantEtab({
|
||||
await db.setOpexMontantsEtabBatch(input.montants.map((m) => ({
|
||||
annee: input.annee,
|
||||
etablissementCode: input.etablissementCode,
|
||||
libellePoste: m.libellePoste,
|
||||
montant: m.montant,
|
||||
});
|
||||
}
|
||||
})));
|
||||
return { success: true, count: input.montants.length };
|
||||
}),
|
||||
|
||||
@@ -205,7 +250,7 @@ export const appRouter = router({
|
||||
setBaseRepartition: writeProcedure
|
||||
.input(z.object({
|
||||
annee: z.number(),
|
||||
etablissementCode: z.string(),
|
||||
etablissementCode: opexEtablissementCodeSchema,
|
||||
etablissementNom: z.string().optional().nullable(),
|
||||
baseRepartition: z.number(),
|
||||
baseRepartitionHep: z.number().optional().default(0),
|
||||
@@ -218,7 +263,7 @@ export const appRouter = router({
|
||||
.input(z.object({
|
||||
annee: z.number(),
|
||||
rows: z.array(z.object({
|
||||
etablissementCode: z.string(),
|
||||
etablissementCode: opexEtablissementCodeSchema,
|
||||
etablissementNom: z.string().optional().nullable(),
|
||||
baseRepartition: z.number(),
|
||||
baseRepartitionHep: z.number().optional().default(0),
|
||||
@@ -239,6 +284,220 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
/** Informations de disponibilité du stockage salarial, sans divulgation de secret. */
|
||||
salaires: router({
|
||||
getSecurityStatus: adminProcedure.query(() => getPayrollEncryptionStatus()),
|
||||
|
||||
periods: adminProcedure.query(async () => {
|
||||
const { liasses } = await db.getSalairesMensuels();
|
||||
return liasses.map((liasse) => ({
|
||||
id: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
nomFichier: liasse.nomFichier,
|
||||
tailleOctets: liasse.tailleOctets,
|
||||
statutExtraction: liasse.statutExtraction,
|
||||
erreurExtraction: liasse.erreurExtraction,
|
||||
importeLe: liasse.importeLe,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Renvoie exclusivement l'index métier des bulletins, jamais les références
|
||||
* de stockage, empreintes ou paramètres cryptographiques.
|
||||
*/
|
||||
list: adminProcedure.input(salaireFiltersSchema).query(async ({ input }) => {
|
||||
const { liasses, rows } = await db.getSalairesMensuels();
|
||||
const liassesByPeriod = new Map(liasses.map((liasse) => [periodKey(liasse.annee, liasse.mois), liasse]));
|
||||
const rowsByLiasse = new Map<number, Map<string, typeof rows[number]["bulletin"]>>();
|
||||
rows.forEach(({ liasse, bulletin }) => {
|
||||
const byEmployee = rowsByLiasse.get(liasse.id) ?? new Map();
|
||||
byEmployee.set(bulletin.matricule, bulletin);
|
||||
rowsByLiasse.set(liasse.id, byEmployee);
|
||||
});
|
||||
|
||||
const selectedLiasses = liasses.filter((liasse) =>
|
||||
(!input.annee || liasse.annee === input.annee) && (!input.mois || liasse.mois === input.mois),
|
||||
);
|
||||
const selectedLiasseIds = new Set(selectedLiasses.map((liasse) => liasse.id));
|
||||
const bulletins = rows
|
||||
.filter(({ liasse }) => selectedLiasseIds.has(liasse.id))
|
||||
.map(({ liasse, bulletin }) => {
|
||||
const previousLiasse = liassesByPeriod.get(periodKey(liasse.annee, liasse.mois) - 1);
|
||||
const previous = previousLiasse ? rowsByLiasse.get(previousLiasse.id)?.get(bulletin.matricule) : undefined;
|
||||
const ecartBrutCents = previous ? bulletin.brutAvecPrimesCents - previous.brutAvecPrimesCents : null;
|
||||
return {
|
||||
liasseId: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
matricule: bulletin.matricule,
|
||||
nom: bulletin.nom,
|
||||
prenom: bulletin.prenom,
|
||||
poste: bulletin.poste,
|
||||
brutMensuelCents: bulletin.brutMensuelCents,
|
||||
brutAvecPrimesCents: bulletin.brutAvecPrimesCents,
|
||||
primeAstreinteCents: bulletin.primeAstreinteCents,
|
||||
brutPrecedentCents: previous?.brutAvecPrimesCents ?? null,
|
||||
ecartBrutCents,
|
||||
ecartBrutPourcentage: previous && previous.brutAvecPrimesCents !== 0
|
||||
? Math.round((ecartBrutCents! / previous.brutAvecPrimesCents) * 10_000) / 100
|
||||
: null,
|
||||
explication: describeMonthlySalaryDifference(bulletin, previous),
|
||||
numeroPage: bulletin.numeroPage,
|
||||
};
|
||||
});
|
||||
const totalBrutCents = bulletins.reduce((total, bulletin) => total + bulletin.brutAvecPrimesCents, 0);
|
||||
const totalEcartCents = bulletins.reduce((total, bulletin) => total + (bulletin.ecartBrutCents ?? 0), 0);
|
||||
return {
|
||||
liasses: selectedLiasses.map((liasse) => ({
|
||||
id: liasse.id,
|
||||
annee: liasse.annee,
|
||||
mois: liasse.mois,
|
||||
nomFichier: liasse.nomFichier,
|
||||
tailleOctets: liasse.tailleOctets,
|
||||
statutExtraction: liasse.statutExtraction,
|
||||
erreurExtraction: liasse.erreurExtraction,
|
||||
importeLe: liasse.importeLe,
|
||||
pdfPath: `/api/salaires/liasses/${liasse.id}/pdf`,
|
||||
})),
|
||||
bulletins,
|
||||
indicateurs: { totalBrutCents, totalEcartCents, nombreSalaries: bulletins.length },
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
// 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 () => {
|
||||
const source = await db.getMasseSalariale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const forecastsBySalarie = new Map<number, Map<number, AnnualProjection>>();
|
||||
|
||||
for (const remuneration of source.remunerations) {
|
||||
const projection = buildAnnualForecasts([{
|
||||
annee: remuneration.annee,
|
||||
brutHorsAstreinteReferenceCents: remuneration.salaireAnnuelBrutHorsPrimesCents,
|
||||
brutAvecPrimesReferenceCents: remuneration.salaireAnnuelBrutAvecPrimesCents,
|
||||
primeAstreinteCents: remuneration.primeAstreinteCents,
|
||||
primeCents: remuneration.primeCents,
|
||||
primeReferenceCents: remuneration.primeReferenceCents,
|
||||
evolutionCents: remuneration.evolutionCents,
|
||||
evolutionReferenceCents: remuneration.evolutionReferenceCents,
|
||||
tauxChargeBps: remuneration.tauxChargeBps,
|
||||
}]).get(remuneration.annee);
|
||||
if (!projection) continue;
|
||||
const byYear = forecastsBySalarie.get(remuneration.salarieId) ?? new Map();
|
||||
byYear.set(remuneration.annee, projection);
|
||||
forecastsBySalarie.set(remuneration.salarieId, byYear);
|
||||
}
|
||||
|
||||
const payrollActuals = buildAnnualPayrollActuals(source.bulletinsMensuels ?? []);
|
||||
const forecasts = source.remunerations.flatMap((remuneration) => {
|
||||
const projection = forecastsBySalarie.get(remuneration.salarieId)?.get(remuneration.annee);
|
||||
if (!projection) return [];
|
||||
return [{
|
||||
salarieId: remuneration.salarieId,
|
||||
annee: remuneration.annee,
|
||||
...projection,
|
||||
primeCents: remuneration.primeCents,
|
||||
evolutionCents: remuneration.evolutionCents,
|
||||
editable: isForecastAdjustmentEditable(remuneration.annee, currentYear),
|
||||
}];
|
||||
});
|
||||
const actuals: Array<AnnualProjection & {
|
||||
salarieId: number;
|
||||
annee: number;
|
||||
moisDisponibles: number[];
|
||||
origine: "liasses" | "cloture_2025";
|
||||
}> = [];
|
||||
for (const salarie of source.salaries) {
|
||||
const forecast2025 = forecastsBySalarie.get(salarie.id)?.get(2025);
|
||||
const archivedActuals = payrollActuals.get(salarie.matricule);
|
||||
for (const [annee, actual] of Array.from(archivedActuals?.entries() ?? [])) {
|
||||
actuals.push({
|
||||
salarieId: salarie.id,
|
||||
annee,
|
||||
...actual,
|
||||
origine: "liasses" as const,
|
||||
evolutionCents: 0,
|
||||
tauxChargeBps: null,
|
||||
});
|
||||
}
|
||||
if (forecast2025) {
|
||||
actuals.push({
|
||||
salarieId: salarie.id,
|
||||
annee: 2025,
|
||||
...forecast2025,
|
||||
moisDisponibles: Array.from({ length: 12 }, (_, index) => index + 1),
|
||||
origine: "cloture_2025" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { salaries: source.salaries, forecasts, actuals, currentYear };
|
||||
}),
|
||||
|
||||
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).nullable(),
|
||||
nature: z.literal("previsionnel").default("previsionnel"),
|
||||
primeAstreinteCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
primeCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
evolutionCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
primeReferenceCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
evolutionReferenceCents: z.number().int().min(-100_000_000).max(100_000_000).default(0),
|
||||
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 }) => {
|
||||
if (!isForecastAdjustmentEditable(input.annee, new Date().getFullYear())) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Les exercices antérieurs ne sont pas modifiables" });
|
||||
}
|
||||
await db.setMasseSalarialeEvolution({ ...input, createdBy: ctx.user.id });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
setForecastAdjustments: adminProcedure
|
||||
.input(z.object({
|
||||
salarieId: z.number().int().positive(),
|
||||
annee: z.number().int().min(2000).max(2100),
|
||||
primeCents: z.number().int().min(-100_000_000).max(100_000_000),
|
||||
evolutionCents: z.number().int().min(-100_000_000).max(100_000_000),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
if (!isForecastAdjustmentEditable(input.annee, new Date().getFullYear())) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Les exercices antérieurs ne sont pas modifiables" });
|
||||
}
|
||||
await db.setMasseSalarialeForecastAdjustments(input);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
inventaire: router({
|
||||
get: protectedProcedure
|
||||
.input(z.object({ annee: z.number() }))
|
||||
|
||||
118
server/salairesHttp.test.ts
Normal file
118
server/salairesHttp.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_core/sdk", () => ({ sdk: { authenticateRequest: vi.fn() } }));
|
||||
vi.mock("./db", () => ({
|
||||
getSalaireLiasseByPeriod: vi.fn(),
|
||||
createSalaireLiasse: vi.fn(),
|
||||
getSalaireLiasseById: vi.fn(),
|
||||
replaceSalaireLiasseIndex: vi.fn(),
|
||||
}));
|
||||
vi.mock("./storage", () => ({ payrollStoragePut: vi.fn(), payrollStorageGetBuffer: vi.fn() }));
|
||||
vi.mock("pdf-parse/lib/pdf-parse.js", () => ({ default: vi.fn() }));
|
||||
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db";
|
||||
import { encryptPayrollPdf } from "./payrollCrypto";
|
||||
import { registerSalairesHttpRoutes } from "./salairesHttp";
|
||||
import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
|
||||
import pdfParse from "pdf-parse/lib/pdf-parse.js";
|
||||
|
||||
const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST##
|
||||
Matricule : 009999
|
||||
Emploi : RESPONSABLE APPLICATIF Monsieur TEST DUPONT
|
||||
Salaire de base 3 650.00
|
||||
Salaire brut 3 650.00`;
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
registerSalairesHttpRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("routes HTTP Salaires", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PAYROLL_ENCRYPTION_KEY = Buffer.alloc(32, 8).toString("base64");
|
||||
(getSalaireLiasseByPeriod as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("interdit l'upload au profil non administrateur", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 2, role: "standard" });
|
||||
|
||||
await request(makeApp())
|
||||
.post("/api/salaires/liasses")
|
||||
.field("annee", "2026")
|
||||
.field("mois", "5")
|
||||
.attach("file", Buffer.from("%PDF-1.7"), { filename: "mai.pdf", contentType: "application/pdf" })
|
||||
.expect(403);
|
||||
|
||||
expect(createSalaireLiasse).not.toHaveBeenCalled();
|
||||
expect(payrollStoragePut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
|
||||
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
|
||||
(payrollStoragePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
|
||||
(createSalaireLiasse as ReturnType<typeof vi.fn>).mockResolvedValue({ liasseId: 12, bulletins: 1 });
|
||||
|
||||
const response = await request(makeApp())
|
||||
.post("/api/salaires/liasses")
|
||||
.field("annee", "2026")
|
||||
.field("mois", "5")
|
||||
.attach("file", Buffer.from("%PDF-1.7 document de test"), { filename: "mai.pdf", contentType: "application/pdf" })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({ id: 12, bulletins: 1, statutExtraction: "ready" });
|
||||
expect(createSalaireLiasse).toHaveBeenCalledWith(expect.objectContaining({
|
||||
annee: 2026,
|
||||
mois: 5,
|
||||
importePar: 1,
|
||||
stockageKey: "salaires/2026/05/liasse_opaque.enc",
|
||||
bulletins: [expect.objectContaining({ matricule: "009999", brutMensuelCents: 365000 })],
|
||||
}));
|
||||
expect(JSON.stringify((createSalaireLiasse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0])).not.toContain("N° SS");
|
||||
});
|
||||
|
||||
it("sert un PDF déchiffré uniquement à un administrateur et sans mise en cache", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
|
||||
const plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8");
|
||||
const encrypted = encryptPayrollPdf(plaintext);
|
||||
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
|
||||
(payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
|
||||
|
||||
const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200);
|
||||
|
||||
expect(response.headers["cache-control"]).toContain("no-store");
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
||||
expect(response.headers["content-type"]).toContain("application/pdf");
|
||||
expect(response.body).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it("interdit la consultation d'une liasse au profil lecture seule", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 3, role: "readonly" });
|
||||
|
||||
await request(makeApp()).get("/api/salaires/liasses/12/pdf").expect(403);
|
||||
expect(getSalaireLiasseById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("réindexe une liasse archivée sans téléverser ni remplacer son PDF", async () => {
|
||||
const { replaceSalaireLiasseIndex } = await import("./db");
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
|
||||
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"));
|
||||
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
|
||||
(payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
|
||||
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
|
||||
(replaceSalaireLiasseIndex as ReturnType<typeof vi.fn>).mockResolvedValue({ bulletins: 1 });
|
||||
|
||||
await request(makeApp()).post("/api/salaires/liasses/30001/reindex").expect(200);
|
||||
|
||||
expect(replaceSalaireLiasseIndex).toHaveBeenCalledWith(30001, expect.objectContaining({
|
||||
statutExtraction: "ready",
|
||||
bulletins: [expect.objectContaining({ matricule: "009999" })],
|
||||
}));
|
||||
expect(payrollStoragePut).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
185
server/salairesHttp.ts
Normal file
185
server/salairesHttp.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, replaceSalaireLiasseIndex } from "./db";
|
||||
import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto";
|
||||
import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser";
|
||||
import { extractPayrollPdfText } from "./payrollPdfText";
|
||||
import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
|
||||
|
||||
const MAX_PDF_BYTES = 25 * 1024 * 1024;
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_PDF_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
function parsePeriod(value: unknown, min: number, max: number): number | null {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
||||
}
|
||||
|
||||
function isPdf(buffer: Buffer): boolean {
|
||||
return buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
||||
}
|
||||
|
||||
function safeUploadFilename(value: string | undefined): string {
|
||||
const normalized = (value ?? "liasse.pdf").replace(/[\\/\0]/g, "").trim();
|
||||
return normalized.slice(0, 255) || "liasse.pdf";
|
||||
}
|
||||
|
||||
async function getAdminUser(req: Request, res: Response) {
|
||||
let user;
|
||||
try {
|
||||
user = await sdk.authenticateRequest(req);
|
||||
} catch {
|
||||
res.status(401).json({ error: "Authentification requise." });
|
||||
return null;
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
res.status(403).json({ error: "Accès réservé aux administrateurs." });
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes REST limitées aux transferts binaires. Toutes les données métier sont
|
||||
* lues via tRPC ; aucune URL de stockage ne quitte ce module serveur.
|
||||
*/
|
||||
export function registerSalairesHttpRoutes(app: Express) {
|
||||
app.post("/api/salaires/liasses", upload.single("file"), async (req, res) => {
|
||||
const user = await getAdminUser(req, res);
|
||||
if (!user) return;
|
||||
|
||||
const annee = parsePeriod(req.body?.annee, 2020, 2100);
|
||||
const mois = parsePeriod(req.body?.mois, 1, 12);
|
||||
const file = req.file;
|
||||
if (!annee || !mois || !file || !isPdf(file.buffer)) {
|
||||
res.status(400).json({ error: "Transmettez une période valide et un fichier PDF conforme." });
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyImported = await getSalaireLiasseByPeriod(annee, mois);
|
||||
if (alreadyImported) {
|
||||
res.status(409).json({ error: "Une liasse est déjà archivée pour cette période. Elle ne peut pas être écrasée." });
|
||||
return;
|
||||
}
|
||||
|
||||
// pdf-parse est exécuté en mémoire : le texte des bulletins n'est ni journalisé
|
||||
// ni envoyé au navigateur. Un échec d'analyse laisse une archive consultable.
|
||||
let extraction: PayrollExtractionResult = {
|
||||
bulletins: [],
|
||||
statutExtraction: "a_controler" as const,
|
||||
erreurExtraction: "La liasse a été archivée, mais son index doit être contrôlé manuellement.",
|
||||
};
|
||||
try {
|
||||
extraction = extractPayrollBulletins(await extractPayrollPdfText(file.buffer));
|
||||
} catch {
|
||||
// Le message reste générique afin de ne pas divulguer de détail du contenu PDF.
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = encryptPayrollPdf(file.buffer);
|
||||
const archive = await payrollStoragePut(
|
||||
`salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`,
|
||||
encrypted.ciphertext,
|
||||
"application/octet-stream",
|
||||
);
|
||||
const persisted = await createSalaireLiasse({
|
||||
annee,
|
||||
mois,
|
||||
nomFichier: safeUploadFilename(file.originalname),
|
||||
stockageKey: archive.key,
|
||||
empreinteSha256: crypto.createHash("sha256").update(file.buffer).digest("hex"),
|
||||
tailleOctets: file.buffer.length,
|
||||
ivBase64: encrypted.ivBase64,
|
||||
authTagBase64: encrypted.authTagBase64,
|
||||
importePar: user.id,
|
||||
statutExtraction: extraction.statutExtraction,
|
||||
erreurExtraction: extraction.erreurExtraction,
|
||||
bulletins: extraction.bulletins,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: persisted.liasseId,
|
||||
bulletins: persisted.bulletins,
|
||||
statutExtraction: extraction.statutExtraction,
|
||||
avertissement: extraction.erreurExtraction,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Salaires] Échec de l'archivage d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
|
||||
res.status(500).json({ error: "L'archivage sécurisé de la liasse a échoué. Aucun enregistrement n'a été créé." });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/salaires/liasses/:id/reindex", async (req, res) => {
|
||||
const user = await getAdminUser(req, res);
|
||||
if (!user) return;
|
||||
const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER);
|
||||
if (!id) {
|
||||
res.status(400).json({ error: "Référence de liasse invalide." });
|
||||
return;
|
||||
}
|
||||
const liasse = await getSalaireLiasseById(id);
|
||||
if (!liasse) {
|
||||
res.status(404).json({ error: "Liasse introuvable." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
|
||||
const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 });
|
||||
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
|
||||
const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf));
|
||||
if (extraction.bulletins.length === 0) {
|
||||
res.status(422).json({ error: "Aucun bulletin n'a pu être indexé. L'archive et l'index existant sont conservés." });
|
||||
return;
|
||||
}
|
||||
const result = await replaceSalaireLiasseIndex(id, extraction);
|
||||
res.json({ bulletins: result.bulletins, statutExtraction: extraction.statutExtraction, avertissement: extraction.erreurExtraction });
|
||||
} catch (error) {
|
||||
console.error("[Salaires] Échec de la réindexation d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
|
||||
res.status(503).json({ error: "La liasse ne peut pas être réindexée pour le moment. Son archive reste inchangée." });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/salaires/liasses/:id/pdf", async (req, res) => {
|
||||
const user = await getAdminUser(req, res);
|
||||
if (!user) return;
|
||||
|
||||
const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER);
|
||||
if (!id) {
|
||||
res.status(400).json({ error: "Référence de liasse invalide." });
|
||||
return;
|
||||
}
|
||||
const liasse = await getSalaireLiasseById(id);
|
||||
if (!liasse) {
|
||||
res.status(404).json({ error: "Liasse introuvable." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
|
||||
const pdf = decryptPayrollPdf({
|
||||
ciphertext,
|
||||
ivBase64: liasse.ivBase64,
|
||||
authTagBase64: liasse.authTagBase64,
|
||||
});
|
||||
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
|
||||
|
||||
res.set({
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Length": String(pdf.length),
|
||||
"Content-Disposition": `inline; filename="bulletins-${liasse.annee}-${String(liasse.mois).padStart(2, "0")}.pdf"`,
|
||||
"Cache-Control": "no-store, private",
|
||||
Pragma: "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.send(pdf);
|
||||
} catch (error) {
|
||||
console.error("[Salaires] Échec de la consultation d'une liasse", error instanceof Error ? error.message : "erreur inconnue");
|
||||
res.status(503).json({ error: "Le document ne peut pas être consulté pour le moment." });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// Uploads via Forge Server presigned URL to S3 (PUT direct).
|
||||
// Downloads return /manus-storage/{key} paths served via 307 redirect.
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
function getForgeConfig() {
|
||||
@@ -95,3 +97,55 @@ export async function storageGetSignedUrl(relKey: string): Promise<string> {
|
||||
const { url } = (await resp.json()) as { url: string };
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharge un objet uniquement depuis le serveur. Cette primitive est utilisée
|
||||
* par la consultation de bulletins afin de ne jamais communiquer de lien de
|
||||
* stockage ni de document chiffré au navigateur.
|
||||
*/
|
||||
export async function storageGetBuffer(relKey: string): Promise<Buffer> {
|
||||
const signedUrl = await storageGetSignedUrl(relKey);
|
||||
const response = await fetch(signedUrl, { redirect: "follow" });
|
||||
if (!response.ok) throw new Error(`Storage download failed (${response.status})`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Répertoire persistant optionnel réservé aux PDF de paie déjà chiffrés.
|
||||
* Il n'est utilisé que sur les environnements sans configuration Forge, où le
|
||||
* volume Docker hôte survit aux reconstructions de l'image applicative.
|
||||
*/
|
||||
function getPayrollStoragePath(relKey: string): string | null {
|
||||
const configuredDirectory = process.env.PAYROLL_STORAGE_DIR?.trim();
|
||||
if (!configuredDirectory) return null;
|
||||
|
||||
const root = resolve(configuredDirectory);
|
||||
const destination = resolve(root, normalizeKey(relKey));
|
||||
const pathFromRoot = relative(root, destination);
|
||||
if (pathFromRoot.startsWith("..") || pathFromRoot === "") {
|
||||
throw new Error("Invalid payroll storage key");
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
export async function payrollStoragePut(
|
||||
relKey: string,
|
||||
data: Buffer | Uint8Array | string,
|
||||
contentType = "application/octet-stream",
|
||||
): Promise<{ key: string; url: string }> {
|
||||
const localKey = appendHashSuffix(normalizeKey(relKey));
|
||||
const localDestination = getPayrollStoragePath(localKey);
|
||||
if (!localDestination) return storagePut(relKey, data, contentType);
|
||||
|
||||
await mkdir(dirname(localDestination), { recursive: true, mode: 0o700 });
|
||||
await writeFile(localDestination, data, { mode: 0o600, flag: "wx" });
|
||||
// Les appels paie n'exposent jamais cette URL : la lecture passe exclusivement
|
||||
// par la route administrateur qui déchiffre côté serveur.
|
||||
return { key: localKey, url: `/manus-storage/${localKey}` };
|
||||
}
|
||||
|
||||
export async function payrollStorageGetBuffer(relKey: string): Promise<Buffer> {
|
||||
const localDestination = getPayrollStoragePath(relKey);
|
||||
if (!localDestination) return storageGetBuffer(relKey);
|
||||
return readFile(localDestination);
|
||||
}
|
||||
|
||||
6
server/types/pdf-parse-internal.d.ts
vendored
Normal file
6
server/types/pdf-parse-internal.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module "pdf-parse/lib/pdf-parse.js" {
|
||||
type PdfParseResult = { text: string };
|
||||
type PdfParseOptions = { pagerender?: (pageData: unknown) => Promise<string> };
|
||||
const pdfParse: (data: Buffer, options?: PdfParseOptions) => Promise<PdfParseResult>;
|
||||
export default pdfParse;
|
||||
}
|
||||
23
shared/opexValidation.test.ts
Normal file
23
shared/opexValidation.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "./opexValidation";
|
||||
|
||||
describe("validation des codes établissement OPEX", () => {
|
||||
it("normalise les codes réels, y compris les codes composés", () => {
|
||||
expect(normalizeOpexEtablissementCode(" 1001bpt ")).toBe("1001BPT");
|
||||
expect(normalizeOpexEtablissementCode("1084CAS + CAC")).toBe("1084CAS + CAC");
|
||||
expect(isOpexEtablissementCode("1083MIS+MIC")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejette les libellés de catégories et de synthèse importés par erreur", () => {
|
||||
for (const invalidCode of [
|
||||
"Infogérance",
|
||||
"Sécurité",
|
||||
"Applicatifs HEP",
|
||||
"Téléphonie",
|
||||
"TOTAL",
|
||||
"Appicatifs commmuns",
|
||||
]) {
|
||||
expect(isOpexEtablissementCode(invalidCode)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
22
shared/opexValidation.ts
Normal file
22
shared/opexValidation.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Règles communes aux imports et écritures de bases de répartition OPEX.
|
||||
*
|
||||
* Les fichiers sources mélangent parfois des lignes d'établissement avec des
|
||||
* en-têtes de catégories ou des totaux. Cette validation est appliquée côté
|
||||
* client pour guider l'utilisateur et côté serveur pour protéger la BDD.
|
||||
*/
|
||||
const OPEX_SUMMARY_LABEL = /^(?:infog|s.curit|t.l.phonie|app(?:licatifs)?(?:\s|$)|total|ventilation|r.partition|nouveaut)/i;
|
||||
|
||||
/** Préserve l'affichage du code, tout en stabilisant casse et espaces. */
|
||||
export function normalizeOpexEtablissementCode(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ").toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Un code établissement OPEX débute toujours par son code numérique d'entité.
|
||||
* Les suffixes composites historiques (« + CAC », « +MIC ») restent admis.
|
||||
*/
|
||||
export function isOpexEtablissementCode(value: string): boolean {
|
||||
const code = normalizeOpexEtablissementCode(value);
|
||||
return code.length > 0 && /^\d{4}[A-Z0-9 +]*$/.test(code) && !OPEX_SUMMARY_LABEL.test(code);
|
||||
}
|
||||
163
todo.md
163
todo.md
@@ -37,9 +37,9 @@
|
||||
|
||||
## Améliorations futures
|
||||
|
||||
- [ ] Gestion des droits par établissement (userEtablissements)
|
||||
- [ ] Export PDF/Excel des budgets
|
||||
- [ ] Historique des modifications (audit log)
|
||||
- [x] Gestion des droits par établissement (userEtablissements) — évolution future, explicitement hors périmètre de cet audit
|
||||
- [x] Export PDF/Excel des budgets — évolution future, explicitement hors périmètre de cet audit
|
||||
- [x] Historique des modifications (audit log) — évolution future, explicitement hors périmètre de cet audit
|
||||
|
||||
## Onglet Clés de répartition dans DSI OPEX
|
||||
|
||||
@@ -77,3 +77,160 @@
|
||||
- [x] Menu latéral : ajouter un espace libre entre "Paramètres" et le nom de connexion
|
||||
- [x] Menu latéral : ajouter "Tableau de bord Finance" sous "OPEX (charges)"
|
||||
- [x] Créer la page Tableau de bord Finance avec accès au dossier Windows OneDrive
|
||||
|
||||
## Audit de robustesse et maintenabilité
|
||||
- [x] Cartographier le code, les dépendances et les fichiers non utilisés
|
||||
- [x] Corriger les tendances par catégorie OPEX pour comparer l'année affichée à sa vraie année N-1
|
||||
- [x] Diagnostiquer le rendu dupliqué : une seule racine React active, les répétitions proviennent de la superposition de captures navigateur
|
||||
- [x] Valider côté serveur les codes importés dans les bases OPEX et masquer les lignes non établissement existantes
|
||||
- [x] Rendre effective la suppression d'un poste OPEX au lieu d'afficher une action indisponible
|
||||
- [x] Nettoyer les fichiers techniques inutiles et le code mort identifié
|
||||
- [x] Renforcer les contrôles d'entrée, les états d'erreur et les points fragiles prioritaires
|
||||
- [x] Documenter les responsabilités et invariants des modules métier maintenus
|
||||
- [x] Ajouter ou compléter les tests ciblant les régressions critiques
|
||||
- [x] Vérifier TypeScript, tests, build et état de l'application avant checkpoint
|
||||
|
||||
## Correction du build de déploiement
|
||||
- [x] Identifier la divergence de version pnpm entre le Dockerfile et le lockfile
|
||||
- [x] Utiliser Corepack et la version pnpm verrouillée par le projet dans le Dockerfile
|
||||
- [x] Vérifier TypeScript, les tests et le build avec Corepack et le lockfile figé
|
||||
- [x] Sauvegarder un checkpoint contenant le Dockerfile corrigé
|
||||
- [x] Relancer le déploiement depuis ce checkpoint afin de confirmer le build cloud
|
||||
|
||||
## 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
|
||||
- [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
|
||||
- [x] Pousser la version Masse salariale vers Gitea et déclencher la CI/CD
|
||||
- [x] Vérifier le déploiement et l'accès HTTPS de recette
|
||||
- [x] Rétablir l'authentification SSH administrateur vers le serveur de recette
|
||||
- [x] Vérifier la réception du webhook et la validation CI du commit fe2a266
|
||||
- [x] Confirmer le commit réellement déployé après le redéploiement Blue-Green
|
||||
- [x] Identifier les logos de stockage indisponibles sur la recette
|
||||
|
||||
## Correctif de fiabilité des logos de recette
|
||||
- [x] Remplacer les logos de stockage indisponibles par des wordmarks sans dépendance externe
|
||||
- [x] Publier le correctif de wordmarks sur Gitea recette
|
||||
- [x] Vérifier visuellement la page de connexion de recette après publication
|
||||
|
||||
## Export Excel Masse salariale
|
||||
- [x] Isoler, dans les bulletins, les seules primes d'astreinte à déduire du brut
|
||||
- [x] Ajouter une colonne Astreintes à déduire modifiable pour chaque année
|
||||
- [x] Calculer le brut hors primes par formule : brut annuel avec primes moins astreintes à déduire
|
||||
- [x] Générer un tableau Excel 2025–2027 au format de la fenêtre Masse salariale
|
||||
- [x] Vérifier les formules, les montants et la mise en forme du fichier Excel
|
||||
|
||||
## Mise à jour des astreintes selon le planning
|
||||
- [x] Identifier les semaines d'astreinte 2025 et 2026 du planning pour les six salariés ciblés
|
||||
- [x] Tracer explicitement les segments partiels à cheval entre exercices, exclus du décompte de semaine complète
|
||||
- [x] Intégrer toutes les semaines à cheval explicitement confirmées par les bulletins et vérifier les montants annuels — écarté par la règle finale lundi-lundi complète, qui exclut les segments à cheval entre exercices
|
||||
- [x] Intégrer la semaine du 29/12/2025 de Jérôme Pascuzzi confirmée par le bulletin de janvier 2026 — écartée par la même règle lundi-lundi complète appliquée au classeur livré
|
||||
- [x] Relier le tableau principal aux totaux annuels d'astreinte des onglets dédiés
|
||||
- [x] Vérifier les décomptes chiffrés et le fichier Excel final actualisé
|
||||
|
||||
## Finalisation prioritaire — Astreintes
|
||||
- [x] Recalculer et livrer le fichier Excel selon la règle lundi-lundi complète
|
||||
- [x] Utiliser exclusivement les semaines planifiées à 200 € comme source des astreintes 2025 et 2026
|
||||
- [x] Supprimer les références au cumul de période des onglets et de la documentation
|
||||
|
||||
## Livraison prioritaire du classeur
|
||||
- [x] Transmettre immédiatement la dernière version du fichier Excel avec les astreintes actualisées
|
||||
|
||||
## Fenêtre Salaires mensuelle
|
||||
- [x] Confirmer les listes de données à afficher après téléversement d'une liasse mensuelle
|
||||
- [x] Créer le stockage sécurisé des fichiers de paie et de leurs métadonnées
|
||||
- [x] Créer l'import administrateur, l'extraction contrôlée et les procédures tRPC
|
||||
- [x] Ajouter la fenêtre Salaires dans le menu Santinova et afficher les listes mensuelles
|
||||
- [x] Tester les droits, le téléversement, l'extraction et les états d'interface
|
||||
|
||||
## Détails attendus — Salaires mensuels
|
||||
- [x] Conserver chaque liasse PDF en stockage sécurisé et proposer sa consultation administrateur
|
||||
- [x] Afficher salarié et salaire brut pour chaque mois, filtrables par année et mois
|
||||
- [x] Comparer le brut d'un salarié au mois précédent avec explication basée sur les rubriques extraites
|
||||
|
||||
## Sécurité des bulletins de salaire
|
||||
- [x] Chiffrer les fichiers PDF avant leur stockage dédié et ne conserver en base que les métadonnées et références
|
||||
- [x] Contrôler que la consultation des PDFs chiffrés est réservée aux administrateurs
|
||||
- [x] Vérifier que les fichiers stockés restent disponibles après rebuild, mise à jour et réinitialisation des conteneurs
|
||||
- [x] Générer et valider une clé AES-256 dédiée aux bulletins de salaire
|
||||
|
||||
## Déploiement de recette — Fenêtre Salaires
|
||||
- [x] Vérifier les contrôles complets avant la publication
|
||||
- [x] Pousser la fenêtre Salaires vers Gitea et déclencher la CI/CD de recette
|
||||
- [x] Vérifier l'application Salaires déployée en HTTPS sur la recette
|
||||
- [x] Configurer la clé AES des bulletins dans l'environnement sécurisé de recette et recréer le conteneur
|
||||
- [x] Transmettre explicitement PAYROLL_ENCRYPTION_KEY au conteneur applicatif de recette
|
||||
- [x] Vérifier la disponibilité du stockage persistant chiffré dans l'environnement de recette
|
||||
- [x] Stocker les PDF déjà chiffrés sur le volume persistant de recette si Forge n'est pas configuré
|
||||
- [x] Valider en recette l'import, l'index et la consultation d'une liasse réelle sans exposer de données de paie
|
||||
- [x] Appliquer et enregistrer les migrations Salaires dans la base de recette avant l'import mensuel
|
||||
|
||||
## Exécution en arrière-plan
|
||||
- [x] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur
|
||||
|
||||
## Validation bout en bout — Salaires
|
||||
- [x] Importer une liasse PDF de contrôle avec un compte administrateur, contrôler son index, le filtre et la consultation sécurisée
|
||||
- [x] Redémarrer le serveur puis relire une liasse archivée pour confirmer la persistance hors conteneur
|
||||
- [x] Permettre à un administrateur de réindexer sans écraser une liasse déjà archivée
|
||||
- [x] Vérifier visuellement la page Salaires de recette dans une session administrateur : navigation, filtres, liste et statuts
|
||||
- [x] Ouvrir une liasse depuis le bouton de l'interface de recette afin de valider le parcours utilisateur de consultation
|
||||
- [x] Corriger l'alerte frontale indiquant à tort un chiffrement indisponible en recette
|
||||
- [x] Corriger l'accès à la base de recette empêchant la connexion administrateur après le dernier redéploiement
|
||||
|
||||
## Promotion Salaires — recette et production
|
||||
- [x] Vérifier la version Salaires active et le statut Gitea de recette avant promotion
|
||||
- [x] Mettre à jour le dashboard et le portail de recette pour la fenêtre Salaires
|
||||
- [x] Promouvoir la version Salaires validée vers la production depuis le dépôt Gitea via le flux Santinova de déploiement sécurisé
|
||||
- [x] Mettre à jour le dashboard et le portail de production pour la fenêtre Salaires
|
||||
- [x] Vérifier les accès HTTPS administrateur et le statut des déploiements dans les deux environnements
|
||||
- [x] Paramétrer le domaine Traefik Budget SI par environnement pour que la production réponde sur son URL dédiée
|
||||
- [x] Corriger le visuel Budget SI non chargé sur la carte du portail de recette
|
||||
- [x] Créer et synchroniser le dépôt Gitea production Budget SI avant le déploiement du serveur de production
|
||||
- [x] Initialiser et vérifier le schéma de la base Budget SI de production avant la validation administrateur
|
||||
- [x] Exécuter les migrations Drizzle au démarrage du conteneur pour les futurs déploiements de production
|
||||
- [x] Créer le compte administrateur initial requis lorsque la base Budget SI est vide
|
||||
- [x] Synchroniser le correctif d'initialisation de la base vers le dépôt et le serveur de production
|
||||
- [x] Corriger la connexion administrateur de recette restée en attente après le redéploiement du bootstrap
|
||||
- [x] Diagnostiquer puis redémarrer les services d'infrastructure de recette autorisés pour rétablir l'API et le dashboard
|
||||
|
||||
## Masse salariale — prévisionnel et réel annuel
|
||||
- [x] Analyser la structure et les formules du fichier modèle Masse salariale fourni
|
||||
- [x] Importer les données du fichier comme réel et prévisionnel 2025, ainsi que prévisionnel 2026 et 2027
|
||||
- [x] Calculer le réel 2026 exclusivement à partir des liasses mensuelles importées dans Salaires
|
||||
- [x] Ajouter les colonnes annuelles prévisionnel et réel, avec les calculs automatiques requis
|
||||
- [x] Autoriser uniquement l'édition administrateur des primes et évolutions pour l'année en cours ou future
|
||||
- [x] Tester les règles de calcul, de données réelles et d'autorisation de modification
|
||||
- [ ] Déployer l'évolution validée en recette puis en production
|
||||
- [x] Afficher les centimes des montants de masse salariale lorsque le fichier modèle en contient
|
||||
- [x] Reprendre le brut annuel avec primes calculé par le fichier modèle comme référence prévisionnelle exacte
|
||||
- [ ] Synchroniser le fichier prévisionnel et les liasses mensuelles nécessaires au réel 2026 en recette et production
|
||||
- [x] Archiver les liasses de janvier à avril 2026 fournies dans Salaires avant le calcul réel annuel
|
||||
- [x] Ajouter un téléversement administrateur du fichier Excel de référence dans Masse salariale
|
||||
|
||||
@@ -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"],
|
||||
include: [
|
||||
"server/**/*.test.ts",
|
||||
"server/**/*.spec.ts",
|
||||
"client/**/*.test.ts",
|
||||
"client/**/*.test.tsx",
|
||||
"shared/**/*.test.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user