Compare commits
16 Commits
827c9ec41e
...
server-sta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1755e181c | ||
|
|
5c8237d5c6 | ||
|
|
a9cea0ecbb | ||
|
|
0612abf415 | ||
|
|
e39dd38bec | ||
|
|
d8b2a8fe6f | ||
|
|
54164b1b33 | ||
|
|
6b83361056 | ||
|
|
d3426c1663 | ||
|
|
4ce2d2877a | ||
|
|
711ce6b83a | ||
|
|
bcb307bde1 | ||
|
|
295ba29329 | ||
|
|
ac0594f5db | ||
|
|
5d18dfa468 | ||
|
|
aebc607e8a |
45
.gitea/workflows/validate.yml
Normal file
45
.gitea/workflows/validate.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Validation applicative
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
- "docs/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
- "docs/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: TypeScript, tests et build
|
||||||
|
# Label dédié : image locale avec Node 22, pnpm verrouillé et Bash déjà installés.
|
||||||
|
runs-on: ci-node22
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Récupérer les sources
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Calculer la clé de cache pnpm
|
||||||
|
id: pnpm-cache-key
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "store=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "lock=$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Restaurer le store pnpm
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache-key.outputs.store }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-cache-key.outputs.lock }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Installer les dépendances verrouillées
|
||||||
|
run: pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
|
- name: Vérifier TypeScript, tests et build
|
||||||
|
run: pnpm verify
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,6 +45,8 @@ pids
|
|||||||
*.seed
|
*.seed
|
||||||
*.pid.lock
|
*.pid.lock
|
||||||
*.bak
|
*.bak
|
||||||
|
backups/
|
||||||
|
storage/
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
16
app.json
Normal file
16
app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "demat-facturation-dsi",
|
||||||
|
"name": "Démat. Facturation DSI",
|
||||||
|
"category": "SANTINOVA",
|
||||||
|
"urls": {
|
||||||
|
"recette": "https://demat-facturation.recette.santinova-soft.org",
|
||||||
|
"prod": "https://demat-facturation.santinova-soft.org"
|
||||||
|
},
|
||||||
|
"containerName": "demat-facturation-app",
|
||||||
|
"image": "images/demat-facturation-dsi.jpg",
|
||||||
|
"giteaRepo": "demat-facturation",
|
||||||
|
"giteaOwner": "manus-admin",
|
||||||
|
"ci": {
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { Route, Switch } from "wouter";
|
import { Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import Home from "./pages/Home";
|
|
||||||
import Login from "./pages/Login";
|
// Pages chargées à la demande : l'écran de connexion reste léger et chaque
|
||||||
import Dashboard from "./pages/Dashboard";
|
// module métier ne télécharge son code qu'au moment où l'utilisateur l'ouvre.
|
||||||
import Upload from "./pages/Upload";
|
const Home = lazy(() => import("./pages/Home"));
|
||||||
import Invoices from "./pages/Invoices";
|
const Login = lazy(() => import("./pages/Login"));
|
||||||
import InvoicesBAP from "./pages/InvoicesBAP";
|
const Dashboard = lazy(() => import("./pages/Dashboard"));
|
||||||
import InvoiceDetail from "./pages/InvoiceDetail";
|
const Upload = lazy(() => import("./pages/Upload"));
|
||||||
import Settings from "./pages/Settings";
|
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||||
import ImportSettings from "./pages/ImportSettings";
|
const InvoicesBAP = lazy(() => import("./pages/InvoicesBAP"));
|
||||||
import History from "./pages/History";
|
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
||||||
import Users from "./pages/Users";
|
const Settings = lazy(() => import("./pages/Settings"));
|
||||||
import ListsAdmin from "./pages/ListsAdmin";
|
const ImportSettings = lazy(() => import("./pages/ImportSettings"));
|
||||||
import AutomationRules from "./pages/AutomationRules";
|
const History = lazy(() => import("./pages/History"));
|
||||||
import BapHistory from "./pages/BapHistory";
|
const Users = lazy(() => import("./pages/Users"));
|
||||||
import ImportReport from "./pages/ImportReport";
|
const ListsAdmin = lazy(() => import("./pages/ListsAdmin"));
|
||||||
import LearningSettings from "./pages/LearningSettings";
|
const AutomationRules = lazy(() => import("./pages/AutomationRules"));
|
||||||
import VentilationFreePro from "./pages/VentilationFreePro";
|
const BapHistory = lazy(() => import("./pages/BapHistory"));
|
||||||
|
const ImportReport = lazy(() => import("./pages/ImportReport"));
|
||||||
|
const LearningSettings = lazy(() => import("./pages/LearningSettings"));
|
||||||
|
const VentilationFreePro = lazy(() => import("./pages/VentilationFreePro"));
|
||||||
|
const WebImportSources = lazy(() => import("./pages/WebImportSources"));
|
||||||
|
|
||||||
|
function RouteFallback() {
|
||||||
|
return <div className="min-h-screen bg-background" aria-busy="true" aria-label="Chargement" />;
|
||||||
|
}
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +51,7 @@ function Router() {
|
|||||||
<Route path="/import-report" component={ImportReport} />
|
<Route path="/import-report" component={ImportReport} />
|
||||||
<Route path="/learning-settings" component={LearningSettings} />
|
<Route path="/learning-settings" component={LearningSettings} />
|
||||||
<Route path="/ventilation-freepro" component={VentilationFreePro} />
|
<Route path="/ventilation-freepro" component={VentilationFreePro} />
|
||||||
|
<Route path="/web-import-sources" component={WebImportSources} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -54,7 +64,9 @@ function App() {
|
|||||||
<ThemeProvider defaultTheme="light">
|
<ThemeProvider defaultTheme="light">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Router />
|
<Suspense fallback={<RouteFallback />}>
|
||||||
|
<Router />
|
||||||
|
</Suspense>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -23,13 +23,11 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { getLoginUrl } from "@/const";
|
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3 } from "lucide-react";
|
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3, Globe } from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
type MenuItem = {
|
type MenuItem = {
|
||||||
icon: any;
|
icon: any;
|
||||||
@@ -75,6 +73,7 @@ const menuStructure: MenuItem[] = [
|
|||||||
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
||||||
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
||||||
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
|
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
|
||||||
|
{ icon: Globe, label: "Connecteurs web", path: "/web-import-sources" },
|
||||||
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ import { useEffect } from "react";
|
|||||||
import { useAuth } from "@/_core/hooks/useAuth";
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
import { Loader2, FileText } from "lucide-react";
|
import { Loader2, FileText } from "lucide-react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|||||||
@@ -11,10 +11,146 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
|
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
|
||||||
Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar,
|
Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar,
|
||||||
ArrowDownToLine, Share2
|
ArrowDownToLine, Share2, DatabaseBackup, HardDrive, CheckCircle, Clock
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
|
||||||
|
// ============= COMPOSANT SAUVEGARDE DB =============
|
||||||
|
function BackupSection() {
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const { data: backupList, refetch: refetchList } = trpc.backup.list.useQuery();
|
||||||
|
const deleteBackupMutation = trpc.backup.delete.useMutation({
|
||||||
|
onSuccess: () => { refetchList(); toast.success("Sauvegarde supprimée"); },
|
||||||
|
onError: (e) => toast.error("Erreur : " + e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerateBackup = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/db-backup", { method: "POST", credentials: "include" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({ error: "Erreur inconnue" }));
|
||||||
|
toast.error("Erreur : " + (err.error || response.statusText));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Déclencher le téléchargement
|
||||||
|
const blob = await response.blob();
|
||||||
|
const contentDisposition = response.headers.get("Content-Disposition") || "";
|
||||||
|
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
|
||||||
|
const fileName = match ? decodeURIComponent(match[1]) : `backup-${new Date().toISOString().slice(0,10)}.sql`;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("Sauvegarde générée et téléchargée");
|
||||||
|
refetchList();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("Erreur : " + e.message);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + " o";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||||
|
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 dark:from-slate-950/20 dark:to-gray-950/20 border-b">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-slate-600 rounded-lg">
|
||||||
|
<DatabaseBackup className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Sauvegarde de la base de données</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
Générer un dump SQL de la base de données et l'enregistrer localement dans le dossier <code className="bg-muted px-1 rounded text-xs">backups/</code>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-6">
|
||||||
|
{/* Bouton générer */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateBackup}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="gap-2 bg-slate-700 hover:bg-slate-800 text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<><Loader2 className="h-5 w-5 animate-spin" />Génération en cours...</>
|
||||||
|
) : (
|
||||||
|
<><HardDrive className="h-5 w-5" />Générer une sauvegarde</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Le dump SQL sera généré, enregistré dans <code className="bg-muted px-1 rounded text-xs">backups/</code> et téléchargé automatiquement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des sauvegardes existantes */}
|
||||||
|
{backupList && backupList.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Sauvegardes enregistrées ({backupList.length})
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{backupList.map((backup) => (
|
||||||
|
<div key={backup.name} className="flex items-center justify-between px-4 py-3 hover:bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium font-mono">{backup.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(backup.createdAt).toLocaleString("fr-FR")} — {formatSize(backup.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs"
|
||||||
|
onClick={() => { window.open(`/api/db-backup/${encodeURIComponent(backup.name)}`, "_blank"); }}
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
Télécharger
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => deleteBackupMutation.mutate({ name: backup.name })}
|
||||||
|
disabled={deleteBackupMutation.isPending}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backupList && backupList.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
<HardDrive className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
Aucune sauvegarde enregistrée
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function ImportSettings() {
|
export default function ImportSettings() {
|
||||||
// Chargement différé : on ne charge les statuts de services qu'après le montage
|
// Chargement différé : on ne charge les statuts de services qu'après le montage
|
||||||
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
|
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
|
||||||
@@ -207,7 +343,7 @@ export default function ImportSettings() {
|
|||||||
|
|
||||||
{/* Onglets Import / Export */}
|
{/* Onglets Import / Export */}
|
||||||
<Tabs defaultValue="import" className="w-full">
|
<Tabs defaultValue="import" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 h-12 mb-6">
|
<TabsList className="grid w-full grid-cols-3 h-12 mb-6">
|
||||||
<TabsTrigger value="import" className="flex items-center gap-2 text-base">
|
<TabsTrigger value="import" className="flex items-center gap-2 text-base">
|
||||||
<ArrowDownToLine className="w-4 h-4" />
|
<ArrowDownToLine className="w-4 h-4" />
|
||||||
Paramètres d'import
|
Paramètres d'import
|
||||||
@@ -216,6 +352,10 @@ export default function ImportSettings() {
|
|||||||
<Share2 className="w-4 h-4" />
|
<Share2 className="w-4 h-4" />
|
||||||
Paramètres d'export
|
Paramètres d'export
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="backup" className="flex items-center gap-2 text-base">
|
||||||
|
<DatabaseBackup className="w-4 h-4" />
|
||||||
|
Sauvegarde DB
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ===== ONGLET IMPORT ===== */}
|
{/* ===== ONGLET IMPORT ===== */}
|
||||||
@@ -955,6 +1095,10 @@ export default function ImportSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{/* ===== ONGLET SAUVEGARDE ===== */}
|
||||||
|
<TabsContent value="backup" className="space-y-6">
|
||||||
|
<BackupSection />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export default function Invoices() {
|
|||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
const [recipientFilter, setRecipientFilter] = useState<string>("all");
|
const [recipientFilter, setRecipientFilter] = useState<string>("all");
|
||||||
|
const [subscriptionFilter, setSubscriptionFilter] = useState<string>("all"); // all | yes | no
|
||||||
|
const [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova
|
||||||
|
const [ventilationFilter, setVentilationFilter] = useState<string>("all");
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
|
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
@@ -197,6 +200,12 @@ export default function Invoices() {
|
|||||||
)
|
)
|
||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
|
const uniqueVentilations = Array.from(
|
||||||
|
new Set(
|
||||||
|
(invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean)
|
||||||
|
)
|
||||||
|
).sort();
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
const handleSort = (field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
setSortDir(d => d === "asc" ? "desc" : "asc");
|
setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||||
@@ -236,7 +245,26 @@ export default function Invoices() {
|
|||||||
if ((inv as any).recipientName !== recipientFilter) return false;
|
if ((inv as any).recipientName !== recipientFilter) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Filter by subscription
|
||||||
|
if (subscriptionFilter !== "all") {
|
||||||
|
const isSub = (inv as any).isSubscription === 1 || (inv as any).isSubscription === true;
|
||||||
|
if (subscriptionFilter === "yes" && !isSub) return false;
|
||||||
|
if (subscriptionFilter === "no" && isSub) return false;
|
||||||
|
}
|
||||||
|
// Filter by entity (SANTINOVA = serviceConcerne === 'DSI SANTINOVA', ITINOVA = autre)
|
||||||
|
if (entityFilter !== "all") {
|
||||||
|
const service = ((inv as any).serviceConcerne || "").trim().toUpperCase();
|
||||||
|
if (entityFilter === "santinova" && service !== "DSI SANTINOVA") return false;
|
||||||
|
if (entityFilter === "itinova" && service === "DSI SANTINOVA") return false;
|
||||||
|
}
|
||||||
|
// Filter by ventilation comptable
|
||||||
|
if (ventilationFilter !== "all") {
|
||||||
|
if (ventilationFilter === "__empty__") {
|
||||||
|
if ((inv as any).ventilationComptable) return false;
|
||||||
|
} else {
|
||||||
|
if ((inv as any).ventilationComptable !== ventilationFilter) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,109 +420,150 @@ export default function Invoices() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{/* ===== CARTOUCHE FILTRES ===== */}
|
||||||
<CardContent className="pt-6">
|
<div className="rounded-xl border border-blue-100 bg-blue-50/60 px-4 py-3 mb-4 shadow-sm">
|
||||||
{/* Search + Recipient Filter */}
|
|
||||||
<div className="mb-4 flex gap-3 flex-wrap">
|
|
||||||
<div className="relative flex-1 min-w-[200px]">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Rechercher par fournisseur, destinataire ou numéro..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 min-w-[220px]">
|
|
||||||
<Filter className="w-4 h-4 text-gray-400 shrink-0" />
|
|
||||||
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Filtrer par destinataire" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Tous les destinataires</SelectItem>
|
|
||||||
<SelectItem value="__empty__">Sans destinataire</SelectItem>
|
|
||||||
{uniqueRecipients.map((r) => (
|
|
||||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode compact/détail + Tri */}
|
|
||||||
<div className="flex gap-2 mb-4 flex-wrap items-center justify-between">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant={compactMode ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCompactMode(true)}
|
|
||||||
title="Mode compact"
|
|
||||||
>
|
|
||||||
<LayoutList className="w-4 h-4 mr-1" /> Compact
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={!compactMode ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCompactMode(false)}
|
|
||||||
title="Mode détail"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="w-4 h-4 mr-1" /> Détail
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<span className="text-sm text-gray-500">Trier par :</span>
|
|
||||||
<Button
|
|
||||||
variant={sortField === "createdAt" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSort("createdAt")}
|
|
||||||
>
|
|
||||||
Date réception <SortIcon field="createdAt" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={sortField === "invoiceDate" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSort("invoiceDate")}
|
|
||||||
>
|
|
||||||
Date facture <SortIcon field="invoiceDate" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Filters */}
|
{/* Ligne 1 : Recherche + affichage compact/détail */}
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-3 items-center flex-wrap mb-3">
|
||||||
|
<div className="relative flex-1 min-w-[220px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-400 w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher par fournisseur, destinataire ou numéro..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 bg-white border-blue-200 focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant={statusFilter === "all" ? "default" : "outline"}
|
variant={compactMode ? "default" : "outline"}
|
||||||
onClick={() => setStatusFilter("all")}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
onClick={() => setCompactMode(true)}
|
||||||
|
title="Mode compact"
|
||||||
|
className={compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
|
||||||
>
|
>
|
||||||
Tous ({statusCounts.all})
|
<LayoutList className="w-4 h-4 mr-1" /> Compact
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={statusFilter === "exported" ? "default" : "outline"}
|
variant={!compactMode ? "default" : "outline"}
|
||||||
onClick={() => setStatusFilter("exported")}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""}
|
onClick={() => setCompactMode(false)}
|
||||||
|
title="Mode détail"
|
||||||
|
className={!compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
|
||||||
>
|
>
|
||||||
Exportés ({statusCounts.exported})
|
<LayoutGrid className="w-4 h-4 mr-1" /> Détail
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "not_exported" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("not_exported")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""}
|
|
||||||
>
|
|
||||||
Non exportés ({statusCounts.not_exported})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "export_error" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("export_error")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""}
|
|
||||||
>
|
|
||||||
Erreurs ({statusCounts.export_error})
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ligne 2 : Filtres destinataire, abonnement, entité, ventilation */}
|
||||||
|
<div className="flex gap-2 items-center flex-wrap mb-3">
|
||||||
|
<Filter className="w-4 h-4 text-blue-400 shrink-0" />
|
||||||
|
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
|
||||||
|
<SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm">
|
||||||
|
<SelectValue placeholder="Destinataire" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Tous destinataires</SelectItem>
|
||||||
|
<SelectItem value="__empty__">Sans destinataire</SelectItem>
|
||||||
|
{uniqueRecipients.map((r) => (
|
||||||
|
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={subscriptionFilter} onValueChange={setSubscriptionFilter}>
|
||||||
|
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
|
||||||
|
<SelectValue placeholder="Abonnement" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Abonnement : tous</SelectItem>
|
||||||
|
<SelectItem value="yes">Abonnement : OUI</SelectItem>
|
||||||
|
<SelectItem value="no">Abonnement : NON</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={entityFilter} onValueChange={setEntityFilter}>
|
||||||
|
<SelectTrigger className="w-[150px] bg-white border-blue-200 text-sm">
|
||||||
|
<SelectValue placeholder="Entité" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toutes entités</SelectItem>
|
||||||
|
<SelectItem value="santinova">SANTINOVA</SelectItem>
|
||||||
|
<SelectItem value="itinova">ITINOVA</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={ventilationFilter} onValueChange={setVentilationFilter}>
|
||||||
|
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
|
||||||
|
<SelectValue placeholder="Ventilation" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toutes ventilations</SelectItem>
|
||||||
|
<SelectItem value="__empty__">Sans ventilation</SelectItem>
|
||||||
|
{uniqueVentilations.map((v) => (
|
||||||
|
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ligne 3 : Boutons statut export + tri */}
|
||||||
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "all" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("all")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "all" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
|
||||||
|
>
|
||||||
|
Tous ({statusCounts.all})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "exported" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("exported")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
|
||||||
|
>
|
||||||
|
Exportés ({statusCounts.exported})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "not_exported" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("not_exported")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
|
||||||
|
>
|
||||||
|
Non exportés ({statusCounts.not_exported})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "export_error" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("export_error")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
|
||||||
|
>
|
||||||
|
Erreurs ({statusCounts.export_error})
|
||||||
|
</Button>
|
||||||
|
<div className="ml-auto flex gap-1 items-center">
|
||||||
|
<span className="text-xs text-blue-500 font-medium mr-1">Trier :</span>
|
||||||
|
<Button
|
||||||
|
variant={sortField === "createdAt" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSort("createdAt")}
|
||||||
|
className={sortField === "createdAt" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
|
||||||
|
>
|
||||||
|
Réception <SortIcon field="createdAt" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortField === "invoiceDate" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSort("invoiceDate")}
|
||||||
|
className={sortField === "invoiceDate" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
|
||||||
|
>
|
||||||
|
Facture <SortIcon field="invoiceDate" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* ===== FIN CARTOUCHE ===== */}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -587,17 +587,19 @@ export default function InvoicesBAP() {
|
|||||||
<h1 className="text-3xl font-bold">Factures BAP</h1>
|
<h1 className="text-3xl font-bold">Factures BAP</h1>
|
||||||
<p className="text-gray-500 mt-1">Factures non-abonnement (Abonnement = NON)</p>
|
<p className="text-gray-500 mt-1">Factures non-abonnement (Abonnement = NON)</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Barre d'actions : 2 lignes */}
|
{/* ===== CARTOUCHE FILTRES + ACTIONS BAP ===== */}
|
||||||
<div className="space-y-2">
|
<div className="rounded-xl border border-emerald-100 bg-emerald-50/60 px-4 py-3 mb-4 shadow-sm">
|
||||||
{/* Ligne 1 : Excel, ZIP, Valider tout en BAP, Importer */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
{/* Ligne 1 : Boutons d'action + Recherche */}
|
||||||
|
<div className="flex gap-2 items-center flex-wrap mb-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleExportExcel}
|
onClick={handleExportExcel}
|
||||||
disabled={selectedIds.length === 0 || exportExcelMutation.isPending}
|
disabled={selectedIds.length === 0 || exportExcelMutation.isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-green-600 text-green-600 hover:bg-green-50"
|
size="sm"
|
||||||
|
className="bg-white border-green-300 text-green-700 hover:bg-green-50"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet className="w-4 h-4 mr-2" />
|
<FileSpreadsheet className="w-4 h-4 mr-1" />
|
||||||
Excel ({selectedIds.length})
|
Excel ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -631,9 +633,10 @@ export default function InvoicesBAP() {
|
|||||||
}}
|
}}
|
||||||
disabled={selectedIds.length === 0 || isZipDownloading}
|
disabled={selectedIds.length === 0 || isZipDownloading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-indigo-500 text-indigo-600 hover:bg-indigo-50"
|
size="sm"
|
||||||
|
className="bg-white border-indigo-300 text-indigo-700 hover:bg-indigo-50"
|
||||||
>
|
>
|
||||||
<FolderDown className={`w-4 h-4 mr-2 ${isZipDownloading ? 'animate-bounce' : ''}`} />
|
<FolderDown className={`w-4 h-4 mr-1 ${isZipDownloading ? 'animate-bounce' : ''}`} />
|
||||||
{isZipDownloading ? 'ZIP...' : `ZIP (${selectedIds.length})`}
|
{isZipDownloading ? 'ZIP...' : `ZIP (${selectedIds.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -643,18 +646,16 @@ export default function InvoicesBAP() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={validateBAPBulkMutation.isPending}
|
disabled={validateBAPBulkMutation.isPending}
|
||||||
|
size="sm"
|
||||||
className="bg-green-700 hover:bg-green-800 text-white"
|
className="bg-green-700 hover:bg-green-800 text-white"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
<ShieldCheck className="w-4 h-4 mr-1" />
|
||||||
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"}
|
{validateBAPBulkMutation.isPending ? "Validation..." : "Valider tout BAP"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setLocation("/upload")}>
|
<Button onClick={() => setLocation("/upload")} size="sm" className="bg-white border-emerald-300 text-emerald-700 hover:bg-emerald-50" variant="outline">
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-1" />
|
||||||
Importer
|
Importer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
{/* Ligne 2 : Supprimer, Dévalider BAP, Relancer */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (confirm(`Voulez-vous vraiment supprimer ${selectedIds.length} facture(s) ?`)) {
|
if (confirm(`Voulez-vous vraiment supprimer ${selectedIds.length} facture(s) ?`)) {
|
||||||
@@ -672,8 +673,9 @@ export default function InvoicesBAP() {
|
|||||||
}}
|
}}
|
||||||
disabled={selectedIds.length === 0}
|
disabled={selectedIds.length === 0}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
Supprimer ({selectedIds.length})
|
Supprimer ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -689,9 +691,10 @@ export default function InvoicesBAP() {
|
|||||||
}}
|
}}
|
||||||
disabled={selectedIds.length === 0 || devalidateBAPMutation.isPending}
|
disabled={selectedIds.length === 0 || devalidateBAPMutation.isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-orange-500 text-orange-600 hover:bg-orange-50"
|
size="sm"
|
||||||
|
className="bg-white border-orange-300 text-orange-700 hover:bg-orange-50"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="w-4 h-4 mr-2 rotate-180" />
|
<ShieldCheck className="w-4 h-4 mr-1 rotate-180" />
|
||||||
{devalidateBAPMutation.isPending ? "Dévalidation..." : `Dévalider BAP (${selectedIds.length})`}
|
{devalidateBAPMutation.isPending ? "Dévalidation..." : `Dévalider BAP (${selectedIds.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -706,154 +709,153 @@ export default function InvoicesBAP() {
|
|||||||
}}
|
}}
|
||||||
disabled={selectedIds.length === 0 || reprocessMutation.isPending}
|
disabled={selectedIds.length === 0 || reprocessMutation.isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-purple-500 text-purple-600 hover:bg-purple-50"
|
size="sm"
|
||||||
|
className="bg-white border-purple-300 text-purple-700 hover:bg-purple-50"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-1 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
{reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`}
|
{reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Recherche à droite */}
|
||||||
|
<div className="relative flex-1 min-w-[200px] ml-auto">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-emerald-400 w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher par fournisseur ou numéro..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 bg-white border-emerald-200 focus:border-emerald-400 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
{/* Ligne 2 : Filtres période (année + mois) */}
|
||||||
<CardContent className="pt-6">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
{/* Search */}
|
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
|
||||||
<div className="mb-4">
|
<CalendarDays className="w-4 h-4" />
|
||||||
<div className="relative">
|
Période :
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Rechercher par fournisseur ou numéro..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
|
||||||
{/* Period Filter + Status Filters */}
|
<SelectTrigger className="w-28 h-8 text-sm bg-white border-emerald-200">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3 p-3 bg-slate-50 dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700">
|
<SelectValue placeholder="Année" />
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400">
|
</SelectTrigger>
|
||||||
<CalendarDays className="w-4 h-4" />
|
<SelectContent>
|
||||||
Période :
|
<SelectItem value="all">Toute année</SelectItem>
|
||||||
</div>
|
{Array.from({ length: 5 }, (_, i) => currentYear - i).map(y => (
|
||||||
{/* Year selector */}
|
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||||
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
|
))}
|
||||||
<SelectTrigger className="w-28 h-8 text-sm">
|
</SelectContent>
|
||||||
<SelectValue placeholder="Année" />
|
</Select>
|
||||||
</SelectTrigger>
|
<Select
|
||||||
<SelectContent>
|
value={selectedMonth}
|
||||||
<SelectItem value="all">Toute année</SelectItem>
|
onValueChange={setSelectedMonth}
|
||||||
{Array.from({ length: 5 }, (_, i) => currentYear - i).map(y => (
|
disabled={selectedYear === "all"}
|
||||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
>
|
||||||
))}
|
<SelectTrigger className="w-36 h-8 text-sm bg-white border-emerald-200">
|
||||||
</SelectContent>
|
<SelectValue placeholder="Mois" />
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
{/* Month selector — disabled when year = all */}
|
<SelectContent>
|
||||||
<Select
|
<SelectItem value="all">Tous les mois</SelectItem>
|
||||||
value={selectedMonth}
|
{[
|
||||||
onValueChange={setSelectedMonth}
|
["01","Janvier"],["02","Février"],["03","Mars"],["04","Avril"],
|
||||||
disabled={selectedYear === "all"}
|
["05","Mai"],["06","Juin"],["07","Juillet"],["08","Août"],
|
||||||
|
["09","Septembre"],["10","Octobre"],["11","Novembre"],["12","Décembre"]
|
||||||
|
].map(([v, label]) => (
|
||||||
|
<SelectItem key={v} value={v}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(selectedYear !== "all" || selectedMonth !== "all") && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
|
||||||
|
className="text-xs text-emerald-600 hover:text-emerald-800 underline"
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-36 h-8 text-sm">
|
Réinitialiser
|
||||||
<SelectValue placeholder="Mois" />
|
</button>
|
||||||
</SelectTrigger>
|
)}
|
||||||
<SelectContent>
|
</div>
|
||||||
<SelectItem value="all">Tous les mois</SelectItem>
|
|
||||||
{[
|
|
||||||
["01","Janvier"],["02","Février"],["03","Mars"],["04","Avril"],
|
|
||||||
["05","Mai"],["06","Juin"],["07","Juillet"],["08","Août"],
|
|
||||||
["09","Septembre"],["10","Octobre"],["11","Novembre"],["12","Décembre"]
|
|
||||||
].map(([v, label]) => (
|
|
||||||
<SelectItem key={v} value={v}>{label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{(selectedYear !== "all" || selectedMonth !== "all") && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
|
|
||||||
className="text-xs text-slate-500 hover:text-slate-700 underline"
|
|
||||||
>
|
|
||||||
Réinitialiser
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tri */}
|
{/* Ligne 3 : Boutons statut + tri */}
|
||||||
<div className="flex gap-2 items-center mb-3">
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
<span className="text-sm text-gray-500">Trier par :</span>
|
<Button
|
||||||
|
variant={statusFilter === "all" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("all")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "all" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
|
||||||
|
>
|
||||||
|
Tous ({statusCounts.all})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "exported" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("exported")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
|
||||||
|
>
|
||||||
|
Exportés ({statusCounts.exported})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "not_exported" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("not_exported")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
|
||||||
|
>
|
||||||
|
Non exportés ({statusCounts.not_exported})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "export_error" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("export_error")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
|
||||||
|
>
|
||||||
|
Erreurs ({statusCounts.export_error})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "bap_validated" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("bap_validated")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
|
||||||
|
>
|
||||||
|
✅ Validées BAP ({statusCounts.bap_validated})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "bap_pending" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("bap_pending")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700 text-white" : "bg-white border-orange-200 text-orange-700 hover:bg-orange-50"}
|
||||||
|
>
|
||||||
|
⏳ En attente BAP ({statusCounts.bap_pending})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "to_complete" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatusFilter("to_complete")}
|
||||||
|
size="sm"
|
||||||
|
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700 text-white" : "bg-white border-amber-200 text-amber-700 hover:bg-amber-50"}
|
||||||
|
>
|
||||||
|
✏️ À compléter ({statusCounts.to_complete})
|
||||||
|
</Button>
|
||||||
|
<div className="ml-auto flex gap-1 items-center">
|
||||||
|
<span className="text-xs text-emerald-500 font-medium mr-1">Trier :</span>
|
||||||
<Button
|
<Button
|
||||||
variant={sortField === "createdAt" ? "default" : "outline"}
|
variant={sortField === "createdAt" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSort("createdAt")}
|
onClick={() => handleSort("createdAt")}
|
||||||
|
className={sortField === "createdAt" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
|
||||||
>
|
>
|
||||||
Date réception <SortIcon field="createdAt" />
|
Réception <SortIcon field="createdAt" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={sortField === "invoiceDate" ? "default" : "outline"}
|
variant={sortField === "invoiceDate" ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSort("invoiceDate")}
|
onClick={() => handleSort("invoiceDate")}
|
||||||
|
className={sortField === "invoiceDate" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
|
||||||
>
|
>
|
||||||
Date facture <SortIcon field="invoiceDate" />
|
Facture <SortIcon field="invoiceDate" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* ===== FIN CARTOUCHE BAP ===== */}
|
||||||
|
|
||||||
{/* Status Filters */}
|
<Card>
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<CardContent className="pt-4">
|
||||||
<Button
|
|
||||||
variant={statusFilter === "all" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("all")}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Tous ({statusCounts.all})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "exported" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("exported")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""}
|
|
||||||
>
|
|
||||||
Exportés ({statusCounts.exported})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "not_exported" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("not_exported")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""}
|
|
||||||
>
|
|
||||||
Non exportés ({statusCounts.not_exported})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "export_error" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("export_error")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""}
|
|
||||||
>
|
|
||||||
Erreurs ({statusCounts.export_error})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "bap_validated" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("bap_validated")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700" : ""}
|
|
||||||
>
|
|
||||||
✅ Validées BAP ({statusCounts.bap_validated})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "bap_pending" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("bap_pending")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700" : ""}
|
|
||||||
>
|
|
||||||
⏳ En attente BAP ({statusCounts.bap_pending})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "to_complete" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("to_complete")}
|
|
||||||
size="sm"
|
|
||||||
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700" : "border-amber-400 text-amber-700 hover:bg-amber-50"}
|
|
||||||
>
|
|
||||||
✏️ À compléter ({statusCounts.to_complete})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
404
client/src/pages/WebImportSources.tsx
Normal file
404
client/src/pages/WebImportSources.tsx
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const CONNECTOR_TYPES = [
|
||||||
|
{ value: "sfr", label: "SFR Pro", url: "https://www.sfr.fr/mon-espace-client/" },
|
||||||
|
{ value: "orange", label: "Orange Pro", url: "https://espaceclient.orange.fr/" },
|
||||||
|
{ value: "bouygues", label: "Bouygues Telecom", url: "https://www.bouyguestelecom.fr/mon-compte/" },
|
||||||
|
{ value: "free", label: "Free Pro", url: "https://pro.free.fr/" },
|
||||||
|
{ value: "starlink", label: "Starlink", url: "https://www.starlink.com/account/" },
|
||||||
|
{ value: "custom", label: "Autre (personnalisé)", url: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREQUENCY_LABELS: Record<string, string> = {
|
||||||
|
manual: "Manuel",
|
||||||
|
daily: "Quotidien",
|
||||||
|
weekly: "Hebdomadaire",
|
||||||
|
monthly: "Mensuel",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Source = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
connectorType: string;
|
||||||
|
portalUrl: string;
|
||||||
|
loginEmail: string;
|
||||||
|
loginPassword: string;
|
||||||
|
frequency: "manual" | "daily" | "weekly" | "monthly";
|
||||||
|
autoEnabled: number;
|
||||||
|
lastSuccessAt: Date | null;
|
||||||
|
lastStatus: string | null;
|
||||||
|
lastImportCount: number | null;
|
||||||
|
apiToken: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
name: string;
|
||||||
|
connectorType: string;
|
||||||
|
portalUrl: string;
|
||||||
|
loginEmail: string;
|
||||||
|
loginPassword: string;
|
||||||
|
frequency: "manual" | "daily" | "weekly" | "monthly";
|
||||||
|
autoEnabled: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: FormData = {
|
||||||
|
name: "",
|
||||||
|
connectorType: "sfr",
|
||||||
|
portalUrl: "https://www.sfr.fr/mon-espace-client/",
|
||||||
|
loginEmail: "",
|
||||||
|
loginPassword: "",
|
||||||
|
frequency: "monthly",
|
||||||
|
autoEnabled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebImportSources() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editSource, setEditSource] = useState<Source | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
const [showToken, setShowToken] = useState<number | null>(null);
|
||||||
|
const [form, setForm] = useState<FormData>(emptyForm);
|
||||||
|
|
||||||
|
const { data: sources = [], refetch } = trpc.webImportSources.list.useQuery();
|
||||||
|
|
||||||
|
const { data: tokenData } = trpc.webImportSources.getToken.useQuery(
|
||||||
|
{ id: showToken! },
|
||||||
|
{ enabled: showToken !== null }
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = trpc.webImportSources.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Source créée", { description: "Le connecteur web a été ajouté." });
|
||||||
|
setShowForm(false);
|
||||||
|
setForm(emptyForm);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error("Erreur", { description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = trpc.webImportSources.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Source mise à jour");
|
||||||
|
setEditSource(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error("Erreur", { description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = trpc.webImportSources.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Source supprimée");
|
||||||
|
setDeleteId(null);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error("Erreur", { description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setEditSource(null);
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(source: Source) {
|
||||||
|
setForm({
|
||||||
|
name: source.name,
|
||||||
|
connectorType: source.connectorType,
|
||||||
|
portalUrl: source.portalUrl,
|
||||||
|
loginEmail: source.loginEmail,
|
||||||
|
loginPassword: "", // Ne pas pré-remplir le mot de passe
|
||||||
|
frequency: source.frequency,
|
||||||
|
autoEnabled: source.autoEnabled,
|
||||||
|
});
|
||||||
|
setEditSource(source);
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnectorTypeChange(value: string) {
|
||||||
|
const connector = CONNECTOR_TYPES.find(c => c.value === value);
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
connectorType: value,
|
||||||
|
name: f.name || connector?.label || "",
|
||||||
|
portalUrl: connector?.url || f.portalUrl,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!form.name || !form.loginEmail || (!editSource && !form.loginPassword)) {
|
||||||
|
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editSource) {
|
||||||
|
const updateData: any = { id: editSource.id, ...form };
|
||||||
|
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
|
||||||
|
updateMutation.mutate(updateData);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="p-6 max-w-5xl mx-auto">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||||
|
<Globe className="w-6 h-6 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Connecteurs web</h1>
|
||||||
|
<p className="text-sm text-gray-500">Import automatique de factures depuis des espaces clients</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Ajouter un connecteur
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info technique */}
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 text-sm text-amber-800">
|
||||||
|
<strong>Fonctionnement :</strong> Un script cron tourne sur le serveur LWS et se connecte automatiquement aux espaces clients configurés pour télécharger les nouvelles factures. Le token API affiché ci-dessous est utilisé par ce script pour s'authentifier auprès de l'application.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des sources */}
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-400">
|
||||||
|
<Globe className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-lg font-medium">Aucun connecteur configuré</p>
|
||||||
|
<p className="text-sm mt-1">Ajoutez un connecteur pour importer automatiquement des factures depuis un espace client web.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(sources as Source[]).map((source) => (
|
||||||
|
<div key={source.id} className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||||
|
<div className="p-2 bg-indigo-50 rounded-lg shrink-0">
|
||||||
|
<Globe className="w-5 h-5 text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-gray-900">{source.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">{source.connectorType}</Badge>
|
||||||
|
<Badge variant={source.autoEnabled ? "default" : "secondary"} className="text-xs">
|
||||||
|
{source.autoEnabled ? "Auto activé" : "Manuel"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">{FREQUENCY_LABELS[source.frequency]}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 truncate">{source.portalUrl}</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-0.5">
|
||||||
|
<span className="font-medium">Identifiant :</span> {source.loginEmail}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||||
|
{source.lastSuccessAt ? (
|
||||||
|
<span className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Dernier import : {new Date(source.lastSuccessAt).toLocaleDateString("fr-FR")}
|
||||||
|
{source.lastImportCount !== null && ` (${source.lastImportCount} facture(s))`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-gray-400">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Jamais importé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{source.lastStatus && !source.lastSuccessAt && (
|
||||||
|
<span className="flex items-center gap-1 text-red-500">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
{source.lastStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs"
|
||||||
|
onClick={() => setShowToken(showToken === source.id ? null : source.id)}
|
||||||
|
>
|
||||||
|
<Key className="w-3 h-3" />
|
||||||
|
Token
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(source)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500 hover:text-red-700 hover:border-red-300"
|
||||||
|
onClick={() => setDeleteId(source.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token API affiché inline */}
|
||||||
|
{showToken === source.id && tokenData && (
|
||||||
|
<div className="mt-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<p className="text-xs text-gray-500 mb-1 font-medium">Token API (à configurer dans le script cron) :</p>
|
||||||
|
<code className="text-xs font-mono text-indigo-700 break-all select-all">{tokenData.apiToken}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog création / édition */}
|
||||||
|
<Dialog open={showForm} onOpenChange={(open) => { if (!open) { setShowForm(false); setEditSource(null); } }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editSource ? "Modifier le connecteur" : "Nouveau connecteur web"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>Type de connecteur</Label>
|
||||||
|
<Select value={form.connectorType} onValueChange={handleConnectorTypeChange}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONNECTOR_TYPES.map(c => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Nom affiché</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="Ex : SFR Pro - Itinova"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>URL de l'espace client</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
value={form.portalUrl}
|
||||||
|
onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Identifiant (email ou login)</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
value={form.loginEmail}
|
||||||
|
onChange={e => setForm(f => ({ ...f, loginEmail: e.target.value }))}
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{editSource ? "Nouveau mot de passe (laisser vide pour ne pas changer)" : "Mot de passe"}</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
type="password"
|
||||||
|
value={form.loginPassword}
|
||||||
|
onChange={e => setForm(f => ({ ...f, loginPassword: e.target.value }))}
|
||||||
|
placeholder={editSource ? "••••••••" : "Mot de passe"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Fréquence</Label>
|
||||||
|
<Select value={form.frequency} onValueChange={(v: any) => setForm(f => ({ ...f, frequency: v }))}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="manual">Manuel</SelectItem>
|
||||||
|
<SelectItem value="daily">Quotidien</SelectItem>
|
||||||
|
<SelectItem value="weekly">Hebdomadaire</SelectItem>
|
||||||
|
<SelectItem value="monthly">Mensuel</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-end pb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={form.autoEnabled === 1}
|
||||||
|
onCheckedChange={v => setForm(f => ({ ...f, autoEnabled: v ? 1 : 0 }))}
|
||||||
|
/>
|
||||||
|
<Label>Import auto activé</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setShowForm(false); setEditSource(null); }}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
{editSource ? "Enregistrer" : "Créer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Confirmation suppression */}
|
||||||
|
<AlertDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Supprimer ce connecteur ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Cette action est irréversible. Le connecteur et son token API seront supprimés.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => deleteId !== null && deleteMutation.mutate({ id: deleteId })}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import bcrypt from "bcrypt";
|
|||||||
async function createAdmin() {
|
async function createAdmin() {
|
||||||
const db = drizzle(process.env.DATABASE_URL);
|
const db = drizzle(process.env.DATABASE_URL);
|
||||||
|
|
||||||
const email = "adminServFacturation";
|
const email = "adminItinova";
|
||||||
const password = "Itinova69!";
|
const password = "Itinova69!";
|
||||||
const name = "Administrateur";
|
const name = "Administrateur";
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ async function createAdmin() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ Utilisateur administrateur créé avec succès !");
|
console.log("✅ Utilisateur administrateur créé avec succès !");
|
||||||
console.log("📧 Email/Login: adminServFacturation");
|
console.log("📧 Email/Login: adminItinova");
|
||||||
console.log("🔑 Mot de passe: Itinova69!");
|
console.log("🔑 Mot de passe: Itinova69!");
|
||||||
console.log("👤 Rôle: admin");
|
console.log("👤 Rôle: admin");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
27
docs/maintenance.md
Normal file
27
docs/maintenance.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Notes de maintenance
|
||||||
|
|
||||||
|
## Stockage et sauvegardes
|
||||||
|
|
||||||
|
Les fichiers PDF et les exports de sauvegarde sont des **données d’exécution**. Ils sont volontairement exclus de Git par `storage/` et `backups/` afin qu’aucune facture ni sauvegarde ne soit envoyée au dépôt source. En recette et en production, ces dossiers doivent être montés sur des volumes Docker persistants.
|
||||||
|
|
||||||
|
Le module `server/databaseBackup.ts` produit un dump SQL autonome sans dépendre de `mysqldump`. Il exporte la structure de chaque table puis ses données par lots de 500 lignes. Il conserve au plus dix fichiers `.sql` locaux ; une sauvegarde est également téléchargée immédiatement depuis l’interface.
|
||||||
|
|
||||||
|
## Authentification et accès
|
||||||
|
|
||||||
|
Les routes de téléchargement BAP, de sauvegarde DB et de récupération de sauvegardes vérifient désormais la session JWT `auth_token`. Les sauvegardes DB sont strictement réservées aux administrateurs. Les cookies utilisent `Secure; SameSite=None` derrière HTTPS et basculent vers `SameSite=Lax` en HTTP local pour rester compatibles avec les navigateurs.
|
||||||
|
|
||||||
|
## Imports web
|
||||||
|
|
||||||
|
L’endpoint d’import web accepte uniquement des PDF de 20 Mo maximum, valide l’en-tête `%PDF`, normalise le nom du fichier et sauvegarde le document dans le stockage persistant avant analyse IA. Les erreurs détaillées restent dans les logs du serveur ; l’API retourne un message générique pour ne pas exposer de secret ou de détail d’infrastructure.
|
||||||
|
|
||||||
|
## Contrôles avant livraison
|
||||||
|
|
||||||
|
Avant chaque livraison, exécuter les commandes suivantes depuis la racine du projet :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
pnpm run check
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Les scripts ponctuels d’exploitation ou contenant des identifiants ne doivent jamais rester dans le répertoire du projet ni être ajoutés au dépôt.
|
||||||
9
drizzle/0035_eminent_dreadnoughts.sql
Normal file
9
drizzle/0035_eminent_dreadnoughts.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `deletedInvoices` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`invoiceNumber` varchar(100) NOT NULL,
|
||||||
|
`totalAmount` varchar(50),
|
||||||
|
`supplierName` varchar(255),
|
||||||
|
`deletedAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
18
drizzle/0036_broken_rattler.sql
Normal file
18
drizzle/0036_broken_rattler.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE `webImportSources` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`name` varchar(100) NOT NULL,
|
||||||
|
`connectorType` varchar(50) NOT NULL,
|
||||||
|
`portalUrl` varchar(500) NOT NULL,
|
||||||
|
`loginEmail` varchar(320) NOT NULL,
|
||||||
|
`loginPassword` text NOT NULL,
|
||||||
|
`frequency` enum('manual','daily','weekly','monthly') NOT NULL DEFAULT 'monthly',
|
||||||
|
`autoEnabled` int NOT NULL DEFAULT 0,
|
||||||
|
`lastSuccessAt` timestamp,
|
||||||
|
`lastStatus` text,
|
||||||
|
`lastImportCount` int DEFAULT 0,
|
||||||
|
`apiToken` varchar(128) NOT NULL,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `webImportSources_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
2229
drizzle/meta/0035_snapshot.json
Normal file
2229
drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2357
drizzle/meta/0036_snapshot.json
Normal file
2357
drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -246,6 +246,20 @@
|
|||||||
"when": 1785167359371,
|
"when": 1785167359371,
|
||||||
"tag": "0034_black_shadowcat",
|
"tag": "0034_black_shadowcat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1785404752594,
|
||||||
|
"tag": "0035_eminent_dreadnoughts",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1785419093588,
|
||||||
|
"tag": "0036_broken_rattler",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -508,3 +508,55 @@ export const freeproSettings = mysqlTable("freeproSettings", {
|
|||||||
});
|
});
|
||||||
export type FreeproSettings = typeof freeproSettings.$inferSelect;
|
export type FreeproSettings = typeof freeproSettings.$inferSelect;
|
||||||
export type InsertFreeproSettings = typeof freeproSettings.$inferInsert;
|
export type InsertFreeproSettings = typeof freeproSettings.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleted invoices blacklist — prevents re-import of manually deleted invoices.
|
||||||
|
* When a user deletes an invoice, its invoiceNumber + totalAmount are stored here.
|
||||||
|
* The email/file import service checks this table before inserting a new invoice.
|
||||||
|
*/
|
||||||
|
export const deletedInvoices = mysqlTable("deletedInvoices", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: int("userId").notNull(),
|
||||||
|
invoiceNumber: varchar("invoiceNumber", { length: 100 }).notNull(),
|
||||||
|
totalAmount: varchar("totalAmount", { length: 50 }),
|
||||||
|
supplierName: varchar("supplierName", { length: 255 }),
|
||||||
|
deletedAt: timestamp("deletedAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
export type DeletedInvoice = typeof deletedInvoices.$inferSelect;
|
||||||
|
export type InsertDeletedInvoice = typeof deletedInvoices.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web import sources — connecteurs web pour scraper des factures depuis des sites
|
||||||
|
* (ex: espace client SFR, Starlink, Orange...) avec login/mot de passe.
|
||||||
|
* Le scraping est exécuté par un script cron externe (Node.js + Playwright) sur LWS.
|
||||||
|
*/
|
||||||
|
export const webImportSources = mysqlTable("webImportSources", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: int("userId").notNull(),
|
||||||
|
/** Nom affiché (ex: "SFR Pro", "Starlink") */
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
/** Type de connecteur — détermine le script Playwright à utiliser */
|
||||||
|
connectorType: varchar("connectorType", { length: 50 }).notNull(), // ex: "sfr", "starlink", "orange"
|
||||||
|
/** URL de l'espace client */
|
||||||
|
portalUrl: varchar("portalUrl", { length: 500 }).notNull(),
|
||||||
|
/** Identifiant de connexion (email ou login) */
|
||||||
|
loginEmail: varchar("loginEmail", { length: 320 }).notNull(),
|
||||||
|
/** Mot de passe chiffré (AES-256) */
|
||||||
|
loginPassword: text("loginPassword").notNull(),
|
||||||
|
/** Fréquence de vérification automatique */
|
||||||
|
frequency: mysqlEnum("frequency", ["manual", "daily", "weekly", "monthly"]).default("monthly").notNull(),
|
||||||
|
/** Activation de l'import automatique */
|
||||||
|
autoEnabled: int("autoEnabled").default(0).notNull(), // 0 = désactivé, 1 = activé
|
||||||
|
/** Date du dernier import réussi */
|
||||||
|
lastSuccessAt: timestamp("lastSuccessAt"),
|
||||||
|
/** Statut du dernier import */
|
||||||
|
lastStatus: text("lastStatus"),
|
||||||
|
/** Nombre de factures importées lors du dernier run */
|
||||||
|
lastImportCount: int("lastImportCount").default(0),
|
||||||
|
/** Token d'API pour que le script externe puisse s'authentifier */
|
||||||
|
apiToken: varchar("apiToken", { length: 128 }).notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
export type WebImportSource = typeof webImportSources.$inferSelect;
|
||||||
|
export type InsertWebImportSource = typeof webImportSources.$inferInsert;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"db:push": "drizzle-kit generate && drizzle-kit migrate"
|
"db:push": "drizzle-kit generate && drizzle-kit migrate",
|
||||||
|
"verify": "pnpm check && pnpm test && pnpm build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.693.0",
|
"@aws-sdk/client-s3": "^3.693.0",
|
||||||
@@ -95,7 +96,6 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
"streamdown": "^1.4.0",
|
|
||||||
"superjson": "^1.13.3",
|
"superjson": "^1.13.3",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -105,16 +105,13 @@
|
|||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
|
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"@types/google.maps": "^3.58.1",
|
|
||||||
"@types/node": "^24.7.0",
|
"@types/node": "^24.7.0",
|
||||||
"@types/react": "^19.2.1",
|
"@types/react": "^19.2.1",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"add": "^2.0.6",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -141,4 +138,4 @@
|
|||||||
"sharp"
|
"sharp"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2131
pnpm-lock.yaml
generated
2131
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
70
scripts/web-import/README.md
Normal file
70
scripts/web-import/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Connecteurs Web - Scripts d'import automatique
|
||||||
|
|
||||||
|
## Prérequis sur le serveur LWS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/web-import
|
||||||
|
npm install playwright
|
||||||
|
npx playwright install chromium --with-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Depuis l'application : **Configuration > Connecteurs web** → créer une source SFR → copier le token API
|
||||||
|
2. Créer un fichier de configuration :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
# Éditer config.json avec vos valeurs
|
||||||
|
```
|
||||||
|
|
||||||
|
Contenu de `config.json` :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfrLogin": "votre-login@sfr.fr",
|
||||||
|
"sfrPassword": "votre-mot-de-passe",
|
||||||
|
"appUrl": "https://demat-facturation.santinova-soft.org",
|
||||||
|
"apiToken": "votre-token-api-copié-depuis-lappli",
|
||||||
|
"downloadDir": "/tmp/sfr-invoices",
|
||||||
|
"processedFile": "/tmp/sfr-processed.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou utiliser des variables d'environnement :
|
||||||
|
```bash
|
||||||
|
export SFR_LOGIN=votre-login@sfr.fr
|
||||||
|
export SFR_PASSWORD=votre-mot-de-passe
|
||||||
|
export APP_URL=https://demat-facturation.santinova-soft.org
|
||||||
|
export API_TOKEN=votre-token-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exécution manuelle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node sfr-connector.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planification (cron)
|
||||||
|
|
||||||
|
Ajouter dans le crontab (`crontab -e`) :
|
||||||
|
|
||||||
|
```
|
||||||
|
# Import SFR le 5 de chaque mois à 8h00
|
||||||
|
0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ajouter un nouveau connecteur
|
||||||
|
|
||||||
|
Dupliquer `sfr-connector.mjs` et adapter :
|
||||||
|
1. L'URL du portail (`portalUrl`)
|
||||||
|
2. Les sélecteurs CSS pour le login et les liens de factures
|
||||||
|
3. Le nom du fichier de suivi (`processedFile`)
|
||||||
|
|
||||||
|
## Fonctionnement
|
||||||
|
|
||||||
|
1. Le script se connecte au site SFR avec les identifiants fournis
|
||||||
|
2. Il navigue vers la section factures
|
||||||
|
3. Il télécharge les PDFs non encore traités
|
||||||
|
4. Il les envoie à l'application via l'endpoint `/api/web-import/push-invoice`
|
||||||
|
5. L'application extrait les données avec l'IA et crée les factures
|
||||||
|
6. Le script marque les factures comme traitées pour éviter les doublons
|
||||||
216
scripts/web-import/sfr-connector.mjs
Normal file
216
scripts/web-import/sfr-connector.mjs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Connecteur SFR Pro - Script cron pour import automatique de factures
|
||||||
|
*
|
||||||
|
* Prérequis sur le serveur LWS :
|
||||||
|
* npm install playwright @playwright/test
|
||||||
|
* npx playwright install chromium
|
||||||
|
*
|
||||||
|
* Configuration :
|
||||||
|
* Copier .env.example en .env et remplir les variables
|
||||||
|
*
|
||||||
|
* Utilisation :
|
||||||
|
* node sfr-connector.mjs
|
||||||
|
*
|
||||||
|
* Cron (mensuel le 5 du mois à 8h) :
|
||||||
|
* 0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// ============ CONFIGURATION ============
|
||||||
|
// Ces variables peuvent être définies dans un fichier .env ou directement ici
|
||||||
|
const CONFIG = {
|
||||||
|
// URL de l'espace client SFR Pro
|
||||||
|
portalUrl: process.env.SFR_PORTAL_URL || 'https://www.sfr-business.fr/espace-client/',
|
||||||
|
// Identifiants SFR
|
||||||
|
login: process.env.SFR_LOGIN || '',
|
||||||
|
password: process.env.SFR_PASSWORD || '',
|
||||||
|
// URL de l'application de dématérialisation
|
||||||
|
appUrl: process.env.APP_URL || 'https://demat-facturation.santinova-soft.org',
|
||||||
|
// Token API de la source web (récupéré depuis l'interface Connecteurs web)
|
||||||
|
apiToken: process.env.API_TOKEN || '',
|
||||||
|
// Dossier temporaire pour les PDFs téléchargés
|
||||||
|
downloadDir: process.env.DOWNLOAD_DIR || '/tmp/sfr-invoices',
|
||||||
|
// Ne pas réimporter les factures déjà traitées (fichier de suivi)
|
||||||
|
processedFile: process.env.PROCESSED_FILE || '/tmp/sfr-processed.json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ HELPERS ============
|
||||||
|
function log(msg) {
|
||||||
|
console.log(`[${new Date().toISOString()}] [SFR] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProcessed() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG.processedFile)) {
|
||||||
|
return JSON.parse(fs.readFileSync(CONFIG.processedFile, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProcessed(list) {
|
||||||
|
fs.writeFileSync(CONFIG.processedFile, JSON.stringify(list, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushInvoiceToApp(filePath, fileName) {
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
const fileBase64 = fileBuffer.toString('base64');
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
apiToken: CONFIG.apiToken,
|
||||||
|
fileName,
|
||||||
|
fileBase64,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(`${CONFIG.appUrl}/api/web-import/push-invoice`);
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
|
const req = lib.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve({ status: res.statusCode, body: JSON.parse(data) });
|
||||||
|
} catch {
|
||||||
|
resolve({ status: res.statusCode, body: data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ CONNECTEUR SFR ============
|
||||||
|
async function runSfrConnector() {
|
||||||
|
log('Démarrage du connecteur SFR Pro');
|
||||||
|
|
||||||
|
if (!CONFIG.login || !CONFIG.password || !CONFIG.apiToken) {
|
||||||
|
log('ERREUR : SFR_LOGIN, SFR_PASSWORD et API_TOKEN sont requis');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le dossier de téléchargement
|
||||||
|
if (!fs.existsSync(CONFIG.downloadDir)) {
|
||||||
|
fs.mkdirSync(CONFIG.downloadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const processed = loadProcessed();
|
||||||
|
let newInvoices = 0;
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
acceptDownloads: true,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Naviguer vers l'espace client SFR
|
||||||
|
log(`Navigation vers ${CONFIG.portalUrl}`);
|
||||||
|
await page.goto(CONFIG.portalUrl, { waitUntil: 'networkidle', timeout: 30000 });
|
||||||
|
|
||||||
|
// 2. Accepter les cookies si présent
|
||||||
|
try {
|
||||||
|
await page.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3000 });
|
||||||
|
log('Cookies acceptés');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 3. Remplir le formulaire de connexion
|
||||||
|
log('Connexion en cours...');
|
||||||
|
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', CONFIG.login);
|
||||||
|
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', CONFIG.password);
|
||||||
|
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
|
||||||
|
|
||||||
|
await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||||
|
log('Connecté');
|
||||||
|
|
||||||
|
// 4. Naviguer vers la section factures
|
||||||
|
// Adapter selon la structure réelle du site SFR Pro
|
||||||
|
await page.goto(`${CONFIG.portalUrl}factures`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
||||||
|
|
||||||
|
// Chercher les liens de factures PDF
|
||||||
|
const invoiceLinks = await page.$$eval(
|
||||||
|
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]',
|
||||||
|
links => links.map(a => ({
|
||||||
|
href: a.href,
|
||||||
|
text: a.textContent?.trim() || '',
|
||||||
|
download: a.getAttribute('download') || '',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
log(`${invoiceLinks.length} lien(s) de facture trouvé(s)`);
|
||||||
|
|
||||||
|
// 5. Télécharger et envoyer chaque facture
|
||||||
|
for (const link of invoiceLinks) {
|
||||||
|
const invoiceId = link.href || link.text;
|
||||||
|
if (processed.includes(invoiceId)) {
|
||||||
|
log(`Déjà traité : ${link.text}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Télécharger le PDF
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
context.waitForEvent('download', { timeout: 15000 }),
|
||||||
|
page.click(`a[href="${link.href}"]`).catch(() => page.goto(link.href)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fileName = download?.suggestedFilename() || `sfr-facture-${Date.now()}.pdf`;
|
||||||
|
const filePath = path.join(CONFIG.downloadDir, fileName);
|
||||||
|
await download?.saveAs(filePath);
|
||||||
|
|
||||||
|
log(`Téléchargé : ${fileName}`);
|
||||||
|
|
||||||
|
// Envoyer à l'application
|
||||||
|
const result = await pushInvoiceToApp(filePath, fileName);
|
||||||
|
log(`Envoyé : ${fileName} → ${JSON.stringify(result.body)}`);
|
||||||
|
|
||||||
|
// Marquer comme traité
|
||||||
|
processed.push(invoiceId);
|
||||||
|
saveProcessed(processed);
|
||||||
|
newInvoices++;
|
||||||
|
|
||||||
|
// Nettoyer le fichier temporaire
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
log(`ERREUR sur ${link.text} : ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
log(`ERREUR FATALE : ${err.message}`);
|
||||||
|
await page.screenshot({ path: path.join(CONFIG.downloadDir, 'error-screenshot.png') }).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Terminé : ${newInvoices} nouvelle(s) facture(s) importée(s)`);
|
||||||
|
return newInvoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ POINT D'ENTRÉE ============
|
||||||
|
runSfrConnector().catch(err => {
|
||||||
|
console.error(`[FATAL] ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
30
server/_core/cookies.test.ts
Normal file
30
server/_core/cookies.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { getSessionCookieOptions } from "./cookies";
|
||||||
|
|
||||||
|
function requestFor(protocol: "http" | "https", forwardedProto?: string): Request {
|
||||||
|
return {
|
||||||
|
protocol,
|
||||||
|
headers: forwardedProto ? { "x-forwarded-proto": forwardedProto } : {},
|
||||||
|
} as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getSessionCookieOptions", () => {
|
||||||
|
it("utilise des cookies sécurisés derrière le proxy HTTPS", () => {
|
||||||
|
expect(getSessionCookieOptions(requestFor("http", "https"))).toMatchObject({
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
secure: true,
|
||||||
|
sameSite: "none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reste compatible avec l’environnement HTTP local", () => {
|
||||||
|
expect(getSessionCookieOptions(requestFor("http"))).toMatchObject({
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
secure: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import type { CookieOptions, Request } from "express";
|
import type { CookieOptions, Request } from "express";
|
||||||
|
|
||||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
/** Detects HTTPS after direct access or a reverse proxy such as Traefik. */
|
||||||
|
|
||||||
function isIpAddress(host: string) {
|
|
||||||
// Basic IPv4 check and IPv6 presence detection.
|
|
||||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
|
|
||||||
return host.includes(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSecureRequest(req: Request) {
|
function isSecureRequest(req: Request) {
|
||||||
if (req.protocol === "https") return true;
|
if (req.protocol === "https") return true;
|
||||||
|
|
||||||
@@ -24,25 +17,13 @@ function isSecureRequest(req: Request) {
|
|||||||
export function getSessionCookieOptions(
|
export function getSessionCookieOptions(
|
||||||
req: Request
|
req: Request
|
||||||
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
|
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
|
||||||
// const hostname = req.hostname;
|
const secure = isSecureRequest(req);
|
||||||
// const shouldSetDomain =
|
|
||||||
// hostname &&
|
|
||||||
// !LOCAL_HOSTS.has(hostname) &&
|
|
||||||
// !isIpAddress(hostname) &&
|
|
||||||
// hostname !== "127.0.0.1" &&
|
|
||||||
// hostname !== "::1";
|
|
||||||
|
|
||||||
// const domain =
|
|
||||||
// shouldSetDomain && !hostname.startsWith(".")
|
|
||||||
// ? `.${hostname}`
|
|
||||||
// : shouldSetDomain
|
|
||||||
// ? hostname
|
|
||||||
// : undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "none",
|
// Browsers reject SameSite=None without Secure; use Lax for local HTTP.
|
||||||
secure: isSecureRequest(req),
|
sameSite: secure ? "none" : "lax",
|
||||||
|
secure,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,43 @@ import net from "net";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
|
import { parse as parseCookies } from "cookie";
|
||||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||||
import { registerOAuthRoutes } from "./oauth";
|
import { registerOAuthRoutes } from "./oauth";
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
import { createContext } from "./context";
|
import { createContext } from "./context";
|
||||||
|
import { getSessionCookieOptions } from "./cookies";
|
||||||
import { serveStatic, setupVite } from "./vite";
|
import { serveStatic, setupVite } from "./vite";
|
||||||
import { getAllUsers, getUserByAzureAdId, getUserByEmail, upsertUser } from "../db";
|
import { getAllUsers, getImportSettingsByUser, getUserByAzureAdId, getUserByEmail, getUserSettings, upsertUser } from "../db";
|
||||||
import { startEmailImportService } from "../emailImportService";
|
import { startEmailImportService } from "../emailImportService";
|
||||||
import { startFolderImportService } from "../folderImportService";
|
import { startFolderImportService } from "../folderImportService";
|
||||||
import { getImportSettingsByUser } from "../db";
|
import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken } from "../auth";
|
||||||
import { handleAzureCallback, isAzureAdConfigured, generateToken } from "../auth";
|
import { createDatabaseBackup } from "../databaseBackup";
|
||||||
|
import { generateStorageKey, localStoragePut } from "../localStorage";
|
||||||
|
|
||||||
|
const MAX_WEB_IMPORT_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Returns the signed local session or sends the appropriate HTTP error. */
|
||||||
|
function requireAuthenticatedUser(req: express.Request, res: express.Response) {
|
||||||
|
const token = parseCookies(req.headers.cookie || "").auth_token;
|
||||||
|
const user = token ? verifyToken(token) : null;
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: "Non authentifié" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restricts a sensitive endpoint to administrators. */
|
||||||
|
function requireAdmin(req: express.Request, res: express.Response) {
|
||||||
|
const user = requireAuthenticatedUser(req, res);
|
||||||
|
if (!user) return null;
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
res.status(403).json({ error: "Accès réservé aux administrateurs" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
function isPortAvailable(port: number): Promise<boolean> {
|
function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@@ -50,6 +77,7 @@ async function startServer() {
|
|||||||
// Accepte les chemins avec sous-dossiers : /api/download-bap/2026-04/filename.pdf
|
// Accepte les chemins avec sous-dossiers : /api/download-bap/2026-04/filename.pdf
|
||||||
// ou via query param pdfPath : /api/download-bap/file.pdf?pdfPath=/storage/2026-04/file.pdf
|
// ou via query param pdfPath : /api/download-bap/file.pdf?pdfPath=/storage/2026-04/file.pdf
|
||||||
app.get("/api/download-bap", (req, res) => {
|
app.get("/api/download-bap", (req, res) => {
|
||||||
|
if (!requireAuthenticatedUser(req, res)) return;
|
||||||
// Mode 1 : query param pdfPath (chemin complet depuis /storage/...)
|
// Mode 1 : query param pdfPath (chemin complet depuis /storage/...)
|
||||||
const pdfPath = req.query.pdfPath as string | undefined;
|
const pdfPath = req.query.pdfPath as string | undefined;
|
||||||
if (!pdfPath) {
|
if (!pdfPath) {
|
||||||
@@ -80,6 +108,7 @@ async function startServer() {
|
|||||||
|
|
||||||
// Compat. ancienne route avec :filename (sans sous-dossier)
|
// Compat. ancienne route avec :filename (sans sous-dossier)
|
||||||
app.get("/api/download-bap/:filename", (req, res) => {
|
app.get("/api/download-bap/:filename", (req, res) => {
|
||||||
|
if (!requireAuthenticatedUser(req, res)) return;
|
||||||
const filename = path.basename(req.params.filename);
|
const filename = path.basename(req.params.filename);
|
||||||
// Chercher dans tous les sous-dossiers de storage
|
// Chercher dans tous les sous-dossiers de storage
|
||||||
const storageRoot = path.resolve("storage");
|
const storageRoot = path.resolve("storage");
|
||||||
@@ -106,6 +135,7 @@ async function startServer() {
|
|||||||
// Route de téléchargement groupé ZIP des PDFs annotés BAP
|
// Route de téléchargement groupé ZIP des PDFs annotés BAP
|
||||||
// POST /api/download-bap-zip avec body { files: Array<{ pdfPath: string, filename: string }> }
|
// POST /api/download-bap-zip avec body { files: Array<{ pdfPath: string, filename: string }> }
|
||||||
app.post("/api/download-bap-zip", (req, res) => {
|
app.post("/api/download-bap-zip", (req, res) => {
|
||||||
|
if (!requireAuthenticatedUser(req, res)) return;
|
||||||
const files: Array<{ pdfPath: string; filename: string }> = req.body.files || [];
|
const files: Array<{ pdfPath: string; filename: string }> = req.body.files || [];
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
res.status(400).json({ error: "Aucun fichier spécifié" });
|
res.status(400).json({ error: "Aucun fichier spécifié" });
|
||||||
@@ -209,10 +239,7 @@ async function startServer() {
|
|||||||
// Générer le token JWT et poser le cookie
|
// Générer le token JWT et poser le cookie
|
||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
res.cookie("auth_token", token, {
|
res.cookie("auth_token", token, {
|
||||||
httpOnly: true,
|
...getSessionCookieOptions(req),
|
||||||
secure: false,
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,6 +251,109 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
|
||||||
|
|
||||||
|
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
|
||||||
|
app.post("/api/db-backup", async (req, res) => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backupDir = path.resolve("backups");
|
||||||
|
const backup = await createDatabaseBackup(process.env.DATABASE_URL, backupDir);
|
||||||
|
console.log(`[Backup] Dump saved to ${backup.filePath} (${backup.size} bytes)`);
|
||||||
|
|
||||||
|
// Retourner le fichier en téléchargement
|
||||||
|
const encodedName = encodeURIComponent(backup.fileName);
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
||||||
|
res.setHeader("Content-Type", "application/sql");
|
||||||
|
res.sendFile(backup.filePath, (err) => {
|
||||||
|
if (err) console.error("[Backup] Error sending file:", err);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[Backup] Error:", err.message);
|
||||||
|
res.status(500).json({ error: "La sauvegarde n’a pas pu être générée. Consultez les journaux serveur." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Télécharger une sauvegarde existante
|
||||||
|
app.get("/api/db-backup/:filename", async (req, res) => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
|
|
||||||
|
const fileName = path.basename(req.params.filename);
|
||||||
|
const filePath = path.join(path.resolve("backups"), fileName);
|
||||||
|
if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; }
|
||||||
|
const encodedName = encodeURIComponent(fileName);
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
||||||
|
res.setHeader("Content-Type", "application/sql");
|
||||||
|
res.sendFile(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/web-import/push-invoice", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiToken, fileName, fileBase64 } = req.body;
|
||||||
|
if (typeof apiToken !== "string" || typeof fileName !== "string" || typeof fileBase64 !== "string") {
|
||||||
|
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safeFileName = path.basename(fileName);
|
||||||
|
if (!safeFileName.toLowerCase().endsWith(".pdf")) {
|
||||||
|
res.status(400).json({ error: "Seuls les fichiers PDF sont acceptés" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(fileBase64, "utf8") > Math.ceil(MAX_WEB_IMPORT_BYTES * 1.34)) {
|
||||||
|
res.status(413).json({ error: "Le fichier dépasse la taille maximale autorisée" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile } = await import('../db');
|
||||||
|
const source = await getWebImportSourceByToken(apiToken);
|
||||||
|
if (!source) {
|
||||||
|
res.status(401).json({ error: "Token invalide" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pdfBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
if (pdfBuffer.length === 0 || pdfBuffer.length > MAX_WEB_IMPORT_BYTES || !pdfBuffer.subarray(0, 4).equals(Buffer.from("%PDF"))) {
|
||||||
|
res.status(400).json({ error: "Le contenu reçu n’est pas un PDF valide" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stocker d'abord le PDF de façon persistante, comme les autres sources d'import.
|
||||||
|
const storageKey = generateStorageKey(source.userId, safeFileName);
|
||||||
|
const { url: fileUrl } = await localStoragePut(storageKey, pdfBuffer, "application/pdf");
|
||||||
|
const sourceFile = await createSourceFile({
|
||||||
|
userId: source.userId,
|
||||||
|
fileName: safeFileName,
|
||||||
|
fileKey: storageKey,
|
||||||
|
fileUrl,
|
||||||
|
});
|
||||||
|
const userSettings = await getUserSettings(source.userId);
|
||||||
|
const aiSettings = {
|
||||||
|
aiProvider: userSettings?.aiProvider || "manus",
|
||||||
|
mistralApiKey: userSettings?.mistralApiKey || undefined,
|
||||||
|
manusForgeApiUrl: userSettings?.manusForgeApiUrl || undefined,
|
||||||
|
manusForgeApiKey: userSettings?.manusForgeApiKey || undefined,
|
||||||
|
geminiApiKey: userSettings?.geminiApiKey || undefined,
|
||||||
|
};
|
||||||
|
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
|
||||||
|
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, userSettings?.llmModel || "mistral-large-latest", undefined, aiSettings);
|
||||||
|
let imported = 0;
|
||||||
|
let duplicates = 0;
|
||||||
|
for (const inv of extractResult.invoices || []) {
|
||||||
|
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
|
||||||
|
if (blacklisted) { duplicates++; continue; }
|
||||||
|
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
|
||||||
|
if (dup) { duplicates++; continue; }
|
||||||
|
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
await updateWebImportSourceStatus(source.id, 'success', imported, true);
|
||||||
|
res.json({ success: true, imported, duplicates, total: (extractResult.invoices || []).length });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[WebImport] Erreur push-invoice:', err.message);
|
||||||
|
res.status(500).json({ error: "L’import web a échoué. Consultez les journaux serveur." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// tRPC API
|
// tRPC API
|
||||||
app.use(
|
app.use(
|
||||||
"/api/trpc",
|
"/api/trpc",
|
||||||
@@ -276,4 +406,7 @@ async function startServer() {
|
|||||||
}, 5000); // Attendre 5s que le serveur soit prêt
|
}, 5000); // Attendre 5s que le serveur soit prêt
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer().catch(console.error);
|
// Vitest ne doit jamais démarrer un serveur HTTP.
|
||||||
|
if (!process.env.VITEST) {
|
||||||
|
startServer().catch(console.error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -329,7 +329,6 @@ export async function invokeLLMWithUserSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provider = userSettings?.aiProvider || "mistral";
|
const provider = userSettings?.aiProvider || "mistral";
|
||||||
const isMistral = provider === "mistral";
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import * as db from "../db";
|
import * as db from "../db";
|
||||||
|
import { normalizeLoginMethod } from "../authMethod";
|
||||||
import { getSessionCookieOptions } from "./cookies";
|
import { getSessionCookieOptions } from "./cookies";
|
||||||
import { sdk } from "./sdk";
|
import { sdk } from "./sdk";
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export function registerOAuthRoutes(app: Express) {
|
|||||||
openId: userInfo.openId,
|
openId: userInfo.openId,
|
||||||
name: userInfo.name || null,
|
name: userInfo.name || null,
|
||||||
email: userInfo.email ?? "unknown@example.com",
|
email: userInfo.email ?? "unknown@example.com",
|
||||||
loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
|
loginMethod: normalizeLoginMethod(userInfo.loginMethod ?? userInfo.platform),
|
||||||
lastSignedIn: new Date(),
|
lastSignedIn: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
|||||||
import { ForbiddenError } from "@shared/_core/errors";
|
import { ForbiddenError } from "@shared/_core/errors";
|
||||||
import axios, { type AxiosInstance } from "axios";
|
import axios, { type AxiosInstance } from "axios";
|
||||||
import { parse as parseCookieHeader } from "cookie";
|
import { parse as parseCookieHeader } from "cookie";
|
||||||
|
import { normalizeLoginMethod } from "../authMethod";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
import type { User } from "../../drizzle/schema";
|
import type { User } from "../../drizzle/schema";
|
||||||
@@ -296,7 +297,7 @@ class SDKServer {
|
|||||||
openId: userInfo.openId,
|
openId: userInfo.openId,
|
||||||
name: userInfo.name || null,
|
name: userInfo.name || null,
|
||||||
email: userInfo.email ?? "unknown@example.com",
|
email: userInfo.email ?? "unknown@example.com",
|
||||||
loginMethod: (userInfo.loginMethod ?? userInfo.platform ?? "manus") as "manus" | "local" | "azure-ad",
|
loginMethod: normalizeLoginMethod(userInfo.loginMethod ?? userInfo.platform),
|
||||||
lastSignedIn: signedInAt,
|
lastSignedIn: signedInAt,
|
||||||
});
|
});
|
||||||
user = await db.getUserByOpenId(userInfo.openId);
|
user = await db.getUserByOpenId(userInfo.openId);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import { ConfidentialClientApplication } from "@azure/msal-node";
|
import { ConfidentialClientApplication } from "@azure/msal-node";
|
||||||
import { getUserByEmail, getUserByAzureAdId } from "./db";
|
import { getUserByEmail } from "./db";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
const SALT_ROUNDS = 10;
|
const SALT_ROUNDS = 10;
|
||||||
|
|||||||
19
server/authMethod.test.ts
Normal file
19
server/authMethod.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeLoginMethod } from "./authMethod";
|
||||||
|
|
||||||
|
describe("normalizeLoginMethod", () => {
|
||||||
|
it("conserve les valeurs de l’enum DB", () => {
|
||||||
|
expect(normalizeLoginMethod("local")).toBe("local");
|
||||||
|
expect(normalizeLoginMethod("azure-ad")).toBe("azure-ad");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("convertit les identifiants de fournisseur Microsoft", () => {
|
||||||
|
expect(normalizeLoginMethod("Microsoft OAuth")).toBe("azure-ad");
|
||||||
|
expect(normalizeLoginMethod("AZURE_ENTRA")).toBe("azure-ad");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("utilise Manus pour toute valeur inconnue au lieu d’échouer en base", () => {
|
||||||
|
expect(normalizeLoginMethod("platform-v2")).toBe("manus");
|
||||||
|
expect(normalizeLoginMethod(undefined)).toBe("manus");
|
||||||
|
});
|
||||||
|
});
|
||||||
23
server/authMethod.ts
Normal file
23
server/authMethod.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** Values accepted by the `users.loginMethod` database enum. */
|
||||||
|
export type LoginMethod = "manus" | "local" | "azure-ad";
|
||||||
|
|
||||||
|
const LOGIN_METHODS = new Set<LoginMethod>(["manus", "local", "azure-ad"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps provider-specific identifiers to the limited database enum.
|
||||||
|
* OAuth identity payloads are external input and must never be persisted verbatim.
|
||||||
|
*/
|
||||||
|
export function normalizeLoginMethod(value: unknown): LoginMethod {
|
||||||
|
if (typeof value !== "string") return "manus";
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (LOGIN_METHODS.has(normalized as LoginMethod)) {
|
||||||
|
return normalized as LoginMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("azure") || normalized.includes("microsoft")) {
|
||||||
|
return "azure-ad";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "manus";
|
||||||
|
}
|
||||||
21
server/databaseBackup.test.ts
Normal file
21
server/databaseBackup.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { toSqlLiteral } from "./databaseBackup";
|
||||||
|
|
||||||
|
describe("toSqlLiteral", () => {
|
||||||
|
it("sérialise les valeurs primitives de manière importable", () => {
|
||||||
|
expect(toSqlLiteral(null)).toBe("NULL");
|
||||||
|
expect(toSqlLiteral(undefined)).toBe("NULL");
|
||||||
|
expect(toSqlLiteral(42.5)).toBe("42.5");
|
||||||
|
expect(toSqlLiteral(true)).toBe("1");
|
||||||
|
expect(toSqlLiteral(false)).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("échappe les caractères sensibles d’une chaîne SQL", () => {
|
||||||
|
expect(toSqlLiteral("O'Hara\\facture\nligne")).toBe("'O\\'Hara\\\\facture\\nligne'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conserve les données binaires et dates sans conversion ambiguë", () => {
|
||||||
|
expect(toSqlLiteral(Buffer.from([0, 255]))).toBe("X'00ff'");
|
||||||
|
expect(toSqlLiteral(new Date("2026-08-17T12:34:56.000Z"))).toBe("'2026-08-17 12:34:56.000'");
|
||||||
|
});
|
||||||
|
});
|
||||||
170
server/databaseBackup.ts
Normal file
170
server/databaseBackup.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import mysql, { type RowDataPacket } from "mysql2/promise";
|
||||||
|
|
||||||
|
/** Number of rows exported per INSERT statement to bound memory usage. */
|
||||||
|
const EXPORT_BATCH_SIZE = 500;
|
||||||
|
/** Keep a short local history while preventing unbounded disk growth. */
|
||||||
|
const MAX_BACKUP_FILES = 10;
|
||||||
|
|
||||||
|
export type DatabaseBackupResult = {
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
size: number;
|
||||||
|
tableCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts one MySQL value to a portable SQL literal.
|
||||||
|
* Binary values, booleans, dates, quotes and backslashes are handled explicitly
|
||||||
|
* so a generated dump can be imported without corrupting invoice data.
|
||||||
|
*/
|
||||||
|
export function toSqlLiteral(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "NULL";
|
||||||
|
if (Buffer.isBuffer(value)) return `X'${value.toString("hex")}'`;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return `'${value.toISOString().replace("T", " ").replace("Z", "")}'`;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
|
||||||
|
if (typeof value === "boolean") return value ? "1" : "0";
|
||||||
|
|
||||||
|
return `'${String(value)
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/\u0000/g, "\\0")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/\r/g, "\\r")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIdentifier(identifier: string): string {
|
||||||
|
if (!/^[A-Za-z0-9_$]+$/.test(identifier)) {
|
||||||
|
throw new Error("Identifiant SQL inattendu lors de la sauvegarde");
|
||||||
|
}
|
||||||
|
return `\`${identifier}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFileName(database: string, now = new Date()): string {
|
||||||
|
const safeDatabase = database.replace(/[^A-Za-z0-9_-]/g, "_");
|
||||||
|
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||||
|
return `backup-${safeDatabase}-${timestamp}.sql`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSslConfig(databaseUrl: URL) {
|
||||||
|
const sslMode = databaseUrl.searchParams.get("ssl-mode")?.toUpperCase();
|
||||||
|
const sslParameter = databaseUrl.searchParams.get("ssl");
|
||||||
|
|
||||||
|
if (sslMode === "REQUIRED" || sslMode === "VERIFY_CA" || sslMode === "VERIFY_IDENTITY") {
|
||||||
|
return { rejectUnauthorized: sslMode === "VERIFY_IDENTITY" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sslParameter && sslParameter !== "false") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(sslParameter) as { rejectUnauthorized?: boolean };
|
||||||
|
} catch {
|
||||||
|
return { rejectUnauthorized: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneOldBackups(backupDir: string): Promise<void> {
|
||||||
|
const entries = await fs.readdir(backupDir, { withFileTypes: true });
|
||||||
|
const backups = await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter(entry => entry.isFile() && entry.name.endsWith(".sql"))
|
||||||
|
.map(async entry => ({
|
||||||
|
name: entry.name,
|
||||||
|
modifiedAt: (await fs.stat(path.join(backupDir, entry.name))).mtimeMs,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
backups.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
||||||
|
await Promise.all(
|
||||||
|
backups.slice(MAX_BACKUP_FILES).map(backup => fs.unlink(path.join(backupDir, backup.name)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a self-contained SQL dump without requiring the mysqldump binary.
|
||||||
|
* Rows are exported in batches to avoid keeping the entire database in memory.
|
||||||
|
*/
|
||||||
|
export async function createDatabaseBackup(
|
||||||
|
databaseUrlValue: string | undefined,
|
||||||
|
backupDir: string
|
||||||
|
): Promise<DatabaseBackupResult> {
|
||||||
|
if (!databaseUrlValue) {
|
||||||
|
throw new Error("DATABASE_URL est absente");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseUrl = new URL(databaseUrlValue);
|
||||||
|
if (!databaseUrl.protocol.startsWith("mysql")) {
|
||||||
|
throw new Error("La sauvegarde requiert une base de données MySQL compatible");
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = databaseUrl.pathname.replace(/^\//, "");
|
||||||
|
if (!database) {
|
||||||
|
throw new Error("Nom de base de données absent de DATABASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
|
const fileName = buildFileName(database);
|
||||||
|
const filePath = path.join(backupDir, fileName);
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host: databaseUrl.hostname,
|
||||||
|
port: Number(databaseUrl.port || "3306"),
|
||||||
|
user: decodeURIComponent(databaseUrl.username),
|
||||||
|
password: decodeURIComponent(databaseUrl.password),
|
||||||
|
database,
|
||||||
|
ssl: buildSslConfig(databaseUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(
|
||||||
|
filePath,
|
||||||
|
`-- Backup généré le ${new Date().toISOString()}\n-- Base : ${database}\nSET FOREIGN_KEY_CHECKS=0;\n\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
|
||||||
|
const tableNames = tables.map(row => String(Object.values(row)[0]));
|
||||||
|
|
||||||
|
for (const tableName of tableNames) {
|
||||||
|
const table = quoteIdentifier(tableName);
|
||||||
|
const [createRows] = await connection.query<RowDataPacket[]>(`SHOW CREATE TABLE ${table}`);
|
||||||
|
const createStatement = String(createRows[0]?.["Create Table"] ?? "");
|
||||||
|
if (!createStatement) throw new Error(`Structure introuvable pour la table ${tableName}`);
|
||||||
|
|
||||||
|
await fs.appendFile(
|
||||||
|
filePath,
|
||||||
|
`-- Table: ${tableName}\nDROP TABLE IF EXISTS ${table};\n${createStatement};\n\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const [rows] = await connection.query<RowDataPacket[]>(
|
||||||
|
`SELECT * FROM ${table} LIMIT ${EXPORT_BATCH_SIZE} OFFSET ${offset}`
|
||||||
|
);
|
||||||
|
if (rows.length === 0) break;
|
||||||
|
|
||||||
|
const columns = Object.keys(rows[0]!).map(quoteIdentifier).join(", ");
|
||||||
|
const values = rows
|
||||||
|
.map(row => `(${Object.values(row).map(toSqlLiteral).join(", ")})`)
|
||||||
|
.join(",\n");
|
||||||
|
await fs.appendFile(filePath, `INSERT INTO ${table} (${columns}) VALUES\n${values};\n\n`, "utf8");
|
||||||
|
offset += rows.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.appendFile(filePath, "SET FOREIGN_KEY_CHECKS=1;\n-- Fin du dump\n", "utf8");
|
||||||
|
await pruneOldBackups(backupDir);
|
||||||
|
const { size } = await fs.stat(filePath);
|
||||||
|
return { fileName, filePath, size, tableCount: tableNames.length };
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(filePath, { force: true });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
server/db.ts
119
server/db.ts
@@ -43,8 +43,11 @@ import {
|
|||||||
InsertBapHistory,
|
InsertBapHistory,
|
||||||
BapHistory,
|
BapHistory,
|
||||||
invoiceLearnings,
|
invoiceLearnings,
|
||||||
InsertInvoiceLearning,
|
InvoiceLearning,
|
||||||
InvoiceLearning
|
deletedInvoices,
|
||||||
|
webImportSources,
|
||||||
|
InsertWebImportSource,
|
||||||
|
WebImportSource
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from './_core/env';
|
import { ENV } from './_core/env';
|
||||||
|
|
||||||
@@ -265,9 +268,33 @@ export async function updateInvoice(id: number, data: Partial<Invoice>) {
|
|||||||
export async function deleteInvoice(id: number) {
|
export async function deleteInvoice(id: 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");
|
||||||
|
// Enregistrer dans la blacklist avant suppression
|
||||||
|
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
|
||||||
|
if (invoice[0] && invoice[0].invoiceNumber) {
|
||||||
|
await db.insert(deletedInvoices).values({
|
||||||
|
userId: invoice[0].userId,
|
||||||
|
invoiceNumber: invoice[0].invoiceNumber,
|
||||||
|
totalAmount: invoice[0].totalAmount ?? undefined,
|
||||||
|
supplierName: invoice[0].supplierName ?? undefined,
|
||||||
|
}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });
|
||||||
|
}
|
||||||
await db.delete(invoices).where(eq(invoices.id, id));
|
await db.delete(invoices).where(eq(invoices.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vérifie si une facture est dans la blacklist (supprimée manuellement) */
|
||||||
|
export async function isInvoiceBlacklisted(
|
||||||
|
invoiceNumber: string | null,
|
||||||
|
userId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!invoiceNumber) return false;
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return false;
|
||||||
|
const result = await db.select().from(deletedInvoices)
|
||||||
|
.where(and(eq(deletedInvoices.userId, userId), eq(deletedInvoices.invoiceNumber, invoiceNumber)))
|
||||||
|
.limit(1);
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchInvoices(userId: number | null, query: string): Promise<Invoice[]> {
|
export async function searchInvoices(userId: number | null, query: string): Promise<Invoice[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
@@ -1120,3 +1147,91 @@ export async function updateFreeproLastRun(
|
|||||||
.set(update)
|
.set(update)
|
||||||
.where(eq(freeproSettings.userId, userId));
|
.where(eq(freeproSettings.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= WEB IMPORT SOURCES =============
|
||||||
|
|
||||||
|
/** Génère un token API aléatoire de 64 caractères */
|
||||||
|
function generateApiToken(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let token = '';
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
token += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebImportSourcesByUser(userId: number): Promise<WebImportSource[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db.select().from(webImportSources).where(eq(webImportSources.userId, userId)).orderBy(desc(webImportSources.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebImportSourceById(id: number): Promise<WebImportSource | undefined> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
const result = await db.select().from(webImportSources).where(eq(webImportSources.id, id)).limit(1);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebImportSourceByToken(token: string): Promise<WebImportSource | undefined> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
const result = await db.select().from(webImportSources).where(eq(webImportSources.apiToken, token)).limit(1);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWebImportSource(data: Omit<InsertWebImportSource, 'apiToken'>): Promise<WebImportSource> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
const apiToken = generateApiToken();
|
||||||
|
const result = await db.insert(webImportSources).values({ ...data, apiToken });
|
||||||
|
const insertedId = Number(result[0].insertId);
|
||||||
|
const inserted = await db.select().from(webImportSources).where(eq(webImportSources.id, insertedId)).limit(1);
|
||||||
|
return inserted[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWebImportSource(id: number, data: Partial<InsertWebImportSource>): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.update(webImportSources).set({ ...data, updatedAt: new Date() }).where(eq(webImportSources.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWebImportSource(id: number): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.delete(webImportSources).where(eq(webImportSources.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWebImportSourceStatus(
|
||||||
|
id: number,
|
||||||
|
status: string,
|
||||||
|
importCount: number,
|
||||||
|
success: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
const update: Partial<InsertWebImportSource> = {
|
||||||
|
lastStatus: status,
|
||||||
|
lastImportCount: importCount,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (success) update.lastSuccessAt = new Date();
|
||||||
|
await db.update(webImportSources).set(update).where(eq(webImportSources.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a source file with the same fileName already exists for this user
|
||||||
|
* Used to prevent duplicate file storage during email import
|
||||||
|
*/
|
||||||
|
export async function findSourceFileByFileName(userId: number, fileName: string): Promise<any | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const result = await db.select()
|
||||||
|
.from(sourceFiles)
|
||||||
|
.where(and(
|
||||||
|
eq(sourceFiles.userId, userId),
|
||||||
|
eq(sourceFiles.fileName, fileName)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
updateSourceFile,
|
updateSourceFile,
|
||||||
getUserSettings,
|
getUserSettings,
|
||||||
findDuplicateInvoice,
|
findDuplicateInvoice,
|
||||||
|
isInvoiceBlacklisted,
|
||||||
createInvoice,
|
createInvoice,
|
||||||
createImportLog,
|
createImportLog,
|
||||||
|
findSourceFileByFileName,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
@@ -31,6 +33,8 @@ interface EmailImportConfig {
|
|||||||
|
|
||||||
// Store active intervals for each user
|
// Store active intervals for each user
|
||||||
const activeIntervals = new Map<number, NodeJS.Timeout>();
|
const activeIntervals = new Map<number, NodeJS.Timeout>();
|
||||||
|
// Verrou anti-concurrence par userId
|
||||||
|
const runningChecks = new Set<number>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a single email attachment (PDF)
|
* Process a single email attachment (PDF)
|
||||||
@@ -50,6 +54,12 @@ async function processEmailAttachment(
|
|||||||
console.log(`[EmailImport] File size: ${fileBuffer.length} bytes`);
|
console.log(`[EmailImport] File size: ${fileBuffer.length} bytes`);
|
||||||
|
|
||||||
// Store source file
|
// Store source file
|
||||||
|
// ANTI-DUPLICATION : vérifier si ce fichier a déjà été importé pour cet utilisateur
|
||||||
|
const existingSourceFile = await findSourceFileByFileName(userId, fileName);
|
||||||
|
if (existingSourceFile) {
|
||||||
|
console.log(`[EmailImport] File ${fileName} already imported for user ${userId} (sourceFile #${existingSourceFile.id}), skipping`);
|
||||||
|
return { success: true, totalInvoices: 0, imported: 0, duplicates: 1, errors: 0 };
|
||||||
|
}
|
||||||
const sourceFileKey = generateStorageKey(userId, fileName);
|
const sourceFileKey = generateStorageKey(userId, fileName);
|
||||||
console.log(`[EmailImport] Generated storage key: ${sourceFileKey}`);
|
console.log(`[EmailImport] Generated storage key: ${sourceFileKey}`);
|
||||||
|
|
||||||
@@ -130,6 +140,19 @@ async function processEmailAttachment(
|
|||||||
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
|
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vérifier la blacklist (factures supprimées manuellement)
|
||||||
|
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
|
||||||
|
if (blacklisted) {
|
||||||
|
duplicatesCount++;
|
||||||
|
duplicateDetails.push({
|
||||||
|
supplierName: invoiceData.supplierName,
|
||||||
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
|
reason: 'blacklisted',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for duplicates (numéro de facture + montant)
|
// Check for duplicates (numéro de facture + montant)
|
||||||
const duplicate = await findDuplicateInvoice(
|
const duplicate = await findDuplicateInvoice(
|
||||||
invoiceData.invoiceNumber,
|
invoiceData.invoiceNumber,
|
||||||
@@ -316,6 +339,12 @@ async function buildImapConfig(config: EmailImportConfig): Promise<Imap.Config>
|
|||||||
* Connect to IMAP and process unread emails with PDF attachments
|
* Connect to IMAP and process unread emails with PDF attachments
|
||||||
*/
|
*/
|
||||||
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
||||||
|
// Anti-concurrence : ne pas lancer si un check est déjà en cours pour cet utilisateur
|
||||||
|
if (runningChecks.has(config.userId)) {
|
||||||
|
console.log(`[EmailImport] Check already running for user ${config.userId}, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runningChecks.add(config.userId);
|
||||||
// Build IMAP config (may involve async OAuth2 token fetch)
|
// Build IMAP config (may involve async OAuth2 token fetch)
|
||||||
const imapConfig = await buildImapConfig(config);
|
const imapConfig = await buildImapConfig(config);
|
||||||
|
|
||||||
@@ -329,7 +358,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
|||||||
imap.once("ready", () => {
|
imap.once("ready", () => {
|
||||||
console.log(`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`);
|
console.log(`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`);
|
||||||
|
|
||||||
openInbox((err, box) => {
|
openInbox((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("[EmailImport] Error opening inbox:", err);
|
console.error("[EmailImport] Error opening inbox:", err);
|
||||||
imap.end();
|
imap.end();
|
||||||
@@ -366,7 +395,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
|||||||
|
|
||||||
const fetch = imap.fetch(results, {
|
const fetch = imap.fetch(results, {
|
||||||
bodies: "",
|
bodies: "",
|
||||||
markSeen: false, // Don't mark as seen yet
|
markSeen: true, // Mark as seen immediately to prevent re-processing
|
||||||
});
|
});
|
||||||
|
|
||||||
const processedEmails: number[] = [];
|
const processedEmails: number[] = [];
|
||||||
@@ -460,11 +489,13 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
imap.once("error", (err) => {
|
imap.once("error", (err) => {
|
||||||
|
runningChecks.delete(config.userId);
|
||||||
console.error("[EmailImport] IMAP connection error:", err);
|
console.error("[EmailImport] IMAP connection error:", err);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once("end", () => {
|
imap.once("end", () => {
|
||||||
|
runningChecks.delete(config.userId);
|
||||||
console.log(`[EmailImport] IMAP connection ended for user ${config.userId}`);
|
console.log(`[EmailImport] IMAP connection ended for user ${config.userId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ interface AutoImportResult {
|
|||||||
// ── Constantes ─────────────────────────────────────────────────────────────
|
// ── Constantes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FREEPRO_BASE_URL = "https://pro.free.fr";
|
const FREEPRO_BASE_URL = "https://pro.free.fr";
|
||||||
const LOGIN_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/#/`;
|
|
||||||
const LOGIN_FORM_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/`;
|
const LOGIN_FORM_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/`;
|
||||||
// Endpoint réel capturé via analyse réseau du portail FreePro (XHR POST)
|
// Endpoint réel capturé via analyse réseau du portail FreePro (XHR POST)
|
||||||
const DO_LOGIN_URL = `${FREEPRO_BASE_URL}/account/security/do_login`;
|
const DO_LOGIN_URL = `${FREEPRO_BASE_URL}/account/security/do_login`;
|
||||||
@@ -77,17 +76,6 @@ function extractCookies(setCookieHeader: string | null): string {
|
|||||||
.join("; ");
|
.join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calcule le label du mois précédent (les factures FreePro arrivent en début de mois suivant)
|
|
||||||
*/
|
|
||||||
function previousMoisLabel(): string {
|
|
||||||
const now = new Date();
|
|
||||||
now.setMonth(now.getMonth() - 1);
|
|
||||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const y = String(now.getFullYear());
|
|
||||||
return `${m}/${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connexion au portail FreePro ───────────────────────────────────────────
|
// ── Connexion au portail FreePro ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { invokeLLM, invokeLLMWithUserSettings } from "./_core/llm";
|
import { invokeLLMWithUserSettings } from "./_core/llm";
|
||||||
import { PDFDocument } from "pdf-lib";
|
import { PDFDocument } from "pdf-lib";
|
||||||
import { createLlmLog } from "./db";
|
import { createLlmLog } from "./db";
|
||||||
import PDFParser from "pdf2json";
|
import PDFParser from "pdf2json";
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, it, expect, beforeAll } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
|
||||||
|
const describeIntegration = process.env.DATABASE_URL ? describe : describe.skip;
|
||||||
import {
|
import {
|
||||||
getLlmFieldsConfigByUser,
|
getLlmFieldsConfigByUser,
|
||||||
upsertLlmFieldConfig,
|
upsertLlmFieldConfig,
|
||||||
initializeDefaultLlmFields
|
initializeDefaultLlmFields
|
||||||
} from "./db";
|
} from "./db";
|
||||||
|
|
||||||
describe("LLM Fields Configuration", () => {
|
describeIntegration("LLM Fields Configuration", () => {
|
||||||
const testUserId = 99999; // Use a high ID to avoid conflicts
|
const testUserId = 99999; // Use a high ID to avoid conflicts
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function generateStorageKey(userId: number, fileName: string): string {
|
|||||||
export async function localStoragePut(
|
export async function localStoragePut(
|
||||||
fileKey: string,
|
fileKey: string,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType?: string
|
_contentType?: string
|
||||||
): Promise<{ key: string; url: string }> {
|
): Promise<{ key: string; url: string }> {
|
||||||
try {
|
try {
|
||||||
const fullPath = path.join(STORAGE_BASE_PATH, fileKey);
|
const fullPath = path.join(STORAGE_BASE_PATH, fileKey);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { promisify } from "util";
|
|||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import * as fsSync from "fs";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
toggleUserActive,
|
toggleUserActive,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
findDuplicateInvoice,
|
findDuplicateInvoice,
|
||||||
|
isInvoiceBlacklisted,
|
||||||
getAllInvoices,
|
getAllInvoices,
|
||||||
getAllImportLogs,
|
getAllImportLogs,
|
||||||
getAllBapHistory,
|
getAllBapHistory,
|
||||||
@@ -76,8 +77,15 @@ import {
|
|||||||
deleteLearning,
|
deleteLearning,
|
||||||
deleteAllLearnings,
|
deleteAllLearnings,
|
||||||
getBapPdfUrlsByInvoiceIds,
|
getBapPdfUrlsByInvoiceIds,
|
||||||
|
getWebImportSourcesByUser,
|
||||||
|
getWebImportSourceById,
|
||||||
|
createWebImportSource,
|
||||||
|
updateWebImportSource,
|
||||||
|
deleteWebImportSource,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
|
||||||
|
import fsSync from "fs";
|
||||||
|
import pathSync from "path";
|
||||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||||
@@ -253,6 +261,19 @@ export const appRouter = router({
|
|||||||
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
|
processingProgress: `Extraction ${i + 1}/${result.invoiceCount} factures...`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vérifier la blacklist (factures supprimées manuellement)
|
||||||
|
const blacklisted = await isInvoiceBlacklisted(invoiceData.invoiceNumber, userId);
|
||||||
|
if (blacklisted) {
|
||||||
|
duplicatesCount++;
|
||||||
|
duplicateDetails.push({
|
||||||
|
supplierName: invoiceData.supplierName,
|
||||||
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
|
reason: 'blacklisted',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for duplicates (numéro de facture + montant)
|
// Check for duplicates (numéro de facture + montant)
|
||||||
const duplicate = await findDuplicateInvoice(
|
const duplicate = await findDuplicateInvoice(
|
||||||
invoiceData.invoiceNumber,
|
invoiceData.invoiceNumber,
|
||||||
@@ -2595,5 +2616,96 @@ export const appRouter = router({
|
|||||||
return { success: true, webUrl: result.webUrl, fileName };
|
return { success: true, webUrl: result.webUrl, fileName };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ============= WEB IMPORT SOURCES =============
|
||||||
|
webImportSources: router({
|
||||||
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return getWebImportSourcesByUser(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
connectorType: z.string().min(1).max(50),
|
||||||
|
portalUrl: z.string().url(),
|
||||||
|
loginEmail: z.string().min(1),
|
||||||
|
loginPassword: z.string().min(1),
|
||||||
|
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
|
||||||
|
autoEnabled: z.number().min(0).max(1).default(0),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
return createWebImportSource({ ...input, userId: ctx.user.id });
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
connectorType: z.string().min(1).max(50).optional(),
|
||||||
|
portalUrl: z.string().url().optional(),
|
||||||
|
loginEmail: z.string().min(1).optional(),
|
||||||
|
loginPassword: z.string().optional(),
|
||||||
|
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
||||||
|
autoEnabled: z.number().min(0).max(1).optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const source = await getWebImportSourceById(input.id);
|
||||||
|
if (!source || source.userId !== ctx.user.id) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
|
||||||
|
}
|
||||||
|
const { id, ...data } = input;
|
||||||
|
await updateWebImportSource(id, data);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const source = await getWebImportSourceById(input.id);
|
||||||
|
if (!source || source.userId !== ctx.user.id) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
|
||||||
|
}
|
||||||
|
await deleteWebImportSource(input.id);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getToken: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const source = await getWebImportSourceById(input.id);
|
||||||
|
if (!source || source.userId !== ctx.user.id) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
|
||||||
|
}
|
||||||
|
return { apiToken: source.apiToken };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ============= BACKUP ROUTES =============
|
||||||
|
backup: router({
|
||||||
|
// Liste les sauvegardes existantes dans le dossier backups/
|
||||||
|
list: adminProcedure.query(async () => {
|
||||||
|
const backupDir = pathSync.resolve("backups");
|
||||||
|
if (!fsSync.existsSync(backupDir)) return [];
|
||||||
|
const files = fsSync.readdirSync(backupDir)
|
||||||
|
.filter(f => f.endsWith(".sql") || f.endsWith(".sql.gz"))
|
||||||
|
.map(f => {
|
||||||
|
const stat = fsSync.statSync(pathSync.join(backupDir, f));
|
||||||
|
return { name: f, size: stat.size, createdAt: stat.mtime };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
return files;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Supprime une sauvegarde
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(z.object({ name: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const backupDir = pathSync.resolve("backups");
|
||||||
|
const filePath = pathSync.join(backupDir, pathSync.basename(input.name));
|
||||||
|
if (!fsSync.existsSync(filePath)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Fichier introuvable' });
|
||||||
|
fsSync.unlinkSync(filePath);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import jsPDFModule from "jspdf";
|
|
||||||
import * as fs from "fs";
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const jsPDF = (jsPDFModule as any).default ?? jsPDFModule;
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
{ structure: "1001BPT", type: "Lien 5G", montantCentimes: 0 },
|
|
||||||
{ structure: "1001VAR - ITEP SESSAD VAREY", type: "Tél. mobile", montantCentimes: 48 },
|
|
||||||
{ structure: "1031MER", type: "Lien 5G", montantCentimes: 2398 },
|
|
||||||
{ structure: "1038MBN", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "1038RAC", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "1042CLA", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "1069BOUIME", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "1069IVP", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "1083ADV", type: "Lien 5G", montantCentimes: 1199 },
|
|
||||||
{ structure: "1083ADV", type: "Lien fibre", montantCentimes: 23996 },
|
|
||||||
{ structure: "1083ADV", type: "Tél. mobile", montantCentimes: 14261 },
|
|
||||||
{ structure: "1083MIS", type: "Tél. mobile", montantCentimes: 9592 },
|
|
||||||
{ structure: "1083QVT", type: "Tél. mobile", montantCentimes: 1199 },
|
|
||||||
{ structure: "1083SYL", type: "Lien 5G", montantCentimes: 1199 },
|
|
||||||
{ structure: "1083SYL", type: "Lien fibre", montantCentimes: 11998 },
|
|
||||||
{ structure: "1083SYL", type: "Tél. mobile", montantCentimes: 7194 },
|
|
||||||
{ structure: "1084CAS", type: "Tél. mobile", montantCentimes: 1199 },
|
|
||||||
{ structure: "2001BRP", type: "Lien 5G", montantCentimes: 1199 },
|
|
||||||
{ structure: "2001MUS", type: "Lien 5G", montantCentimes: 0 },
|
|
||||||
{ structure: "2001ROS", type: "Tél. mobile", montantCentimes: 1223 },
|
|
||||||
{ structure: "2011MON", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "2013ANG", type: "Lien 5G", montantCentimes: 1199 },
|
|
||||||
{ structure: "2021MOU", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "2063VSJ", type: "Lien fibre", montantCentimes: 5999 },
|
|
||||||
{ structure: "2069MAU", type: "Lien 5G", montantCentimes: 1199 },
|
|
||||||
{ structure: "2069SAL", type: "Tél. mobile", montantCentimes: 1199 },
|
|
||||||
{ structure: "2081ANC", type: "Lien 5G", montantCentimes: 1199 },
|
|
||||||
{ structure: "2081BLA", type: "Lien 5G", montantCentimes: 0 },
|
|
||||||
{ structure: "3069UDA", type: "Lien 5G", montantCentimes: 1199 },
|
|
||||||
{ structure: "3069UDA", type: "Lien fibre", montantCentimes: 27599 },
|
|
||||||
{ structure: "3069UDA", type: "Tél. mobile", montantCentimes: -1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatMontant = (centimes: number): string => {
|
|
||||||
const euros = centimes / 100;
|
|
||||||
const abs = Math.abs(euros);
|
|
||||||
const str = abs.toFixed(2).replace(".", ",");
|
|
||||||
const parts = str.split(",");
|
|
||||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
|
||||||
return (euros < 0 ? "-" : "") + parts.join(",") + " EUR";
|
|
||||||
};
|
|
||||||
|
|
||||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
|
||||||
const pageW = 210;
|
|
||||||
const pageH = 297;
|
|
||||||
const margin = 14;
|
|
||||||
const tableStartY = 31;
|
|
||||||
const tableEndY = pageH - 10;
|
|
||||||
const usableW = pageW - margin * 2;
|
|
||||||
|
|
||||||
// En-tête
|
|
||||||
doc.setFontSize(7);
|
|
||||||
doc.setFont("helvetica", "normal");
|
|
||||||
doc.text("Edite le 05/06/2026 - 17:00", pageW - margin, 7, { align: "right" });
|
|
||||||
doc.setFontSize(13);
|
|
||||||
doc.setFont("helvetica", "bold");
|
|
||||||
doc.text("Ventilation facture FREE PRO", pageW / 2, 13, { align: "center" });
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.text("01/01/2025", pageW / 2, 20, { align: "center" });
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont("helvetica", "bold");
|
|
||||||
doc.text("ref_piece :", margin, 27);
|
|
||||||
doc.setFont("helvetica", "normal");
|
|
||||||
doc.text("F202501004510", margin + 22, 27);
|
|
||||||
|
|
||||||
// Calcul dimensions
|
|
||||||
const nbRows = lines.length + 2;
|
|
||||||
const availH = tableEndY - tableStartY;
|
|
||||||
const rowH = availH / nbRows;
|
|
||||||
const fontSize = Math.max(5, Math.min(9, Math.floor(rowH * 0.55 / 0.353)));
|
|
||||||
|
|
||||||
console.log(`nbRows=${nbRows}, availH=${availH.toFixed(1)}mm, rowH=${rowH.toFixed(2)}mm, fontSize=${fontSize}pt`);
|
|
||||||
console.log(`Tableau: ${tableStartY}mm → ${(tableStartY + nbRows * rowH).toFixed(1)}mm (limite=${tableEndY}mm)`);
|
|
||||||
|
|
||||||
const col0W = usableW * 0.45;
|
|
||||||
const col1W = usableW * 0.32;
|
|
||||||
const col2W = usableW * 0.23;
|
|
||||||
const col1X = margin + col0W;
|
|
||||||
const col2X = col1X + col1W;
|
|
||||||
|
|
||||||
doc.setFontSize(fontSize);
|
|
||||||
|
|
||||||
const drawRow = (y: number, c0: string, c1: string, c2: string, bold: boolean, bg?: [number,number,number]) => {
|
|
||||||
if (bg) { doc.setFillColor(bg[0], bg[1], bg[2]); doc.rect(margin, y, usableW, rowH, "F"); }
|
|
||||||
doc.setFont("helvetica", bold ? "bold" : "normal");
|
|
||||||
const textY = y + rowH * 0.65;
|
|
||||||
const pad = 1.5;
|
|
||||||
doc.text(c0, margin + pad, textY, { maxWidth: col0W - pad * 2 });
|
|
||||||
doc.text(c1, col1X + pad, textY, { maxWidth: col1W - pad * 2 });
|
|
||||||
doc.text(c2, col2X + col2W - pad, textY, { align: "right", maxWidth: col2W - pad * 2 });
|
|
||||||
};
|
|
||||||
const drawHLine = (y: number, lw: number, r: number, g: number, b: number) => {
|
|
||||||
doc.setDrawColor(r, g, b); doc.setLineWidth(lw);
|
|
||||||
doc.line(margin, y, margin + usableW, y);
|
|
||||||
};
|
|
||||||
const drawVLines = (y: number, h: number) => {
|
|
||||||
doc.setDrawColor(180, 180, 180); doc.setLineWidth(0.1);
|
|
||||||
doc.line(margin, y, margin, y + h);
|
|
||||||
doc.line(col1X, y, col1X, y + h);
|
|
||||||
doc.line(col2X, y, col2X, y + h);
|
|
||||||
doc.line(margin + usableW, y, margin + usableW, y + h);
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerY = tableStartY;
|
|
||||||
drawHLine(headerY, 0.4, 0, 0, 0);
|
|
||||||
drawRow(headerY, "Structure", "Type", "Montant TTC", true);
|
|
||||||
drawHLine(headerY + rowH, 0.4, 0, 0, 0);
|
|
||||||
drawVLines(headerY, rowH);
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const l = lines[i];
|
|
||||||
const y = headerY + rowH * (i + 1);
|
|
||||||
const bg: [number,number,number] | undefined = i % 2 === 1 ? [248,248,248] : undefined;
|
|
||||||
drawRow(y, l.structure, l.type, formatMontant(l.montantCentimes), false, bg);
|
|
||||||
drawHLine(y + rowH, 0.1, 200, 200, 200);
|
|
||||||
drawVLines(y, rowH);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
|
|
||||||
const footerY = headerY + rowH * (lines.length + 1);
|
|
||||||
drawHLine(footerY, 0.4, 0, 0, 0);
|
|
||||||
drawRow(footerY, "Total general", "", formatMontant(totalCentimes), true, [240,240,240]);
|
|
||||||
drawHLine(footerY + rowH, 0.4, 0, 0, 0);
|
|
||||||
drawVLines(footerY, rowH);
|
|
||||||
|
|
||||||
const pdfBytes = doc.output("arraybuffer");
|
|
||||||
fs.writeFileSync("/tmp/test_freepro_01_25.pdf", Buffer.from(pdfBytes));
|
|
||||||
console.log("PDF généré : /tmp/test_freepro_01_25.pdf");
|
|
||||||
console.log(`Nombre de pages : ${doc.getNumberOfPages()}`);
|
|
||||||
23
todo.md
23
todo.md
@@ -684,3 +684,26 @@
|
|||||||
- [ ] Modifier getServiceSignaturesByUser → retourner toutes les signatures de service (sans filtre userId)
|
- [ ] Modifier getServiceSignaturesByUser → retourner toutes les signatures de service (sans filtre userId)
|
||||||
- [ ] Migrer les données en production : dédoublonner les listes fusionnées
|
- [ ] Migrer les données en production : dédoublonner les listes fusionnées
|
||||||
- [ ] Déployer en production
|
- [ ] Déployer en production
|
||||||
|
|
||||||
|
## Connecteurs web (scraping login/mdp)
|
||||||
|
- [ ] Table webImportSources dans le schéma DB
|
||||||
|
- [ ] Procédures tRPC CRUD pour webImportSources
|
||||||
|
- [ ] Interface de gestion des sources web dans les paramètres
|
||||||
|
- [ ] Endpoint API sécurisé pour déclencher l'import et recevoir les PDFs
|
||||||
|
- [ ] Script cron externe Node.js + Playwright pour SFR
|
||||||
|
- [ ] Framework connecteur générique extensible
|
||||||
|
|
||||||
|
## Audit technique et robustesse
|
||||||
|
- [x] Inventorier les artefacts, scripts et dépendances inutilisés
|
||||||
|
- [x] Supprimer les artefacts de développement et le code mort confirmés
|
||||||
|
- [x] Charger les pages à la demande pour réduire le JavaScript initial
|
||||||
|
- [x] Consolider le mécanisme de sauvegarde de base de données et ses validations
|
||||||
|
- [x] Renforcer les contrôles d’accès et la gestion d’erreur des endpoints critiques
|
||||||
|
- [x] Documenter les modules métier, les invariants et les décisions techniques critiques
|
||||||
|
- [x] Ajouter des tests de non-régression ciblés et vérifier build, types et tests
|
||||||
|
- [x] Normaliser les valeurs OAuth de loginMethod avant écriture en base
|
||||||
|
|
||||||
|
## Déploiement recette — audit de robustesse
|
||||||
|
- [ ] Pousser le checkpoint d’audit vers Gitea recette
|
||||||
|
- [ ] Reconstruire l’application sur le serveur de recette
|
||||||
|
- [ ] Vérifier le commit, les conteneurs et la disponibilité HTTP en recette
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { jsxLocPlugin } from "@builder.io/vite-plugin-jsx-loc";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { vitePluginManusRuntime } from "vite-plugin-manus-runtime";
|
import { vitePluginManusRuntime } from "vite-plugin-manus-runtime";
|
||||||
|
|
||||||
|
|
||||||
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime()];
|
const plugins = [react(), tailwindcss(), vitePluginManusRuntime()];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins,
|
plugins,
|
||||||
|
|||||||
Reference in New Issue
Block a user