Compare commits
5 Commits
06afee0d0b
...
3b52527fd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b52527fd0 | ||
|
|
eec1c4e9f7 | ||
|
|
9a1b9f0ac6 | ||
|
|
07b96c03c1 | ||
|
|
615dd46059 |
@@ -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
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Loader2, Send, User, Sparkles } from "lucide-react";
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { Streamdown } from "streamdown";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Message type matching server-side LLM Message interface
|
|
||||||
*/
|
|
||||||
export type Message = {
|
|
||||||
role: "system" | "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AIChatBoxProps = {
|
|
||||||
/**
|
|
||||||
* Messages array to display in the chat.
|
|
||||||
* Should match the format used by invokeLLM on the server.
|
|
||||||
*/
|
|
||||||
messages: Message[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback when user sends a message.
|
|
||||||
* Typically you'll call a tRPC mutation here to invoke the LLM.
|
|
||||||
*/
|
|
||||||
onSendMessage: (content: string) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the AI is currently generating a response
|
|
||||||
*/
|
|
||||||
isLoading?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder text for the input field
|
|
||||||
*/
|
|
||||||
placeholder?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom className for the container
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of the chat box (default: 600px)
|
|
||||||
*/
|
|
||||||
height?: string | number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empty state message to display when no messages
|
|
||||||
*/
|
|
||||||
emptyStateMessage?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suggested prompts to display in empty state
|
|
||||||
* Click to send directly
|
|
||||||
*/
|
|
||||||
suggestedPrompts?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A ready-to-use AI chat box component that integrates with the LLM system.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Matches server-side Message interface for seamless integration
|
|
||||||
* - Markdown rendering with Streamdown
|
|
||||||
* - Auto-scrolls to latest message
|
|
||||||
* - Loading states
|
|
||||||
* - Uses global theme colors from index.css
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const ChatPage = () => {
|
|
||||||
* const [messages, setMessages] = useState<Message[]>([
|
|
||||||
* { role: "system", content: "You are a helpful assistant." }
|
|
||||||
* ]);
|
|
||||||
*
|
|
||||||
* const chatMutation = trpc.ai.chat.useMutation({
|
|
||||||
* onSuccess: (response) => {
|
|
||||||
* // Assuming your tRPC endpoint returns the AI response as a string
|
|
||||||
* setMessages(prev => [...prev, {
|
|
||||||
* role: "assistant",
|
|
||||||
* content: response
|
|
||||||
* }]);
|
|
||||||
* },
|
|
||||||
* onError: (error) => {
|
|
||||||
* console.error("Chat error:", error);
|
|
||||||
* // Optionally show error message to user
|
|
||||||
* }
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* const handleSend = (content: string) => {
|
|
||||||
* const newMessages = [...messages, { role: "user", content }];
|
|
||||||
* setMessages(newMessages);
|
|
||||||
* chatMutation.mutate({ messages: newMessages });
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <AIChatBox
|
|
||||||
* messages={messages}
|
|
||||||
* onSendMessage={handleSend}
|
|
||||||
* isLoading={chatMutation.isPending}
|
|
||||||
* suggestedPrompts={[
|
|
||||||
* "Explain quantum computing",
|
|
||||||
* "Write a hello world in Python"
|
|
||||||
* ]}
|
|
||||||
* />
|
|
||||||
* );
|
|
||||||
* };
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AIChatBox({
|
|
||||||
messages,
|
|
||||||
onSendMessage,
|
|
||||||
isLoading = false,
|
|
||||||
placeholder = "Type your message...",
|
|
||||||
className,
|
|
||||||
height = "600px",
|
|
||||||
emptyStateMessage = "Start a conversation with AI",
|
|
||||||
suggestedPrompts,
|
|
||||||
}: AIChatBoxProps) {
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputAreaRef = useRef<HTMLFormElement>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
// Filter out system messages
|
|
||||||
const displayMessages = messages.filter((msg) => msg.role !== "system");
|
|
||||||
|
|
||||||
// Calculate min-height for last assistant message to push user message to top
|
|
||||||
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (containerRef.current && inputAreaRef.current) {
|
|
||||||
const containerHeight = containerRef.current.offsetHeight;
|
|
||||||
const inputHeight = inputAreaRef.current.offsetHeight;
|
|
||||||
const scrollAreaHeight = containerHeight - inputHeight;
|
|
||||||
|
|
||||||
// Reserve space for:
|
|
||||||
// - padding (p-4 = 32px top+bottom)
|
|
||||||
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
|
|
||||||
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
|
|
||||||
const userMessageReservedHeight = 56;
|
|
||||||
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
|
|
||||||
|
|
||||||
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Scroll to bottom helper function with smooth animation
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
const viewport = scrollAreaRef.current?.querySelector(
|
|
||||||
'[data-radix-scroll-area-viewport]'
|
|
||||||
) as HTMLDivElement;
|
|
||||||
|
|
||||||
if (viewport) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
viewport.scrollTo({
|
|
||||||
top: viewport.scrollHeight,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const trimmedInput = input.trim();
|
|
||||||
if (!trimmedInput || isLoading) return;
|
|
||||||
|
|
||||||
onSendMessage(trimmedInput);
|
|
||||||
setInput("");
|
|
||||||
|
|
||||||
// Scroll immediately after sending
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
// Keep focus on input
|
|
||||||
textareaRef.current?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{ height }}
|
|
||||||
>
|
|
||||||
{/* Messages Area */}
|
|
||||||
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
|
|
||||||
{displayMessages.length === 0 ? (
|
|
||||||
<div className="flex h-full flex-col p-4">
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Sparkles className="size-12 opacity-20" />
|
|
||||||
<p className="text-sm">{emptyStateMessage}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{suggestedPrompts && suggestedPrompts.length > 0 && (
|
|
||||||
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
|
|
||||||
{suggestedPrompts.map((prompt, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => onSendMessage(prompt)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{prompt}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="flex flex-col space-y-4 p-4">
|
|
||||||
{displayMessages.map((message, index) => {
|
|
||||||
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
|
|
||||||
const isLastMessage = index === displayMessages.length - 1;
|
|
||||||
const shouldApplyMinHeight =
|
|
||||||
isLastMessage && !isLoading && minHeightForLastMessage > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
"flex gap-3",
|
|
||||||
message.role === "user"
|
|
||||||
? "justify-end items-start"
|
|
||||||
: "justify-start items-start"
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
shouldApplyMinHeight
|
|
||||||
? { minHeight: `${minHeightForLastMessage}px` }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{message.role === "assistant" && (
|
|
||||||
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"max-w-[80%] rounded-lg px-4 py-2.5",
|
|
||||||
message.role === "user"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{message.role === "assistant" ? (
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
|
||||||
<Streamdown>{message.content}</Streamdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="whitespace-pre-wrap text-sm">
|
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message.role === "user" && (
|
|
||||||
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
|
|
||||||
<User className="size-4 text-secondary-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div
|
|
||||||
className="flex items-start gap-3"
|
|
||||||
style={
|
|
||||||
minHeightForLastMessage > 0
|
|
||||||
? { minHeight: `${minHeightForLastMessage}px` }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-muted px-4 py-2.5">
|
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<form
|
|
||||||
ref={inputAreaRef}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="flex gap-2 p-4 border-t bg-background/50 items-end"
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="flex-1 max-h-32 resize-none min-h-9"
|
|
||||||
rows={1}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="icon"
|
|
||||||
disabled={!input.trim() || isLoading}
|
|
||||||
className="shrink-0 h-[38px] w-[38px]"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -22,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 (
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
import { useAuth } from "@/_core/hooks/useAuth";
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarInset,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarTrigger,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { getLoginUrl } from "@/const";
|
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
|
||||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
|
||||||
import { useLocation } from "wouter";
|
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
|
||||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
|
||||||
const DEFAULT_WIDTH = 280;
|
|
||||||
const MIN_WIDTH = 200;
|
|
||||||
const MAX_WIDTH = 480;
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
||||||
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
||||||
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
|
||||||
});
|
|
||||||
const { loading, user } = useAuth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
|
||||||
}, [sidebarWidth]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <DashboardLayoutSkeleton />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
|
|
||||||
<div className="flex flex-col items-center gap-6">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-center">
|
|
||||||
Sign in to continue
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
||||||
Access to this dashboard requires authentication. Continue to launch the login flow.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = getLoginUrl();
|
|
||||||
}}
|
|
||||||
size="lg"
|
|
||||||
className="w-full shadow-lg hover:shadow-xl transition-all"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": `${sidebarWidth}px`,
|
|
||||||
} as CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
|
|
||||||
{children}
|
|
||||||
</DashboardLayoutContent>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardLayoutContentProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
setSidebarWidth: (width: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function DashboardLayoutContent({
|
|
||||||
children,
|
|
||||||
setSidebarWidth,
|
|
||||||
}: DashboardLayoutContentProps) {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const [location, setLocation] = useLocation();
|
|
||||||
const { state, toggleSidebar } = useSidebar();
|
|
||||||
const isCollapsed = state === "collapsed";
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
||||||
const activeMenuItem = menuItems.find(item => item.path === location);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isCollapsed) {
|
|
||||||
setIsResizing(false);
|
|
||||||
}
|
|
||||||
}, [isCollapsed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isResizing) return;
|
|
||||||
|
|
||||||
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
|
||||||
const newWidth = e.clientX - sidebarLeft;
|
|
||||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
|
||||||
setSidebarWidth(newWidth);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsResizing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isResizing) {
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
document.body.style.cursor = "col-resize";
|
|
||||||
document.body.style.userSelect = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
document.body.style.cursor = "";
|
|
||||||
document.body.style.userSelect = "";
|
|
||||||
};
|
|
||||||
}, [isResizing, setSidebarWidth]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="relative" ref={sidebarRef}>
|
|
||||||
<Sidebar
|
|
||||||
collapsible="icon"
|
|
||||||
className="border-r-0"
|
|
||||||
disableTransition={isResizing}
|
|
||||||
>
|
|
||||||
<SidebarHeader className="h-16 justify-center">
|
|
||||||
<div className="flex items-center gap-3 px-2 transition-all w-full">
|
|
||||||
<button
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
|
||||||
aria-label="Toggle navigation"
|
|
||||||
>
|
|
||||||
<PanelLeft className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
{!isCollapsed ? (
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="font-semibold tracking-tight truncate">
|
|
||||||
Navigation
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
|
||||||
|
|
||||||
<SidebarContent className="gap-0">
|
|
||||||
<SidebarMenu className="px-2 py-1">
|
|
||||||
{menuItems.map(item => {
|
|
||||||
const isActive = location === item.path;
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={item.path}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={isActive}
|
|
||||||
onClick={() => setLocation(item.path)}
|
|
||||||
tooltip={item.label}
|
|
||||||
className={`h-10 transition-all font-normal`}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarContent>
|
|
||||||
|
|
||||||
<SidebarFooter className="p-3">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
||||||
<Avatar className="h-9 w-9 border shrink-0">
|
|
||||||
<AvatarFallback className="text-xs font-medium">
|
|
||||||
{(user?.firstName || user?.login)?.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
|
||||||
<p className="text-sm font-medium truncate leading-none">
|
|
||||||
{user?.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user?.login || "-"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
|
||||||
{user?.email || "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={logout}
|
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>Sign out</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
<div
|
|
||||||
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
|
||||||
onMouseDown={() => {
|
|
||||||
if (isCollapsed) return;
|
|
||||||
setIsResizing(true);
|
|
||||||
}}
|
|
||||||
style={{ zIndex: 50 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SidebarInset>
|
|
||||||
{isMobile && (
|
|
||||||
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="tracking-tight text-foreground">
|
|
||||||
{activeMenuItem?.label ?? "Menu"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<main className="flex-1 p-4">{children}</main>
|
|
||||||
</SidebarInset>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ function SortIcon({ field, sortField, sortOrder }: { field: SortField; sortField
|
|||||||
: <ArrowDown className="w-3.5 h-3.5 text-primary" />;
|
: <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 */}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
interface ManusDialogProps {
|
|
||||||
title?: string;
|
|
||||||
logo?: string;
|
|
||||||
open?: boolean;
|
|
||||||
onLogin: () => void;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ManusDialog({
|
|
||||||
title,
|
|
||||||
logo,
|
|
||||||
open = false,
|
|
||||||
onLogin,
|
|
||||||
onOpenChange,
|
|
||||||
onClose,
|
|
||||||
}: ManusDialogProps) {
|
|
||||||
const [internalOpen, setInternalOpen] = useState(open);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onOpenChange) {
|
|
||||||
setInternalOpen(open);
|
|
||||||
}
|
|
||||||
}, [open, onOpenChange]);
|
|
||||||
|
|
||||||
const handleOpenChange = (nextOpen: boolean) => {
|
|
||||||
if (onOpenChange) {
|
|
||||||
onOpenChange(nextOpen);
|
|
||||||
} else {
|
|
||||||
setInternalOpen(nextOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextOpen) {
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={onOpenChange ? open : internalOpen}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
>
|
|
||||||
<DialogContent className="py-5 bg-[#f8f8f7] rounded-[20px] w-[400px] shadow-[0px_4px_11px_0px_rgba(0,0,0,0.08)] border border-[rgba(0,0,0,0.08)] backdrop-blur-2xl p-0 gap-0 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2 p-5 pt-12">
|
|
||||||
{logo ? (
|
|
||||||
<div className="w-16 h-16 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] flex items-center justify-center">
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt="Dialog graphic"
|
|
||||||
className="w-10 h-10 rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Title and subtitle */}
|
|
||||||
{title ? (
|
|
||||||
<DialogTitle className="text-xl font-semibold text-[#34322d] leading-[26px] tracking-[-0.44px]">
|
|
||||||
{title}
|
|
||||||
</DialogTitle>
|
|
||||||
) : null}
|
|
||||||
<DialogDescription className="text-sm text-[#858481] leading-5 tracking-[-0.154px]">
|
|
||||||
Please login with Manus to continue
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="px-5 py-5">
|
|
||||||
{/* Login button */}
|
|
||||||
<Button
|
|
||||||
onClick={onLogin}
|
|
||||||
className="w-full h-10 bg-[#1a1a19] hover:bg-[#1a1a19]/90 text-white rounded-[10px] text-sm font-medium leading-5 tracking-[-0.154px]"
|
|
||||||
>
|
|
||||||
Login with Manus
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
/**
|
|
||||||
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
|
|
||||||
*
|
|
||||||
* USAGE FROM PARENT COMPONENT:
|
|
||||||
* ======
|
|
||||||
*
|
|
||||||
* const mapRef = useRef<google.maps.Map | null>(null);
|
|
||||||
*
|
|
||||||
* <MapView
|
|
||||||
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
|
|
||||||
* initialZoom={15}
|
|
||||||
* onMapReady={(map) => {
|
|
||||||
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
|
|
||||||
* </MapView>
|
|
||||||
*
|
|
||||||
* ======
|
|
||||||
* Available Libraries and Core Features:
|
|
||||||
* -------------------------------
|
|
||||||
* 📍 MARKER (from `marker` library)
|
|
||||||
* - Attaches to map using { map, position }
|
|
||||||
* new google.maps.marker.AdvancedMarkerElement({
|
|
||||||
* map,
|
|
||||||
* position: { lat: 37.7749, lng: -122.4194 },
|
|
||||||
* title: "San Francisco",
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* -------------------------------
|
|
||||||
* 🏢 PLACES (from `places` library)
|
|
||||||
* - Does not attach directly to map; use data with your map manually.
|
|
||||||
* const place = new google.maps.places.Place({ id: PLACE_ID });
|
|
||||||
* await place.fetchFields({ fields: ["displayName", "location"] });
|
|
||||||
* map.setCenter(place.location);
|
|
||||||
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
|
|
||||||
*
|
|
||||||
* -------------------------------
|
|
||||||
* 🧭 GEOCODER (from `geocoding` library)
|
|
||||||
* - Standalone service; manually apply results to map.
|
|
||||||
* const geocoder = new google.maps.Geocoder();
|
|
||||||
* geocoder.geocode({ address: "New York" }, (results, status) => {
|
|
||||||
* if (status === "OK" && results[0]) {
|
|
||||||
* map.setCenter(results[0].geometry.location);
|
|
||||||
* new google.maps.marker.AdvancedMarkerElement({
|
|
||||||
* map,
|
|
||||||
* position: results[0].geometry.location,
|
|
||||||
* });
|
|
||||||
* }
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* -------------------------------
|
|
||||||
* 📐 GEOMETRY (from `geometry` library)
|
|
||||||
* - Pure utility functions; not attached to map.
|
|
||||||
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
|
|
||||||
*
|
|
||||||
* -------------------------------
|
|
||||||
* 🛣️ ROUTES (from `routes` library)
|
|
||||||
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
|
|
||||||
* const directionsService = new google.maps.DirectionsService();
|
|
||||||
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
|
|
||||||
* directionsService.route(
|
|
||||||
* { origin, destination, travelMode: "DRIVING" },
|
|
||||||
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* -------------------------------
|
|
||||||
* 🌦️ MAP LAYERS (attach directly to map)
|
|
||||||
* - new google.maps.TrafficLayer().setMap(map);
|
|
||||||
* - new google.maps.TransitLayer().setMap(map);
|
|
||||||
* - new google.maps.BicyclingLayer().setMap(map);
|
|
||||||
*
|
|
||||||
* -------------------------------
|
|
||||||
* ✅ SUMMARY
|
|
||||||
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
|
|
||||||
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
|
|
||||||
* - “data-only” → Place, Geometry utilities.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/// <reference types="@types/google.maps" />
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { usePersistFn } from "@/hooks/usePersistFn";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
google?: typeof google;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
|
|
||||||
const FORGE_BASE_URL =
|
|
||||||
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
|
|
||||||
"https://forge.butterfly-effect.dev";
|
|
||||||
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
|
|
||||||
|
|
||||||
function loadMapScript() {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
|
|
||||||
script.async = true;
|
|
||||||
script.crossOrigin = "anonymous";
|
|
||||||
script.onload = () => {
|
|
||||||
resolve(null);
|
|
||||||
script.remove(); // Clean up immediately
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
console.error("Failed to load Google Maps script");
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MapViewProps {
|
|
||||||
className?: string;
|
|
||||||
initialCenter?: google.maps.LatLngLiteral;
|
|
||||||
initialZoom?: number;
|
|
||||||
onMapReady?: (map: google.maps.Map) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MapView({
|
|
||||||
className,
|
|
||||||
initialCenter = { lat: 37.7749, lng: -122.4194 },
|
|
||||||
initialZoom = 12,
|
|
||||||
onMapReady,
|
|
||||||
}: MapViewProps) {
|
|
||||||
const mapContainer = useRef<HTMLDivElement>(null);
|
|
||||||
const map = useRef<google.maps.Map | null>(null);
|
|
||||||
|
|
||||||
const init = usePersistFn(async () => {
|
|
||||||
await loadMapScript();
|
|
||||||
if (!mapContainer.current) {
|
|
||||||
console.error("Map container not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
map.current = new window.google.maps.Map(mapContainer.current, {
|
|
||||||
zoom: initialZoom,
|
|
||||||
center: initialCenter,
|
|
||||||
mapTypeControl: true,
|
|
||||||
fullscreenControl: true,
|
|
||||||
zoomControl: true,
|
|
||||||
streetViewControl: true,
|
|
||||||
mapId: "DEMO_MAP_ID",
|
|
||||||
});
|
|
||||||
if (onMapReady) {
|
|
||||||
onMapReady(map.current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
init();
|
|
||||||
}, [init]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
50
client/src/lib/opexTrends.test.ts
Normal file
50
client/src/lib/opexTrends.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { calculateCategoryTrends } from "./opexTrends";
|
||||||
|
|
||||||
|
describe("calculateCategoryTrends", () => {
|
||||||
|
it("compare les montants de l'exercice affiché à ceux de sa vraie année N-1", () => {
|
||||||
|
const trends = calculateCategoryTrends(
|
||||||
|
[
|
||||||
|
{ categorie: "App global", montant: 504_980 },
|
||||||
|
{ categorie: "App HEP", montant: 98_000 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ categorie: "App global", montant: 505_730 },
|
||||||
|
{ categorie: "App HEP", montant: 98_000 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Régression : 2027 ne doit jamais réutiliser le +26 % (comparaison 2026/2025).
|
||||||
|
expect(trends["App global"]).toEqual({
|
||||||
|
montantN1: 505_730,
|
||||||
|
variationPct: -0.1,
|
||||||
|
tendance: "stable",
|
||||||
|
});
|
||||||
|
expect(trends["App HEP"]).toEqual({
|
||||||
|
montantN1: 98_000,
|
||||||
|
variationPct: 0,
|
||||||
|
tendance: "stable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identifie une nouvelle catégorie et une variation significative", () => {
|
||||||
|
const trends = calculateCategoryTrends(
|
||||||
|
[
|
||||||
|
{ categorie: "App global", montant: 1_050 },
|
||||||
|
{ categorie: "Sécurité", montant: 20 },
|
||||||
|
],
|
||||||
|
[{ categorie: "App global", montant: 1_000 }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trends["App global"]).toEqual({
|
||||||
|
montantN1: 1_000,
|
||||||
|
variationPct: 5,
|
||||||
|
tendance: "hausse",
|
||||||
|
});
|
||||||
|
expect(trends["Sécurité"]).toEqual({
|
||||||
|
montantN1: null,
|
||||||
|
variationPct: null,
|
||||||
|
tendance: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
client/src/lib/opexTrends.ts
Normal file
73
client/src/lib/opexTrends.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Calculs purs des tendances OPEX utilisées par les vignettes de catégories.
|
||||||
|
*
|
||||||
|
* Cette fonction ne dépend volontairement ni de React, ni des fichiers JSON
|
||||||
|
* historiques : la comparaison est toujours faite avec les montants réellement
|
||||||
|
* chargés pour l'exercice précédent.
|
||||||
|
*/
|
||||||
|
export type OpexCategoryLine = {
|
||||||
|
categorie: string | null | undefined;
|
||||||
|
montant: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpexCategoryTrend = {
|
||||||
|
montantN1: number | null;
|
||||||
|
variationPct: number | null;
|
||||||
|
tendance: "hausse" | "baisse" | "stable" | "new";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Toute variation strictement inférieure à ce seuil est considérée stable. */
|
||||||
|
export const STABLE_VARIATION_THRESHOLD_PERCENT = 2;
|
||||||
|
|
||||||
|
function toCategoryTotals(lines: OpexCategoryLine[]): Record<string, number> {
|
||||||
|
return lines.reduce<Record<string, number>>((totals, line) => {
|
||||||
|
const category = line.categorie?.trim() || "Autre";
|
||||||
|
totals[category] = (totals[category] ?? 0) + line.montant;
|
||||||
|
return totals;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les tendances par catégorie pour l'exercice courant.
|
||||||
|
*
|
||||||
|
* Les pourcentages sont arrondis à une décimale pour correspondre à l'affichage
|
||||||
|
* et éviter des différences entre le badge et les données fournies au composant.
|
||||||
|
*/
|
||||||
|
export function calculateCategoryTrends(
|
||||||
|
currentLines: OpexCategoryLine[],
|
||||||
|
previousLines: OpexCategoryLine[],
|
||||||
|
): Record<string, OpexCategoryTrend> {
|
||||||
|
const currentTotals = toCategoryTotals(currentLines);
|
||||||
|
const previousTotals = toCategoryTotals(previousLines);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(currentTotals).map(([category, currentAmount]) => {
|
||||||
|
const previousAmount = previousTotals[category] ?? 0;
|
||||||
|
|
||||||
|
if (previousAmount === 0) {
|
||||||
|
return [
|
||||||
|
category,
|
||||||
|
{
|
||||||
|
montantN1: null,
|
||||||
|
variationPct: null,
|
||||||
|
tendance: "new" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawVariation = ((currentAmount - previousAmount) / previousAmount) * 100;
|
||||||
|
const variationPct = Math.round(rawVariation * 10) / 10;
|
||||||
|
const tendance =
|
||||||
|
Math.abs(variationPct) < STABLE_VARIATION_THRESHOLD_PERCENT
|
||||||
|
? "stable"
|
||||||
|
: variationPct > 0
|
||||||
|
? "hausse"
|
||||||
|
: "baisse";
|
||||||
|
|
||||||
|
return [
|
||||||
|
category,
|
||||||
|
{ montantN1: previousAmount, variationPct, tendance },
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -83,11 +83,6 @@ export default function DsiCapex() {
|
|||||||
{ retry: false, refetchOnWindowFocus: false }
|
{ 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 [];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronDown,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|||||||
3
drizzle/0004_known_shiva.sql
Normal file
3
drizzle/0004_known_shiva.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `capex_lignes` ADD CONSTRAINT `capex_lignes_annee_etab_cle_idx` UNIQUE(`annee`,`etablissementCode`,`cle`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `opex_montants_etab` ADD CONSTRAINT `opex_montants_annee_etab_poste_idx` UNIQUE(`annee`,`etablissementCode`,`libellePoste`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `opex_postes` ADD CONSTRAINT `opex_postes_annee_libelle_idx` UNIQUE(`annee`,`libelle`);
|
||||||
918
drizzle/meta/0004_snapshot.json
Normal file
918
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,918 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"id": "fcb67a8c-d69f-4eab-b81e-e3256730a27b",
|
||||||
|
"prevId": "44abeed3-e7bf-47cd-a29c-59a27bf57f1a",
|
||||||
|
"tables": {
|
||||||
|
"capex_lignes": {
|
||||||
|
"name": "capex_lignes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"annee": {
|
||||||
|
"name": "annee",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"etablissementCode": {
|
||||||
|
"name": "etablissementCode",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cle": {
|
||||||
|
"name": "cle",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"montant": {
|
||||||
|
"name": "montant",
|
||||||
|
"type": "decimal(12,2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"capex_lignes_annee_etab_cle_idx": {
|
||||||
|
"name": "capex_lignes_annee_etab_cle_idx",
|
||||||
|
"columns": [
|
||||||
|
"annee",
|
||||||
|
"etablissementCode",
|
||||||
|
"cle"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"capex_lignes_id": {
|
||||||
|
"name": "capex_lignes_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"etablissements": {
|
||||||
|
"name": "etablissements",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"nom": {
|
||||||
|
"name": "nom",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"groupe": {
|
||||||
|
"name": "groupe",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ville": {
|
||||||
|
"name": "ville",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"actif": {
|
||||||
|
"name": "actif",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"etablissements_id": {
|
||||||
|
"name": "etablissements_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"etablissements_code_unique": {
|
||||||
|
"name": "etablissements_code_unique",
|
||||||
|
"columns": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"inventaire_meta": {
|
||||||
|
"name": "inventaire_meta",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"annee": {
|
||||||
|
"name": "annee",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dateImport": {
|
||||||
|
"name": "dateImport",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"nbEtablissements": {
|
||||||
|
"name": "nbEtablissements",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"nbFixes": {
|
||||||
|
"name": "nbFixes",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"nbPortables": {
|
||||||
|
"name": "nbPortables",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"inventaire_meta_id": {
|
||||||
|
"name": "inventaire_meta_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"inventaire_meta_annee_unique": {
|
||||||
|
"name": "inventaire_meta_annee_unique",
|
||||||
|
"columns": [
|
||||||
|
"annee"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"inventaire_postes": {
|
||||||
|
"name": "inventaire_postes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"annee": {
|
||||||
|
"name": "annee",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"etablissementCode": {
|
||||||
|
"name": "etablissementCode",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"libelle": {
|
||||||
|
"name": "libelle",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"typePoste": {
|
||||||
|
"name": "typePoste",
|
||||||
|
"type": "enum('fixe','portable')",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dateRef": {
|
||||||
|
"name": "dateRef",
|
||||||
|
"type": "varchar(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ageAns": {
|
||||||
|
"name": "ageAns",
|
||||||
|
"type": "decimal(5,2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"modele": {
|
||||||
|
"name": "modele",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fabricant": {
|
||||||
|
"name": "fabricant",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"inventaire_postes_id": {
|
||||||
|
"name": "inventaire_postes_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"opex_bases_repartition": {
|
||||||
|
"name": "opex_bases_repartition",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"annee": {
|
||||||
|
"name": "annee",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"etablissementCode": {
|
||||||
|
"name": "etablissementCode",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"etablissementNom": {
|
||||||
|
"name": "etablissementNom",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"baseRepartition": {
|
||||||
|
"name": "baseRepartition",
|
||||||
|
"type": "decimal(20,6)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"baseRepartitionHep": {
|
||||||
|
"name": "baseRepartitionHep",
|
||||||
|
"type": "decimal(20,6)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'0'"
|
||||||
|
},
|
||||||
|
"modeManuel": {
|
||||||
|
"name": "modeManuel",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"opex_bases_repartition_annee_etab_idx": {
|
||||||
|
"name": "opex_bases_repartition_annee_etab_idx",
|
||||||
|
"columns": [
|
||||||
|
"annee",
|
||||||
|
"etablissementCode"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"opex_bases_repartition_id": {
|
||||||
|
"name": "opex_bases_repartition_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"opex_montants_etab": {
|
||||||
|
"name": "opex_montants_etab",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"annee": {
|
||||||
|
"name": "annee",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"etablissementCode": {
|
||||||
|
"name": "etablissementCode",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"libellePoste": {
|
||||||
|
"name": "libellePoste",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"montant": {
|
||||||
|
"name": "montant",
|
||||||
|
"type": "decimal(12,2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"opex_montants_annee_etab_poste_idx": {
|
||||||
|
"name": "opex_montants_annee_etab_poste_idx",
|
||||||
|
"columns": [
|
||||||
|
"annee",
|
||||||
|
"etablissementCode",
|
||||||
|
"libellePoste"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"opex_montants_etab_id": {
|
||||||
|
"name": "opex_montants_etab_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"opex_postes": {
|
||||||
|
"name": "opex_postes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"annee": {
|
||||||
|
"name": "annee",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"colIdx": {
|
||||||
|
"name": "colIdx",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"libelle": {
|
||||||
|
"name": "libelle",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"libelleCourt": {
|
||||||
|
"name": "libelleCourt",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"libelleDetail": {
|
||||||
|
"name": "libelleDetail",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fournisseur": {
|
||||||
|
"name": "fournisseur",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"categorie": {
|
||||||
|
"name": "categorie",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"facturation": {
|
||||||
|
"name": "facturation",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"modeVentilation": {
|
||||||
|
"name": "modeVentilation",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'Prorata C 6'"
|
||||||
|
},
|
||||||
|
"compte": {
|
||||||
|
"name": "compte",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"name": "detail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"budgetN1": {
|
||||||
|
"name": "budgetN1",
|
||||||
|
"type": "decimal(12,2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"montant": {
|
||||||
|
"name": "montant",
|
||||||
|
"type": "decimal(12,2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"isCustom": {
|
||||||
|
"name": "isCustom",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"opex_postes_annee_libelle_idx": {
|
||||||
|
"name": "opex_postes_annee_libelle_idx",
|
||||||
|
"columns": [
|
||||||
|
"annee",
|
||||||
|
"libelle"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"opex_postes_id": {
|
||||||
|
"name": "opex_postes_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"opex_validated": {
|
||||||
|
"name": "opex_validated",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"annee": {
|
||||||
|
"name": "annee",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"validatedAt": {
|
||||||
|
"name": "validatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"validatedBy": {
|
||||||
|
"name": "validatedBy",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"opex_validated_id": {
|
||||||
|
"name": "opex_validated_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"opex_validated_annee_unique": {
|
||||||
|
"name": "opex_validated_annee_unique",
|
||||||
|
"columns": [
|
||||||
|
"annee"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"parametres_app": {
|
||||||
|
"name": "parametres_app",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"cle": {
|
||||||
|
"name": "cle",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"valeur": {
|
||||||
|
"name": "valeur",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"parametres_app_id": {
|
||||||
|
"name": "parametres_app_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"parametres_app_cle_unique": {
|
||||||
|
"name": "parametres_app_cle_unique",
|
||||||
|
"columns": [
|
||||||
|
"cle"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"user_etablissements": {
|
||||||
|
"name": "user_etablissements",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"etablissementCode": {
|
||||||
|
"name": "etablissementCode",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_etablissements_id": {
|
||||||
|
"name": "user_etablissements_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"name": "login",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(320)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"passwordHash": {
|
||||||
|
"name": "passwordHash",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"firstName": {
|
||||||
|
"name": "firstName",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lastName": {
|
||||||
|
"name": "lastName",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "enum('admin','standard','readonly')",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'standard'"
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"name": "isActive",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"lastSignedIn": {
|
||||||
|
"name": "lastSignedIn",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"users_id": {
|
||||||
|
"name": "users_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_login_unique": {
|
||||||
|
"name": "users_login_unique",
|
||||||
|
"columns": [
|
||||||
|
"login"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"tables": {},
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { createConnection } from 'mysql2/promise';
|
|
||||||
import { config } from 'dotenv';
|
|
||||||
config();
|
|
||||||
|
|
||||||
const conn = await createConnection(process.env.DATABASE_URL);
|
|
||||||
|
|
||||||
// Libellés parasites connus (noms de catégories ou lignes de synthèse)
|
|
||||||
const PARASITES_KEYWORDS = [
|
|
||||||
'Infogérance', 'Infogerance', 'Sécurité', 'Securite',
|
|
||||||
'Téléphonie', 'Telephonie',
|
|
||||||
'App global', 'App HEP', 'App SMR',
|
|
||||||
'Applicatifs', // commence par
|
|
||||||
'TOTAL', 'Total',
|
|
||||||
'Ventilation', 'Répartition', 'Repartition',
|
|
||||||
'Nouveautés', 'Nouveautes',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 1. Lister les lignes parasites
|
|
||||||
const [rows] = await conn.execute(
|
|
||||||
`SELECT id, annee, etablissementCode, etablissementNom FROM opex_bases_repartition ORDER BY annee, etablissementCode`
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('=== Toutes les lignes opex_bases_repartition ===');
|
|
||||||
for (const r of rows) {
|
|
||||||
const code = r.etablissementCode ?? '';
|
|
||||||
const nom = r.etablissementNom ?? '';
|
|
||||||
const isParasite = PARASITES_KEYWORDS.some(k =>
|
|
||||||
code.toLowerCase().includes(k.toLowerCase()) ||
|
|
||||||
nom.toLowerCase().includes(k.toLowerCase())
|
|
||||||
) || /^(total|ventilation|répartition|applicatifs)/i.test(code.trim());
|
|
||||||
if (isParasite) {
|
|
||||||
console.log(` PARASITE id=${r.id} annee=${r.annee} code="${code}" nom="${nom}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Supprimer les parasites
|
|
||||||
const [delResult] = await conn.execute(
|
|
||||||
`DELETE FROM opex_bases_repartition
|
|
||||||
WHERE etablissementCode REGEXP '^(Infog|S.curit|T.l.phonie|App global|App HEP|App SMR|Applicatifs|TOTAL|Total|Ventilation|R.partition|Nouveaut)'
|
|
||||||
OR etablissementNom REGEXP '^(Infog|S.curit|T.l.phonie|App global|App HEP|App SMR|Applicatifs|TOTAL|Total|Ventilation|R.partition|Nouveaut)'`
|
|
||||||
);
|
|
||||||
console.log(`\nSupprimé ${delResult.affectedRows} lignes parasites.`);
|
|
||||||
|
|
||||||
// 3. Vérification finale
|
|
||||||
const [remaining] = await conn.execute(
|
|
||||||
`SELECT annee, COUNT(*) as cnt FROM opex_bases_repartition GROUP BY annee ORDER BY annee`
|
|
||||||
);
|
|
||||||
console.log('=== Lignes restantes par année ===');
|
|
||||||
console.table(remaining);
|
|
||||||
|
|
||||||
await conn.end();
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { createConnection } from 'mysql2/promise';
|
|
||||||
import { config } from 'dotenv';
|
|
||||||
config();
|
|
||||||
|
|
||||||
const conn = await createConnection(process.env.DATABASE_URL);
|
|
||||||
|
|
||||||
// 1. Voir les doublons
|
|
||||||
const [doublons] = await conn.execute(
|
|
||||||
'SELECT annee, libelle, COUNT(*) as cnt FROM opex_postes GROUP BY annee, libelle HAVING cnt > 1 ORDER BY cnt DESC LIMIT 30'
|
|
||||||
);
|
|
||||||
console.log('=== Doublons (annee, libelle) ===');
|
|
||||||
console.table(doublons);
|
|
||||||
|
|
||||||
// 2. Total par année
|
|
||||||
const [totaux] = await conn.execute(
|
|
||||||
'SELECT annee, COUNT(*) as cnt FROM opex_postes GROUP BY annee ORDER BY annee'
|
|
||||||
);
|
|
||||||
console.log('=== Total postes par année ===');
|
|
||||||
console.table(totaux);
|
|
||||||
|
|
||||||
// 3. Libellés qui ressemblent à des catégories (parasites)
|
|
||||||
const [parasites] = await conn.execute(
|
|
||||||
`SELECT id, annee, libelle, categorie FROM opex_postes
|
|
||||||
WHERE libelle IN ('Infogérance','Sécurité','Téléphonie','App global','App HEP','App SMR',
|
|
||||||
'Applicatifs communs','Applicatifs PA','Applicatifs HEP','Applicatifs SMR','Nouveautés','TOTAL',
|
|
||||||
'Ventilation cout','Ventilation coût')
|
|
||||||
OR libelle REGEXP '^(total|ventilation|répartition)'
|
|
||||||
ORDER BY annee, libelle`
|
|
||||||
);
|
|
||||||
console.log('=== Libellés parasites en BDD ===');
|
|
||||||
console.table(parasites);
|
|
||||||
|
|
||||||
await conn.end();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
143
server/db.ts
143
server/db.ts
@@ -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),
|
||||||
);
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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),
|
||||||
|
|||||||
23
shared/opexValidation.test.ts
Normal file
23
shared/opexValidation.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "./opexValidation";
|
||||||
|
|
||||||
|
describe("validation des codes établissement OPEX", () => {
|
||||||
|
it("normalise les codes réels, y compris les codes composés", () => {
|
||||||
|
expect(normalizeOpexEtablissementCode(" 1001bpt ")).toBe("1001BPT");
|
||||||
|
expect(normalizeOpexEtablissementCode("1084CAS + CAC")).toBe("1084CAS + CAC");
|
||||||
|
expect(isOpexEtablissementCode("1083MIS+MIC")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejette les libellés de catégories et de synthèse importés par erreur", () => {
|
||||||
|
for (const invalidCode of [
|
||||||
|
"Infogérance",
|
||||||
|
"Sécurité",
|
||||||
|
"Applicatifs HEP",
|
||||||
|
"Téléphonie",
|
||||||
|
"TOTAL",
|
||||||
|
"Appicatifs commmuns",
|
||||||
|
]) {
|
||||||
|
expect(isOpexEtablissementCode(invalidCode)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
22
shared/opexValidation.ts
Normal file
22
shared/opexValidation.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Règles communes aux imports et écritures de bases de répartition OPEX.
|
||||||
|
*
|
||||||
|
* Les fichiers sources mélangent parfois des lignes d'établissement avec des
|
||||||
|
* en-têtes de catégories ou des totaux. Cette validation est appliquée côté
|
||||||
|
* client pour guider l'utilisateur et côté serveur pour protéger la BDD.
|
||||||
|
*/
|
||||||
|
const OPEX_SUMMARY_LABEL = /^(?:infog|s.curit|t.l.phonie|app(?:licatifs)?(?:\s|$)|total|ventilation|r.partition|nouveaut)/i;
|
||||||
|
|
||||||
|
/** Préserve l'affichage du code, tout en stabilisant casse et espaces. */
|
||||||
|
export function normalizeOpexEtablissementCode(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, " ").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un code établissement OPEX débute toujours par son code numérique d'entité.
|
||||||
|
* Les suffixes composites historiques (« + CAC », « +MIC ») restent admis.
|
||||||
|
*/
|
||||||
|
export function isOpexEtablissementCode(value: string): boolean {
|
||||||
|
const code = normalizeOpexEtablissementCode(value);
|
||||||
|
return code.length > 0 && /^\d{4}[A-Z0-9 +]*$/.test(code) && !OPEX_SUMMARY_LABEL.test(code);
|
||||||
|
}
|
||||||
30
todo.md
30
todo.md
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user