mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: add no-sales-6-months tab to stock management page
Adds a second tab "Sans vente 6 mois" on the grid page listing all products with no sale in the last 6 months, sorted by oldest last sale first. Resolves merge conflicts from upstream refactor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
dc16beeda4
51 files changed
+4889
-863
No files matched your search
@@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Send, Bot, User, Database, Loader2, AlertCircle, ChevronDown, Trash2, DollarSign } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Strip HTML tags and convert common HTML constructs to Markdown equivalents
|
||||
function htmlToMarkdown(text: string): string {
|
||||
return text
|
||||
// Block-level → markdown
|
||||
.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_, level, content) => `${"#".repeat(Number(level))} ${content.replace(/<[^>]+>/g, "")}\n`)
|
||||
.replace(/<strong[^>]*>(.*?)<\/strong>/gis, "**$1**")
|
||||
.replace(/<b[^>]*>(.*?)<\/b>/gis, "**$1**")
|
||||
.replace(/<em[^>]*>(.*?)<\/em>/gis, "*$1*")
|
||||
.replace(/<i[^>]*>(.*?)<\/i>/gis, "*$1*")
|
||||
.replace(/<code[^>]*>(.*?)<\/code>/gis, "`$1`")
|
||||
.replace(/<pre[^>]*>(.*?)<\/pre>/gis, (_, c) => "```\n" + c.replace(/<[^>]+>/g, "") + "\n```")
|
||||
.replace(/<li[^>]*>(.*?)<\/li>/gis, "- $1")
|
||||
.replace(/<ul[^>]*>|<\/ul>/gis, "")
|
||||
.replace(/<ol[^>]*>|<\/ol>/gis, "")
|
||||
.replace(/<tr[^>]*>/gis, "")
|
||||
.replace(/<\/tr>/gis, "\n")
|
||||
.replace(/<th[^>]*>(.*?)<\/th>/gis, "| **$1** ")
|
||||
.replace(/<td[^>]*>(.*?)<\/td>/gis, "| $1 ")
|
||||
.replace(/<table[^>]*>|<\/table>|<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gis, "")
|
||||
.replace(/<br\s*\/?>/gis, "\n")
|
||||
.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n")
|
||||
.replace(/<div[^>]*>(.*?)<\/div>/gis, "$1\n")
|
||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)")
|
||||
// Strip any remaining tags
|
||||
.replace(/<[^>]+>/g, "")
|
||||
// Decode HTML entities
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
// Clean up excessive blank lines
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
free: boolean;
|
||||
promptPrice: number; // $ per token
|
||||
completionPrice: number;
|
||||
}
|
||||
|
||||
interface ToolEvent {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolEvents?: ToolEvent[];
|
||||
}
|
||||
|
||||
interface SessionUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-2.0-flash-001";
|
||||
|
||||
// Compute cost from token counts and model pricing
|
||||
function computeCost(promptTokens: number, completionTokens: number, model: ModelInfo | null): number {
|
||||
if (!model) return 0;
|
||||
return promptTokens * model.promptPrice + completionTokens * model.completionPrice;
|
||||
}
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
if (usd === 0) return "Gratuit";
|
||||
if (usd < 0.001) return `$${(usd * 1000).toFixed(4)}m`;
|
||||
return `$${usd.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function ToolCallBlock({ event }: { event: ToolEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const label = event.name === "execute_sql"
|
||||
? `SQL: ${String(event.args.query ?? "").substring(0, 60)}${String(event.args.query ?? "").length > 60 ? "…" : ""}`
|
||||
: event.name === "get_db_schema"
|
||||
? "Schéma DB"
|
||||
: event.name;
|
||||
|
||||
return (
|
||||
<div className="my-1 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] overflow-hidden text-xs">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--bg-base)] transition-colors"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 text-[var(--accent)] shrink-0" />
|
||||
<span className="flex-1 text-[var(--text-secondary)] font-mono truncate">{label}</span>
|
||||
<ChevronDown className={cn("w-3.5 h-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && event.result && (
|
||||
<pre className="px-3 pb-3 pt-1 text-[10px] text-[var(--text-secondary)] overflow-x-auto whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
||||
{event.result}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: Message }) {
|
||||
const isUser = msg.role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3 mb-4", isUser && "flex-row-reverse")}>
|
||||
{/* Avatar */}
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
||||
isUser ? "bg-[var(--accent)] text-white" : "bg-[var(--bg-elevated)] border border-[var(--border)]"
|
||||
)}>
|
||||
{isUser
|
||||
? <User className="w-4 h-4" />
|
||||
: <Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 max-w-[85%]", isUser && "flex flex-col items-end")}>
|
||||
{/* Tool events (assistant only) */}
|
||||
{msg.toolEvents && msg.toolEvents.length > 0 && (
|
||||
<div className="w-full mb-2">
|
||||
{msg.toolEvents.map((ev, i) => (
|
||||
<ToolCallBlock key={i} event={ev} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text bubble */}
|
||||
{msg.content && (
|
||||
<div className={cn(
|
||||
"rounded-2xl px-4 py-3 text-sm leading-relaxed break-words",
|
||||
isUser
|
||||
? "bg-[var(--accent)] text-white rounded-tr-md"
|
||||
: "bg-[var(--bg-elevated)] border border-[var(--border)] text-[var(--text-primary)] rounded-tl-md"
|
||||
)}>
|
||||
{isUser ? msg.content : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
h1: ({ children }) => <h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-0.5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-0.5">{children}</ol>,
|
||||
li: ({ children }) => <li className="ml-2">{children}</li>,
|
||||
code: ({ inline, children }: { inline?: boolean; children?: React.ReactNode }) =>
|
||||
inline
|
||||
? <code className="px-1 py-0.5 rounded bg-[var(--bg-base)] border border-[var(--border)] font-mono text-xs text-[var(--accent)]">{children}</code>
|
||||
: <pre className="my-2 p-3 rounded-lg bg-[var(--bg-base)] border border-[var(--border)] overflow-x-auto"><code className="font-mono text-xs text-[var(--text-primary)] whitespace-pre">{children}</code></pre>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)] pl-3 my-2 text-[var(--text-secondary)] italic">{children}</blockquote>,
|
||||
table: ({ children }) => <div className="overflow-x-auto my-2"><table className="w-full text-xs border-collapse">{children}</table></div>,
|
||||
thead: ({ children }) => <thead className="bg-[var(--bg-base)]">{children}</thead>,
|
||||
th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 text-left font-semibold">{children}</th>,
|
||||
td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="border-[var(--border)] my-3" />,
|
||||
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--accent)] underline hover:opacity-80">{children}</a>,
|
||||
}}
|
||||
>
|
||||
{htmlToMarkdown(msg.content)}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function AdminAiChatPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_MODEL);
|
||||
const [aiProvider, setAiProviderState] = useState<"openrouter" | "google">("openrouter");
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingModels, setLoadingModels] = useState(true);
|
||||
const [sessionUsage, setSessionUsage] = useState<SessionUsage>({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null;
|
||||
|
||||
// Fetch config + models
|
||||
useEffect(() => {
|
||||
setLoadingModels(true);
|
||||
fetch("/api/admin/ai-config")
|
||||
.then(r => r.json())
|
||||
.then(async d => {
|
||||
const provider: "openrouter" | "google" = d.provider ?? "openrouter";
|
||||
setAiProviderState(provider);
|
||||
if (provider === "google") {
|
||||
const gRes = await fetch("/api/google-ai/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
setModels((gRes.models ?? []).map((m: { id: string; name: string }) => ({
|
||||
id: m.id, name: m.name, free: false, promptPrice: 0, completionPrice: 0,
|
||||
})));
|
||||
setSelectedModel(d.googleAiModel ?? "gemini-2.0-flash");
|
||||
} else {
|
||||
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
if (od.models) setModels(od.models);
|
||||
if (d.openRouterModel) setSelectedModel(d.openRouterModel);
|
||||
}
|
||||
})
|
||||
.catch(async () => {
|
||||
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
if (od.models) setModels(od.models);
|
||||
})
|
||||
.finally(() => setLoadingModels(false));
|
||||
}, []);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, loading]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMsg: Message = { role: "user", content: input.trim() };
|
||||
setInput("");
|
||||
setError(null);
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setLoading(true);
|
||||
|
||||
// Build messages to send (excluding tool events, which are local display only)
|
||||
const apiMessages = [...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const assistantMsg: Message = { role: "assistant", content: "", toolEvents: [] };
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/ai-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: apiMessages, model: selectedModel }),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Erreur serveur");
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const pendingToolEvents: ToolEvent[] = [];
|
||||
let lastToolStart: ToolEvent | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6).trim();
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
|
||||
if (event.type === "text") {
|
||||
setMessages(prev => {
|
||||
const copy = [...prev];
|
||||
const last = { ...copy[copy.length - 1] };
|
||||
last.content += event.content;
|
||||
copy[copy.length - 1] = last;
|
||||
return copy;
|
||||
});
|
||||
} else if (event.type === "tool_start") {
|
||||
lastToolStart = { name: event.name, args: event.args };
|
||||
} else if (event.type === "tool_result") {
|
||||
if (lastToolStart) {
|
||||
const ev: ToolEvent = { ...lastToolStart, result: event.result };
|
||||
pendingToolEvents.push(ev);
|
||||
lastToolStart = null;
|
||||
setMessages(prev => {
|
||||
const copy = [...prev];
|
||||
const last = { ...copy[copy.length - 1] };
|
||||
last.toolEvents = [...pendingToolEvents];
|
||||
copy[copy.length - 1] = last;
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
} else if (event.type === "usage") {
|
||||
const pt = event.prompt_tokens ?? 0;
|
||||
const ct = event.completion_tokens ?? 0;
|
||||
const cost = computeCost(pt, ct, selectedModelInfo);
|
||||
setSessionUsage(prev => ({
|
||||
promptTokens: prev.promptTokens + pt,
|
||||
completionTokens: prev.completionTokens + ct,
|
||||
costUsd: prev.costUsd + cost,
|
||||
}));
|
||||
} else if (event.type === "error") {
|
||||
setError(event.message);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [input, loading, messages, selectedModel, selectedModelInfo]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const clearConversation = () => {
|
||||
setMessages([]);
|
||||
setSessionUsage({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
|
||||
setError(null);
|
||||
if (loading && abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<Bot className="w-5 h-5 text-[var(--accent)]" />
|
||||
Admin AI Chat
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Accès direct à la base de données · Admin uniquement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Usage / Cost */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] text-xs text-[var(--text-secondary)]">
|
||||
<DollarSign className="w-3.5 h-3.5 text-[var(--accent)]" />
|
||||
<span className="font-mono font-semibold">{formatCost(sessionUsage.costUsd)}</span>
|
||||
<span className="text-[var(--text-muted)]">session</span>
|
||||
{sessionUsage.promptTokens > 0 && (
|
||||
<span className="text-[var(--text-muted)] ml-1">
|
||||
({sessionUsage.promptTokens + sessionUsage.completionTokens} tok)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[var(--border)] text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model selector */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0",
|
||||
aiProvider === "google"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "bg-[var(--accent-bg)] text-[var(--accent)]"
|
||||
)}>
|
||||
{aiProvider === "google" ? "Google AI" : "OpenRouter"}
|
||||
</span>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Chargement des modèles…
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={e => setSelectedModel(e.target.value)}
|
||||
className="flex-1 min-w-0 text-sm bg-transparent text-[var(--text-primary)] border-none outline-none cursor-pointer"
|
||||
>
|
||||
{models.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} {m.free ? "(Gratuit)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedModelInfo && !selectedModelInfo.free && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] shrink-0 font-mono">
|
||||
<span>in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M</span>
|
||||
<span>·</span>
|
||||
<span>out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedModelInfo?.free && (
|
||||
<span className="text-[10px] text-emerald-500 font-semibold shrink-0">GRATUIT</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 rounded-xl bg-[var(--bg-base)] border border-[var(--border)] p-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
||||
<Bot className="w-8 h-8 text-[var(--accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-primary)] font-medium mb-1">Assistant Admin CollectFlow</p>
|
||||
<p className="text-sm text-[var(--text-muted)] max-w-md">
|
||||
Posez des questions sur vos données, demandez des analyses, ou explorez la base de données.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 max-w-lg mt-2">
|
||||
{[
|
||||
"Combien de produits par fournisseur ?",
|
||||
"Quels sont les 10 produits avec le plus de CA ?",
|
||||
"Montre-moi les snapshots récents",
|
||||
"Répartition des gammes A/B/C/D/Z",
|
||||
].map(suggestion => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => { setInput(suggestion); textareaRef.current?.focus(); }}
|
||||
className="text-xs text-left px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-base)] text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} />
|
||||
))}
|
||||
|
||||
{loading && messages[messages.length - 1]?.role !== "assistant" && (
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-tl-md bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--accent-error-bg)] border border-[var(--accent-error-border,var(--border))] text-sm text-[var(--accent-error)] mb-4">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="mt-3 shrink-0">
|
||||
<div className="flex items-end gap-2 p-2 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus-within:border-[var(--accent)] transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Posez une question… (Entrée pour envoyer, Shift+Entrée pour nouvelle ligne)"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none py-2 px-2 max-h-32 overflow-y-auto"
|
||||
style={{ minHeight: "40px" }}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 128) + "px";
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || loading}
|
||||
className="w-9 h-9 rounded-lg bg-[var(--accent)] text-white flex items-center justify-center hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0"
|
||||
>
|
||||
{loading
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Send className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-muted)] mt-1.5 text-center">
|
||||
L'IA peut exécuter des requêtes SELECT sur la base de données · Les modifications ne sont pas autorisées
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function AnalyticsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-40 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 w-32 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
<div className="h-9 w-32 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]" />
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="rounded-2xl border border-[var(--border)] overflow-hidden bg-[var(--bg-elevated)]">
|
||||
<div className="h-10 bg-[var(--bg-base)] border-b border-[var(--border)]" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-10 border-b border-[var(--border)] flex items-center px-4 gap-4">
|
||||
<div className="h-3 w-32 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-20 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-20 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-16 rounded bg-[var(--border)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des statistiques analytiques..." />;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, CheckCircle2, AlertCircle, Minus } from "lucide-react";
|
||||
import type { PgCommandeAutoRow } from "./page";
|
||||
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
const SITES: { code: string; label: string }[] = [
|
||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
||||
{ code: "579", label: "579 — Houdemont" },
|
||||
];
|
||||
|
||||
function fmt(n: number) {
|
||||
return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
||||
}
|
||||
|
||||
function SortIcon({ col, sortKey, sortDir }: { col: string; sortKey: string; sortDir: SortDir }) {
|
||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
|
||||
type SK = keyof PgCommandeAutoRow;
|
||||
const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde"];
|
||||
|
||||
function FrancoCell({ atteint, ecart, franco }: { atteint: boolean | null; ecart: number; franco: number }) {
|
||||
// franco === 0 : aucune donnée franco disponible
|
||||
// atteint === null ET franco === 0 : pas de franco (N/A ou données manquantes)
|
||||
// atteint === null ET franco > 0 : franco connu mais statut non déterminé → on affiche quand même le montant
|
||||
if (franco === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
|
||||
<Minus className="w-3 h-3" /> Pas de franco
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (atteint === null) {
|
||||
// Franco connu mais statut non calculé par l'API
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
|
||||
<Minus className="w-3 h-3" /> N/A ({fmt(franco)})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (atteint) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
||||
<CheckCircle2 className="w-3 h-3" /> Atteint
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-semibold text-amber-700">
|
||||
<AlertCircle className="w-3 h-3" /> −{fmt(Math.abs(ecart))} manquant
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) {
|
||||
const [sortKey, setSortKey] = useState<SK>("nomfou");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
function handleSort(key: SK) {
|
||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return rows.filter(r => !q || r.nomfou.toLowerCase().includes(q) || r.codefou.toLowerCase().includes(q));
|
||||
}, [rows, search]);
|
||||
|
||||
const sorted = useMemo(() => [...filtered].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
if (NUMERIC.includes(sortKey)) {
|
||||
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av);
|
||||
}
|
||||
return sortDir === "asc"
|
||||
? String(av ?? "").localeCompare(String(bv ?? ""), "fr")
|
||||
: String(bv ?? "").localeCompare(String(av ?? ""), "fr");
|
||||
}), [filtered, sortKey, sortDir]);
|
||||
|
||||
const nbFrancoAtteint = rows.filter(r => r.franco_atteint === true).length;
|
||||
const totalMontant = rows.reduce((s, r) => s + Number(r.montant_cde), 0);
|
||||
|
||||
const th = (key: SK, label: string, right = false) => (
|
||||
<th
|
||||
className={[
|
||||
"cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900",
|
||||
right ? "text-right" : "text-left",
|
||||
].join(" ")}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* En-tête magasin */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
|
||||
{rows.length} fournisseur{rows.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{nbFrancoAtteint > 0 && (
|
||||
<span className="rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
||||
{nbFrancoAtteint} franco atteint
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">
|
||||
Total : <span className="font-semibold text-gray-800">{fmt(totalMontant)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un fournisseur…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codefou", "Code")}
|
||||
{th("nomfou", "Fournisseur")}
|
||||
{th("nb_articles", "Articles", true)}
|
||||
{th("montant_cde", "Montant CDE auto", true)}
|
||||
{th("franco", "Franco", true)}
|
||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">
|
||||
Statut franco
|
||||
</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-gray-400">
|
||||
Aucun fournisseur en commande automatique
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sorted.map(row => {
|
||||
const francoAtteint = row.franco_atteint === true;
|
||||
const hasFranco = row.franco > 0 && row.franco_atteint !== null;
|
||||
return (
|
||||
<tr
|
||||
key={`${row.site}-${row.codefou}`}
|
||||
className={[
|
||||
"transition-colors",
|
||||
francoAtteint
|
||||
? "bg-emerald-50/50 hover:bg-emerald-50"
|
||||
: "hover:bg-gray-50",
|
||||
].join(" ")}
|
||||
>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{row.codefou}</td>
|
||||
<td className="px-3 py-2.5 font-medium text-gray-900">{row.nomfou}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700">{row.nb_articles}</td>
|
||||
<td className={[
|
||||
"px-3 py-2.5 text-right tabular-nums font-semibold",
|
||||
francoAtteint ? "text-emerald-700" : "text-blue-700",
|
||||
].join(" ")}>
|
||||
{fmt(Number(row.montant_cde))}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-600">
|
||||
{row.franco > 0 ? fmt(Number(row.franco)) : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<FrancoCell
|
||||
atteint={row.franco_atteint}
|
||||
ecart={Number(row.ecart_franco)}
|
||||
franco={Number(row.franco)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) {
|
||||
const bySite = useMemo(() => {
|
||||
const map = new Map<string, PgCommandeAutoRow[]>();
|
||||
for (const r of rows) {
|
||||
if (!map.has(r.site)) map.set(r.site, []);
|
||||
map.get(r.site)!.push(r);
|
||||
}
|
||||
return map;
|
||||
}, [rows]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-12 text-center text-gray-400 shadow-sm">
|
||||
Aucune commande automatique trouvée.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{SITES.map(({ code, label }) => {
|
||||
const siteRows = bySite.get(code) ?? [];
|
||||
return (
|
||||
<StoreTable key={code} site={code} label={label} rows={siteRows} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function CommandesAutoLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des commandes automatiques..." />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
|
||||
import { CommandesAutoClient } from "./client";
|
||||
|
||||
export type { PgCommandeAutoRow };
|
||||
|
||||
export default async function CommandesAutoPage() {
|
||||
const rows = await pgGetCommandesAuto();
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Commandes automatiques</h1>
|
||||
<CommandesAutoClient rows={rows} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-48 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
<div className="h-5 w-32 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
</div>
|
||||
|
||||
{/* KPI cards row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-2xl bg-[var(--bg-elevated)] border border-[var(--border)]" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="h-64 rounded-2xl bg-[var(--bg-elevated)] border border-[var(--border)]" />
|
||||
<div className="h-64 rounded-2xl bg-[var(--bg-elevated)] border border-[var(--border)]" />
|
||||
</div>
|
||||
|
||||
{/* Bottom tables */}
|
||||
<div className="h-80 rounded-2xl bg-[var(--bg-elevated)] border border-[var(--border)]" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération du tableau de bord..." />;
|
||||
}
|
||||
@@ -1,21 +1,5 @@
|
||||
export default function ExportsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse">
|
||||
{/* Header */}
|
||||
<div className="h-7 w-56 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-2xl border border-[var(--border)] overflow-hidden bg-[var(--bg-elevated)]">
|
||||
<div className="h-10 bg-[var(--bg-base)] border-b border-[var(--border)]" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-10 border-b border-[var(--border)] flex items-center px-4 gap-4">
|
||||
<div className="h-3 w-24 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-32 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-20 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-16 rounded bg-[var(--border)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function ExportsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des exports..." />;
|
||||
}
|
||||
@@ -1,39 +1,5 @@
|
||||
export default function GridLoading() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div
|
||||
className="flex flex-col items-center gap-5 px-10 py-8 rounded-2xl shadow-2xl border"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
{/* Spinner */}
|
||||
<div className="relative w-12 h-12">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full border-[3px] animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
borderTopColor: "var(--accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className="text-sm font-semibold tracking-wide"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Chargement des données
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
Récupération des articles et calcul des scores...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function GridLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des articles et calcul des scores..." />;
|
||||
}
|
||||
@@ -33,16 +33,15 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
||||
return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
|
||||
}
|
||||
|
||||
// 3. Load product data — pagination pour ne pas crasher le JS client
|
||||
console.log(`\n>>> [GridPage] Fetching page 0 for: ${codeFournisseur}, shop: ${magasin}`);
|
||||
const { rows: initialRows, total } = await getGridData(codeFournisseur, magasin, filters, 0, 200);
|
||||
console.log(`>>> [GridPage] Received ${initialRows.length}/${total} rows from getGridData`);
|
||||
// 3. Load real product data for the selected supplier
|
||||
console.log(`\n>>> [GridPage] Fetching data for: ${codeFournisseur}, shop: ${magasin}`);
|
||||
const rows = await getGridData(codeFournisseur, magasin, filters);
|
||||
console.log(`>>> [GridPage] Received ${rows.length} rows from getGridData`);
|
||||
const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur);
|
||||
|
||||
return (
|
||||
<GridClient
|
||||
initialRows={initialRows}
|
||||
initialTotal={total}
|
||||
initialRows={rows}
|
||||
codeFournisseur={codeFournisseur}
|
||||
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
|
||||
fournisseurs={fournisseurs}
|
||||
|
||||
@@ -32,6 +32,7 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(DEFAULT_DIR);
|
||||
const [filterFournisseur, setFilterFournisseur] = useState<string>("Tous");
|
||||
const [filterNomenclature, setFilterNomenclature] = useState<string>("Tous");
|
||||
|
||||
// Dates locales pour les inputs (évite rechargement à chaque frappe)
|
||||
const [localDebut, setLocalDebut] = useState(dateDebut);
|
||||
@@ -42,6 +43,13 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
return ["Tous", ...Array.from(set).sort((a, b) => a.localeCompare(b, "fr"))];
|
||||
}, [pivotted]);
|
||||
|
||||
const nomenclatures = useMemo(() => {
|
||||
const map = new Map<string, string>(); // code → libelle
|
||||
for (const r of pivotted) map.set(r.nomenclature_code, r.nomenclature);
|
||||
const entries = Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0], "fr"));
|
||||
return [{ code: "Tous", libelle: "Tous" }, ...entries.map(([code, libelle]) => ({ code, libelle }))];
|
||||
}, [pivotted]);
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === "desc" ? "asc" : "desc");
|
||||
@@ -65,8 +73,11 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
filterFournisseur === "Tous" ? pivotted : pivotted.filter(r => r.fournisseur === filterFournisseur),
|
||||
[pivotted, filterFournisseur]
|
||||
pivotted.filter(r =>
|
||||
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
|
||||
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
|
||||
),
|
||||
[pivotted, filterFournisseur, filterNomenclature]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() =>
|
||||
@@ -87,16 +98,18 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function ColHeader({ label, sortable, k }: { label: string; sortable?: SortKey; k: string }) {
|
||||
function ColHeader({ label, sortable, k, group }: { label: string; sortable?: SortKey; k: string; group?: "292" | "579" | "total" }) {
|
||||
const isActive = sortable && sortKey === sortable;
|
||||
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
|
||||
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
|
||||
return (
|
||||
<th
|
||||
key={k}
|
||||
onClick={sortable ? () => handleSort(sortable) : undefined}
|
||||
className={[
|
||||
"px-4 py-2 text-right text-xs font-semibold whitespace-nowrap select-none",
|
||||
sortable ? "cursor-pointer hover:bg-blue-50" : "",
|
||||
isActive ? "bg-blue-50 text-blue-700" : "text-gray-600",
|
||||
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
|
||||
sortable ? "cursor-pointer" : "",
|
||||
isActive ? activeBg : `${groupBg} text-gray-600`,
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
@@ -159,6 +172,24 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
{/* Filtre nomenclature */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Nomenclature</label>
|
||||
<select
|
||||
value={filterNomenclature}
|
||||
onChange={e => setFilterNomenclature(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400 min-w-[200px]"
|
||||
>
|
||||
{nomenclatures.map(n => (
|
||||
<option key={n.code} value={n.code}>
|
||||
{n.code === "Tous" ? "Tous" : `${n.code} — ${n.libelle}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
||||
{sorted.length} produits
|
||||
</span>
|
||||
@@ -178,9 +209,10 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2} className="w-24 border-b-2 border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Code</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Désignation</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Fournisseur</th>
|
||||
<th rowSpan={2} className="w-20 border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Code</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Désignation</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Fournisseur</th>
|
||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Nom.</th>
|
||||
<th colSpan={4} className="border-b border-l-2 border-blue-200 bg-blue-50 px-4 py-2 text-center text-xs font-bold text-blue-700 tracking-wide">
|
||||
Frouard / Nancy — 292
|
||||
</th>
|
||||
@@ -192,82 +224,88 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<ColHeader label="Qté" sortable="qte292" k="qte292" />
|
||||
<ColHeader label="CA TTC" sortable="ca292" k="ca292" />
|
||||
<ColHeader label="Stock" sortable="stock292" k="stock292" />
|
||||
<th className="bg-blue-50/40 px-4 py-2 text-right text-xs font-semibold text-blue-500">% Marge</th>
|
||||
<ColHeader label="Qté" sortable="qte579" k="qte579" />
|
||||
<ColHeader label="CA TTC" sortable="ca579" k="ca579" />
|
||||
<ColHeader label="Stock" sortable="stock579" k="stock579" />
|
||||
<th className="bg-violet-50/40 px-4 py-2 text-right text-xs font-semibold text-violet-500">% Marge</th>
|
||||
<ColHeader label="Qté" sortable="qteTotal" k="qteTotal" />
|
||||
<ColHeader label="CA TTC" sortable="caTotal" k="caTotal" />
|
||||
<ColHeader label="Stock" sortable="stockTotal" k="stockTotal" />
|
||||
<th className="bg-emerald-50/40 px-4 py-2 text-right text-xs font-semibold text-emerald-600">% Marge</th>
|
||||
<ColHeader label="Qté" sortable="qte292" k="qte292" group="292" />
|
||||
<ColHeader label="CA TTC" sortable="ca292" k="ca292" group="292" />
|
||||
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock292" k="stock292" group="292" />
|
||||
<ColHeader label="Qté" sortable="qte579" k="qte579" group="579" />
|
||||
<ColHeader label="CA TTC" sortable="ca579" k="ca579" group="579" />
|
||||
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock579" k="stock579" group="579" />
|
||||
<ColHeader label="Qté" sortable="qteTotal" k="qteTotal" group="total" />
|
||||
<ColHeader label="CA TTC" sortable="caTotal" k="caTotal" group="total" />
|
||||
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stockTotal" k="stockTotal" group="total" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row, idx) => (
|
||||
<tr key={row.codein} className={`border-b border-gray-100 ${idx % 2 === 0 ? "bg-white" : "bg-gray-50/60"} hover:bg-yellow-50/40 transition-colors`}>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-400">{row.codein}</td>
|
||||
<td className="px-4 py-2.5 text-gray-900 font-medium max-w-[260px] truncate" title={row.libelle.trim()}>{row.libelle.trim()}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap">{row.fournisseur}</td>
|
||||
<td className={`border-l-2 border-blue-100 px-4 py-2.5 text-right tabular-nums text-gray-700 ${sortKey === "qte292" ? "bg-blue-50/50 font-semibold" : ""}`}>
|
||||
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
|
||||
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-xs text-gray-500 whitespace-nowrap">{row.fournisseur}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-xs text-center whitespace-nowrap" title={row.nomenclature_code ? `${row.nomenclature_code} — ${row.nomenclature}` : ""}>
|
||||
{row.nomenclature_code
|
||||
? <span className="font-mono font-semibold text-gray-700">{row.nomenclature_code}</span>
|
||||
: <span className="text-gray-300">—</span>
|
||||
}
|
||||
</td>
|
||||
<td className={`border-l-2 border-blue-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
|
||||
{row.qte292 > 0 ? formatQte(row.qte292) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums text-gray-800 ${sortKey === "ca292" ? "bg-blue-50/50 font-semibold" : ""}`}>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
|
||||
{row.ca292 > 0 ? formatCA(row.ca292) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums text-xs ${sortKey === "stock292" ? "bg-blue-50/50 font-semibold text-blue-700" : "text-amber-600"}`}>
|
||||
{row.stock292 > 0 ? formatQte(row.stock292) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-blue-500">
|
||||
<td className="bg-blue-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-blue-500">
|
||||
{row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""}
|
||||
</td>
|
||||
<td className={`border-l-2 border-violet-100 px-4 py-2.5 text-right tabular-nums text-gray-700 ${sortKey === "qte579" ? "bg-violet-50/50 font-semibold" : ""}`}>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock292" ? "bg-blue-100 font-semibold text-blue-700" : "bg-blue-50/60 text-amber-600"}`}>
|
||||
{row.stock292 > 0 ? formatQte(row.stock292) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`border-l-2 border-violet-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
|
||||
{row.qte579 > 0 ? formatQte(row.qte579) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums text-gray-800 ${sortKey === "ca579" ? "bg-violet-50/50 font-semibold" : ""}`}>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
|
||||
{row.ca579 > 0 ? formatCA(row.ca579) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums text-xs ${sortKey === "stock579" ? "bg-violet-50/50 font-semibold text-violet-700" : "text-amber-600"}`}>
|
||||
{row.stock579 > 0 ? formatQte(row.stock579) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-violet-500">
|
||||
<td className="bg-violet-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-violet-500">
|
||||
{row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""}
|
||||
</td>
|
||||
<td className={`border-l-2 border-emerald-100 px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900 ${sortKey === "qteTotal" ? "bg-emerald-50/50" : ""}`}>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock579" ? "bg-violet-100 font-semibold text-violet-700" : "bg-violet-50/60 text-amber-600"}`}>
|
||||
{row.stock579 > 0 ? formatQte(row.stock579) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`border-l-2 border-emerald-200 px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "qteTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
|
||||
{row.qteTotal > 0 ? formatQte(row.qteTotal) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900 ${sortKey === "caTotal" ? "bg-emerald-50/50" : ""}`}>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "caTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
|
||||
{row.caTotal > 0 ? formatCA(row.caTotal) : <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums text-xs font-semibold ${sortKey === "stockTotal" ? "bg-emerald-50/50 text-emerald-700" : row.stockTotal <= 0 ? "text-red-400" : "text-amber-600"}`}>
|
||||
{row.stockTotal > 0 ? formatQte(row.stockTotal) : <span className="text-red-400 font-semibold">0</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-emerald-600">
|
||||
<td className="bg-emerald-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-emerald-600">
|
||||
{row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}
|
||||
</td>
|
||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs font-semibold ${sortKey === "stockTotal" ? "bg-emerald-100 text-emerald-700" : row.stockTotal <= 0 ? "bg-emerald-50/60 text-red-400" : "bg-emerald-50/60 text-amber-600"}`}>
|
||||
{row.stockTotal > 0 ? formatQte(row.stockTotal) : <span className="text-red-400 font-semibold">0</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-100">
|
||||
<td colSpan={3} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
<tr className="border-t-2 border-gray-300">
|
||||
<td colSpan={4} className="bg-gray-100 px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {sorted.length} articles
|
||||
</td>
|
||||
<td className="border-l-2 border-blue-200 px-4 py-3 text-right font-bold tabular-nums text-gray-900">{formatQte(totals.qte292)}</td>
|
||||
<td className="px-4 py-3 text-right font-bold tabular-nums text-gray-900">{formatCA(totals.ca292)}</td>
|
||||
<td className="px-4 py-3 text-right text-xs font-bold tabular-nums text-amber-600">{formatQte(totals.stock292)}</td>
|
||||
<td className="px-4 py-3 text-right text-xs tabular-nums text-blue-600">{formatPct(totals.ca292, totals.marge292)}</td>
|
||||
<td className="border-l-2 border-violet-200 px-4 py-3 text-right font-bold tabular-nums text-gray-900">{formatQte(totals.qte579)}</td>
|
||||
<td className="px-4 py-3 text-right font-bold tabular-nums text-gray-900">{formatCA(totals.ca579)}</td>
|
||||
<td className="px-4 py-3 text-right text-xs font-bold tabular-nums text-amber-600">{formatQte(totals.stock579)}</td>
|
||||
<td className="px-4 py-3 text-right text-xs tabular-nums text-violet-600">{formatPct(totals.ca579, totals.marge579)}</td>
|
||||
<td className="border-l-2 border-emerald-200 px-4 py-3 text-right font-bold tabular-nums text-gray-900">{formatQte(totals.qteTotal)}</td>
|
||||
<td className="px-4 py-3 text-right font-bold tabular-nums text-gray-900">{formatCA(totals.caTotal)}</td>
|
||||
<td className="px-4 py-3 text-right text-xs font-bold tabular-nums text-amber-600">{formatQte(totals.stockTotal)}</td>
|
||||
<td className="px-4 py-3 text-right text-xs tabular-nums text-emerald-700 font-semibold">{formatPct(totals.caTotal, totals.margeTotal)}</td>
|
||||
<td className="border-l-2 border-blue-300 bg-blue-100 px-4 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte292)}</td>
|
||||
<td className="bg-blue-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca292)}</td>
|
||||
<td className="bg-blue-100 px-2 py-3 text-center text-xs tabular-nums text-blue-700 font-semibold">{formatPct(totals.ca292, totals.marge292)}</td>
|
||||
<td className="bg-blue-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock292)}</td>
|
||||
<td className="border-l-2 border-violet-300 bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte579)}</td>
|
||||
<td className="bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca579)}</td>
|
||||
<td className="bg-violet-100 px-2 py-3 text-center text-xs tabular-nums text-violet-700 font-semibold">{formatPct(totals.ca579, totals.marge579)}</td>
|
||||
<td className="bg-violet-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock579)}</td>
|
||||
<td className="border-l-2 border-emerald-300 bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qteTotal)}</td>
|
||||
<td className="bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.caTotal)}</td>
|
||||
<td className="bg-emerald-100 px-2 py-3 text-center text-xs tabular-nums text-emerald-800 font-semibold">{formatPct(totals.caTotal, totals.margeTotal)}</td>
|
||||
<td className="bg-emerald-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stockTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function HitParadeLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-44 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 w-28 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
<div className="h-9 w-28 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Podium / top cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-32 rounded-2xl bg-[var(--bg-elevated)] border border-[var(--border)]" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rankings table */}
|
||||
<div className="rounded-2xl border border-[var(--border)] overflow-hidden bg-[var(--bg-elevated)]">
|
||||
<div className="h-10 bg-[var(--bg-base)] border-b border-[var(--border)]" />
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="h-10 border-b border-[var(--border)] flex items-center px-4 gap-4">
|
||||
<div className="h-3 w-6 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-40 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-24 rounded bg-[var(--border)]" />
|
||||
<div className="h-3 w-16 rounded bg-[var(--border)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération du classement des ventes..." />;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { pgGetHitParade, pgGetStockForCodeins } from "@/lib/pg-ff-client";
|
||||
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
|
||||
import { HitParadeClient } from "./client";
|
||||
|
||||
export interface HitParadePivotRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
fournisseur: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
qte292: number;
|
||||
ca292: number;
|
||||
marge292: number;
|
||||
@@ -19,9 +21,7 @@ export interface HitParadePivotRow {
|
||||
stockTotal: number;
|
||||
}
|
||||
|
||||
function pivotHitParade(
|
||||
rows: Array<{ codein: string; libelle: string; fournisseur: string; site: string; qte_vendue: number; ca_ttc: number; marge: number }>
|
||||
): HitParadePivotRow[] {
|
||||
function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
|
||||
const map = new Map<string, HitParadePivotRow>();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -30,10 +30,15 @@ function pivotHitParade(
|
||||
codein: row.codein,
|
||||
libelle: row.libelle,
|
||||
fournisseur: row.fournisseur,
|
||||
nomenclature_code: row.nomenclature_code,
|
||||
nomenclature: row.nomenclature,
|
||||
qte292: 0, ca292: 0, marge292: 0,
|
||||
qte579: 0, ca579: 0, marge579: 0,
|
||||
qteTotal: 0, caTotal: 0, margeTotal: 0,
|
||||
stock292: 0, stock579: 0, stockTotal: 0,
|
||||
// Stock comes from the SQL query directly (LEFT JOIN cube_stock)
|
||||
stock292: row.stock292 ?? 0,
|
||||
stock579: row.stock579 ?? 0,
|
||||
stockTotal: row.stockTotal ?? 0,
|
||||
});
|
||||
}
|
||||
const entry = map.get(row.codein)!;
|
||||
@@ -75,13 +80,6 @@ export default async function HitParadePage(props: {
|
||||
const rows = await pgGetHitParade(dateDebut, dateFin);
|
||||
const pivotted = pivotHitParade(rows);
|
||||
|
||||
const codeins = pivotted.map(r => r.codein);
|
||||
const stockMap = await pgGetStockForCodeins(codeins);
|
||||
for (const row of pivotted) {
|
||||
const s = stockMap.get(row.codein);
|
||||
if (s) { row.stock292 = s.stock292; row.stock579 = s.stock579; row.stockTotal = s.stockTotal; }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import type { Publicite } from "./page";
|
||||
|
||||
const FIRST_YEAR = 2020;
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
function fmtCA(v: number | string) {
|
||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(Number(v));
|
||||
}
|
||||
|
||||
function fmtPct(v: number | string) {
|
||||
return Number(v).toFixed(2).replace(".", ",") + " %";
|
||||
}
|
||||
|
||||
type SortKey = "intitule" | "site" | "ca_pub_periode_pub" | "pourc_capub_catotal" | "qte_vendue_pub" | "taux_sortie";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
year: number;
|
||||
publicites: Publicite[];
|
||||
}
|
||||
|
||||
export function PublicitesClient({ year, publicites }: Props) {
|
||||
const router = useRouter();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = Array.from({ length: currentYear - FIRST_YEAR + 1 }, (_, i) => currentYear - i);
|
||||
|
||||
const [filterSite, setFilterSite] = useState<string>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("intitule");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
const validPublicites = useMemo(() => publicites.filter(p => p.site !== "000"), [publicites]);
|
||||
|
||||
const sites = useMemo(() => {
|
||||
const s = new Set(validPublicites.map(p => p.site).filter(Boolean));
|
||||
return Array.from(s).sort();
|
||||
}, [validPublicites]);
|
||||
|
||||
const SITE_COLORS: Record<string, string> = useMemo(() => {
|
||||
const palette = [
|
||||
"bg-blue-50 text-blue-700",
|
||||
"bg-violet-50 text-violet-700",
|
||||
"bg-emerald-50 text-emerald-700",
|
||||
"bg-rose-50 text-rose-700",
|
||||
"bg-amber-50 text-amber-700",
|
||||
"bg-cyan-50 text-cyan-700",
|
||||
"bg-pink-50 text-pink-700",
|
||||
"bg-indigo-50 text-indigo-700",
|
||||
];
|
||||
return Object.fromEntries(sites.map((s, i) => [s, palette[i % palette.length]]));
|
||||
}, [sites]);
|
||||
|
||||
function handleYearChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
router.push(`/publicites?year=${e.target.value}`);
|
||||
}
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = filterSite === "all" ? validPublicites : validPublicites.filter(p => p.site === filterSite);
|
||||
rows = [...rows].sort((a, b) => {
|
||||
let va: string | number;
|
||||
let vb: string | number;
|
||||
if (sortKey === "intitule" || sortKey === "site") {
|
||||
va = String(a[sortKey] ?? "").toLowerCase();
|
||||
vb = String(b[sortKey] ?? "").toLowerCase();
|
||||
} else {
|
||||
va = Number(a[sortKey]);
|
||||
vb = Number(b[sortKey]);
|
||||
}
|
||||
if (va < vb) return sortDir === "asc" ? -1 : 1;
|
||||
if (va > vb) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return rows;
|
||||
}, [validPublicites, filterSite, sortKey, sortDir]);
|
||||
|
||||
function thProps(key: SortKey, className: string) {
|
||||
return {
|
||||
className: `${className} cursor-pointer select-none hover:bg-gray-100 transition-colors`,
|
||||
onClick: () => handleSort(key),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de contrôles */}
|
||||
<div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Année</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={handleYearChange}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-400 min-w-[120px]"
|
||||
>
|
||||
{years.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{sites.length > 1 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Magasin</label>
|
||||
<select
|
||||
value={filterSite}
|
||||
onChange={e => setFilterSite(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-400 min-w-[160px]"
|
||||
>
|
||||
<option value="all">Tous les magasins</option>
|
||||
{sites.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
||||
{filtered.length} publicité{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 text-sm">
|
||||
Aucune publicité trouvée pour {year}.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 w-20">N°</th>
|
||||
<th {...thProps("intitule", "px-3 py-3 text-left text-xs font-semibold text-gray-500")}>
|
||||
Opération <SortIcon col="intitule" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("site", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Magasin <SortIcon col="site" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date début</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date fin</th>
|
||||
<th {...thProps("ca_pub_periode_pub", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
||||
CA pub période <SortIcon col="ca_pub_periode_pub" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("pourc_capub_catotal", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
||||
% CA total <SortIcon col="pourc_capub_catotal" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("qte_vendue_pub", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Qté vendue <SortIcon col="qte_vendue_pub" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Clients</th>
|
||||
<th {...thProps("taux_sortie", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Taux sortie <SortIcon col="taux_sortie" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Nb articles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((pub, idx) => (
|
||||
<tr
|
||||
key={`${pub.tcr_code}-${pub.site}-${idx}`}
|
||||
className="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{pub.tcr_code}</td>
|
||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{pub.intitule}</td>
|
||||
<td className="px-3 py-2.5 text-xs text-gray-600 whitespace-nowrap">
|
||||
<span className={`inline-block rounded-md px-2 py-0.5 font-medium ${SITE_COLORS[pub.site] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
{pub.site || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
||||
{fmtDate(pub.date_debut)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
||||
{fmtDate(pub.date_fin)}
|
||||
</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">
|
||||
{fmtCA(pub.ca_pub_periode_pub)}
|
||||
</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right tabular-nums">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
Number(pub.pourc_capub_catotal) >= 20
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: Number(pub.pourc_capub_catotal) >= 10
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{fmtPct(pub.pourc_capub_catotal)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">
|
||||
{Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">
|
||||
{Number(pub.client_pub_periode).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">
|
||||
{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-500 text-xs">
|
||||
{pub.nb_articles}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<td colSpan={5} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {filtered.length} publicités
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
||||
{fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))}
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right tabular-nums text-xs font-semibold text-orange-700">
|
||||
—
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
||||
{filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function PublicitesLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des publicités..." />;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PublicitesClient } from "./client";
|
||||
|
||||
export interface Publicite {
|
||||
tcr_code: string;
|
||||
intitule: string;
|
||||
date_debut: string;
|
||||
date_fin: string;
|
||||
site: string;
|
||||
ca_pub_periode_pub: number;
|
||||
qte_vendue_pub: number;
|
||||
ca_total_periode_pub: number;
|
||||
pourc_capub_catotal: number;
|
||||
client_pub_periode: number;
|
||||
client_total_periode: number;
|
||||
taux_sortie: number;
|
||||
marge: number;
|
||||
taux_marge: number;
|
||||
nb_articles: number;
|
||||
}
|
||||
|
||||
async function fetchPublicites(year: number): Promise<{ data: Publicite[]; error?: string }> {
|
||||
const dateDebut = `${year}-01-01`;
|
||||
const dateFin = `${year}-12-31`;
|
||||
const url = `https://api.ffnancy.fr/api/publicites?dateDebut=${dateDebut}&dateFin=${dateFin}&limit=500`;
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) return { data: [], error: `Erreur API: ${res.status} ${res.statusText}` };
|
||||
const json = await res.json();
|
||||
return { data: (json.publicites ?? []) as Publicite[] };
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { data: [], error: `Impossible de joindre l'API: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PublicitesPage(props: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const year = Number(searchParams.year) || currentYear;
|
||||
|
||||
const { data: publicites, error } = await fetchPublicites(year);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Publicités</h1>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<PublicitesClient year={year} publicites={publicites} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="h-7 w-40 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
{/* Settings sections */}
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border border-[var(--border)] overflow-hidden bg-[var(--bg-elevated)] p-6 flex flex-col gap-4">
|
||||
<div className="h-5 w-36 rounded bg-[var(--border)]" />
|
||||
<div className="h-10 rounded-lg bg-[var(--bg-base)]" />
|
||||
<div className="h-10 rounded-lg bg-[var(--bg-base)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
export default function SettingsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des paramètres..." />;
|
||||
}
|
||||
@@ -118,9 +118,17 @@ export default function SettingsPage() {
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [models, setModels] = useState<OpenRouterModel[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("google/gemini-flash-1.5");
|
||||
const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001");
|
||||
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
// AI Provider
|
||||
const [aiProvider, setAiProvider] = useState<"openrouter" | "google">("openrouter");
|
||||
const [googleAiKey, setGoogleAiKey] = useState("");
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [googleAiModel, setGoogleAiModel] = useState("");
|
||||
const [googleModels, setGoogleModels] = useState<{ id: string; name: string }[]>([]);
|
||||
const [googleModelsStatus, setGoogleModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -133,6 +141,20 @@ export default function SettingsPage() {
|
||||
getDatabaseUrl
|
||||
} = useDbSettingsStore();
|
||||
|
||||
const fetchGoogleModels = useCallback(async (key: string) => {
|
||||
if (!key.trim()) return;
|
||||
setGoogleModelsStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/google-ai/models", { headers: { "x-google-ai-key": key } });
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setGoogleModels(data.models ?? []);
|
||||
setGoogleModelsStatus("ok");
|
||||
} catch {
|
||||
setGoogleModelsStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchModels = useCallback(async (key: string) => {
|
||||
if (!key.trim()) return;
|
||||
setModelsStatus("loading");
|
||||
@@ -171,8 +193,14 @@ export default function SettingsPage() {
|
||||
if (config.openRouterModel) {
|
||||
setSelectedModel(config.openRouterModel);
|
||||
}
|
||||
if (config.aiProvider) setAiProvider(config.aiProvider);
|
||||
if (config.googleAiKey) {
|
||||
setGoogleAiKey(config.googleAiKey);
|
||||
fetchGoogleModels(config.googleAiKey);
|
||||
}
|
||||
if (config.googleAiModel) setGoogleAiModel(config.googleAiModel);
|
||||
}
|
||||
}, [fetchModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]);
|
||||
}, [fetchModels, fetchGoogleModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -195,7 +223,7 @@ export default function SettingsPage() {
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving");
|
||||
const url = getDatabaseUrl();
|
||||
const res = await saveDatabaseSettings(url, apiKey, selectedModel);
|
||||
const res = await saveDatabaseSettings(url, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel);
|
||||
if (res.success) {
|
||||
setSaveStatus("saved");
|
||||
setTimeout(() => setSaveStatus("idle"), 2500);
|
||||
@@ -308,6 +336,86 @@ export default function SettingsPage() {
|
||||
{/* API FF Nancy */}
|
||||
<FfApiStatusSection />
|
||||
|
||||
{/* AI Provider selector */}
|
||||
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setAiProvider("openrouter")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "openrouter" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
OpenRouter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAiProvider("google")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "google" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
Google AI Studio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiProvider === "google" && (
|
||||
<>
|
||||
<Field label="Clé API Google AI Studio" hint="Disponible sur aistudio.google.com/apikey — commence par AIza...">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showGoogleKey ? "text" : "password"}
|
||||
placeholder="AIzaSy..."
|
||||
value={googleAiKey}
|
||||
onChange={(e) => setGoogleAiKey(e.target.value)}
|
||||
className="apple-input font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowGoogleKey((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{showGoogleKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchGoogleModels(googleAiKey)}
|
||||
disabled={!googleAiKey || googleModelsStatus === "loading"}
|
||||
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
|
||||
>
|
||||
{googleModelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Charger les modèles
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label={`Modèle Google${googleModels.length > 0 ? ` (${googleModels.length} disponibles)` : ""}`}
|
||||
hint={googleModelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : googleModelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
|
||||
>
|
||||
{googleModels.length > 0 ? (
|
||||
<select
|
||||
value={googleAiModel}
|
||||
onChange={(e) => setGoogleAiModel(e.target.value)}
|
||||
className="apple-input"
|
||||
>
|
||||
{googleModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={googleAiModel}
|
||||
onChange={(e) => setGoogleAiModel(e.target.value)}
|
||||
placeholder="ex: gemini-2.0-flash"
|
||||
className="apple-input"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{aiProvider === "openrouter" && (
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
Utilise la clé OpenRouter configurée ci-dessous et le modèle sélectionné.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
|
||||
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
|
||||
@@ -342,21 +450,27 @@ export default function SettingsPage() {
|
||||
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
|
||||
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
|
||||
>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={models.length === 0}
|
||||
className="apple-input"
|
||||
>
|
||||
{models.length === 0
|
||||
? <option>— Modèles non chargés —</option>
|
||||
: models.map((m) => (
|
||||
{models.length > 0 ? (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="apple-input"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.free ? "🆓 " : ""}{m.name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="ex: google/gemini-2.0-flash-001"
|
||||
className="apple-input"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
export default function SnapshotsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-36 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
<div className="h-9 w-36 rounded-lg bg-[var(--bg-elevated)]" />
|
||||
</div>
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
{/* Cards grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-36 rounded-2xl bg-[var(--bg-elevated)] border border-[var(--border)]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function SnapshotsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des snapshots..." />;
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react";
|
||||
import type { PgStockNegatifRow, PgStockSansVenteRow } from "./page";
|
||||
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function fmtDate(raw: string | null) {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
if (isNaN(d.getTime())) return raw;
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
function getYear(raw: string | null): string {
|
||||
if (!raw) return "";
|
||||
const d = new Date(raw);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return String(d.getFullYear());
|
||||
}
|
||||
|
||||
function SortIcon<T extends string>({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) {
|
||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
|
||||
function FilterSelect({ label, value, onChange, options }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-600 whitespace-nowrap">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[150px]"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSortedRows<T extends Record<string, unknown>>(rows: T[], numericKeys: string[]) {
|
||||
const [sortKey, setSortKey] = useState<keyof T>(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function handleSort(key: keyof T) {
|
||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => [...rows].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
if (numericKeys.includes(sortKey as string)) {
|
||||
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av);
|
||||
}
|
||||
const as = String(av ?? "").toLowerCase();
|
||||
const bs = String(bv ?? "").toLowerCase();
|
||||
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
|
||||
}), [rows, sortKey, sortDir]);
|
||||
|
||||
return { sorted, sortKey, sortDir, handleSort };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 1 — Stock Négatif
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) {
|
||||
type SK = keyof PgStockNegatifRow;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[rows]
|
||||
);
|
||||
const annees = useMemo(() =>
|
||||
[...new Set(rows.map(r => getYear(r.derniereentree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniereentree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [rows, filterFournisseur, filterAnnee, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockNegatifRow>(
|
||||
filtered,
|
||||
["stockdispo"]
|
||||
);
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key)}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Stock Négatif");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 16 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Code article ou libellé…"
|
||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stockdispo", "Stock")}
|
||||
{th("dernierevente", "Dernière vente")}
|
||||
{th("derniereentree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-3 py-8 text-center text-gray-400">Aucun article en stock négatif</td></tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-red-600">{row.stockdispo}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.dernierevente)}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniereentree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 2 — Entrées sans vente
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) {
|
||||
type SK = keyof PgStockSansVenteRow;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const baseRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]);
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[baseRows]
|
||||
);
|
||||
const annees = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => getYear(r.derniere_entree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[baseRows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return baseRows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniere_entree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [baseRows, filterFournisseur, filterAnnee, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockSansVenteRow>(
|
||||
filtered,
|
||||
["stock_actuel"]
|
||||
);
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key)}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Entrées sans vente");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Entrees_Sans_Vente_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Code article ou libellé…"
|
||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stock_actuel", "Stock actuel")}
|
||||
{th("derniere_entree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composant principal avec onglets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABS = [
|
||||
{ key: "negatif", label: "Stock négatif" },
|
||||
{ key: "sans-vente", label: "Entrées sans vente" },
|
||||
] as const;
|
||||
|
||||
type TabKey = typeof TABS[number]["key"];
|
||||
|
||||
interface Props {
|
||||
rowsNegatif: PgStockNegatifRow[];
|
||||
rowsSansVente: PgStockSansVenteRow[];
|
||||
magasin: string;
|
||||
tab: string;
|
||||
sites: { code: string; label: string }[];
|
||||
}
|
||||
|
||||
export function GestionStockClient({ rowsNegatif, rowsSansVente, magasin, tab, sites }: Props) {
|
||||
const router = useRouter();
|
||||
const activeTab: TabKey = (TABS.some(t => t.key === tab) ? tab : "negatif") as TabKey;
|
||||
|
||||
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const val = e.target.value;
|
||||
const params = new URLSearchParams();
|
||||
if (val) params.set("magasin", val);
|
||||
params.set("tab", activeTab);
|
||||
router.push(`/stock-negatif?${params.toString()}`);
|
||||
}
|
||||
|
||||
function handleTabChange(key: TabKey) {
|
||||
const params = new URLSearchParams();
|
||||
if (magasin) params.set("magasin", magasin);
|
||||
params.set("tab", key);
|
||||
router.push(`/stock-negatif?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Magasin</label>
|
||||
<select
|
||||
value={magasin}
|
||||
onChange={handleMagasinChange}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Tous les magasins</option>
|
||||
{sites.map(s => (
|
||||
<option key={s.code} value={s.code}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex gap-1">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
className={[
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
||||
activeTab === t.key
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
<span className={[
|
||||
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
|
||||
].join(" ")}>
|
||||
{t.key === "negatif" ? rowsNegatif.length : rowsSansVente.filter(r => r.stock_actuel !== 0).length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />}
|
||||
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function StockNegatifLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Récupération des stocks négatifs..." />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow } from "@/lib/pg-ff-client";
|
||||
import { GestionStockClient } from "./client";
|
||||
|
||||
export type { PgStockNegatifRow, PgStockSansVenteRow };
|
||||
|
||||
const SITES = [
|
||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
||||
{ code: "579", label: "579 — Houdemont" },
|
||||
];
|
||||
|
||||
export default async function GestionStockPage(props: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const magasin = (searchParams.magasin as string) || "";
|
||||
const tab = (searchParams.tab as string) || "negatif";
|
||||
|
||||
const [rowsNegatif, rowsSansVente] = await Promise.all([
|
||||
pgGetStockNegatif(magasin || undefined),
|
||||
pgGetStockSansVente(magasin || undefined),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Gestion de Stock</h1>
|
||||
<GestionStockClient
|
||||
rowsNegatif={rowsNegatif}
|
||||
rowsSansVente={rowsSansVente}
|
||||
magasin={magasin}
|
||||
tab={tab}
|
||||
sites={SITES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
|
||||
interface AiConfig {
|
||||
provider: "openrouter" | "google";
|
||||
openRouterKey?: string;
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
}
|
||||
|
||||
async function getAiConfig(): Promise<AiConfig> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return {
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterKey: config.openRouterKey ?? process.env.OPENROUTER_API_KEY,
|
||||
googleAiKey: config.googleAiKey ?? process.env.GOOGLE_AI_KEY,
|
||||
googleAiModel: config.googleAiModel ?? "gemini-2.0-flash",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
provider: "openrouter",
|
||||
openRouterKey: process.env.OPENROUTER_API_KEY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
// ─── Tool definitions ────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLS_OPENROUTER = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "call_api",
|
||||
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: "string",
|
||||
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
|
||||
},
|
||||
},
|
||||
required: ["endpoint"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TOOLS_GOOGLE = [{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "call_api",
|
||||
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: Type.STRING,
|
||||
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
|
||||
},
|
||||
},
|
||||
required: ["endpoint"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
// ─── API caller ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function callFfApi(endpoint: string): Promise<string> {
|
||||
// Sécurité : uniquement GET, uniquement les endpoints documentés
|
||||
const allowedPrefixes = [
|
||||
"/api/health", "/api/sync", "/api/schema",
|
||||
"/api/articles", "/api/stock", "/api/fournisseurs",
|
||||
"/api/commandes", "/api/mouvements", "/api/performance",
|
||||
"/api/publicites", "/api/commandes-auto",
|
||||
];
|
||||
const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p));
|
||||
if (!isAllowed) {
|
||||
return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${FF_API_BASE}${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return `Erreur API ${res.status}: ${await res.text()}`;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = JSON.stringify(data);
|
||||
|
||||
// Tronquer si trop long (max 6000 chars)
|
||||
if (text.length > 6000) {
|
||||
return text.substring(0, 6000) + `\n...(tronqué, ${text.length} caractères total)`;
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
return `Erreur réseau: ${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire (Foirfouille Nancy).
|
||||
|
||||
## Outil disponible
|
||||
- \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON
|
||||
|
||||
## API FF Nancy — Documentation complète
|
||||
Base URL : ${FF_API_BASE}
|
||||
Format des dates : YYYY-MM-DD
|
||||
Identifiants articles : toujours \`no_id\` (entier interne)
|
||||
Codes site : ex 000, 001, 002…
|
||||
|
||||
### Santé / Infra
|
||||
- GET /api/health — vérifie que l'API et PostgreSQL répondent
|
||||
- GET /api/sync/status — état de la synchronisation en cours
|
||||
- GET /api/schema/tables?search=nom — liste les tables PG
|
||||
- GET /api/schema/tables/:nom — colonnes d'une table
|
||||
- GET /api/schema/top?limit=10 — tables les plus peuplées
|
||||
|
||||
### Articles
|
||||
- GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page)
|
||||
- GET /api/articles/:id — détail complet d'un article (no_id)
|
||||
- GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN
|
||||
- GET /api/articles/:id/mouvements — historique des mouvements
|
||||
- GET /api/articles/:id/mensuel — CA mensuel de l'article
|
||||
|
||||
Le champ **prix d'achat (PA)** est retourné directement dans GET /api/articles (champ \`pa\` ou \`prix_achat\`).
|
||||
Pour un article précis : GET /api/articles/:id/referentiel pour voir tous les tarifs fournisseurs.
|
||||
|
||||
### Stock
|
||||
- GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page)
|
||||
- GET /api/stock/article/:id — stock par site pour un article (no_id) → contient aussi \`prmp\` (Prix de Revient Moyen Pondéré)
|
||||
- GET /api/stock/site/:site — tous les articles d'un site donné
|
||||
- GET /api/stock/article/:id/historique — historique stock de l'article
|
||||
- GET /api/stock/article/:id/periode?dateDebut=&dateFin= — stock sur période
|
||||
- GET /api/stock/valorisation — valeur totale du stock par site
|
||||
|
||||
Le **PRMP** (prix réel moyen pondéré) est dans GET /api/stock/article/:id, champ \`prmp\` par site.
|
||||
|
||||
### Fournisseurs
|
||||
- GET /api/fournisseurs?search=nom — liste des fournisseurs (champs : code, nom)
|
||||
- GET /api/fournisseurs/:code/articles — articles du fournisseur
|
||||
- GET /api/fournisseurs/:code/commandes — commandes du fournisseur
|
||||
|
||||
### Commandes
|
||||
- GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs
|
||||
- GET /api/commandes/:noCommande — détail d'une commande + ses lignes
|
||||
- GET /api/commandes/receptions/liste — liste des réceptions
|
||||
|
||||
### Commandes automatiques
|
||||
- GET /api/commandes-auto?site=&codefou= — résumé des propositions par fournisseur/site avec comparaison franco
|
||||
Champs : franco_ht, montant_propo_ht, franco_atteint (OUI/NON/N/A), ecart_franco
|
||||
- GET /api/commandes-auto/:codefou?site= — détail d'un fournisseur : résumé + liste des articles proposés avec stock et montant
|
||||
|
||||
### Mouvements
|
||||
- GET /api/mouvements/types — types de mouvements disponibles
|
||||
- GET /api/mouvements/articles?dateDebut=&dateFin=&site= — mouvements par article
|
||||
- GET /api/mouvements/entrees?dateDebut=&dateFin= — entrées marchandises
|
||||
- GET /api/mouvements/regularisations?dateDebut=&dateFin= — régularisations inventaire
|
||||
- GET /api/mouvements/reglements?dateDebut=&dateFin= — règlements
|
||||
- GET /api/mouvements/synthese?dateDebut=&dateFin=&site= — synthèse des mouvements
|
||||
|
||||
### Performance / CA
|
||||
- GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=mois — CA global (groupBy: jour ou mois)
|
||||
- GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=20&groupBy=ca — top articles (groupBy: qte, ca ou marge)
|
||||
- GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau= — CA par nomenclature
|
||||
- GET /api/performance/ca/fournisseur?dateDebut=&dateFin=&site= — CA par fournisseur
|
||||
- GET /api/performance/ca/gamme?dateDebut=&dateFin=&site= — CA par gamme (A/B/C/D/Z)
|
||||
- GET /api/performance/dashboard — indicateurs résumés pour dashboard
|
||||
|
||||
### Publicités
|
||||
- GET /api/publicites?search=&site=&dateDebut=&dateFin=&page=1&limit=50 — liste des publicités
|
||||
- GET /api/publicites/:code?site= — détail d'une publicité
|
||||
|
||||
## Contexte métier CollectFlow
|
||||
- Application de gestion de gammes pour magasins Foirfouille Nancy
|
||||
- Gammes A/B/C/D/Z : A=pilier, B=bonne rotation, C=performance, D=saisonnier/niche, Z=sortie
|
||||
- Les ventes sont suivies par magasin (site) et par mois
|
||||
- Les utilisateurs arbitrent les classifications gamme par fournisseur
|
||||
|
||||
## Stratégie d'analyse
|
||||
1. Si tu ne connais pas un code fournisseur ou un no_id, commence par chercher avec /api/fournisseurs?search= ou /api/articles?search=
|
||||
2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse
|
||||
3. Pour le prix d'achat : utilise /api/articles?codefou=CODE (champ pa) ou /api/articles/:id/referentiel
|
||||
4. Pour le PRMP (prix réel) : utilise /api/stock/article/:id (champ prmp par site)
|
||||
5. Ajoute toujours \`limit\` pour éviter des réponses trop volumineuses (limit=20 à 100 selon le contexte)
|
||||
|
||||
## Format de réponse — IMPORTANT
|
||||
- Réponds TOUJOURS en **Markdown** : titres ##, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code
|
||||
- N'utilise JAMAIS de HTML (<div>, <table>, <b>, <br>, etc.)
|
||||
- Réponds toujours en français
|
||||
- Sois précis, structuré et complet — ne tronque jamais ta réponse
|
||||
- Si les données sont nombreuses, résume et mets en avant les points clés`;
|
||||
|
||||
// ─── OpenRouter handler ───────────────────────────────────────────────────────
|
||||
|
||||
async function callOpenRouter(
|
||||
messages: object[],
|
||||
model: string,
|
||||
apiKey: string,
|
||||
useTools: boolean,
|
||||
stream: boolean
|
||||
): Promise<Response> {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 8192,
|
||||
};
|
||||
if (useTools) {
|
||||
body.tools = TOOLS_OPENROUTER;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
if (stream) {
|
||||
body.stream = true;
|
||||
body.stream_options = { include_usage: true };
|
||||
}
|
||||
return fetch(OPENROUTER_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://collectflow.app",
|
||||
"X-Title": "CollectFlow Admin Chat",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Google AI handler ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
|
||||
if (!config.googleAiKey) {
|
||||
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
const model = config.googleAiModel ?? "gemini-2.0-flash";
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function send(event: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
|
||||
|
||||
const contents: Array<{ role: string; parts: any[] }> = [
|
||||
...messages.map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
];
|
||||
|
||||
for (let round = 0; round < 10; round++) {
|
||||
const streamResponse = await ai.models.generateContentStream({
|
||||
model,
|
||||
contents: contents as any,
|
||||
config: {
|
||||
systemInstruction: SYSTEM_PROMPT,
|
||||
tools: TOOLS_GOOGLE as any,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
let collectedText = "";
|
||||
const collectedParts: any[] = [];
|
||||
|
||||
for await (const chunk of streamResponse) {
|
||||
const text = chunk.text;
|
||||
if (text) {
|
||||
collectedText += text;
|
||||
send({ type: "text", content: text });
|
||||
}
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
if (parts) collectedParts.push(...parts);
|
||||
}
|
||||
|
||||
const fnCallParts = collectedParts.filter((p: any) => p.functionCall);
|
||||
|
||||
if (fnCallParts.length === 0) break;
|
||||
|
||||
contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts });
|
||||
|
||||
const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
|
||||
for (const part of fnCallParts) {
|
||||
const fn = part.functionCall as { name: string; args: Record<string, unknown> };
|
||||
const args = fn.args ?? {};
|
||||
send({ type: "tool_start", name: fn.name, args });
|
||||
|
||||
let result = "";
|
||||
if (fn.name === "call_api") {
|
||||
result = await callFfApi(args.endpoint as string);
|
||||
} else {
|
||||
result = `Outil inconnu: ${fn.name}`;
|
||||
}
|
||||
|
||||
send({ type: "tool_result", name: fn.name, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||
toolResultParts.push({ functionResponse: { name: fn.name, response: { output: result } } });
|
||||
}
|
||||
|
||||
contents.push({ role: "user", parts: toolResultParts as any });
|
||||
}
|
||||
|
||||
send({ type: "usage", prompt_tokens: 0, completion_tokens: 0 });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
send({ type: "error", message: msg });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── POST handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== "admin") {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
|
||||
}
|
||||
|
||||
const { messages, model } = await req.json();
|
||||
const aiConfig = await getAiConfig();
|
||||
|
||||
if (aiConfig.provider === "google") {
|
||||
return handleGoogleAi(messages, aiConfig);
|
||||
}
|
||||
|
||||
const apiKey = aiConfig.openRouterKey;
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
const fullMessages: object[] = [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
...messages,
|
||||
];
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function send(event: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
|
||||
try {
|
||||
let currentMessages = [...fullMessages];
|
||||
let totalPromptTokens = 0;
|
||||
let totalCompletionTokens = 0;
|
||||
let directAnswer: string | null = null;
|
||||
|
||||
for (let round = 0; round < 10; round++) {
|
||||
const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
|
||||
if (!res.ok) {
|
||||
send({ type: "error", message: `OpenRouter error: ${await res.text()}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.usage) {
|
||||
totalPromptTokens += data.usage.prompt_tokens ?? 0;
|
||||
totalCompletionTokens += data.usage.completion_tokens ?? 0;
|
||||
}
|
||||
|
||||
const assistantMessage = data.choices?.[0]?.message;
|
||||
if (!assistantMessage) break;
|
||||
currentMessages.push(assistantMessage);
|
||||
|
||||
const toolCalls = assistantMessage.tool_calls;
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
directAnswer = assistantMessage.content ?? "";
|
||||
break;
|
||||
}
|
||||
|
||||
const toolResults: object[] = [];
|
||||
for (const tc of toolCalls) {
|
||||
const fnName = tc.function?.name;
|
||||
let args: Record<string, unknown> = {};
|
||||
try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ }
|
||||
|
||||
send({ type: "tool_start", name: fnName, args });
|
||||
|
||||
let result = "";
|
||||
if (fnName === "call_api") {
|
||||
result = await callFfApi(args.endpoint as string);
|
||||
} else {
|
||||
result = `Outil inconnu: ${fnName}`;
|
||||
}
|
||||
|
||||
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||
toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
|
||||
}
|
||||
currentMessages.push(...toolResults);
|
||||
}
|
||||
|
||||
if (directAnswer !== null) {
|
||||
send({ type: "text", content: directAnswer });
|
||||
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Final streaming call after tool use
|
||||
const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
|
||||
if (!finalRes.ok) {
|
||||
send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = finalRes.body?.getReader();
|
||||
if (!reader) { controller.close(); return; }
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let finalUsage: { prompt_tokens?: number; completion_tokens?: number } | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += textDecoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw === "[DONE]") continue;
|
||||
try {
|
||||
const chunk = JSON.parse(raw);
|
||||
const delta = chunk.choices?.[0]?.delta?.content;
|
||||
if (delta) send({ type: "text", content: delta });
|
||||
if (chunk.usage) finalUsage = chunk.usage;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (finalUsage) {
|
||||
totalPromptTokens += finalUsage.prompt_tokens ?? 0;
|
||||
totalCompletionTokens += finalUsage.completion_tokens ?? 0;
|
||||
}
|
||||
|
||||
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
send({ type: "error", message: msg });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return NextResponse.json({
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterModel: config.openRouterModel,
|
||||
googleAiModel: config.googleAiModel,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ provider: "openrouter" });
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const SITE_LABELS: Record<string, string> = { "292": "Frouard", "579": "Houdemon
|
||||
export async function POST(req: NextRequest) {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
||||
const model = config?.openRouterModel || "google/gemini-flash-1.5";
|
||||
const model = config?.openRouterModel || "google/gemini-2.0-flash-001";
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("[AI] OPENROUTER_API_KEY is missing.");
|
||||
@@ -54,8 +54,22 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const client = new OpenRouterClient({ apiKey, model });
|
||||
const result = await client.analyzeProduct(enrichedBody);
|
||||
const FALLBACK_MODEL = "google/gemini-2.0-flash-001";
|
||||
let client = new OpenRouterClient({ apiKey, model });
|
||||
let result;
|
||||
try {
|
||||
result = await client.analyzeProduct(enrichedBody);
|
||||
} catch (modelErr) {
|
||||
// If configured model fails (e.g., deprecated), retry with fallback
|
||||
const msg = modelErr instanceof Error ? modelErr.message : "";
|
||||
if (model !== FALLBACK_MODEL && (msg.includes("400") || msg.includes("404") || msg.includes("OpenRouter error"))) {
|
||||
console.warn(`[AI] Model "${model}" failed (${msg}), retrying with fallback ${FALLBACK_MODEL}`);
|
||||
client = new OpenRouterClient({ apiKey, model: FALLBACK_MODEL });
|
||||
result = await client.analyzeProduct(enrichedBody);
|
||||
} else {
|
||||
throw modelErr;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -36,7 +36,7 @@ const BatchAnalyzeSchema = z.object({
|
||||
export async function POST(req: NextRequest) {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
||||
const model = config?.openRouterModel || "google/gemini-flash-1.5";
|
||||
const model = config?.openRouterModel || "google/gemini-2.0-flash-001";
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("[batch-analyze] API key manquante.");
|
||||
@@ -107,14 +107,12 @@ REPONDS EN JSON VALIDE uniquement :
|
||||
const userPrompt = `${products.length} produits du rayon "${rayon}" (sur ${supplierStats?.totalProducts ?? products.length} au total).
|
||||
${JSON.stringify(products, null, 2)}`;
|
||||
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
const FALLBACK_MODEL = "google/gemini-2.0-flash-001";
|
||||
const tryModel = async (modelToTry: string) => fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
model: modelToTry,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt }
|
||||
@@ -124,6 +122,14 @@ ${JSON.stringify(products, null, 2)}`;
|
||||
}),
|
||||
});
|
||||
|
||||
let response = await tryModel(model);
|
||||
|
||||
// Fallback if configured model is deprecated/not found
|
||||
if (!response.ok && response.status === 400 && model !== FALLBACK_MODEL) {
|
||||
console.warn(`[batch-analyze] Model "${model}" returned 400, retrying with fallback ${FALLBACK_MODEL}`);
|
||||
response = await tryModel(FALLBACK_MODEL);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
|
||||
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
/**
|
||||
* GET /api/commandes-auto[?site=292&codefou=N009]
|
||||
*
|
||||
* Proxy vers https://api.ffnancy.fr/api/commandes-auto
|
||||
* Transfère les query params site et codefou si présents.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const params = new URLSearchParams();
|
||||
if (searchParams.get("site")) params.set("site", searchParams.get("site")!);
|
||||
if (searchParams.get("codefou")) params.set("codefou", searchParams.get("codefou")!);
|
||||
|
||||
const qs = params.toString();
|
||||
const url = `${FF_API_BASE}/api/commandes-auto${qs ? `?${qs}` : ""}`;
|
||||
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `API upstream HTTP ${res.status}` }, { status: res.status });
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
console.error("[api/commandes-auto]", e);
|
||||
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,73 @@ export async function GET(req: NextRequest) {
|
||||
mvtartDiag = { error: String(e) };
|
||||
}
|
||||
|
||||
// Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto
|
||||
let francoDiag: unknown;
|
||||
try {
|
||||
const francoColumns = await db.execute(sql`
|
||||
SELECT table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE column_name ILIKE '%franco%'
|
||||
OR column_name ILIKE '%min_cmd%'
|
||||
OR column_name ILIKE '%mincde%'
|
||||
OR column_name ILIKE '%min_commande%'
|
||||
ORDER BY table_name, column_name
|
||||
`);
|
||||
// Sample des 3 premières lignes de fouident pour voir toutes les colonnes
|
||||
const fouidentSample = await db.execute(sql`SELECT * FROM fouident LIMIT 3`);
|
||||
const foucadSample = await db.execute(sql`SELECT * FROM foucad LIMIT 5`);
|
||||
francoDiag = {
|
||||
francoColumns: francoColumns.rows,
|
||||
fouidentColumns: fouidentSample.rows.length > 0 ? Object.keys(fouidentSample.rows[0] as object) : [],
|
||||
fouidentSample: fouidentSample.rows,
|
||||
foucadColumns: foucadSample.rows.length > 0 ? Object.keys(foucadSample.rows[0] as object) : [],
|
||||
foucadSample: foucadSample.rows,
|
||||
};
|
||||
} catch (e) {
|
||||
francoDiag = { error: String(e) };
|
||||
}
|
||||
|
||||
// Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant
|
||||
let commandesAutoFrancoAudit: unknown;
|
||||
try {
|
||||
const caRes = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" });
|
||||
const caData = await caRes.json();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const caRows: any[] = Array.isArray(caData) ? caData : (caData?.propositions ?? caData?.data ?? []);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const withFranco = caRows.filter((r: any) => r.franco_ht != null && Number(r.franco_ht) > 0);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const withoutFranco = caRows.filter((r: any) => r.franco_ht == null || Number(r.franco_ht) === 0);
|
||||
commandesAutoFrancoAudit = {
|
||||
total: caRows.length,
|
||||
avecFranco: withFranco.length,
|
||||
sansFranco: withoutFranco.length,
|
||||
lignesSansFranco: withoutFranco.map((r: any) => ({
|
||||
site: r.site, codefou: r.codefou, nom_fou: r.nom_fou,
|
||||
franco_ht: r.franco_ht, franco_atteint: r.franco_atteint,
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
commandesAutoFrancoAudit = { error: String(e) };
|
||||
}
|
||||
|
||||
// Probe des fournisseurs N/A pour trouver où est leur franco
|
||||
const [
|
||||
detailM036_292,
|
||||
detailM030_579,
|
||||
fournisseurM036,
|
||||
fournisseurM030,
|
||||
] = await Promise.all([
|
||||
probe(`${FF_API_BASE}/api/commandes-auto/M036?site=292`),
|
||||
probe(`${FF_API_BASE}/api/commandes-auto/M030?site=579`),
|
||||
probe(`${FF_API_BASE}/api/fournisseurs/M036`),
|
||||
probe(`${FF_API_BASE}/api/fournisseurs/M030`),
|
||||
]);
|
||||
const francoNaProbe = { detailM036_292, detailM030_579, fournisseurM036, fournisseurM030 };
|
||||
|
||||
// Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API
|
||||
const rawCommandesAuto = await probe(`${FF_API_BASE}/api/commandes-auto`);
|
||||
|
||||
return NextResponse.json({
|
||||
timestamp: new Date().toISOString(),
|
||||
dateRange: { dateDebut, dateFin },
|
||||
@@ -215,5 +282,9 @@ export async function GET(req: NextRequest) {
|
||||
postgresql,
|
||||
ventesProduitsDiag,
|
||||
mvtartDiag,
|
||||
francoDiag,
|
||||
rawCommandesAuto,
|
||||
commandesAutoFrancoAudit,
|
||||
francoNaProbe,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
async function getSavedGoogleKey(): Promise<string | null> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.googleAiKey ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = req.headers.get("x-google-ai-key")
|
||||
|| process.env.GOOGLE_AI_KEY
|
||||
|| await getSavedGoogleKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No Google AI key provided" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=100`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return NextResponse.json({ error: err }, { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Keep only generative models that support generateContent
|
||||
const models = (data.models as Array<{
|
||||
name: string;
|
||||
displayName: string;
|
||||
supportedGenerationMethods?: string[];
|
||||
description?: string;
|
||||
}>)
|
||||
.filter(m =>
|
||||
m.supportedGenerationMethods?.includes("generateContent") &&
|
||||
!m.name.includes("embedding") &&
|
||||
!m.name.includes("aqa")
|
||||
)
|
||||
.map(m => ({
|
||||
id: m.name.replace("models/", ""),
|
||||
name: m.displayName,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "fr"));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const codeFournisseur = searchParams.get("fournisseur");
|
||||
|
||||
if (!codeFournisseur) {
|
||||
return NextResponse.json({ error: "Paramètre 'fournisseur' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
const page = parseInt(searchParams.get("page") ?? "0", 10);
|
||||
const pageSize = parseInt(searchParams.get("pageSize") ?? "200", 10);
|
||||
const offsetRaw = searchParams.get("offset");
|
||||
const offset = offsetRaw ? parseInt(offsetRaw, 10) : undefined;
|
||||
const search = searchParams.get("search") ?? undefined;
|
||||
const gamme = searchParams.get("gamme") ?? undefined;
|
||||
const code3 = searchParams.get("code3") ?? undefined;
|
||||
const magasin = searchParams.get("magasin") ?? undefined;
|
||||
|
||||
try {
|
||||
const result = await getProductRows({
|
||||
codeFournisseur,
|
||||
magasin,
|
||||
page: isNaN(page) ? 0 : page,
|
||||
pageSize: isNaN(pageSize) ? 200 : pageSize,
|
||||
offset: offset !== undefined && !isNaN(offset) ? offset : undefined,
|
||||
search,
|
||||
gamme: gamme || null,
|
||||
code3: code3 || null,
|
||||
});
|
||||
|
||||
return NextResponse.json(result, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "private, max-age=30, stale-while-revalidate=60",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[GET /api/grid/rows] Error:", error);
|
||||
return NextResponse.json({ error: "Erreur lors de la récupération des données" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
async function getSavedOpenRouterKey(): Promise<string | null> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.openRouterKey ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = req.headers.get("x-openrouter-key") || process.env.OPENROUTER_API_KEY;
|
||||
const apiKey = req.headers.get("x-openrouter-key")
|
||||
|| process.env.OPENROUTER_API_KEY
|
||||
|| await getSavedOpenRouterKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
|
||||
@@ -13,7 +28,7 @@ export async function GET(req: NextRequest) {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
next: { revalidate: 3600 }, // Cache 1h
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -23,10 +38,12 @@ export async function GET(req: NextRequest) {
|
||||
const data = await res.json();
|
||||
|
||||
// Return simplified list sorted by name
|
||||
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string } }>)
|
||||
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string; completion: string } }>)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
promptPrice: parseFloat(m.pricing?.prompt ?? "0"),
|
||||
completionPrice: parseFloat(m.pricing?.completion ?? "0"),
|
||||
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
interface LoadingModalProps {
|
||||
message?: string;
|
||||
subMessage?: string;
|
||||
}
|
||||
|
||||
export default function LoadingModal({
|
||||
message = "Chargement des données",
|
||||
subMessage = "Veuillez patienter...",
|
||||
}: LoadingModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div
|
||||
className="flex flex-col items-center gap-5 px-10 py-8 rounded-2xl shadow-2xl border"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="relative w-12 h-12">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full border-[3px] animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
borderTopColor: "var(--accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className="text-sm font-semibold tracking-wide"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{subMessage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,8 +13,12 @@ const NAV_ITEMS = [
|
||||
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
||||
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
||||
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
||||
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
|
||||
{ icon: PackageMinus, label: "Gestion de Stock", href: "/stock-negatif" },
|
||||
{ icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" },
|
||||
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
||||
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
|
||||
{ icon: MessageSquareCode, label: "AI Chat Admin", href: "/admin/ai-chat", adminOnly: true },
|
||||
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import path from "path";
|
||||
let pool: Pool | null = null;
|
||||
let currentDb: NodePgDatabase<typeof schema> | null = null;
|
||||
|
||||
export function getPool(): Pool {
|
||||
getDb(); // ensure pool is initialized
|
||||
return pool!;
|
||||
}
|
||||
|
||||
function maskUrl(url: string | undefined) {
|
||||
if (!url) return "undefined";
|
||||
return url.replace(/:([^@]+)@/, ":****@");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react";
|
||||
import { AiExplanationModal } from "./ai-explanation-modal";
|
||||
import { useAiCopilotStore } from "../store/use-ai-copilot-store";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
|
||||
@@ -14,6 +15,8 @@ interface AiInsightBlockProps {
|
||||
}
|
||||
|
||||
export function AiInsightBlock({ row }: AiInsightBlockProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
const insight = useAiCopilotStore((s: any) => s.insights[row.codein]);
|
||||
const analyzeProduct = useAiCopilotStore((s: any) => s.analyzeProduct);
|
||||
const setLoading = useAiCopilotStore((s: any) => s.setLoading);
|
||||
@@ -125,6 +128,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
|
||||
};
|
||||
|
||||
if (status === "idle") {
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows, type GetProductRowsResult } from "./api/get-product-rows";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
import { getProductRows } from "./api/get-product-rows";
|
||||
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
|
||||
/**
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
@@ -21,21 +21,19 @@ export async function getMagasins() {
|
||||
|
||||
/**
|
||||
* Nomenclature filter is disabled — code3 is not available from the API.
|
||||
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
|
||||
*/
|
||||
export async function getAvailableNomenclature() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les produits d'un fournisseur.
|
||||
* La charge est paginée (renvoie la première page en SSR) pour empêcher le figeage du thread principal UI.
|
||||
* Get product data for a specific supplier and store.
|
||||
*/
|
||||
export async function getGridData(
|
||||
codeFournisseur: string,
|
||||
magasin: string = "TOTAL",
|
||||
filters?: Partial<GridFilters>,
|
||||
page: number = 0,
|
||||
pageSize: number = 200,
|
||||
): Promise<GetProductRowsResult> {
|
||||
return getProductRows({ codeFournisseur, magasin, filters, page, pageSize });
|
||||
filters?: Partial<GridFilters>
|
||||
): Promise<ProductRow[]> {
|
||||
return getProductRows({ codeFournisseur, magasin, filters });
|
||||
}
|
||||
@@ -16,42 +16,16 @@ import {
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
// ─── Interfaces ───────────────────────────────────────────────────────────────
|
||||
export interface GetProductRowsInput {
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
magasin?: string;
|
||||
filters?: Partial<GridFilters>;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
gamme?: string | null;
|
||||
code3?: string | null;
|
||||
}
|
||||
|
||||
export interface GetProductRowsResult {
|
||||
rows: ProductRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// Fonction utilitaire cachée pour mémoiser le chargement par fournisseur
|
||||
const getCachedSupplierRows = (codeFournisseur: string) => {
|
||||
return unstable_cache(
|
||||
async () => _fetchAllProductRows(codeFournisseur),
|
||||
[`grid-rows-${codeFournisseur}`],
|
||||
{ revalidate: 300, tags: [`grid-rows-${codeFournisseur}`] }
|
||||
)();
|
||||
};
|
||||
|
||||
// ─── Fonction interne : fetch complet non-paginé (cachée) ───────────────────
|
||||
async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow[]> {
|
||||
const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side
|
||||
console.log(`\n>>> [_fetchAllProductRows] (NO CACHE) supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const { codeFournisseur, magasin = "TOTAL" } = input;
|
||||
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||
|
||||
try {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
@@ -96,6 +70,8 @@ async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow
|
||||
codein: art.codein,
|
||||
codeFournisseur: art.codefou ?? codeFournisseur,
|
||||
nomFournisseur: art.nomfou ?? "",
|
||||
fournisseurPrincipalCode: art.codefou_principal ?? undefined,
|
||||
fournisseurPrincipalNom: art.nomfou_principal ?? undefined,
|
||||
libelle1: art.libelle1 ?? "",
|
||||
gtin: art.gtin ?? "",
|
||||
reference: art.reference ?? "",
|
||||
@@ -318,79 +294,14 @@ async function _fetchAllProductRows(codeFournisseur: string): Promise<ProductRow
|
||||
|
||||
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
|
||||
const allRows = Array.from(productMap.values());
|
||||
const rows = allRows.filter(p => p.codeGamme?.trim().toUpperCase() !== "Y" || p.totalQuantite > 0);
|
||||
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
|
||||
const excludedY = allRows.length - rows.length;
|
||||
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
|
||||
|
||||
return computeProductScores(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[_fetchAllProductRows] Error for ${codeFournisseur}:`, error);
|
||||
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fonction publique ─────────────
|
||||
/**
|
||||
* Point d'entrée unique pour la grille.
|
||||
* - Retourne les lignes paginées pour préserver le client.
|
||||
*/
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<GetProductRowsResult> {
|
||||
const {
|
||||
codeFournisseur,
|
||||
page,
|
||||
pageSize = 200,
|
||||
offset,
|
||||
search,
|
||||
gamme,
|
||||
code3,
|
||||
} = input;
|
||||
|
||||
// Récupérer toutes les lignes mises en cache pour éviter les 650 requêtes SQL
|
||||
const allRows = await getCachedSupplierRows(codeFournisseur);
|
||||
|
||||
let filtered = allRows;
|
||||
|
||||
if (search && search.trim() !== "") {
|
||||
const q = search.trim().toLowerCase();
|
||||
filtered = filtered.filter(r =>
|
||||
(r.libelle1 ?? "").toLowerCase().includes(q) ||
|
||||
(r.codein ?? "").toLowerCase().includes(q) ||
|
||||
(r.reference ?? "").toLowerCase().includes(q) ||
|
||||
(r.gtin ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
if (gamme !== undefined && gamme !== null && gamme !== "") {
|
||||
filtered = filtered.filter(r => {
|
||||
const g = r.codeGamme ?? "";
|
||||
const norm = g.trim() === "" ? "Aucune" : g;
|
||||
return norm === gamme;
|
||||
});
|
||||
}
|
||||
|
||||
if (code3) {
|
||||
filtered = filtered.filter(r => r.code3 === code3);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
|
||||
if (page === undefined) {
|
||||
return { rows: filtered, total, page: 0, pageSize: total, totalPages: 1 };
|
||||
}
|
||||
|
||||
const safePageSize = Math.max(1, Math.min(pageSize, 20000));
|
||||
const totalPages = Math.ceil(total / safePageSize);
|
||||
const safePage = Math.max(0, Math.min(page ?? 0, Math.max(0, totalPages - 1)));
|
||||
|
||||
// Priorité à l'offset s'il est fourni (pour la pagination côté client qui ne respecte pas les blocs de 10k au départ)
|
||||
const startIdx = offset !== undefined ? offset : safePage * safePageSize;
|
||||
|
||||
if (startIdx >= total) {
|
||||
return { rows: [], total, page: safePage, pageSize: safePageSize, totalPages };
|
||||
}
|
||||
|
||||
const rows = filtered.slice(startIdx, startIdx + safePageSize);
|
||||
|
||||
return { rows, total, page: safePage, pageSize: safePageSize, totalPages };
|
||||
}
|
||||
@@ -41,6 +41,10 @@ export function BulkAiAnalyzer() {
|
||||
const { rows } = useGridStore.getState();
|
||||
isCancelledRef.current = false;
|
||||
|
||||
// Show immediate visual feedback before any async work
|
||||
setIsAnalyzing(true);
|
||||
setProgress({ current: 0, total: 0, message: "Chargement du contexte...", errors: 0 });
|
||||
|
||||
// 1. Fetch AI Context for the Supplier
|
||||
let supplierContext = "";
|
||||
if (supplierCode) {
|
||||
@@ -166,7 +170,6 @@ export function BulkAiAnalyzer() {
|
||||
|
||||
// 2c. Context profiling en micro-batches asynchrones
|
||||
// Le score est déjà calculé par score-engine.ts (champ row.score sur chaque ProductRow).
|
||||
setIsAnalyzing(true);
|
||||
setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du contexte...", errors: 0 });
|
||||
|
||||
const SCORING_BATCH_SIZE = 25;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown } from "lucide-react";
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown, Table2 } from "lucide-react";
|
||||
import * as ExcelJS from "exceljs";
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
@@ -11,6 +11,52 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { rows, draftChanges } = useGridStore();
|
||||
|
||||
const handleExportAllProducts = async () => {
|
||||
setIsOpen(false);
|
||||
if (rows.length === 0) {
|
||||
alert("Aucune donnée à exporter.");
|
||||
return;
|
||||
}
|
||||
|
||||
const supplier = nomFournisseur || rows[0]?.nomFournisseur || "Fournisseur";
|
||||
|
||||
const payload = {
|
||||
nomFournisseur: supplier,
|
||||
rows: rows.map(r => ({
|
||||
codein: r.codein,
|
||||
libelle1: r.libelle1 || "",
|
||||
code3: r.code3 || "",
|
||||
libelle3: r.libelle3 || "",
|
||||
codeGamme: (draftChanges[r.codein] ?? r.codeGamme) as string | null,
|
||||
totalQuantite: r.totalQuantite,
|
||||
totalCa: r.totalCa,
|
||||
tauxMarge: r.tauxMarge,
|
||||
gammeAvant: r.codeGammeInit ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/export/excel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Erreur lors de l'export");
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Assortiment_Complet_${supplier.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Une erreur s'est produite lors de l'export Excel.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsOpen(false);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
@@ -157,6 +203,19 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
|
||||
<div className="my-1 h-px bg-[var(--border)] mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handleExportAllProducts}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
|
||||
>
|
||||
<Table2 className="w-4 h-4 text-blue-400" />
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">Tous les produits (Excel)</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Gamme avant/après, delta, stats 12m</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-[var(--border)] mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handlePrintPDF}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { BulkAiAnalyzer } from "./bulk-ai-analyzer";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
@@ -27,6 +28,8 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
|
||||
}
|
||||
|
||||
export function FloatingSummaryBar() {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
const { summary, resetDrafts, rows, filters, draftChanges } = useGridStore();
|
||||
const { resetInsights } = useAiCopilotStore();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -175,7 +178,7 @@ export function FloatingSummaryBar() {
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 items-center">
|
||||
<BulkAiAnalyzer />
|
||||
{isAdmin && <BulkAiAnalyzer />}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition, useCallback, useMemo } from "react";
|
||||
import { useEffect, useRef, useState, useTransition, useMemo } from "react";
|
||||
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||
@@ -9,15 +9,15 @@ import { ExportDropdown } from "@/features/grid/components/export-dropdown";
|
||||
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
import { useInfiniteGrid } from "@/features/grid/hooks/use-infinite-grid";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { CheckCircle, AlertCircle, Loader2, StopCircle } from "lucide-react";
|
||||
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { computeProductScores } from "@/lib/score-engine";
|
||||
|
||||
interface GridClientProps {
|
||||
initialRows: ProductRow[];
|
||||
initialTotal: number;
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string;
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
@@ -25,11 +25,10 @@ interface GridClientProps {
|
||||
magasin: string;
|
||||
}
|
||||
|
||||
export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string })?.role === "admin";
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
const setRows = useGridStore((s) => s.setRows);
|
||||
const appendRows = useGridStore((s) => s.appendRows);
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
@@ -48,20 +47,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// ─── Chargement progressif des pages suivantes ─────────────────────────────
|
||||
const handlePageLoaded = useCallback((newRows: ProductRow[]) => {
|
||||
// Ajout O(n_nouveaux_items) au lieu de O(N_total)
|
||||
appendRows(newRows);
|
||||
}, [appendRows]);
|
||||
|
||||
const { total, isLoadingMore, hasMore, loadNextPage, stopLoading, progress } = useInfiniteGrid({
|
||||
codeFournisseur,
|
||||
magasin: magasin || "TOTAL",
|
||||
initialTotal,
|
||||
pageSize: 10000,
|
||||
onPageLoaded: handlePageLoaded
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
@@ -70,6 +55,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const currentQueryString = searchParams.toString();
|
||||
// Set the active query, ensuring it starts with ? if not empty
|
||||
setActiveGridQuery(currentQueryString ? `?${currentQueryString}` : "");
|
||||
}, [searchParams, setActiveGridQuery, isMounted]);
|
||||
|
||||
@@ -82,24 +68,16 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
setFilter("code3", null);
|
||||
prevFournisseurRef.current = codeFournisseur;
|
||||
}
|
||||
// Reset du store pour ce fournisseur
|
||||
setRows(initialRows);
|
||||
const scoredRows = computeProductScores([...initialRows]);
|
||||
setRows(scoredRows);
|
||||
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
setActiveMagasin(magasin || "TOTAL");
|
||||
}, [magasin, setActiveMagasin, isMounted]);
|
||||
|
||||
// Chargement automatique des pages suivantes en arrière-plan
|
||||
useEffect(() => {
|
||||
if (!isMounted || !hasMore || isLoadingMore) return;
|
||||
// Délai court pour ne pas bloquer le rendu
|
||||
const timer = setTimeout(loadNextPage, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isMounted, hasMore, isLoadingMore, loadNextPage]);
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
const result = await save();
|
||||
@@ -123,7 +101,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
|
||||
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
|
||||
{" "}• {total.toLocaleString("fr-FR")} références
|
||||
{" "}• {initialRows.length} références
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -154,32 +132,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barre de progression chargement en arrière-plan */}
|
||||
{(hasMore || isLoadingMore) && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-medium"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
Téléchargement : {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
|
||||
</span>
|
||||
<div className="flex-1 h-1 w-[100px] rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%`, background: "var(--accent)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={stopLoading}
|
||||
className="ml-2 flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-red-500/10 text-red-600 transition-colors"
|
||||
title="Arrêter le téléchargement du reste des références"
|
||||
>
|
||||
<StopCircle className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] uppercase font-bold">Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="shrink-0 print:hidden">
|
||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -268,39 +268,27 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
|
||||
// =========================================================================
|
||||
|
||||
export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
// L'abonnement doit être ultra-minimal ! JAMAIS de destructuring brut sur useGridStore().
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
|
||||
const { rows, filters, displayDensity, draftChanges, activeMagasin } = useGridStore();
|
||||
|
||||
// Filtre client-side par code3 (famille) et codeGamme sans abonnement réactif aux brouillons pour éviter le freeze.
|
||||
// Filtre client-side par code3 (famille) et codeGamme
|
||||
const filteredData = useMemo(() => {
|
||||
const { code3, codeGamme } = filters;
|
||||
if (!code3 && !codeGamme) return rows;
|
||||
|
||||
// On récupère le state de façon non-réactive pour ne pas trigger 130k renders
|
||||
const currentDrafts = useGridStore.getState().draftChanges;
|
||||
|
||||
return rows.filter(r => {
|
||||
if (code3 && r.code3 !== code3) return false;
|
||||
if (codeGamme) {
|
||||
const g = currentDrafts[r.codein] ?? r.codeGamme ?? "";
|
||||
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
|
||||
const norm = g.trim() === "" ? "Aucune" : g;
|
||||
if (norm !== codeGamme) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, filters]); // ❌ Surtout pas draftChanges ici
|
||||
}, [rows, filters, draftChanges]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Selectors granulaires
|
||||
const columnVisibility = useGridStore((s) => s.columnVisibility);
|
||||
const setColumnVisibility = useGridStore((s) => s.setColumnVisibility);
|
||||
const columnSizing = useGridStore((s) => s.columnSizing);
|
||||
const setColumnSizing = useGridStore((s) => s.setColumnSizing);
|
||||
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
@@ -442,33 +430,45 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
accessorKey: "libelle1",
|
||||
header: "Désignation",
|
||||
size: 280,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 overflow-hidden w-full pr-1">
|
||||
<span className="text-[13px] font-bold truncate flex-1" title={row.original.libelle1} style={{ color: "var(--text-primary)" }}>
|
||||
{row.original.libelle1}
|
||||
</span>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{row.original.workingStores.map((magasin) => {
|
||||
const config = getStoreConfig(magasin);
|
||||
return (
|
||||
<div
|
||||
key={magasin}
|
||||
title={`Travaillé par : ${SITE_LABELS[magasin]?.nom ?? magasin}`}
|
||||
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
|
||||
style={{
|
||||
background: config.bg,
|
||||
borderColor: config.border,
|
||||
color: config.text,
|
||||
}}
|
||||
>
|
||||
<Store className="w-2.5 h-2.5" strokeWidth={3} />
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const isDifferentSupplier = r.fournisseurPrincipalCode && r.fournisseurPrincipalCode !== r.codeFournisseur;
|
||||
const warnTitle = isDifferentSupplier
|
||||
? `Dernier fournisseur enregistré : ${r.fournisseurPrincipalNom ?? r.fournisseurPrincipalCode} (${r.fournisseurPrincipalCode}) — les ventes sont à attribuer à ce fournisseur`
|
||||
: undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-hidden w-full pr-1">
|
||||
{isDifferentSupplier && (
|
||||
<span title={warnTitle} className="shrink-0 flex items-center">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] font-bold truncate flex-1" title={r.libelle1} style={{ color: "var(--text-primary)" }}>
|
||||
{r.libelle1}
|
||||
</span>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{r.workingStores.map((magasin) => {
|
||||
const config = getStoreConfig(magasin);
|
||||
return (
|
||||
<div
|
||||
key={magasin}
|
||||
title={`Travaillé par : ${SITE_LABELS[magasin]?.nom ?? magasin}`}
|
||||
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
|
||||
style={{
|
||||
background: config.bg,
|
||||
borderColor: config.border,
|
||||
color: config.text,
|
||||
}}
|
||||
>
|
||||
<Store className="w-2.5 h-2.5" strokeWidth={3} />
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "libelle3",
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
export interface InfiniteGridOptions {
|
||||
codeFournisseur: string | null;
|
||||
initialTotal: number;
|
||||
pageSize?: number;
|
||||
magasin?: string;
|
||||
search?: string;
|
||||
gamme?: string | null;
|
||||
code3?: string | null;
|
||||
onPageLoaded: (newRows: ProductRow[]) => void;
|
||||
}
|
||||
|
||||
export interface InfiniteGridState {
|
||||
total: number;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadNextPage: () => void;
|
||||
stopLoading: () => void;
|
||||
reset: () => void;
|
||||
progress: number;
|
||||
loadedPagesCount: number;
|
||||
}
|
||||
|
||||
export function useInfiniteGrid({
|
||||
codeFournisseur,
|
||||
initialTotal,
|
||||
pageSize = 10000,
|
||||
magasin,
|
||||
search,
|
||||
gamme,
|
||||
code3,
|
||||
onPageLoaded,
|
||||
}: InfiniteGridOptions): InfiniteGridState {
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [isStopped, setIsStopped] = useState(false);
|
||||
const [loadedPagesCount, setLoadedPagesCount] = useState(1); // Page 0 est initialRows
|
||||
|
||||
const fetchIdRef = useRef(0);
|
||||
const loadedPagesRef = useRef(new Set<number>([0]));
|
||||
|
||||
const buildUrl = useCallback(
|
||||
(page: number): string => {
|
||||
if (!codeFournisseur) return "";
|
||||
const params = new URLSearchParams({
|
||||
fournisseur: codeFournisseur,
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
|
||||
if (page > 0) {
|
||||
params.set("offset", String(200 + (page - 1) * pageSize));
|
||||
}
|
||||
|
||||
if (search) params.set("search", search);
|
||||
if (gamme) params.set("gamme", gamme);
|
||||
if (code3) params.set("code3", code3);
|
||||
if (magasin) params.set("magasin", magasin);
|
||||
return `/api/grid/rows?${params.toString()}`;
|
||||
},
|
||||
[codeFournisseur, pageSize, search, gamme, code3, magasin]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTotal(initialTotal);
|
||||
setCurrentPage(0);
|
||||
setLoadedPagesCount(1);
|
||||
setIsStopped(false);
|
||||
loadedPagesRef.current = new Set([0]);
|
||||
fetchIdRef.current += 1;
|
||||
}, [codeFournisseur, initialTotal, search, gamme, code3, magasin]);
|
||||
|
||||
const loadNextPage = useCallback(async () => {
|
||||
if (!codeFournisseur || isLoadingMore || isStopped) return;
|
||||
|
||||
const nextPage = currentPage + 1;
|
||||
const alreadyLoaded = loadedPagesRef.current.has(nextPage);
|
||||
|
||||
// Page 0 comprenait 200 éléments. Le reste est divisé par pageSize (10000).
|
||||
const remainingItems = Math.max(0, total - 200);
|
||||
const totalPages = 1 + Math.ceil(remainingItems / pageSize);
|
||||
|
||||
if (alreadyLoaded || nextPage >= totalPages) return;
|
||||
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
setIsLoadingMore(true);
|
||||
|
||||
try {
|
||||
const url = buildUrl(nextPage);
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (currentFetchId !== fetchIdRef.current) return;
|
||||
|
||||
const data = await response.json() as {
|
||||
rows: ProductRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
loadedPagesRef.current.add(nextPage);
|
||||
setLoadedPagesCount(prev => prev + 1);
|
||||
setTotal(data.total);
|
||||
setCurrentPage(nextPage);
|
||||
|
||||
// Envoyer directement au Store Zustand pour bypasser le cycle React DOM
|
||||
onPageLoaded(data.rows);
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "AbortError") return;
|
||||
console.error("[useInfiniteGrid] loadNextPage error:", error);
|
||||
} finally {
|
||||
if (currentFetchId === fetchIdRef.current) {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [codeFournisseur, currentPage, total, pageSize, isLoadingMore, isStopped, buildUrl, onPageLoaded]);
|
||||
|
||||
const stopLoading = useCallback(() => {
|
||||
setIsStopped(true);
|
||||
setIsLoadingMore(false);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
fetchIdRef.current += 1;
|
||||
loadedPagesRef.current = new Set([0]);
|
||||
setTotal(initialTotal);
|
||||
setCurrentPage(0);
|
||||
setLoadedPagesCount(1);
|
||||
setIsLoadingMore(false);
|
||||
setIsStopped(false);
|
||||
}, [initialTotal]);
|
||||
|
||||
// On part du principe qu'il y en a 200 + n * 10 000
|
||||
const loadedItemsEstimation = 200 + (loadedPagesCount - 1) * pageSize;
|
||||
const hasMore = !isStopped && loadedItemsEstimation < total;
|
||||
const progress = total > 0 ? Math.round(Math.min((loadedItemsEstimation / total) * 100, 100)) : 100;
|
||||
|
||||
return {
|
||||
total,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
loadNextPage,
|
||||
stopLoading,
|
||||
reset,
|
||||
progress,
|
||||
loadedPagesCount,
|
||||
};
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/gr
|
||||
interface GridState {
|
||||
/** Source data from server */
|
||||
rows: ProductRow[];
|
||||
/** Index O(1) pour les lookups par codein (Map<codein, ProductRow>) */
|
||||
rowsIndex: Map<string, ProductRow>;
|
||||
/** Draft edits: codein → new GammeCode */
|
||||
draftChanges: Record<string, GammeCode>;
|
||||
filters: GridFilters;
|
||||
@@ -27,7 +25,6 @@ interface GridState {
|
||||
|
||||
// Actions
|
||||
setRows: (rows: ProductRow[]) => void;
|
||||
appendRows: (newRows: ProductRow[]) => void;
|
||||
setDraftGamme: (codein: string, gamme: GammeCode) => void;
|
||||
resetDrafts: () => void;
|
||||
/** Clear specific draft changes by codein */
|
||||
@@ -67,7 +64,6 @@ export const useGridStore = create<GridState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
rows: [],
|
||||
rowsIndex: new Map(),
|
||||
activeMagasin: "TOTAL",
|
||||
draftChanges: {},
|
||||
filters: {
|
||||
@@ -98,27 +94,12 @@ export const useGridStore = create<GridState>()(
|
||||
},
|
||||
|
||||
setRows: (rows) => {
|
||||
// Construire l'index en O(n) une seule fois au lieu de O(n) à chaque lookup.
|
||||
const rowsIndex = new Map<string, ProductRow>(rows.map(r => [r.codein, r]));
|
||||
set({ rows, rowsIndex, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
|
||||
},
|
||||
|
||||
appendRows: (newRows) => {
|
||||
const { rows, rowsIndex, draftChanges, activeMagasin } = get();
|
||||
// O(n) uniquement sur les nouveaux éléments, au lieu de re-calculer les 130k existants
|
||||
const mergedRows = [...rows, ...newRows];
|
||||
newRows.forEach(r => rowsIndex.set(r.codein, r));
|
||||
set({
|
||||
rows: mergedRows,
|
||||
rowsIndex,
|
||||
summary: computeSummary(mergedRows, draftChanges, activeMagasin)
|
||||
});
|
||||
set({ rows, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
|
||||
},
|
||||
|
||||
setDraftGamme: (codein, gamme) => {
|
||||
const { rows, rowsIndex, draftChanges: oldDrafts } = get();
|
||||
// Lookup O(1) via l'index au lieu de O(n) via .find()
|
||||
const originalRow = rowsIndex.get(codein);
|
||||
const { rows, draftChanges: oldDrafts } = get();
|
||||
const originalRow = rows.find(r => r.codein === codein);
|
||||
const originalGamme = originalRow?.codeGamme;
|
||||
|
||||
const draftChanges = { ...oldDrafts };
|
||||
@@ -163,12 +144,11 @@ export const useGridStore = create<GridState>()(
|
||||
},
|
||||
|
||||
batchSetDraftGamme: (newChanges) => {
|
||||
const { rows, rowsIndex, draftChanges: oldDrafts } = get();
|
||||
const { rows, draftChanges: oldDrafts } = get();
|
||||
const updatedDrafts = { ...oldDrafts };
|
||||
|
||||
Object.entries(newChanges).forEach(([codein, gamme]) => {
|
||||
// Lookup O(1) via l'index au lieu de O(n) via .find()
|
||||
const originalRow = rowsIndex.get(codein);
|
||||
const originalRow = rows.find(r => r.codein === codein);
|
||||
const originalGamme = originalRow?.codeGamme;
|
||||
|
||||
if (gamme === originalGamme) {
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface DbConfig {
|
||||
url: string;
|
||||
openRouterKey?: string;
|
||||
openRouterModel?: string;
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(url: string) {
|
||||
@@ -34,9 +37,16 @@ export async function testDatabaseConnection(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDatabaseSettings(url: string, openRouterKey?: string, openRouterModel?: string) {
|
||||
export async function saveDatabaseSettings(
|
||||
url: string,
|
||||
openRouterKey?: string,
|
||||
openRouterModel?: string,
|
||||
aiProvider?: "openrouter" | "google",
|
||||
googleAiKey?: string,
|
||||
googleAiModel?: string,
|
||||
) {
|
||||
try {
|
||||
const config: DbConfig = { url, openRouterKey, openRouterModel };
|
||||
const config: DbConfig = { url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
|
||||
// S'assurer que le dossier data existe
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
+295
-35
@@ -42,9 +42,11 @@ export interface PgArticle {
|
||||
pcb?: number;
|
||||
reference?: string;
|
||||
ean13?: string;
|
||||
gtin?: string; // art_gtin.gtin (préférentiel)
|
||||
pv_central?: number; // article_infosup.prix_vente_mini
|
||||
pa?: number; // cube_pa.pa
|
||||
gtin?: string; // art_gtin.gtin (préférentiel)
|
||||
pv_central?: number; // article_infosup.prix_vente_mini
|
||||
pa?: number; // cube_pa.pa
|
||||
codefou_principal?: string; // artfou1.code où preference=1
|
||||
nomfou_principal?: string; // nom du fournisseur principal
|
||||
}
|
||||
|
||||
export interface PgMensuelRow {
|
||||
@@ -117,6 +119,7 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
|
||||
// Pas de filtre "actif" : on retourne tous les articles pour ne pas exclure les articles
|
||||
// d'une session précédente (snapshot) qui n'ont pas de gamme DB ni de ventes récentes.
|
||||
// Le filtrage gamme-Y sans ventes est fait en aval dans getProductRows (Phase 10).
|
||||
// afpref : fournisseur préférentiel (preference=1) de l'article — peut différer du fournisseur consulté.
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT DISTINCT ON (a.no_id)
|
||||
a.no_id,
|
||||
@@ -129,15 +132,27 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
|
||||
af.ean13,
|
||||
COALESCE(ag.gtin, af.ean13) AS gtin,
|
||||
ai.prix_vente_mini AS pv_central,
|
||||
pa.pa
|
||||
pa.pa,
|
||||
last_fournisseur.codefou_dernier AS codefou_principal,
|
||||
last_fournisseur.nomfou_dernier AS nomfou_principal
|
||||
FROM artfou1 af
|
||||
JOIN articles a ON a.no_id = af.art_no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
|
||||
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
|
||||
LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af2.code AS codefou_dernier, fi2.nom AS nomfou_dernier
|
||||
FROM artfou1 af2
|
||||
LEFT JOIN fouident fi2 ON fi2.code = af2.code
|
||||
WHERE af2.art_no_id = a.no_id
|
||||
ORDER BY af2.no_id DESC
|
||||
LIMIT 1
|
||||
) last_fournisseur ON true
|
||||
WHERE af.code = ${codefou}
|
||||
AND a.codein IS NOT NULL
|
||||
AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on'))
|
||||
AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on'))
|
||||
ORDER BY a.no_id, af.no_id
|
||||
`);
|
||||
|
||||
@@ -702,34 +717,71 @@ export interface HitParadeRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
fournisseur: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
site: string;
|
||||
qte_vendue: number;
|
||||
ca_ttc: number;
|
||||
marge: number;
|
||||
stock292: number;
|
||||
stock579: number;
|
||||
stockTotal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC et marge par site.
|
||||
* Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site.
|
||||
* Le stock est intégré via un LEFT JOIN — pas de requête séparée, pas de tableau de paramètres.
|
||||
*/
|
||||
export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise<HitParadeRow[]> {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH ventes AS (
|
||||
SELECT
|
||||
a.no_id AS art_no_id,
|
||||
a.codein::text AS codein,
|
||||
a.libelle1::text AS libelle,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature,
|
||||
m.site,
|
||||
SUM(ABS(m.qtemvt))::float AS qte_vendue,
|
||||
ABS(SUM(m.mntmvtttc))::float AS ca_ttc,
|
||||
SUM(m.margemvt)::float AS marge
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
|
||||
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.genremvt = 3
|
||||
GROUP BY a.no_id, a.codein, a.libelle1, fi.nom, af.code, n.code, n.libelle, m.site
|
||||
),
|
||||
stock_agg AS (
|
||||
SELECT
|
||||
cs.artnoid,
|
||||
SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292,
|
||||
SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579,
|
||||
SUM(cs.qte)::float AS stockTotal
|
||||
FROM cube_stock cs
|
||||
WHERE cs.artnoid IN (SELECT DISTINCT art_no_id FROM ventes)
|
||||
GROUP BY cs.artnoid
|
||||
)
|
||||
SELECT
|
||||
a.codein::text AS codein,
|
||||
a.libelle1::text AS libelle,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
m.site,
|
||||
SUM(ABS(m.qtemvt))::float AS qte_vendue,
|
||||
ABS(SUM(m.mntmvtttc))::float AS ca_ttc,
|
||||
SUM(m.margemvt)::float AS marge
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.genremvt = 3
|
||||
GROUP BY a.codein, a.libelle1, fi.nom, af.code, m.site
|
||||
ORDER BY ca_ttc DESC
|
||||
v.codein,
|
||||
v.libelle,
|
||||
v.fournisseur,
|
||||
v.nomenclature_code,
|
||||
v.nomenclature,
|
||||
v.site,
|
||||
v.qte_vendue,
|
||||
v.ca_ttc,
|
||||
v.marge,
|
||||
COALESCE(s.stock292, 0) AS stock292,
|
||||
COALESCE(s.stock579, 0) AS stock579,
|
||||
COALESCE(s.stockTotal, 0) AS "stockTotal"
|
||||
FROM ventes v
|
||||
LEFT JOIN stock_agg s ON s.artnoid = v.art_no_id
|
||||
ORDER BY v.ca_ttc DESC
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${dateDebut}→${dateFin}`);
|
||||
@@ -749,26 +801,25 @@ export interface StockBySite {
|
||||
/**
|
||||
* Retourne le stock actuel (stockdispo) par codein pour une liste de codeins.
|
||||
* 1 seule requête bulk — utilisé pour enrichir le Hit Parade.
|
||||
*
|
||||
* On passe les codeins comme une SEULE chaîne CSV ($1) et on utilise
|
||||
* string_to_array côté PG — cela évite l'expansion Drizzle en $1,$2,...$N
|
||||
* qui déclencherait le PG error 54011 (ROW > 1664 entries).
|
||||
*/
|
||||
export async function pgGetStockForCodeins(codeins: string[]): Promise<Map<string, StockBySite>> {
|
||||
if (codeins.length === 0) return new Map();
|
||||
|
||||
// Drizzle expands JS arrays into individual $1,$2,... params regardless of syntax,
|
||||
// hitting PostgreSQL's 1664-entry ROW expression limit. Use sql.raw() with an escaped
|
||||
// literal array instead — codeins come from the DB so single-quote escaping is sufficient.
|
||||
const arrayLiteral = codeins.map(c => `'${String(c).replace(/'/g, "''")}'`).join(",");
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
cs.site,
|
||||
cs.stockdispo::float AS stockdispo
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
WHERE a.codein = ANY(ARRAY[${sql.raw(arrayLiteral)}])
|
||||
`);
|
||||
const codeinsStr = codeins.join(",");
|
||||
const result = await pgNoParallel(
|
||||
sql`SELECT a.codein, cs.site, cs.stockdispo::float AS stockdispo
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
WHERE a.codein = ANY(string_to_array(${codeinsStr}, ','))`
|
||||
);
|
||||
const rows = result.rows as { codein: string; site: string; stockdispo: number }[];
|
||||
|
||||
const map = new Map<string, StockBySite>();
|
||||
for (const r of result.rows as Array<{ codein: string; site: string; stockdispo: number }>) {
|
||||
for (const r of rows) {
|
||||
if (!map.has(r.codein)) map.set(r.codein, { stock292: 0, stock579: 0, stockTotal: 0 });
|
||||
const entry = map.get(r.codein)!;
|
||||
if (r.site === "292") entry.stock292 += r.stockdispo;
|
||||
@@ -777,3 +828,212 @@ export async function pgGetStockForCodeins(codeins: string[]): Promise<Map<strin
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock négatif — tous magasins ou par site
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PgStockNegatifRow {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
fournisseur: string;
|
||||
site: string;
|
||||
stockdispo: number;
|
||||
dernierevente: string | null;
|
||||
derniereentree: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les articles avec un stock disponible négatif.
|
||||
* Si `site` est fourni, filtre sur ce magasin uniquement.
|
||||
*/
|
||||
export interface PgStockSansVenteRow {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
fournisseur: string;
|
||||
site: string;
|
||||
stock_actuel: number;
|
||||
derniere_entree: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les articles ayant des entrées en stock (hors mois courant)
|
||||
* mais aucune vente enregistrée sur ce site.
|
||||
*/
|
||||
export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVenteRow[]> {
|
||||
const siteFilter = site ? sql`AND m.site = ${site}` : sql``;
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
COALESCE(a.libelle1, '') AS libelle1,
|
||||
COALESCE(f.nom, af.code) AS fournisseur,
|
||||
m.site,
|
||||
COALESCE(cs.qte::float, 0) AS stock_actuel,
|
||||
MAX(m.datmvt)::text AS derniere_entree
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
JOIN fouident f ON f.code = af.code
|
||||
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
|
||||
WHERE m.genremvt IN (1, 2)
|
||||
AND m.site IN ('292', '579')
|
||||
AND date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE)
|
||||
${siteFilter}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM mvtart mv2
|
||||
WHERE mv2.artnoid = m.artnoid
|
||||
AND mv2.site = m.site
|
||||
AND mv2.genremvt = 3
|
||||
)
|
||||
GROUP BY a.codein, a.libelle1, f.nom, af.code, m.site, cs.qte
|
||||
ORDER BY MAX(m.datmvt) DESC
|
||||
`);
|
||||
return result.rows as PgStockSansVenteRow[];
|
||||
}
|
||||
|
||||
export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRow[]> {
|
||||
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
COALESCE(a.libelle1, '') AS libelle1,
|
||||
COALESCE(f.nom, af.code) AS fournisseur,
|
||||
cs.site,
|
||||
cs.qte::float AS stockdispo,
|
||||
cs.dernierevente::text,
|
||||
(
|
||||
SELECT MAX(m.datmvt)::text
|
||||
FROM mvtart m
|
||||
WHERE m.artnoid = cs.artnoid
|
||||
AND m.site = cs.site
|
||||
AND m.genremvt IN (1, 2)
|
||||
) AS derniereentree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
JOIN fouident f ON f.code = af.code
|
||||
WHERE cs.qte < 0
|
||||
AND cs.site IN ('292', '579')
|
||||
${siteFilter}
|
||||
ORDER BY cs.stockdispo ASC
|
||||
`);
|
||||
return result.rows as PgStockNegatifRow[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commandes automatiques — par site + fournisseur avec franco
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PgCommandeAutoRow {
|
||||
site: string;
|
||||
codefou: string;
|
||||
nomfou: string;
|
||||
franco: number;
|
||||
nb_articles: number;
|
||||
montant_cde: number;
|
||||
franco_atteint: boolean | null;
|
||||
ecart_franco: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function mapCommandeAutoRow(r: any): PgCommandeAutoRow {
|
||||
return {
|
||||
site: String(r.site ?? ""),
|
||||
codefou: String(r.codefou ?? ""),
|
||||
nomfou: String(r.nom_fou ?? r.nomfou ?? ""),
|
||||
franco: Number(r.franco_ht ?? r.franco ?? 0),
|
||||
nb_articles: Number(r.nb_articles ?? 0),
|
||||
montant_cde: Number(r.montant_propo_ht ?? r.montant_cde ?? 0),
|
||||
franco_atteint: r.franco_atteint === "OUI" ? true
|
||||
: r.franco_atteint === "NON" ? false
|
||||
: r.franco_atteint === "N/A" ? null
|
||||
: r.franco_atteint != null ? Boolean(r.franco_atteint)
|
||||
: null,
|
||||
ecart_franco: Number(r.ecart_franco ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste des fournisseurs en commande automatique via l'API Hostinger.
|
||||
* GET https://api.ffnancy.fr/api/commandes-auto
|
||||
*
|
||||
* Pour les fournisseurs dont le franco est absent de la réponse globale (franco_ht null),
|
||||
* on enrichit via le detail endpoint /api/commandes-auto/:codefou?site= qui contient
|
||||
* toujours le résumé franco du fournisseur.
|
||||
*/
|
||||
export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
// L'API retourne { count: N, propositions: [...] } ou un tableau direct
|
||||
const rows: unknown[] = Array.isArray(data)
|
||||
? data
|
||||
: (data?.propositions ?? data?.data ?? data?.items ?? data?.results ?? []);
|
||||
|
||||
// Diagnostic : log les clés du premier row pour détecter les changements de format
|
||||
if (rows.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sample = rows[0] as any;
|
||||
console.log(`[pg-ff] pgGetCommandesAuto sample keys:`, Object.keys(sample));
|
||||
console.log(`[pg-ff] pgGetCommandesAuto sample franco fields: franco_ht=${sample.franco_ht} franco=${sample.franco} franco_atteint=${sample.franco_atteint}`);
|
||||
}
|
||||
|
||||
console.log(`[pg-ff] pgGetCommandesAuto: ${rows.length} lignes (API Hostinger)`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapped = (rows as any[]).map(mapCommandeAutoRow);
|
||||
|
||||
// Enrichissement : pour les fournisseurs sans franco dans la réponse globale,
|
||||
// on appelle le detail endpoint qui contient toujours franco_ht dans son résumé.
|
||||
const missingFranco = mapped.filter(r => r.franco === 0 && r.codefou && r.site);
|
||||
if (missingFranco.length > 0) {
|
||||
console.log(`[pg-ff] pgGetCommandesAuto: ${missingFranco.length} lignes sans franco → enrichissement via detail endpoint`);
|
||||
// Regrouper par (codefou, site) pour éviter les doublons
|
||||
const pairs = Array.from(
|
||||
new Map(missingFranco.map(r => [`${r.codefou}|${r.site}`, r])).values()
|
||||
);
|
||||
const details = await Promise.allSettled(
|
||||
pairs.map(r =>
|
||||
fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(r.codefou)}?site=${encodeURIComponent(r.site)}`, { cache: "no-store" })
|
||||
.then(res2 => res2.ok ? res2.json() : null)
|
||||
.then(detail => {
|
||||
if (!detail) return null;
|
||||
// Le detail endpoint retourne { resume: { franco_ht, ... }, articles: [...] }
|
||||
// ou directement les champs du résumé
|
||||
const resume = detail?.resume ?? detail?.summary ?? detail;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const francoVal = Number((resume as any)?.franco_ht ?? (resume as any)?.franco ?? 0);
|
||||
return { codefou: r.codefou, site: r.site, franco: francoVal };
|
||||
})
|
||||
.catch(() => null)
|
||||
)
|
||||
);
|
||||
|
||||
// Construire un map (codefou|site) → franco enrichi
|
||||
const francoMap = new Map<string, number>();
|
||||
for (const result of details) {
|
||||
if (result.status === "fulfilled" && result.value && result.value.franco > 0) {
|
||||
francoMap.set(`${result.value.codefou}|${result.value.site}`, result.value.franco);
|
||||
}
|
||||
}
|
||||
console.log(`[pg-ff] pgGetCommandesAuto: francos enrichis:`, Object.fromEntries(francoMap));
|
||||
|
||||
// Appliquer les francos enrichis
|
||||
return mapped.map(r => {
|
||||
if (r.franco === 0) {
|
||||
const enrichedFranco = francoMap.get(`${r.codefou}|${r.site}`);
|
||||
if (enrichedFranco && enrichedFranco > 0) {
|
||||
return { ...r, franco: enrichedFranco };
|
||||
}
|
||||
}
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ export default auth((req) => {
|
||||
const { nextUrl } = req;
|
||||
|
||||
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
|
||||
const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public");
|
||||
const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/");
|
||||
|
||||
// 1. Laisser passer les requêtes d'auth API
|
||||
if (isApiAuthRoute) return NextResponse.next();
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface ProductRow {
|
||||
codein: string;
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string;
|
||||
/** Dernier fournisseur enregistré pour cet article (artfou1.no_id DESC). Peut différer du fournisseur consulté. */
|
||||
fournisseurPrincipalCode?: string;
|
||||
fournisseurPrincipalNom?: string;
|
||||
libelle1: string;
|
||||
gtin: string;
|
||||
reference: string;
|
||||
|
||||
Reference in new issue
Block a user