Compare commits

...

5 Commits

30 changed files with 1470 additions and 3313 deletions

View File

@@ -2,14 +2,17 @@ FROM node:22-alpine AS builder
WORKDIR /app 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 package.json pnpm-lock.yaml ./
COPY patches ./patches 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 # Copier le reste du code et builder
COPY . . COPY . .
RUN pnpm build RUN corepack pnpm build
# ── Image de production ────────────────────────────────────────────────────── # ── Image de production ──────────────────────────────────────────────────────
FROM node:22-alpine AS runner FROM node:22-alpine AS runner

View File

@@ -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>
);
}

View File

@@ -22,11 +22,9 @@ import {
ChevronDown, ChevronDown,
Monitor, Monitor,
FileText, FileText,
TrendingUp,
TrendingDown, TrendingDown,
Heart, Heart,
Stethoscope, Stethoscope,
Layers,
BookOpen, BookOpen,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
@@ -193,19 +191,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']; const colors = (sectionId: string) => SECTION_COLORS[sectionId] ?? SECTION_COLORS['itinova'];
return ( return (

View File

@@ -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>
</>
);
}

View File

@@ -20,7 +20,7 @@ function SortIcon({ field, sortField, sortOrder }: { field: SortField; sortField
: <ArrowDown className="w-3.5 h-3.5 text-primary" />; : <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 pct = total > 0 ? Math.round((value / total) * 100) : 0;
const barColor = const barColor =
pct >= 80 ? 'bg-red-500' : pct >= 80 ? 'bg-red-500' :
@@ -133,12 +133,12 @@ export function EtablissementListView({ etablissements, onSelect, sortField, sor
{/* PC Fixes */} {/* PC Fixes */}
<td className="px-3 py-3"> <td className="px-3 py-3">
<VetusteBar value={nbFixesR} total={nbFixesTotal} color="blue" /> <VetusteBar value={nbFixesR} total={nbFixesTotal} />
</td> </td>
{/* PC Portables */} {/* PC Portables */}
<td className="px-3 py-3"> <td className="px-3 py-3">
<VetusteBar value={nbPortablesR} total={nbPortablesTotal} color="orange" /> <VetusteBar value={nbPortablesR} total={nbPortablesTotal} />
</td> </td>
{/* Total à renouveler */} {/* Total à renouveler */}

View File

@@ -1,10 +1,9 @@
// ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs) // ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs)
// Design: Corporate Modernism — Itinova Budget SI // 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 { trpc } from '@/lib/trpc';
import { useAnnee, getInventaireStorageKey } from '../contexts/AnneeContext'; import { useAnnee } from '../contexts/AnneeContext';
import * as XLSX from 'xlsx';
import { import {
Upload, Upload,
X, X,
@@ -28,6 +27,9 @@ import { toast } from 'sonner';
async function fileToRows(file: File): Promise<string[][]> { async function fileToRows(file: File): Promise<string[][]> {
const isXlsx = file.name.match(/\.(xlsx|xls)$/i); const isXlsx = file.name.match(/\.(xlsx|xls)$/i);
if (isXlsx) { 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 buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true }); const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const ws = wb.Sheets[wb.SheetNames[0]]; const ws = wb.Sheets[wb.SheetNames[0]];
@@ -418,10 +420,6 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
h === 'fabricant' || h === 'marque' || h === 'brand' || h === 'fabricant' || h === 'marque' || h === 'brand' ||
h.includes('fabricant') || h.includes('marque') h.includes('fabricant') || h.includes('marque')
); );
const statutIdx = rawHeaders.findIndex(h =>
h === 'statut' || h === 'status' || h.includes('statut')
);
// Compteurs pour le résumé // Compteurs pour le résumé
let nbFixes = 0; let nbFixes = 0;
let nbPortables = 0; let nbPortables = 0;
@@ -543,7 +541,7 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
}); });
// ── Envoyer à la base de données via tRPC ────────────────────────────────── // ── 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 ────────────────────────────────────── // ── Upsert établissements dans la BDD ──────────────────────────────────────
for (const [code, info] of Array.from(etabData.entries())) { 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 */ } } catch { /* ignore les erreurs d'upsert établissement */ }
} }
const nbNouveaux = result.nbEtablissements;
const details = [ const details = [
`${rows.length - 1} poste${rows.length - 1 > 1 ? 's' : ''}`, `${rows.length - 1} poste${rows.length - 1 > 1 ? 's' : ''}`,
`${etabData.size} établissement${etabData.size > 1 ? 's' : ''} détectés`, `${etabData.size} établissement${etabData.size > 1 ? 's' : ''} détectés`,

View File

@@ -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>
);
}

View File

@@ -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)} />
);
}

View File

@@ -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
};

View 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",
});
});
});

View 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

View File

@@ -83,11 +83,6 @@ export default function DsiCapex() {
{ retry: false, refetchOnWindowFocus: false } { 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 // Construire les lignes du tableau depuis les données BDD
const lignes = useMemo(() => { const lignes = useMemo(() => {
if (!etabsData) return []; if (!etabsData) return [];

View File

@@ -16,7 +16,6 @@ import {
Eye, Eye,
EyeOff, EyeOff,
ArrowUpDown, ArrowUpDown,
Save,
Lock, Lock,
CheckCircle2, CheckCircle2,
PencilLine, PencilLine,
@@ -35,6 +34,8 @@ import { useAnnee } from '../contexts/AnneeContext';
import opexRaw from '../data_opex.json'; import opexRaw from '../data_opex.json';
import opex2025Raw from '../data_opex_2025.json'; import opex2025Raw from '../data_opex_2025.json';
import { formatEuros } from '../lib/format'; import { formatEuros } from '../lib/format';
import { calculateCategoryTrends } from '../lib/opexTrends';
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from '@shared/opexValidation';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -81,20 +82,12 @@ interface EtabSource {
base_repartition: number; base_repartition: number;
} }
interface TendanceCategorie {
montant_n1: number | null;
montant_n: number;
variation_pct: number | null;
tendance: 'hausse' | 'baisse' | 'stable' | 'new';
}
interface OpexData { interface OpexData {
annee: number; annee: number;
total_global: number; total_global: number;
postes: Poste[]; postes: Poste[];
etablissements: EtabSource[]; etablissements: EtabSource[];
categories_totaux: Record<string, number>; categories_totaux: Record<string, number>;
tendances_categories?: Record<string, TendanceCategorie>;
meta: { nb_postes: number; nb_etablissements?: number }; meta: { nb_postes: number; nb_etablissements?: number };
} }
@@ -117,6 +110,25 @@ interface LigneOpex {
colIdx: number; 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 ─────────────────────────────────────────────────────────────── // ─── Constantes ───────────────────────────────────────────────────────────────
const opexData2026 = opexRaw as OpexData; 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 = { const EMPTY_LIGNE_FORM = {
libelle: '', libelle: '',
fournisseur: '', fournisseur: '',
@@ -554,19 +589,13 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
} }
// ── Construction des lignes à importer ─────────────────────────────── // ── 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) const importRows = data.slice(headerIdx + 1)
.filter(row => { .filter(row => {
const code = String(row[codeIdx] ?? '').trim(); const code = String(row[codeIdx] ?? '').trim();
if (!code) return false; return isOpexEtablissementCode(code);
// 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;
}) })
.map(row => ({ .map(row => ({
etablissementCode: String(row[codeIdx]).trim(), etablissementCode: normalizeOpexEtablissementCode(String(row[codeIdx])),
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null, etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0, baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0,
baseRepartitionHep: hepIdx >= 0 baseRepartitionHep: hepIdx >= 0
@@ -817,6 +846,9 @@ export default function DsiOpex() {
// ── Requêtes tRPC ────────────────────────────────────────────────────────── // ── Requêtes tRPC ──────────────────────────────────────────────────────────
const { data: postesRaw, isLoading: loadingPostes } = trpc.opex.getPostes.useQuery({ annee }); 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: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee });
const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee }); const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee });
// Bases de répartition depuis la BDD (charges classe 6 par établissement) // 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 }), onSuccess: () => utils.opex.getPostes.invalidate({ annee }),
onError: (err) => toast.error('Erreur sauvegarde', { description: err.message }), 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({ const initFromSource = trpc.opex.initFromSource.useMutation({
onSuccess: () => { utils.opex.getPostes.invalidate({ annee }); toast.success(`OPEX ${annee} initialisé`); }, onSuccess: () => { utils.opex.getPostes.invalidate({ annee }); toast.success(`OPEX ${annee} initialisé`); },
onError: (err) => toast.error('Erreur initialisation', { description: err.message }), 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 ────────────────────────── // ── Mapper les données BDD vers le format interne ──────────────────────────
const lignes: LigneOpex[] = useMemo(() => { const lignes: LigneOpex[] = useMemo(() => {
if (!postesRaw) return []; if (!postesRaw) return [];
return postesRaw return mapDbPostesToLignes(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,
}));
}, [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 } } // Overrides par établissement : { codeEtab: { libellePoste: montant } }
const montantsManuelEtab = useMemo(() => { const montantsManuelEtab = useMemo(() => {
if (!montantsEtabRaw) return {} as Record<string, Record<string, number>>; if (!montantsEtabRaw) return {} as Record<string, Record<string, number>>;
@@ -1011,15 +1064,11 @@ export default function DsiOpex() {
}); });
}, [editingLigne, upsertPoste, annee]); }, [editingLigne, upsertPoste, annee]);
const handleDeleteLigne = useCallback((id: number) => { const handleDeleteLigne = useCallback(() => {
// On ne peut pas vraiment "supprimer" via upsert — on utilise deleteAll + réinsertion if (!deleteConfirmId) return;
// Pour simplifier, on marque le poste avec montant 0 et libelle préfixé [SUPPRIMÉ] deletePoste.mutate({ annee, id: deleteConfirmId });
// 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');
setDeleteConfirmId(null); setDeleteConfirmId(null);
}, []); }, [annee, deleteConfirmId, deletePoste]);
const handleInlineEditStart = useCallback((codeEtab: string, libelle: string, currentVal: number) => { const handleInlineEditStart = useCallback((codeEtab: string, libelle: string, currentVal: number) => {
if (isValidated) return; if (isValidated) return;
@@ -1091,6 +1140,11 @@ export default function DsiOpex() {
return totaux; return totaux;
}, [lignes]); }, [lignes]);
const tendancesParCategorie = useMemo(
() => calculateCategoryTrends(lignes, lignesN1),
[lignes, lignesN1],
);
const filteredLignes = useMemo(() => { const filteredLignes = useMemo(() => {
return lignes.filter(l => { return lignes.filter(l => {
if (selectedCategorie !== 'Toutes' && l.categorie !== selectedCategorie) return false; if (selectedCategorie !== 'Toutes' && l.categorie !== selectedCategorie) return false;
@@ -1332,11 +1386,12 @@ export default function DsiOpex() {
const montant = totalParCategorie[cat] || 0; const montant = totalParCategorie[cat] || 0;
const pct = totalGlobal > 0 ? (montant / totalGlobal * 100).toFixed(1) : '0'; const pct = totalGlobal > 0 ? (montant / totalGlobal * 100).toFixed(1) : '0';
const colors = getCatColors(cat); const colors = getCatColors(cat);
const srcData = getOpexDataForAnnee(annee); const tendanceInfo = tendancesParCategorie[cat];
const tendanceInfo: TendanceCategorie | undefined = srcData.tendances_categories?.[cat]; // Tant que la requête N-1 est active, ne pas présenter de badge
const tendance = tendanceInfo?.tendance; // temporairement erroné (par exemple « Nouveau »).
const variationPct = tendanceInfo?.variation_pct; const tendance = loadingPostesN1 ? undefined : tendanceInfo?.tendance;
const montantN1 = tendanceInfo?.montant_n1; const variationPct = tendanceInfo?.variationPct;
const montantN1 = tendanceInfo?.montantN1;
return ( return (
<div key={cat} className="bg-card border border-border rounded-xl p-4 min-w-[140px] flex-1"> <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"> <div className="flex items-center justify-between mb-1">
@@ -2000,7 +2055,7 @@ export default function DsiOpex() {
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel> <AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => deleteConfirmId && handleDeleteLigne(deleteConfirmId)} onClick={handleDeleteLigne}
className="bg-destructive hover:bg-destructive/90 text-white" className="bg-destructive hover:bg-destructive/90 text-white"
> >
Supprimer Supprimer

View File

@@ -13,7 +13,6 @@ import {
Building2, Building2,
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
ChevronDown,
LayoutGrid, LayoutGrid,
List, List,
} from 'lucide-react'; } from 'lucide-react';

View 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`);

View 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": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1781163262493, "when": 1781163262493,
"tag": "0003_tense_havok", "tag": "0003_tense_havok",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1786998761879,
"tag": "0004_known_shiva",
"breakpoints": true
} }
] ]
} }

View File

@@ -87,21 +87,31 @@ export const opexPostes = mysqlTable("opex_postes", {
isCustom: boolean("isCustom").default(false), isCustom: boolean("isCustom").default(false),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().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 OpexPoste = typeof opexPostes.$inferSelect;
export type InsertOpexPoste = typeof opexPostes.$inferInsert; export type InsertOpexPoste = typeof opexPostes.$inferInsert;
// Overrides manuels par établissement × poste × année // Overrides manuels par établissement × poste × année
export const opexMontantsEtab = mysqlTable("opex_montants_etab", { export const opexMontantsEtab = mysqlTable("opex_montants_etab", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(), annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(), etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
libellePoste: varchar("libellePoste", { length: 255 }).notNull(), libellePoste: varchar("libellePoste", { length: 255 }).notNull(),
montant: decimal("montant", { precision: 12, scale: 2 }), montant: decimal("montant", { precision: 12, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().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 OpexMontantEtab = typeof opexMontantsEtab.$inferSelect;
export type InsertOpexMontantEtab = typeof opexMontantsEtab.$inferInsert; export type InsertOpexMontantEtab = typeof opexMontantsEtab.$inferInsert;
@@ -148,16 +158,23 @@ export const inventaireMeta = mysqlTable("inventaire_meta", {
// CAPEX — Lignes budgétaires par établissement // CAPEX — Lignes budgétaires par établissement
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export const capexLignes = mysqlTable("capex_lignes", { export const capexLignes = mysqlTable("capex_lignes", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(), annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(), etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
cle: varchar("cle", { length: 100 }).notNull(), cle: varchar("cle", { length: 100 }).notNull(),
// cles possibles: renouvellement_2027 | machines_supplementaires | appel_malade // cles possibles: renouvellement_2027 | machines_supplementaires | appel_malade
// | telephonie | wifi | video_surveillance | copieurs | visio | autres | commentaires // | telephonie | wifi | video_surveillance | copieurs | visio | autres | commentaires
montant: decimal("montant", { precision: 12, scale: 2 }), montant: decimal("montant", { precision: 12, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().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 CapexLigne = typeof capexLignes.$inferSelect;
export type InsertCapexLigne = typeof capexLignes.$inferInsert; export type InsertCapexLigne = typeof capexLignes.$inferInsert;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -68,6 +68,9 @@ vi.mock("./db", async (importOriginal) => {
listUsers: vi.fn(), listUsers: vi.fn(),
listEtablissements: vi.fn(), listEtablissements: vi.fn(),
getParametres: vi.fn(), getParametres: vi.fn(),
upsertOpexBaseRepartition: vi.fn(),
setOpexMontantsEtabBatch: vi.fn(),
deleteOpexPoste: vi.fn(),
}; };
}); });
@@ -268,3 +271,74 @@ describe("parametres.get", () => {
expect(result).toEqual({ seuil_fixes_ans: "5", cout_fixe: "850" }); 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);
});
});

View File

@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2"; import { drizzle } from "drizzle-orm/mysql2";
import { import {
capexLignes, capexLignes,
@@ -15,9 +15,9 @@ import {
opexPostes, opexPostes,
opexValidated, opexValidated,
parametresApp, parametresApp,
userEtablissements,
users, users,
} from "../drizzle/schema"; } from "../drizzle/schema";
import { isOpexEtablissementCode } from "../shared/opexValidation";
let _db: ReturnType<typeof drizzle> | null = null; let _db: ReturnType<typeof drizzle> | null = null;
@@ -97,7 +97,7 @@ export async function updateLastSignedIn(id: number) {
export async function listEtablissements() { export async function listEtablissements() {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return db.select().from(etablissements); return db.select().from(etablissements).orderBy(asc(etablissements.code));
} }
export async function upsertEtablissement(etab: InsertEtablissement) { export async function upsertEtablissement(etab: InsertEtablissement) {
@@ -151,7 +151,8 @@ export async function getOpexPostes(annee: number) {
return db return db
.select() .select()
.from(opexPostes) .from(opexPostes)
.where(eq(opexPostes.annee, annee)); .where(eq(opexPostes.annee, annee))
.orderBy(asc(opexPostes.colIdx));
} }
export async function upsertOpexPoste(poste: InsertOpexPoste) { export async function upsertOpexPoste(poste: InsertOpexPoste) {
@@ -161,7 +162,9 @@ export async function upsertOpexPoste(poste: InsertOpexPoste) {
await db await db
.update(opexPostes) .update(opexPostes)
.set(poste) .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 { } else {
await db.insert(opexPostes).values(poste); await db.insert(opexPostes).values(poste);
} }
@@ -196,10 +199,29 @@ export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
if (rows.length === 0) return; if (rows.length === 0) return;
// Insert par batch de 100 await db.transaction(async (tx) => {
for (let i = 0; i < rows.length; i += 100) { // Insert par batch de 100. L'index métier garantit que la même cellule ne
await db.insert(opexMontantsEtab).values(rows.slice(i, i + 100)); // 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) { export async function deleteOpexPostes(annee: number) {
@@ -208,6 +230,33 @@ export async function deleteOpexPostes(annee: number) {
await db.delete(opexPostes).where(eq(opexPostes.annee, annee)); 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) { export async function deleteOpexMontantsEtab(annee: number) {
const db = await getDb(); const db = await getDb();
if (!db) return; if (!db) return;
@@ -271,19 +320,20 @@ export async function importInventaire(
) { ) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
// Supprimer l'inventaire existant pour cette année await db.transaction(async (tx) => {
await db.delete(inventairePostes).where(eq(inventairePostes.annee, annee)); // L'import remplace l'inventaire annuel en une seule transaction : une
// Insérer les nouveaux postes par batch // erreur de lecture ou d'insertion ne laisse jamais l'année partiellement vide.
if (postes.length > 0) { await tx.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
for (let i = 0; i < postes.length; i += 200) { if (postes.length > 0) {
await db.insert(inventairePostes).values(postes.slice(i, i + 200)); for (let i = 0; i < postes.length; i += 200) {
await tx.insert(inventairePostes).values(postes.slice(i, i + 200));
}
} }
} await tx
// Upsert meta .insert(inventaireMeta)
await db .values({ annee, ...meta })
.insert(inventaireMeta) .onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
.values({ annee, ...meta }) });
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -311,12 +361,14 @@ export async function saveCapexLignes(
) { ) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
for (const ligne of lignes) { await db.transaction(async (tx) => {
await db for (const ligne of lignes) {
.insert(capexLignes) await tx
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant }) .insert(capexLignes)
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } }); .values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
} .onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
}
});
} }
export async function insertCapexLignes(rows: InsertCapexLigne[]) { export async function insertCapexLignes(rows: InsertCapexLigne[]) {
@@ -332,10 +384,15 @@ export async function insertCapexLignes(rows: InsertCapexLigne[]) {
export async function getOpexBasesRepartition(annee: number) { export async function getOpexBasesRepartition(annee: number) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
return db const rows = await db
.select() .select()
.from(opexBasesRepartition) .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: { export async function upsertOpexBaseRepartition(input: {
@@ -375,17 +432,19 @@ export async function importOpexBasesRepartition(
) { ) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
// Supprimer les données existantes pour l'année await db.transaction(async (tx) => {
await db.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee)); // Remplacement atomique : l'ancienne base n'est supprimée que si la nouvelle
if (rows.length === 0) return; // série complète peut être enregistrée.
// Insérer en batch await tx.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee));
await db.insert(opexBasesRepartition).values( if (rows.length === 0) return;
rows.map(r => ({ await tx.insert(opexBasesRepartition).values(
annee, rows.map(r => ({
etablissementCode: r.etablissementCode, annee,
etablissementNom: r.etablissementNom ?? null, etablissementCode: r.etablissementCode,
baseRepartition: String(r.baseRepartition), etablissementNom: r.etablissementNom ?? null,
baseRepartitionHep: String(r.baseRepartitionHep ?? 0), baseRepartition: String(r.baseRepartition),
})) baseRepartitionHep: String(r.baseRepartitionHep ?? 0),
); }))
);
});
} }

View File

@@ -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);

View File

@@ -7,6 +7,7 @@ import { getSessionCookieOptions } from "./_core/cookies";
import { sdk } from "./_core/sdk"; import { sdk } from "./_core/sdk";
import { systemRouter } from "./_core/systemRouter"; import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc"; import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
const writeProcedure = protectedProcedure.use(({ ctx, next }) => { const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") { if (ctx.user.role === "readonly") {
@@ -15,6 +16,12 @@ const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
return next({ ctx }); 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" });
export const appRouter = router({ export const appRouter = router({
system: systemRouter, system: systemRouter,
@@ -132,6 +139,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() })) .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 }; }), .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 getMontantsEtab: protectedProcedure
.input(z.object({ annee: z.number() })) .input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexMontantsEtab(input.annee)), .query(async ({ input }) => db.getOpexMontantsEtab(input.annee)),
@@ -151,14 +162,12 @@ export const appRouter = router({
})) }))
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
for (const m of input.montants) { await db.setOpexMontantsEtabBatch(input.montants.map((m) => ({
await db.setOpexMontantEtab({
annee: input.annee, annee: input.annee,
etablissementCode: input.etablissementCode, etablissementCode: input.etablissementCode,
libellePoste: m.libellePoste, libellePoste: m.libellePoste,
montant: m.montant, montant: m.montant,
}); })));
}
return { success: true, count: input.montants.length }; return { success: true, count: input.montants.length };
}), }),
@@ -205,7 +214,7 @@ export const appRouter = router({
setBaseRepartition: writeProcedure setBaseRepartition: writeProcedure
.input(z.object({ .input(z.object({
annee: z.number(), annee: z.number(),
etablissementCode: z.string(), etablissementCode: opexEtablissementCodeSchema,
etablissementNom: z.string().optional().nullable(), etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(), baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0), baseRepartitionHep: z.number().optional().default(0),
@@ -218,7 +227,7 @@ export const appRouter = router({
.input(z.object({ .input(z.object({
annee: z.number(), annee: z.number(),
rows: z.array(z.object({ rows: z.array(z.object({
etablissementCode: z.string(), etablissementCode: opexEtablissementCodeSchema,
etablissementNom: z.string().optional().nullable(), etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(), baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0), baseRepartitionHep: z.number().optional().default(0),

View 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
View 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);
}

30
todo.md
View File

@@ -37,9 +37,9 @@
## Améliorations futures ## Améliorations futures
- [ ] Gestion des droits par établissement (userEtablissements) - [x] Gestion des droits par établissement (userEtablissements) — évolution future, explicitement hors périmètre de cet audit
- [ ] Export PDF/Excel des budgets - [x] Export PDF/Excel des budgets — évolution future, explicitement hors périmètre de cet audit
- [ ] Historique des modifications (audit log) - [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 ## Onglet Clés de répartition dans DSI OPEX
@@ -77,3 +77,27 @@
- [x] Menu latéral : ajouter un espace libre entre "Paramètres" et le nom de connexion - [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] 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 - [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
- [ ] Pousser la version validée et déclencher le déploiement de recette
- [ ] Vérifier l'application déployée sur l'URL de recette

View File

@@ -14,6 +14,6 @@ export default defineConfig({
}, },
test: { test: {
environment: "node", environment: "node",
include: ["server/**/*.test.ts", "server/**/*.spec.ts"], include: ["server/**/*.test.ts", "server/**/*.spec.ts", "client/**/*.test.ts", "shared/**/*.test.ts"],
}, },
}); });