feat(grid): ajout algorithme score produit avec parametrage dynamique et page d'aide

This commit is contained in:
Michael committed 2026-02-21 04:00:51 +01:00
1 parent f540e4f2b3
commit 3544c0c898
67 files changed
+19476 -15

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
export default function ExportsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Exports</h1>
<p className="text-sm text-slate-400 mt-1">Génération des fichiers Excel différentiels pour l&apos;ERP.</p>
</div>
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
<p className="text-slate-500 text-sm">Sélectionnez un fournisseur dans la grille, puis cliquez sur <strong className="text-slate-400">«&nbsp;Exporter&nbsp;»</strong>.</p>
<p className="text-slate-600 text-xs mt-1">L&apos;export génère un fichier <code className="text-slate-500">.xlsx</code> avec le delta avant/après des gammes révisées.</p>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions";
import { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
import { Suspense } from "react";
interface GridPageProps {
searchParams: Promise<{ fournisseur?: string; magasin?: string }>;
}
export default async function GridPage({ searchParams }: GridPageProps) {
const params = await searchParams;
const codeFournisseur = params.fournisseur;
const magasin = params.magasin ?? "TOTAL";
// 1. Fetch available suppliers & stores
const [fournisseurs, magasins] = await Promise.all([
getFournisseurs(),
getMagasins()
]);
// 2. If no supplier selected, show compact selection UI
if (!codeFournisseur) {
return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
}
// 3. Load real product data for the selected supplier
const rows = await getGridData(codeFournisseur, magasin);
const selectedFournisseur = fournisseurs.find((f) => f.code === codeFournisseur);
return (
<GridClient
initialRows={rows}
codeFournisseur={codeFournisseur}
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
fournisseurs={fournisseurs}
magasins={magasins}
magasin={magasin}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Sidebar } from "@/components/shared/sidebar";
import { Header } from "@/components/shared/header";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: "var(--bg-base)" }}>
<Sidebar />
<main className="flex-1 ml-60 flex flex-col h-full overflow-hidden">
<Header />
<div className="flex-1 overflow-hidden">
{children}
</div>
</main>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { BarChart3, ArrowRight, TrendingUp, Award, Layers } from "lucide-react";
import Link from "next/link";
function InfoCard({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
return (
<div
className="rounded-[14px] overflow-hidden"
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
>
<div className="px-5 py-4 flex items-center gap-2.5" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={2} />
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
</div>
<div className="px-5 py-5" style={{ background: "var(--bg-surface)" }}>
{children}
</div>
</div>
);
}
function FormulaStep({ step, title, formula }: { step: number; title: string; formula: string }) {
return (
<div className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold flex-shrink-0 mt-0.5"
style={{ background: "var(--accent-bg)", color: "var(--accent)", border: "1px solid var(--accent-border)" }}
>
{step}
</div>
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>{title}</p>
<code className="text-[12px] mt-1 block px-3 py-2 rounded-[8px] font-mono" style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)", border: "1px solid var(--border)" }}>
{formula}
</code>
</div>
</div>
);
}
function ScoreBadge({ value, label }: { value: string; label: string }) {
const num = parseFloat(value);
const color = num >= 80 ? "text-emerald-500" : num >= 50 ? "text-amber-500" : "text-rose-500";
const bg = num >= 80 ? "rgba(16, 185, 129, 0.1)" : num >= 50 ? "rgba(245, 158, 11, 0.1)" : "rgba(244, 63, 94, 0.1)";
const borderColor = num >= 80 ? "rgba(16, 185, 129, 0.2)" : num >= 50 ? "rgba(245, 158, 11, 0.2)" : "rgba(244, 63, 94, 0.2)";
return (
<div className="text-center">
<div
className={`text-[18px] font-black ${color} inline-flex items-center justify-center w-12 h-12 rounded-[10px]`}
style={{ background: bg, border: `1px solid ${borderColor}` }}
>
{value}
</div>
<p className="text-[11px] mt-1.5 font-medium" style={{ color: "var(--text-muted)" }}>{label}</p>
</div>
);
}
export default function ScorePage() {
return (
<div className="p-6 space-y-6 max-w-2xl overflow-y-auto h-full">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
Score Produit
</h1>
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>
Comprenez comment chaque produit est évalué pour le référencement.
</p>
</div>
{/* Principe fondamental */}
<InfoCard icon={Award} title="Principe fondamental">
<div
className="rounded-[10px] px-4 py-3"
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}
>
<p className="text-[13px] font-medium" style={{ color: "var(--accent)" }}>
Un produit excellent sur <strong>une seule dimension</strong> suffit à en faire un bon produit.
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-secondary)" }}>
Un produit qui fait 30% du volume du fournisseur est un bon produit même si sa marge est faible.
</p>
</div>
</InfoCard>
{/* Les 3 axes */}
<InfoCard icon={Layers} title="Les 3 axes d'évaluation">
<div className="space-y-3">
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
Chaque produit est évalué sur son <strong>poids relatif par rapport au meilleur produit</strong> :
</p>
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Quantité", desc: "Volume vendu", icon: "📦" },
{ label: "Chiffre d'Affaires", desc: "Montant total", icon: "💰" },
{ label: "Marge", desc: "Profit généré", icon: "📈" },
].map(({ label, desc, icon }) => (
<div
key={label}
className="px-3 py-3 rounded-[10px] text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<div className="text-lg">{icon}</div>
<p className="text-[12px] font-semibold mt-1" style={{ color: "var(--text-primary)" }}>{label}</p>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{desc}</p>
</div>
))}
</div>
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
Poids = (métrique du produit ÷ métrique du Produit N°1) × 100
</p>
</div>
</InfoCard>
{/* Formule */}
<InfoCard icon={TrendingUp} title="Formule de calcul">
<div className="space-y-4">
<FormulaStep
step={1}
title="Calculer les 3 scores de base (indexés sur le TOP 100)"
formula="score_axe = produit.métrique / top1_fournisseur × 100"
/>
<FormulaStep
step={2}
title="Prendre le meilleur axe (MAX)"
formula="base = MAX(poids_qty, poids_ca, poids_marge)"
/>
<FormulaStep
step={3}
title="Ajouter le bonus polyvalence"
formula="bonus = nb_axes_forts × bonus_par_axe"
/>
<FormulaStep
step={4}
title="Score final"
formula="score = MIN(base + bonus, 100)"
/>
<div
className="rounded-[10px] px-4 py-3 mt-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
<strong>Axe fort</strong> = un axe dont le poids dépasse le seuil (configurable dans Paramètres).
Un produit peut obtenir un bonus s'il excelle sur 2 ou 3 axes à la fois.
</p>
</div>
</div>
</InfoCard>
{/* Signification des couleurs */}
<InfoCard icon={BarChart3} title="Lecture du score">
<div className="flex items-center justify-center gap-6 py-2">
<ScoreBadge value="85" label="Excellent" />
<ScoreBadge value="55" label="Correct" />
<ScoreBadge value="12" label="Faible" />
</div>
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>≥ 80</strong> — Produit stratégique, très forte contribution</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>50 – 79</strong> — Contribution significative, à conserver</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>&lt; 50</strong> — Contribution faible, à réévaluer</p>
</div>
</div>
</InfoCard>
{/* Lien vers les paramètres */}
<Link
href="/settings"
className="flex items-center gap-2 px-4 py-3 rounded-[10px] transition-all hover:gap-3"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--accent)" }}
>
<span className="text-[13px] font-medium">Ajuster les paramètres du score</span>
<ArrowRight className="w-4 h-4 ml-auto" />
</Link>
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
"use client";
import { useState, useCallback } from "react";
import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, BarChart3, RotateCcw } from "lucide-react";
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store";
interface OpenRouterModel { id: string; name: string; free: boolean; }
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section
className="rounded-[14px] overflow-hidden"
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
>
<div className="px-5 py-4" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
{subtitle && <p className="text-[12px] mt-0.5" style={{ color: "var(--text-secondary)" }}>{subtitle}</p>}
</div>
<div className="px-5 py-5 space-y-4" style={{ background: "var(--bg-surface)" }}>
{children}
</div>
</section>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium" style={{ color: "var(--text-secondary)" }}>{label}</label>
{children}
{hint && <p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{hint}</p>}
</div>
);
}
export default function SettingsPage() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
const setSeuilAxeFort = useScoreSettingsStore((s) => s.setSeuilAxeFort);
const setBonusParAxe = useScoreSettingsStore((s) => s.setBonusParAxe);
const resetScoreDefaults = useScoreSettingsStore((s) => s.resetDefaults);
const [showKey, setShowKey] = useState(false);
const [apiKey, setApiKey] = useState("");
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [saveStatus, setSaveStatus] = useState<"idle" | "saved">("idle");
const [models, setModels] = useState<OpenRouterModel[]>([]);
const [selectedModel, setSelectedModel] = useState("google/gemini-flash-1.5");
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const fetchModels = useCallback(async (key: string) => {
if (!key.trim()) return;
setModelsStatus("loading");
try {
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
if (!res.ok) throw new Error();
const data = await res.json();
setModels(data.models ?? []);
setModelsStatus("ok");
} catch {
setModelsStatus("error");
}
}, []);
const testConnection = async () => {
setDbStatus("testing");
await new Promise((r) => setTimeout(r, 1200));
setDbStatus("error");
};
return (
<div className="space-y-6 max-w-xl">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>Paramètres</h1>
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>Configuration de la connexion et des services.</p>
</div>
{/* PostgreSQL */}
<Section title="PostgreSQL" subtitle="Connexion à votre instance Docker ou distante">
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
<input type="text" placeholder="localhost" className="apple-input font-mono" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Port">
<input type="text" placeholder="5432" defaultValue="5432" className="apple-input font-mono" />
</Field>
<Field label="Base de données">
<input type="text" placeholder="collectflow" className="apple-input font-mono" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Utilisateur">
<input type="text" placeholder="postgres" className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<input type="password" placeholder="••••••••" className="apple-input font-mono" />
</Field>
</div>
<div className="flex items-center gap-2.5">
<input type="checkbox" id="ssl" className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer" />
<label htmlFor="ssl" className="text-[12px] cursor-pointer" style={{ color: "var(--text-secondary)" }}>Connexion SSL</label>
</div>
<div className="flex items-center gap-3">
<button onClick={testConnection} disabled={dbStatus === "testing"} className="apple-btn-secondary">
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
Tester la connexion
</button>
{dbStatus === "error" && (
<span className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Connexion refusée — vérifiez les paramètres</span>
)}
</div>
</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-...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
placeholder="sk-or-v1-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
>
{showKey ? <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={() => fetchModels(apiKey)}
disabled={!apiKey || modelsStatus === "loading"}
className="apple-btn-secondary whitespace-nowrap"
>
{modelsStatus === "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 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) => (
<option key={m.id} value={m.id}>
{m.free ? "🆓 " : ""}{m.name}
</option>
))
}
</select>
</Field>
</Section>
{/* Apparence */}
<Section title="Apparence">
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Thème</p>
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Interface claire ou sombre</p>
</div>
{/* Segment control */}
<div className="segment-control">
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
<Sun className="w-3.5 h-3.5" /> Clair
</button>
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
<Moon className="w-3.5 h-3.5" /> Sombre
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Densité de la grille</p>
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Hauteur de ligne par défaut</p>
</div>
<select
className="apple-input w-auto"
value={displayDensity}
onChange={(e) => setDisplayDensity(e.target.value as any)}
>
<option value="compact">Compact (32px)</option>
<option value="normal">Normal (40px)</option>
<option value="comfortable">Spacieux (48px)</option>
</select>
</div>
</Section>
{/* Score Produit */}
<Section title="Score Produit" subtitle="Paramètres de l'algorithme d'évaluation des performances">
<Field
label={`Seuil d'axe fort : ${seuilAxeFort.toFixed(1)}%`}
hint="Pourcentage minimum du poids fournisseur pour qu'une dimension (Quantité, CA, Marge) soit considérée comme un point fort du produit."
>
<div className="flex items-center gap-4">
<input
type="range"
min="5"
max="100"
step="5"
value={seuilAxeFort}
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value))}
className="flex-1 accent-emerald-500 cursor-pointer"
/>
<div className="relative w-20">
<input
type="number"
min="5"
max="100"
step="5"
value={seuilAxeFort}
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value) || 30.0)}
className="apple-input font-mono-nums text-right pr-6"
/>
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
</div>
</div>
</Field>
<Field
label={`Bonus par axe : +${bonusParAxe} pts`}
hint="Points supplémentaires accordés au score final pour chaque axe dépassant le seuil."
>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="20"
step="1"
value={bonusParAxe}
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10))}
className="flex-1 accent-emerald-500 cursor-pointer"
/>
<div className="w-20">
<input
type="number"
min="0"
max="20"
step="1"
value={bonusParAxe}
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10) || 0)}
className="apple-input font-mono-nums text-right"
/>
</div>
</div>
</Field>
<div className="pt-2">
<button
onClick={resetScoreDefaults}
className="flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-emerald-500"
style={{ color: "var(--text-secondary)" }}
>
<RotateCcw className="w-3.5 h-3.5" />
Rétablir les valeurs par défaut
</button>
</div>
</Section>
{/* Save */}
<div className="flex items-center gap-3">
<button
onClick={() => { setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2500); }}
className="apple-btn-primary"
style={{ background: "var(--accent)" }}
>
{saveStatus === "saved" ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{saveStatus === "saved" ? "Sauvegardé !" : "Sauvegarder"}
</button>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export default function SnapshotsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Snapshots</h1>
<p className="text-sm text-slate-400 mt-1">Historique de vos sessions d&apos;arbitrage sauvegardées.</p>
</div>
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
<p className="text-slate-500 text-sm">Aucun snapshot enregistré.</p>
<p className="text-slate-600 text-xs mt-1">Utilisez le bouton <strong className="text-slate-500">«&nbsp;Valider&nbsp;»</strong> dans la grille pour créer votre premier snapshot.</p>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const AnalyzeSchema = z.object({
codein: z.string(),
libelle1: z.string(),
totalCa: z.number(),
tauxMarge: z.number(),
totalQuantite: z.number(),
sales12m: z.record(z.string(), z.number()),
codeGamme: z.string().nullable(),
});
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const SYSTEM_PROMPT = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel.
En analysant les données de ventes fournies, génère une recommandation de gamme concise (A=Cœur, B=Complémentaire, C=Saisonnier, Z=Sortie).
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
Format: "[Recommandation]: [Justification courte basée sur les données]"`;
export async function POST(req: NextRequest) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured" }, { status: 503 });
}
const body = await req.json();
const parsed = AnalyzeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
}
const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme } = parsed.data;
// Format monthly sales for the prompt
const monthlySummary = Object.entries(sales12m)
.map(([k, v]) => `${k}: ${Math.round(v)} unités`)
.join(", ");
const userMessage = `Produit: "${libelle1}" (Code: ${codein})
Gamme actuelle: ${codeGamme ?? "Non définie"}
CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités
Historique mensuel: ${monthlySummary}
Quelle gamme recommandes-tu et pourquoi ?`;
try {
const response = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://collectflow.app",
"X-Title": "CollectFlow AI Copilot",
},
body: JSON.stringify({
model: "google/gemini-flash-1.5",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
max_tokens: 150,
temperature: 0.3,
}),
});
if (!response.ok) {
const err = await response.text();
return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status });
}
const data = await response.json();
const insight = data.choices?.[0]?.message?.content ?? "Analyse indisponible.";
return NextResponse.json({ insight, codein });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
const ExportSchema = z.object({
nomFournisseur: z.string(),
rows: z.array(z.object({
codein: z.string(),
libelle1: z.string(),
code3: z.string(),
libelle3: z.string(),
codeGamme: z.string().nullable(),
totalQuantite: z.number(),
totalCa: z.number(),
tauxMarge: z.number(),
// Before gamme for delta
gammeAvant: z.string().nullable().optional(),
})),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = ExportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const { nomFournisseur, rows } = parsed.data;
const workbook = new ExcelJS.Workbook();
workbook.creator = "CollectFlow";
workbook.created = new Date();
const sheet = workbook.addWorksheet("Assortiment", {
views: [{ state: "frozen", ySplit: 1 }],
});
// Header style
const headerStyle: Partial<ExcelJS.Style> = {
font: { bold: true, color: { argb: "FFFFFFFF" } },
fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } },
alignment: { horizontal: "center" },
border: { bottom: { style: "thin", color: { argb: "FF334155" } } },
};
sheet.columns = [
{ header: "Code", key: "codein", width: 14 },
{ header: "Désignation", key: "libelle1", width: 40 },
{ header: "Nomenclature", key: "libelle3", width: 25 },
{ header: "Gamme Avant", key: "gammeAvant", width: 14 },
{ header: "Gamme Après", key: "codeGamme", width: 14 },
{ header: "Delta", key: "delta", width: 10 },
{ header: "Vol. 12m", key: "totalQuantite", width: 12 },
{ header: "CA 12m (€)", key: "totalCa", width: 14 },
{ header: "Marge (%)", key: "tauxMarge", width: 12 },
];
// Apply header style
sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); });
sheet.getRow(1).height = 22;
// Gamme colours
const GAMME_COLORS: Record<string, string> = {
A: "FF064E3B", B: "FF1E3A5F", C: "FF78350F", Z: "FF4C0519",
};
for (const row of rows) {
const delta = row.gammeAvant !== row.codeGamme ? `${row.gammeAvant ?? "—"} → ${row.codeGamme ?? "—"}` : "";
const excelRow = sheet.addRow({
codein: row.codein,
libelle1: row.libelle1,
libelle3: row.libelle3,
gammeAvant: row.gammeAvant ?? "—",
codeGamme: row.codeGamme ?? "—",
delta,
totalQuantite: Math.round(row.totalQuantite),
totalCa: parseFloat(row.totalCa.toFixed(2)),
tauxMarge: parseFloat(row.tauxMarge.toFixed(2)),
});
// Colour the "Gamme Après" cell
const gammeCell = excelRow.getCell("codeGamme");
if (row.codeGamme && GAMME_COLORS[row.codeGamme]) {
gammeCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: GAMME_COLORS[row.codeGamme] } };
gammeCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
}
// Highlight changed rows
if (delta) {
excelRow.getCell("delta").font = { bold: true, color: { argb: "FFFBBF24" } };
}
// Format numbers
excelRow.getCell("totalCa").numFmt = '#,##0.00 "€"';
excelRow.getCell("tauxMarge").numFmt = '0.00"%"';
}
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
const filename = `Assortiment_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const apiKey = req.headers.get("x-openrouter-key") || process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
}
try {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
next: { revalidate: 3600 }, // Cache 1h
});
if (!res.ok) {
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
}
const data = await res.json();
// Return simplified list sorted by name
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string } }>)
.map((m) => ({
id: m.id,
name: m.name,
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
}))
.sort((a, b) => {
// Free models first, then alphabetical
if (a.free && !b.free) return -1;
if (!a.free && b.free) return 1;
return 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 });
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+319
View File
@@ -0,0 +1,319 @@
@import "tailwindcss";
/* ═══════════════════════════════════════════
CollectFlow — Apple-inspired Design System
═══════════════════════════════════════════ */
/* ── Dark Mode (default) ── */
:root {
/* Apple macOS dark exact values */
--bg-base: #000000;
--bg-surface: #111111;
--bg-elevated: #1c1c1e;
--bg-overlay: rgba(28, 28, 30, 0.85);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.12);
--text-primary: #f5f5f7;
--text-secondary: #a1a1a6;
--text-muted: #86868b;
--accent: #2dd4bf;
/* emerald-ish, Apple-tuned */
--accent-bg: rgba(45, 212, 191, 0.10);
--accent-border: rgba(45, 212, 191, 0.20);
/* Semantic UI accents */
--accent-success: #10b981;
--accent-success-bg: rgba(16, 185, 129, 0.10);
--accent-warning: #f59e0b;
--accent-warning-bg: rgba(245, 158, 11, 0.10);
--accent-error: #f43f5e;
--accent-error-bg: rgba(244, 63, 94, 0.10);
/* Primary Action / Brand */
--brand-solid: #10b981;
--brand-hover: #059669;
--action-secondary-bg: #2c2c2e;
--action-secondary-text: #f5f5f7;
--action-secondary-border: rgba(255, 255, 255, 0.1);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.6), 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.7), 0 1px 4px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.6);
}
/* ── Light Mode ── */
.light {
--bg-base: #f5f5f7;
/* Apple light gray */
--bg-surface: #ffffff;
--bg-elevated: #f0f0f5;
--bg-overlay: rgba(255, 255, 255, 0.82);
--border: rgba(0, 0, 0, 0.07);
--border-strong: rgba(0, 0, 0, 0.12);
--text-primary: #1d1d1f;
/* Apple dark */
--text-secondary: #515154;
--text-muted: #86868b;
--accent: #0d9488;
/* deeper teal in light */
--accent-bg: rgba(13, 148, 136, 0.08);
--accent-border: rgba(13, 148, 136, 0.18);
/* Semantic UI accents (Light Mode Adjustments) */
--accent-success: #059669;
--accent-success-bg: rgba(5, 150, 105, 0.08);
--accent-warning: #d97706;
--accent-warning-bg: rgba(217, 119, 6, 0.08);
--accent-error: #e11d48;
--accent-error-bg: rgba(225, 29, 72, 0.08);
/* Primary Action / Brand (Light) */
--brand-solid: #0d9488;
--brand-hover: #0f766e;
--action-secondary-bg: #ffffff;
--action-secondary-text: #1d1d1f;
--action-secondary-border: rgba(0, 0, 0, 0.1);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
}
@layer base {
html {
color-scheme: dark;
/* Force no system scroll jump */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html.light {
color-scheme: light;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-sans, -apple-system, "Inter", system-ui, sans-serif);
font-size: 14px;
line-height: 1.5;
transition: background-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Apple-style focus ring */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
}
/* ── Typography ── */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-muted {
color: var(--text-muted);
}
/* ── Surfaces ── */
.surface {
background: var(--bg-surface);
}
.elevated {
background: var(--bg-elevated);
}
/* ── Glass panels (sidebar, header) ── */
.glass {
background: var(--bg-overlay);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
}
/* ── Apple border ── */
.border-apple {
border: 1px solid var(--border);
}
.border-apple-strong {
border: 1px solid var(--border-strong);
}
/* ── Tabular numbers & Mono ── */
.tabular-nums {
font-variant-numeric: tabular-nums;
letter-spacing: -0.015em;
}
.font-mono-nums {
font-family: var(--font-sans, system-ui, sans-serif);
font-variant-numeric: tabular-nums;
letter-spacing: -0.015em;
}
/* ── Refined scrollbar ── */
.overflow-auto::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.overflow-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-auto::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 99px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.light .overflow-auto::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.18);
}
/* ── Heatmap cells ── */
.heatmap-zero {
background: var(--bg-elevated);
color: var(--text-muted);
}
.heatmap-low {
background: rgba(45, 212, 191, 0.08);
color: rgba(45, 212, 191, 0.70);
}
.heatmap-mid {
background: rgba(45, 212, 191, 0.15);
color: rgba(45, 212, 191, 0.90);
}
.heatmap-high {
background: rgba(45, 212, 191, 0.25);
color: #2dd4bf;
}
.light .heatmap-low {
background: rgba(13, 148, 136, 0.08);
color: #0f766e;
}
.light .heatmap-mid {
background: rgba(13, 148, 136, 0.14);
color: #0d9488;
}
.light .heatmap-high {
background: rgba(13, 148, 136, 0.22);
color: #0f766e;
}
/* ── Apple-style input ── */
.apple-input {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
}
.apple-input::placeholder {
color: var(--text-muted);
}
.apple-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-bg);
}
/* ── Apple-style button ── */
.apple-btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
border: none;
}
.apple-btn-primary:hover {
opacity: 0.88;
}
.apple-btn-primary:active {
transform: scale(0.97);
}
.apple-btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.apple-btn-secondary:hover {
background: var(--bg-overlay);
}
/* ── Segment control (theme toggle) ── */
.segment-control {
display: inline-flex;
gap: 2px;
padding: 3px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 9px;
}
.segment-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 14px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: none;
background: transparent;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.segment-btn.active {
background: var(--bg-surface);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/components/shared/theme-provider";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
export const metadata: Metadata = {
title: "CollectFlow — Dashboard Décisionnel",
description: "Révision d'Assortiment Haute Performance",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/grid");
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { Search, X, Sun, Moon } from "lucide-react";
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
export function Header() {
const { resolvedTheme, setTheme } = useTheme();
const setFilter = useGridStore((s) => s.setFilter);
const search = useGridStore((s) => s.filters.search);
const isDark = resolvedTheme === "dark";
return (
<header
className="h-12 flex items-center justify-between px-5 sticky top-0 z-20 glass"
style={{ borderBottom: "1px solid var(--border)" }}
>
{/* Search */}
<div className="relative w-72">
<Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
style={{ color: "var(--text-muted)" }}
strokeWidth={2}
/>
<input
type="search"
placeholder="Rechercher…"
value={search}
onChange={(e) => setFilter("search", e.target.value)}
className="apple-input pr-8 h-8 text-[13px]"
style={{ paddingLeft: "32px" }}
/>
{search && (
<button
onClick={() => setFilter("search", "")}
className="absolute right-2.5 top-1/2 -translate-y-1/2"
>
<X className="w-3 h-3" style={{ color: "var(--text-muted)" }} />
</button>
)}
</div>
{/* Right controls */}
<div className="flex items-center gap-2">
{/* Theme toggle */}
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Mode clair" : "Mode sombre"}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all duration-150"
style={{ color: "var(--text-secondary)" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{isDark
? <Sun className="w-[15px] h-[15px]" strokeWidth={1.8} />
: <Moon className="w-[15px] h-[15px]" strokeWidth={1.8} />}
</button>
{/* Divider */}
<div className="w-px h-5 mx-1" style={{ background: "var(--border)" }} />
{/* User */}
<div className="flex items-center gap-2">
<div className="w-[26px] h-[26px] rounded-full bg-emerald-500 dark:bg-emerald-600 flex items-center justify-center text-[11px] font-bold text-white shadow-sm border border-white/20">
M
</div>
<span className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
Michael
</span>
</div>
</div>
</header>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3 } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
{ icon: FileDown, label: "Exports", href: "/exports" },
{ icon: BarChart3, label: "Score", href: "/score" },
{ icon: Settings, label: "Paramètres", href: "/settings" },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside
className="w-60 flex-shrink-0 flex flex-col h-screen fixed left-0 top-0 z-30 glass"
style={{ borderRight: "1px solid var(--border)" }}
>
{/* Logo */}
<div className="px-5 py-[18px]" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-[8px] bg-emerald-500 dark:bg-emerald-600 flex items-center justify-center shadow-sm border border-white/10">
<Package className="w-[15px] h-[15px] text-white" strokeWidth={2.2} />
</div>
<span className="text-[15px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
CollectFlow
</span>
</div>
</div>
{/* Navigation */}
<nav className="px-2 pt-2.5 space-y-0.5">
{NAV_ITEMS.map(({ icon: Icon, label, href }) => {
const isActive = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
style={{
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
background: isActive ? "var(--bg-elevated)" : "transparent",
}}
className={cn(
"flex items-center gap-2.5 px-3 py-[7px] rounded-[8px] text-[13px] font-medium transition-all duration-150",
!isActive && "hover:bg-[var(--bg-elevated)]"
)}
onMouseEnter={(e) => {
if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)";
}}
onMouseLeave={(e) => {
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<Icon className="w-[15px] h-[15px] shrink-0" strokeWidth={1.8} />
{label}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="mt-auto px-4 py-3" style={{ borderTop: "1px solid var(--border)" }}>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>v1.0.0-beta</p>
</div>
</aside>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
storageKey="collectflow-theme"
>
{children}
</NextThemesProvider>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { drizzle } from "drizzle-orm/pg-proxy"; // Or node-postgres if using directly
import { drizzle as nodeDrizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = nodeDrizzle(pool, { schema });
+62
View File
@@ -0,0 +1,62 @@
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(),
// Identification produit
codein: varchar("codein", { length: 20 }).notNull(),
codeFournisseur: varchar("code_fournisseur", { length: 20 }),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
colisage: numeric("colisage", { precision: 10, scale: 5 }),
// Gamme & Nomenclature
codeGamme: varchar("code_gamme", { length: 20 }),
code3: varchar("code3", { length: 20 }),
libelle3: varchar("libelle3", { length: 500 }),
// Dimension magasin & période
magasin: varchar("magasin", { length: 20 }).notNull(),
codeMagasin: varchar("code_magasin", { length: 10 }),
annee: smallint("annee"),
mois: smallint("mois"),
periode: varchar("periode", { length: 10 }).notNull(),
// Métriques
quantite: numeric("quantite", { precision: 12, scale: 2 }),
montantMvt: numeric("montant_mvt", { precision: 14, scale: 4 }),
margeMvt: numeric("marge_mvt", { precision: 14, scale: 4 }),
// Métadonnées
importedAt: timestamp("imported_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
uniqueIndex("uq_ventes_produit_magasin_periode").on(table.codein, table.magasin, table.periode),
index("idx_ventes_codein").on(table.codein),
index("idx_ventes_fournisseur").on(table.codeFournisseur),
index("idx_ventes_gamme").on(table.codeGamme),
index("idx_ventes_code3").on(table.code3),
index("idx_ventes_magasin").on(table.magasin),
index("idx_ventes_periode").on(table.periode),
index("idx_ventes_annee_mois").on(table.annee, table.mois),
index("idx_ventes_updated_at").on(table.updatedAt),
];
});
/** Snapshot of a complete arbitrage session (Epic 5) */
export const sessionSnapshots = pgTable("session_snapshots", {
id: serial("id").primaryKey(),
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
magasin: varchar("magasin", { length: 20 }).notNull(),
/** JSON map of codein → { before: GammeCode, after: GammeCode } */
changes: jsonb("changes").notNull(),
/** Summary stats at time of snapshot */
summaryJson: jsonb("summary_json"),
/** Label for the snapshot */
label: text("label"),
createdAt: timestamp("created_at").defaultNow(),
});
@@ -0,0 +1,69 @@
"use client";
import { Bot, Loader2, AlertCircle, Sparkles } from "lucide-react";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import type { ProductRow } from "@/types/grid";
import { cn } from "@/lib/utils";
interface AiInsightBlockProps {
row: ProductRow;
}
export function AiInsightBlock({ row }: AiInsightBlockProps) {
const { insights, analyzeProduct } = useAiCopilotStore();
const insight = insights[row.codein];
const status = insight?.status ?? "idle";
const handleAnalyze = () => {
if (status === "loading") return;
analyzeProduct({
codein: row.codein,
libelle1: row.libelle1,
totalCa: row.totalCa,
tauxMarge: row.tauxMarge,
totalQuantite: row.totalQuantite,
sales12m: row.sales12m,
codeGamme: row.codeGamme,
});
};
if (status === "idle") {
return (
<button
onClick={handleAnalyze}
className="flex items-center gap-1.5 text-[11px] font-medium transition-colors group"
style={{ color: "var(--text-secondary)" }}
>
<Sparkles className="w-3 h-3 text-indigo-500 dark:text-indigo-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors" />
<span className="group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors">Analyser</span>
</button>
);
}
if (status === "loading") {
return (
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-3 h-3 animate-spin" />
<span className="italic">Analyse IA...</span>
</div>
);
}
if (status === "error") {
return (
<div className="flex items-center gap-1.5 text-[11px] text-rose-600 dark:text-rose-500">
<AlertCircle className="w-3 h-3" />
<span>Erreur</span>
</div>
);
}
return (
<div className="flex items-start gap-1.5 group cursor-pointer" onClick={handleAnalyze} title="Cliquer pour re-analyser">
<Bot className="w-3.5 h-3.5 mt-0.5 text-indigo-600 dark:text-indigo-400 shrink-0" />
<span className={cn("text-[11px] italic line-clamp-2 leading-[1.3]")} style={{ color: "var(--text-secondary)" }}>
{insight?.insight}
</span>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import { create } from "zustand";
interface AiInsight {
codein: string;
insight: string;
status: "idle" | "loading" | "done" | "error";
}
interface AiCopilotState {
insights: Record<string, AiInsight>;
analyzeProduct: (payload: {
codein: string;
libelle1: string;
totalCa: number;
tauxMarge: number;
totalQuantite: number;
sales12m: Record<string, number>;
codeGamme: string | null;
}) => Promise<void>;
}
export const useAiCopilotStore = create<AiCopilotState>((set) => ({
insights: {},
analyzeProduct: async (payload) => {
const { codein } = payload;
// Mark as loading
set((s) => ({
insights: { ...s.insights, [codein]: { codein, insight: "", status: "loading" } },
}));
try {
const res = await fetch("/api/ai/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: data.insight, status: "done" },
},
}));
} catch {
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: "Erreur lors de l'analyse.", status: "error" },
},
}));
}
},
}));
+41
View File
@@ -0,0 +1,41 @@
"use server";
import { FOURNISSEURS, generateMockRows } from "@/lib/mock-data";
import type { ProductRow } from "@/types/grid";
/**
* Get the list of all unique suppliers (Mocked for now).
*/
export async function getFournisseurs() {
// Simulating a database latency
await new Promise((r) => setTimeout(r, 100));
return FOURNISSEURS;
}
/**
* Get the list of all magasins (Mocked).
*/
export async function getMagasins() {
await new Promise((r) => setTimeout(r, 50));
return [
{ code: "M001", nom: "BORDEAUX LAC" },
{ code: "M002", nom: "TOULOUSE BLAGNAC" },
{ code: "M003", nom: "NANTES BEAUJOIRE" },
{ code: "M004", nom: "LILLE FLANDRES" },
{ code: "M005", nom: "MARSEILLE VALENTINE" },
];
}
/**
* Get grid data for a specific supplier and store (Mocked for now).
* In SQL:
* - If magasin === "TOTAL": SELECT SUM(quantite), AVG(marge)... GROUP BY produit_id
* - If magasin === "M001": SELECT quantite, marge... WHERE magasin_id = 'M001'
*/
export async function getGridData(codeFournisseur: string, magasin: string = "TOTAL"): Promise<ProductRow[]> {
// Simulating a database latency
await new Promise((r) => setTimeout(r, 300));
// Use the existing mock generator - passing magasin to see how it affects data
return generateMockRows(codeFournisseur, 85);
}
+69
View File
@@ -0,0 +1,69 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and, ne, sql } from "drizzle-orm";
import type { ProductRow, GammeCode } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
interface GetProductRowsInput {
codeFournisseur: string;
magasin?: string;
}
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL" } = input;
// Get all rows for this supplier (excluding the synthetic TOTAL period)
const rows = await db
.select()
.from(ventesProduits)
.where(
and(
eq(ventesProduits.codeFournisseur, codeFournisseur),
ne(ventesProduits.periode, "TOTAL"),
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
)
);
// Group by codein and aggregate monthly sales
const productMap = new Map<string, ProductRow>();
for (const row of rows) {
const existing = productMap.get(row.codein);
const monthKey = row.periode;
const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0"));
const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0"));
const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0"));
if (existing) {
existing.sales12m[monthKey] = (existing.sales12m[monthKey] ?? 0) + qty;
existing.totalQuantite += qty;
existing.totalCa += ca;
existing.totalMarge += marge;
existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0;
} else {
productMap.set(row.codein, {
codein: row.codein,
codeFournisseur: row.codeFournisseur ?? "",
nomFournisseur: row.nomFournisseur ?? "",
libelle1: row.libelle1 ?? "",
gtin: row.gtin ?? "",
reference: row.reference ?? "",
code3: row.code3 ?? "",
libelle3: row.libelle3 ?? "",
codeGamme: (row.codeGamme as GammeCode | null),
codeGammeDraft: null,
sales12m: { [monthKey]: qty },
totalQuantite: qty,
totalCa: ca,
totalMarge: marge,
tauxMarge: ca > 0 ? (marge / ca) * 100 : 0,
score: 0,
aiRecommendation: null,
});
}
}
return computeProductScores(Array.from(productMap.values()));
}
@@ -0,0 +1,49 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
const SaveDraftsSchema = z.object({
changes: z.array(
z.object({
codein: z.string(),
magasin: z.string(),
codeGamme: z.enum(["A", "B", "C", "Z"]),
})
),
});
export async function saveDraftChanges(
raw: unknown
): Promise<{ success: boolean; saved: number; error?: string }> {
const parsed = SaveDraftsSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
}
const { changes } = parsed.data;
try {
// Batch update all rows for given codein × magasin
await Promise.all(
changes.map(({ codein, magasin, codeGamme }) =>
db
.update(ventesProduits)
.set({ codeGamme })
.where(
and(
eq(ventesProduits.codein, codein),
eq(ventesProduits.magasin, magasin)
)
)
)
);
return { success: true, saved: changes.length };
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return { success: false, saved: 0, error: msg };
}
}
@@ -0,0 +1,56 @@
"use client";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
const GAMME_OPTIONS: { label: string; code: GammeCode; color: string }[] = [
{ label: "A — Cœur", code: "A", color: "border-emerald-600 text-emerald-400 hover:bg-emerald-900/20" },
{ label: "B — Complémentaire", code: "B", color: "border-blue-600 text-blue-400 hover:bg-blue-900/20" },
{ label: "C — Saisonnier", code: "C", color: "border-amber-600 text-amber-400 hover:bg-amber-900/20" },
{ label: "Z — Sortie", code: "Z", color: "border-rose-600 text-rose-400 hover:bg-rose-900/20" },
];
interface BulkActionToolbarProps {
selectedCodeins: string[];
onClearSelection: () => void;
}
export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) {
const { setDraftGamme } = useGridStore();
if (selectedCodeins.length === 0) return null;
const applyBulk = (code: GammeCode) => {
selectedCodeins.forEach((codein) => setDraftGamme(codein, code));
onClearSelection();
};
return (
<div className="flex items-center gap-3 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
</span>
<div className="h-4 w-px bg-slate-700/50" />
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-tight">Appliquer en masse :</span>
{GAMME_OPTIONS.map(({ label, code, color }) => (
<button
key={code}
onClick={() => applyBulk(code)}
className={cn(
"px-3 py-1 text-xs font-bold border rounded transition-colors",
color
)}
>
{label}
</button>
))}
<button
onClick={onClearSelection}
className="ml-auto text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Annuler
</button>
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
interface FinancialCellProps {
totalCa: number;
totalMarge: number;
tauxMarge: number;
}
function getMarginColor(pct: number): string {
if (pct >= 45) return "#10b981"; // emerald-500
if (pct >= 20) return "#f59e0b"; // amber-500
return "#f43f5e"; // rose-500
}
export function FinancialCell({ totalCa, totalMarge, tauxMarge }: FinancialCellProps) {
const isHighMargin = tauxMarge >= 45;
const isMedMargin = tauxMarge >= 20 && tauxMarge < 45;
let textAccent = "var(--accent-error)";
let bgAccent = "var(--accent-error-bg)";
if (isHighMargin) {
textAccent = "var(--accent-success)";
bgAccent = "var(--accent-success-bg)";
} else if (isMedMargin) {
textAccent = "var(--accent-warning)";
bgAccent = "var(--accent-warning-bg)";
}
return (
<div
className="flex flex-col items-center justify-center rounded-lg py-1 shadow-sm w-full max-w-[130px] mx-auto transition-colors border-apple"
style={{
background: bgAccent,
borderColor: "transparent"
}}
>
<span
className="font-mono-nums text-[13px] font-bold whitespace-nowrap"
style={{ color: textAccent }}
>
{totalCa.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
<div className="flex items-center gap-1.5 mt-0.5 opacity-80">
<span
className="font-mono-nums text-[10px] px-1 py-0.5 rounded font-bold whitespace-nowrap bg-white/20 dark:bg-black/20"
style={{ color: textAccent }}
>
{tauxMarge.toFixed(1)}%
</span>
<span className="font-mono-nums text-[10px] font-semibold text-slate-500 dark:text-slate-400 whitespace-nowrap">
{totalMarge.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useGridStore } from "@/features/grid/store/use-grid-store";
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
// Determine color based on label to match the prototype
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
return (
<div className="flex flex-col items-center">
<span className="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-xl ${valColor}`}>{value}</span>
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
</div>
);
}
export function FloatingSummaryBar() {
const { summary, draftChanges } = useGridStore();
const hasDrafts = Object.keys(draftChanges).length > 0;
return (
<div
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
style={{
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
}}
>
<div className="text-xs font-bold px-4 py-2 rounded-full border flex items-center gap-2 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-500">
{hasDrafts && (
<span className="ml-2 text-[10px] font-black uppercase text-amber-600 dark:text-amber-500 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded border border-amber-200 dark:border-amber-800">
{Object.keys(draftChanges).length} modif.
</span>
)}
</div>
<div className="flex space-x-12 items-center">
<Stat
label="Volume Total Vendu"
value={Math.round(summary.totalQuantite).toLocaleString("fr-FR")}
/>
<Stat
label="CA Global Généré"
value={summary.totalCa.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
/>
<Stat
label="Taux de Marge Moyen"
value={`${summary.tauxMargeGlobal.toFixed(1)}%`}
sub={summary.totalMarge.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
/>
</div>
<div className="flex space-x-3">
<button
className="px-6 py-3 rounded-xl text-sm font-bold transition-all active:scale-95 border hover:brightness-105"
style={{
background: "var(--action-secondary-bg)",
color: "var(--action-secondary-text)",
borderColor: "var(--action-secondary-border)"
}}
>
Export Excel
</button>
<button
className="px-8 py-3 rounded-xl text-sm font-black transition-all shadow-lg active:scale-95 text-white hover:brightness-110"
style={{
background: "var(--brand-solid)",
}}
>
Valider ce Fournisseur
</button>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
interface GammeSelectProps {
value: GammeCode | null;
isDraft: boolean;
onChange: (gamme: GammeCode) => void;
}
const GAMME_STYLES: Record<GammeCode, string> = {
A: "border-[2px] border-emerald-500 bg-emerald-50 text-emerald-900 dark:border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-100",
B: "border-[2px] border-blue-500 bg-blue-50 text-blue-900 dark:border-blue-400 dark:bg-blue-900/40 dark:text-blue-100",
C: "border-[2px] border-amber-500 bg-amber-50 text-amber-900 dark:border-amber-400 dark:bg-amber-900/40 dark:text-amber-100",
Z: "border-[2px] border-rose-600 bg-rose-50 text-rose-900 dark:border-rose-500 dark:bg-rose-900/50 dark:text-rose-100",
};
export function GammeSelect({ value, isDraft, onChange }: GammeSelectProps) {
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value as GammeCode)}
className={cn(
"w-full text-xs font-black rounded-lg py-2 px-2 outline-none cursor-pointer shadow-sm transition-all text-center",
"focus:ring-2 focus:ring-brand-500/20",
value
? GAMME_STYLES[value]
: "border-2 border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-500",
isDraft && "shadow-[0_0_10px_rgba(251,191,36,0.5)] border-amber-400"
)}
>
<option value="" disabled>—</option>
<option value="A">A - Cœur</option>
<option value="B">B - Standard</option>
<option value="C">C - Optionnel</option>
<option value="Z">Z - Sortie</option>
</select>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState, useTransition } 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";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store";
import { computeProductScores } from "@/lib/score-engine";
interface GridClientProps {
initialRows: ProductRow[];
codeFournisseur: string;
nomFournisseur: string;
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
magasin: string;
}
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const setRows = useGridStore((s) => s.setRows);
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const { save, hasDrafts, count } = useSaveDrafts(magasin);
useEffect(() => {
// Deep clone to avoid mutating the React prop directly across re-renders
const rowsCopy = JSON.parse(JSON.stringify(initialRows));
const scoredRows = computeProductScores(rowsCopy, { seuilAxeFort, bonusParAxe });
setRows(scoredRows);
}, [initialRows, setRows, seuilAxeFort, bonusParAxe]);
const handleSave = () => {
startTransition(async () => {
const result = await save();
setSaveStatus(result.success ? "success" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
});
};
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
return (
<div className="space-y-3 pb-20">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Révision d&apos;Assortiment</h1>
<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>
{" "}• {initialRows.length} références
</p>
</div>
{hasDrafts && (
<button
onClick={handleSave}
disabled={isPending}
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "success" ? (
<CheckCircle className="w-4 h-4" />
) : saveStatus === "error" ? (
<AlertCircle className="w-4 h-4" />
) : null}
{isPending
? "Sauvegarde..."
: saveStatus === "success"
? "Sauvegardé !"
: saveStatus === "error"
? "Erreur"
: `Valider ${count} changement${count > 1 ? "s" : ""}`}
</button>
)}
</div>
{/* Filters */}
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
{/* Bulk toolbar (contextual) */}
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
{/* Main grid */}
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
/>
{/* Summary bar */}
<FloatingSummaryBar />
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import { Search, X, ChevronDown } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
const GAMME_FILTERS: { label: string; value: GammeCode | null }[] = [
{ label: "Tous", value: null },
{ label: "A", value: "A" },
{ label: "B", value: "B" },
{ label: "C", value: "C" },
{ label: "Z", value: "Z" },
];
import { SupplierCombobox } from "./supplier-combobox";
import { StoreCombobox } from "./store-combobox";
import { useRouter, useSearchParams } from "next/navigation";
interface GridFilterBarProps {
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
}
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const { filters, setFilter } = useGridStore();
const router = useRouter();
const searchParams = useSearchParams();
const handleSupplierSelect = (code: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("fournisseur", code);
router.push(`/grid?${params.toString()}`);
};
const handleStoreSelect = (code: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("magasin", code);
router.push(`/grid?${params.toString()}`);
};
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Supplier Selector */}
<SupplierCombobox
fournisseurs={fournisseurs}
selectedCode={searchParams.get("fournisseur")}
onSelect={handleSupplierSelect}
className="w-[280px]"
/>
{/* Store Selector */}
<StoreCombobox
magasins={magasins}
selectedCode={searchParams.get("magasin")}
onSelect={handleStoreSelect}
className="w-[220px]"
/>
{/* Search */}
<div className="relative">
<Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5"
style={{ color: "var(--text-muted)" }}
/>
<input
type="search"
placeholder="Rechercher (code, désignation...)"
value={filters.search}
onChange={(e) => setFilter("search", e.target.value)}
className="apple-input w-72 pr-8"
style={{ paddingLeft: "32px" }}
/>
{filters.search && (
<button
onClick={() => setFilter("search", "")}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
</button>
)}
</div>
{/* Gamme quick filter */}
<div
className="flex items-center gap-1 p-0.5 rounded-md"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
{GAMME_FILTERS.map(({ label, value }) => (
<button
key={label}
onClick={() => setFilter("codeGamme", value)}
className={cn(
"px-2.5 py-1 text-[11px] font-semibold rounded-[4px] transition-colors",
filters.codeGamme === value
? "shadow-sm"
: "hover:bg-[var(--bg-surface)]"
)}
style={{
background: filters.codeGamme === value ? "var(--bg-surface)" : "transparent",
color: filters.codeGamme === value ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{label}
</button>
))}
</div>
{/* Nomenclature filter */}
<div className="relative">
<select
value={filters.code3 ?? ""}
onChange={(e) => setFilter("code3", e.target.value || null)}
className="apple-input pr-8 appearance-none"
style={{ width: "180px" }}
>
<option value="">Toutes nomenclatures</option>
</select>
<ChevronDown
className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 pointer-events-none"
style={{ color: "var(--text-muted)" }}
/>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
interface HeatmapCellProps {
value: number | null;
}
function getHeatmapStyle(value: number | null): { background: string; color: string } {
if (value === null || value === 0) {
return { background: "var(--bg-elevated)", color: "var(--text-muted)" };
}
const abs = Math.abs(value);
if (abs > 100) return { background: "rgba(45,212,191,0.22)", color: "var(--accent)" };
if (abs > 30) return { background: "rgba(45,212,191,0.14)", color: "var(--accent)" };
if (abs > 10) return { background: "rgba(45,212,191,0.08)", color: "var(--accent)" };
return { background: "var(--bg-elevated)", color: "var(--text-secondary)" };
}
export function HeatmapCell({ value }: HeatmapCellProps) {
const style = getHeatmapStyle(value);
const display = value === null || value === 0 ? "—" : Math.round(Math.abs(value)).toString();
return (
<div
className="rounded text-center text-[12px] tabular-nums px-0.5 py-0.5 font-bold tracking-tighter transition-colors min-h-[24px] flex items-center justify-center font-mono-nums"
style={style}
title={value !== null ? `${Math.abs(value).toLocaleString("fr-FR")} unités` : "Aucune vente"}
>
{display}
</div>
);
}
@@ -0,0 +1,341 @@
"use client";
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
SortingState,
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { FinancialCell } from "@/features/grid/components/financial-cell";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import { AiInsightBlock } from "@/features/ai-copilot/components/ai-insight-block";
function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
const MONTHS_12 = getLast12Months();
function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
}
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
const { rows, draftChanges, setDraftGamme, filters, displayDensity } = useGridStore();
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const tableContainerRef = useRef<HTMLDivElement>(null);
const rowHeight = displayDensity === "compact" ? 32 : displayDensity === "normal" ? 40 : 48;
const cellPadding = displayDensity === "compact" ? "py-1" : displayDensity === "normal" ? "py-1.5" : "py-2.5";
const handleRowSelectionChange = useCallback(
(updater: React.SetStateAction<RowSelectionState>) => {
setRowSelection(updater);
},
[]
);
// Reset selection when rows change (e.g. supplier change)
useEffect(() => {
setRowSelection({});
}, [rows]);
const prevSelectionRef = useRef<string[]>([]);
// Propagate selection changes via useEffect to avoid "update during render" error
useEffect(() => {
if (!onSelectionChange) return;
const selectedIdxs = Object.keys(rowSelection).filter((k) => rowSelection[k]);
const selectedCodeins = selectedIdxs
.map((idx) => rows[parseInt(idx)]?.codein ?? "")
.filter(Boolean);
// Only update if selection actually changed to avoid re-render loops & console warnings
const currentString = JSON.stringify(selectedCodeins);
const prevString = JSON.stringify(prevSelectionRef.current);
if (currentString !== prevString) {
prevSelectionRef.current = selectedCodeins;
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [rowSelection, onSelectionChange, rows]);
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
{
id: "select",
size: 36,
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={(el) => { if (el) el.indeterminate = table.getIsSomeRowsSelected(); }}
onChange={table.getToggleAllRowsSelectedHandler()}
className="accent-emerald-500 cursor-pointer"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => e.stopPropagation()}
className="accent-emerald-500 cursor-pointer"
/>
),
},
{
accessorKey: "codein",
header: "Code",
size: 90,
cell: ({ getValue }) => <span className="font-mono-nums font-bold text-[12px] tracking-tight opacity-70" style={{ color: "var(--text-muted)" }}>{getValue<string>()}</span>,
},
{
accessorKey: "libelle1",
header: "Désignation",
size: 280,
cell: ({ getValue }) => (
<span className="text-[13px] font-bold truncate block max-w-[270px]" title={getValue<string>()} style={{ color: "var(--text-primary)" }}>
{getValue<string>()}
</span>
),
},
{
accessorKey: "libelle3",
header: "Famille",
size: 160,
cell: ({ getValue }) => (
<span className="text-[12px] truncate block text-left w-full opacity-70" style={{ color: "var(--text-secondary)" }}>
{getValue<string>()}
</span>
),
},
{
accessorKey: "score",
header: "Score",
size: 60,
cell: ({ getValue }) => {
const val = getValue<number>();
const color = val >= 80 ? "text-emerald-500" : val >= 50 ? "text-amber-500" : "text-rose-500";
return (
<div className={cn("text-center font-black text-[13px]", color)}>
{val}
</div>
);
},
},
...MONTHS_12.map((monthKey) => ({
id: `month_${monthKey}`,
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
size: 68,
enableSorting: false,
cell: ({ row }: { row: { original: ProductRow } }) => (
<HeatmapCell value={row.original.sales12m[monthKey] ?? null} />
),
})),
{
accessorKey: "totalQuantite",
header: () => <div className="text-center w-full">Tot. 12m</div>,
size: 90,
cell: ({ getValue }) => (
<div className="mx-auto w-[60px] text-center px-1 font-mono-nums text-sm font-black py-1.5 rounded-md border" style={{
background: "var(--bg-elevated)",
color: "var(--text-primary)",
borderColor: "var(--border-strong)"
}}>
{Math.round(getValue<number>()).toLocaleString("fr-FR")}
</div>
),
},
{
id: "financial",
header: () => <div className="text-center w-full">CA / Marge</div>,
size: 160,
cell: ({ row }) => (
<FinancialCell
totalCa={row.original.totalCa}
totalMarge={row.original.totalMarge}
tauxMarge={row.original.tauxMarge}
/>
),
},
{
id: "gammeInitial",
header: () => <div className="text-center w-full">Init.</div>,
size: 56,
cell: ({ row }) => (
<div className="text-center font-bold text-[12px]" style={{ color: "var(--text-secondary)" }}>
{row.original.codeGamme || "-"}
</div>
),
},
{
id: "gamme",
header: "Gamme",
size: 110,
cell: ({ row }) => {
const effectiveGamme = (draftChanges[row.original.codein] ?? row.original.codeGamme) as GammeCode | null;
return (
<GammeSelect
value={effectiveGamme}
isDraft={!!draftChanges[row.original.codein]}
onChange={(g: GammeCode) => setDraftGamme(row.original.codein, g)}
/>
);
},
},
{
id: "ai",
header: "Recommandation IA",
size: 230,
enableSorting: false,
cell: ({ row }) => <AiInsightBlock row={row.original} />,
},
], [draftChanges, setDraftGamme]);
const table = useReactTable({
data: rows,
columns,
state: { sorting, globalFilter: filters.search, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
});
const { rows: tableRows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => rowHeight,
overscan: 20,
});
const totalWidth = columns.reduce((s, c) => s + ((c as { size?: number }).size ?? 150), 0);
return (
<div
ref={tableContainerRef}
className="overflow-auto rounded-[12px]"
style={{
height: "calc(100vh - 240px)",
background: "var(--bg-surface)",
border: "1px solid var(--border)",
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
<thead className="sticky top-0 z-10 block" style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{headerGroup.headers.map((header) => {
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "financial" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score";
const size = header.getSize();
return (
<th
key={header.id}
className="px-2 py-2 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap select-none flex items-center"
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-muted)",
justifyContent: isCenter ? "center" : "flex-start"
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className={`flex items-center gap-1 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3" />
: <ChevronsUpDown className="w-3 h-3 opacity-30" />
)}
</div>
</th>
);
})}
</tr>
))}
</thead>
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const effectiveGamme = draftChanges[row.original.codein] ?? row.original.codeGamme;
const isSelected = row.getIsSelected();
return (
<tr
key={row.id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
onClick={() => row.toggleSelected()}
className={cn(
"absolute w-full flex items-center cursor-pointer transition-colors",
effectiveGamme === "Z" && "opacity-40"
)}
style={{
height: `${rowHeight}px`,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: "1px solid var(--border)",
background: isSelected ? "var(--accent-bg)" : undefined,
borderLeft: isSelected ? "2px solid var(--accent)" : undefined,
}}
>
{row.getVisibleCells().map((cell) => {
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || cell.column.id === "libelle3";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "financial" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "score";
const size = cell.column.getSize();
return (
<td
key={cell.id}
className={`px-2 overflow-hidden flex items-center`}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
height: `${rowHeight}px`,
justifyContent: isCenter ? "center" : "flex-start"
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Search, Building2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface Store {
code: string;
nom: string;
}
interface StoreComboboxProps {
magasins: Store[];
selectedCode: string | null;
onSelect: (code: string) => void;
className?: string;
}
export function StoreCombobox({ magasins, selectedCode, onSelect, className }: StoreComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const options = React.useMemo(() => [
{ code: "TOTAL", nom: "Tous les magasins (Total)" },
...magasins
], [magasins]);
const selectedStore = options.find((s) => s.code === (selectedCode || "TOTAL"));
const filtered = React.useMemo(() => {
if (!search) return options;
const s = search.toLowerCase();
return options.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50);
}, [options, search]);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className={cn("relative w-[280px]", className)} ref={containerRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-blue-500/50 transition-all shadow-sm group"
>
<div className="flex items-center gap-2 min-w-0">
<Building2 className="w-3.5 h-3.5 text-slate-400 group-hover:text-blue-500 transition-colors" />
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedStore ? selectedStore.nom : "TOTAL"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<input
autoFocus
placeholder="Rechercher un magasin..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
</div>
) : (
filtered.map((s) => (
<button
key={s.code}
onClick={() => {
onSelect(s.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
s.code === (selectedCode || "TOTAL")
? "bg-blue-500/10 text-blue-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
)}
>
<div className="flex flex-col min-w-0 pr-2">
<span className="truncate">{s.nom}</span>
<span className="text-[10px] opacity-50 font-mono italic">{s.code === "TOTAL" ? "Agrégation nationale" : `Magasin ID: ${s.code}`}</span>
</div>
{s.code === (selectedCode || "TOTAL") && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
interface Supplier {
code: string;
nom: string;
}
interface SupplierComboboxProps {
fournisseurs: Supplier[];
selectedCode: string | null;
onSelect: (code: string) => void;
className?: string;
}
export function SupplierCombobox({ fournisseurs, selectedCode, onSelect, className }: SupplierComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const selectedSupplier = fournisseurs.find((f) => f.code === selectedCode);
const filtered = React.useMemo(() => {
if (!search) return fournisseurs;
const s = search.toLowerCase();
return fournisseurs.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50); // Limit display for performance
}, [fournisseurs, search]);
// Handle click outside to close
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className={cn("relative w-[300px]", className)} ref={containerRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-emerald-500/50 transition-all shadow-sm group"
>
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedSupplier ? selectedSupplier.nom : "Sélectionner un fournisseur..."}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<input
autoFocus
placeholder="Rechercher..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
</div>
) : (
filtered.map((f) => (
<button
key={f.code}
onClick={() => {
onSelect(f.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
f.code === selectedCode
? "bg-emerald-500/10 text-emerald-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
)}
>
<div className="flex flex-col min-w-0 pr-2">
<span className="truncate">{f.nom}</span>
<span className="text-[10px] opacity-50 font-mono">Code: {f.code}</span>
</div>
{f.code === selectedCode && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))
)}
{fournisseurs.length > 50 && !search && (
<div className="px-4 py-2 text-[10px] text-center text-slate-400 uppercase tracking-widest font-bold bg-slate-50/50 dark:bg-slate-800/50">
Saisissez pour filtrer les {fournisseurs.length} entrées
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
import { SupplierCombobox } from "./supplier-combobox";
import { useRouter } from "next/navigation";
interface Supplier {
code: string;
nom: string;
}
interface SupplierSelectionLandingProps {
fournisseurs: Supplier[];
}
export function SupplierSelectionLanding({ fournisseurs }: SupplierSelectionLandingProps) {
const router = useRouter();
const handleSelect = (code: string) => {
router.push(`/grid?fournisseur=${code}`);
};
return (
<div className="h-full flex flex-col items-center justify-center p-6 text-center animate-in fade-in zoom-in duration-500">
<div className="max-w-md space-y-6">
<div className="space-y-4">
<div className="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center mx-auto mb-2 text-emerald-600">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2" />
</svg>
</div>
<h1 className="text-4xl font-black tracking-tight text-slate-900 dark:text-white">CollectFlow</h1>
<p className="text-lg text-slate-500 dark:text-slate-400">
Optimisez votre assortiment en quelques clics. Sélectionnez un fournisseur pour commencer.
</p>
</div>
<div className="flex justify-center pt-4">
<SupplierCombobox
fournisseurs={fournisseurs}
selectedCode={null}
onSelect={handleSelect}
className="w-[400px] !h-12 text-lg shadow-xl"
/>
</div>
<div className="pt-8 text-[11px] text-slate-400 uppercase tracking-[0.2em] font-bold">
Accès Rapide • {fournisseurs.length} Fournisseurs Disponibles
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, Building2, ChevronRight } from "lucide-react";
interface Supplier {
code: string;
nom: string;
}
export function SupplierSelection({ fournisseurs }: { fournisseurs: Supplier[] }) {
const router = useRouter();
const [search, setSearch] = useState("");
const filtered = fournisseurs.filter((f) =>
f.nom.toLowerCase().includes(search.toLowerCase()) ||
f.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="max-w-4xl mx-auto py-12 px-6">
<div className="mb-10 text-center">
<h1 className="text-[32px] font-bold tracking-tight mb-2">Choisir un fournisseur</h1>
<p className="text-[15px]" style={{ color: "var(--text-secondary)" }}>
Sélectionnez un fournisseur pour commencer la révision de l'assortiment.
</p>
</div>
<div className="relative mb-8 max-w-xl mx-auto">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Rechercher par nom ou code..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="apple-input pl-12 h-14 text-lg w-full"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((f) => (
<button
key={f.code}
onClick={() => router.push(`/grid?fournisseur=${f.code}`)}
className="flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 hover:border-emerald-500/50 hover:bg-emerald-50/10 transition-all text-left shadow-sm group active:scale-[0.98]"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-500 group-hover:bg-emerald-500/10 group-hover:text-emerald-500 transition-colors">
<Building2 className="w-6 h-6" />
</div>
<div>
<div className="text-[17px] font-bold tracking-tight text-slate-900 dark:text-white">
{f.nom}
</div>
<div className="text-[13px] font-medium text-slate-500">
Code: {f.code}
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-emerald-500 transition-colors" />
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-20 bg-slate-50/50 dark:bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<p className="text-slate-500 font-medium">Aucun fournisseur trouvé pour "{search}"</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
"use client";
import { useCallback } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
import { GammeCode } from "@/types/grid";
/**
* Builds the payload from Zustand draftChanges and calls the Server Action.
* Returns a cleanup function called after successful save.
*/
export function useSaveDrafts(magasin: string) {
const { draftChanges, resetDrafts } = useGridStore();
const save = useCallback(async () => {
const changes = Object.entries(draftChanges).map(([codein, codeGamme]) => ({
codein,
magasin,
codeGamme: codeGamme as GammeCode,
}));
if (changes.length === 0) return { success: true, saved: 0 };
const result = await saveDraftChanges({ changes });
if (result.success) resetDrafts();
return result;
}, [draftChanges, magasin, resetDrafts]);
return { save, hasDrafts: Object.keys(draftChanges).length > 0, count: Object.keys(draftChanges).length };
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
interface GridState {
/** Source data from server */
rows: ProductRow[];
/** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>;
filters: GridFilters;
summary: GridSummary;
displayDensity: "compact" | "normal" | "comfortable";
// Actions
setRows: (rows: ProductRow[]) => void;
setDraftGamme: (codein: string, gamme: GammeCode) => void;
resetDrafts: () => void;
setFilter: (key: keyof GridFilters, value: string | null) => void;
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
}
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
const active = rows.filter((r) => {
const g = drafts[r.codein] ?? r.codeGamme;
return g !== "Z";
});
const totalCa = active.reduce((s, r) => s + (r.totalCa ?? 0), 0);
const totalMarge = active.reduce((s, r) => s + (r.totalMarge ?? 0), 0);
return {
totalRows: active.length,
totalQuantite: active.reduce((s, r) => s + (r.totalQuantite ?? 0), 0),
totalCa,
totalMarge,
tauxMargeGlobal: totalCa > 0 ? (totalMarge / totalCa) * 100 : 0,
};
}
export const useGridStore = create<GridState>()(
persist(
(set, get) => ({
rows: [],
draftChanges: {},
filters: {
magasin: null,
codeFournisseur: null,
code3: null,
codeGamme: null,
search: "",
},
summary: {
totalRows: 0,
totalQuantite: 0,
totalCa: 0,
totalMarge: 0,
tauxMargeGlobal: 0,
},
displayDensity: "normal",
setRows: (rows) => {
set({ rows, summary: computeSummary(rows, get().draftChanges) });
},
setDraftGamme: (codein, gamme) => {
const draftChanges = { ...get().draftChanges, [codein]: gamme };
set({ draftChanges, summary: computeSummary(get().rows, draftChanges) });
},
resetDrafts: () => {
set({ draftChanges: {}, summary: computeSummary(get().rows, {}) });
},
setFilter: (key, value) => {
set((state) => ({ filters: { ...state.filters, [key]: value } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
}),
{
name: "collectflow-grid-storage",
// Only persist filters and display density, do not persist data rows and drafts
partialize: (state) => ({
filters: state.filters,
displayDensity: state.displayDensity,
}),
}
)
);
@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { DEFAULT_SCORE_SETTINGS, type ScoreSettings } from "@/lib/score-engine";
interface ScoreSettingsState extends ScoreSettings {
setSeuilAxeFort: (v: number) => void;
setBonusParAxe: (v: number) => void;
resetDefaults: () => void;
}
export const useScoreSettingsStore = create<ScoreSettingsState>()(
persist(
(set) => ({
...DEFAULT_SCORE_SETTINGS,
setSeuilAxeFort: (v) => set({ seuilAxeFort: v }),
setBonusParAxe: (v) => set({ bonusParAxe: v }),
resetDefaults: () => set({ ...DEFAULT_SCORE_SETTINGS }),
}),
{
name: "collectflow-score-settings",
}
)
);
@@ -0,0 +1,45 @@
"use server";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { z } from "zod";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
nomFournisseur: z.string().optional(),
magasin: z.string(),
label: z.string().optional(),
changes: z.record(z.string(), z.object({
before: z.string().nullable(),
after: z.string(),
})),
summary: z.object({
totalRows: z.number(),
totalCa: z.number(),
totalMarge: z.number(),
tauxMargeGlobal: z.number(),
}).optional(),
});
export async function saveSnapshot(raw: unknown) {
const parsed = SaveSnapshotSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: "Validation failed: " + parsed.error.message };
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary } = parsed.data;
const [created] = await db
.insert(sessionSnapshots)
.values({
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: label ?? `Snapshot — ${new Date().toLocaleDateString("fr-FR")}`,
changes,
summaryJson: summary ?? null,
})
.returning({ id: sessionSnapshots.id });
return { success: true, snapshotId: created?.id };
}
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github"; // Example provider
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
});
+89
View File
@@ -0,0 +1,89 @@
import type { ProductRow, GammeCode } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
const FOURNISSEURS = Array.from({ length: 125 }).map((_, i) => ({
code: `F${String(i + 1).padStart(3, "0")}`,
nom: [
"HENKEL DISTRIBUTION", "GLAMA INTERNATIONAL", "FRANCO & FILS", "L'ORÉAL PARIS", "UNILEVER SERVICES",
"PROCTER & GAMBLE", "JOHNSON & JOHNSON", "NESTLÉ RETAIL", "DANONE DISTRIBUTION", "FERRERO FRANCE"
][i % 10] + (i > 9 ? ` - Entrepôt ${Math.floor(i / 10)}` : "")
}));
const GAMMES: GammeCode[] = ["A", "A", "A", "B", "B", "C", "Z"];
const NOMENCLATURES = [
{ code: "320211", libelle: "Entretien Maison" },
{ code: "310105", libelle: "Hygiène Corporelle" },
{ code: "410203", libelle: "Cosmétiques Femme" },
{ code: "220401", libelle: "Alimentation Bio" },
{ code: "510302", libelle: "Bricolage & Outillage" },
];
const PRODUITS = [
"Lessive Liquide 3L", "Gel Douche Vanille 500ml", "Shampoing Éclat 400ml",
"Crème Hydratante SPF50", "Désinfectant Multi-Surface 1L", "Liquide Vaisselle Citron 750ml",
"Déodorant Bille 50ml", "Après-Shampooing 300ml", "Mousse Rasage 200ml",
"Dentifrice Blancheur 100ml", "Savon Solide Surgras 100g", "Crème Mains Nourrissante 75ml",
"Nettoyant WC 500ml", "Assouplissant Fleurs Blanches 2L", "Spray Cuisine Dégraissant",
"Gel Hydroalcoolique 500ml", "Lingettes Bébé x72", "Couches T4 x44",
"Lait Corps Karité 400ml", "Masque Visage Argile 100ml", "Fond de Teint N°03",
"Rouge à Lèvres Corail", "Mascara Allongeant Noir", "Eyeliner Fin Brun",
"Vernis Ongles Rose", "Blush Pêche Doux", "Palette Ombres 12 teintes",
"Huile Corps Éclat 150ml", "Sérum Anti-Âge 30ml", "Contour Yeux 15ml",
];
function rnd(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function generateSales12m(): Record<string, number> {
const sales: Record<string, number> = {};
const now = new Date(2026, 1, 1); // Feb 2026
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`;
// Simulate seasonality + some zeros
const base = rnd(0, 200);
sales[key] = base < 30 ? 0 : base;
}
return sales;
}
export function generateMockRows(codeFournisseur: string = "F001", count: number = 80): ProductRow[] {
const rows: ProductRow[] = [];
const fournisseur = FOURNISSEURS.find((f) => f.code === codeFournisseur) ?? FOURNISSEURS[0];
for (let i = 0; i < count; i++) {
const sales12m = generateSales12m();
const totalQuantite = Object.values(sales12m).reduce((s, v) => s + v, 0);
const prixUnitaire = rnd(3, 45);
const totalCa = totalQuantite * prixUnitaire * (1 + rnd(0, 30) / 100);
const tauxMarge = rnd(12, 58);
const totalMarge = totalCa * tauxMarge / 100;
const nomenclature = NOMENCLATURES[i % NOMENCLATURES.length];
const gamme = GAMMES[Math.floor(Math.random() * GAMMES.length)];
rows.push({
codein: `${fournisseur.code}-${String(i + 1).padStart(4, "0")}`,
codeFournisseur: fournisseur.code,
nomFournisseur: fournisseur.nom,
libelle1: PRODUITS[i % PRODUITS.length],
gtin: `340${String(rnd(100000000, 999999999))}`,
reference: `REF${String(rnd(10000, 99999))}`,
code3: nomenclature.code,
libelle3: nomenclature.libelle,
codeGamme: gamme,
codeGammeDraft: null,
sales12m,
totalQuantite,
totalCa: Math.round(totalCa * 100) / 100,
totalMarge: Math.round(totalMarge * 100) / 100,
tauxMarge,
score: 0, // Computed below by computeProductScores
aiRecommendation: null,
});
}
return computeProductScores(rows);
}
export { FOURNISSEURS };
+69
View File
@@ -0,0 +1,69 @@
/**
* CollectFlow — Score Engine (Proposition B: MAX + Bonus polyvalence)
*
* Calcule le score de performance d'un produit en fonction de son poids
* relatif chez le fournisseur sur 3 axes : Quantité, CA, Marge.
*
* Formule :
* poids = produit.metric / total_fournisseur × 100
* base = MAX(poids_qty, poids_ca, poids_marge)
* bonus = count(poids > seuil) × bonusParAxe
* score = MIN(base + bonus, 100)
*/
import type { ProductRow } from "@/types/grid";
export interface ScoreSettings {
/** Seuil en % pour considérer un axe comme "fort" (défaut: 2.0) */
seuilAxeFort: number;
/** Points bonus par axe dépassant le seuil (défaut: 5) */
bonusParAxe: number;
}
export const DEFAULT_SCORE_SETTINGS: ScoreSettings = {
seuilAxeFort: 30.0,
bonusParAxe: 10,
};
/**
* Calcule les scores pour un ensemble de produits d'un même fournisseur.
* Les rows sont modifiées en place (champ `score`) et retournées.
*/
export function computeProductScores(
rows: ProductRow[],
settings: ScoreSettings = DEFAULT_SCORE_SETTINGS
): ProductRow[] {
if (rows.length === 0) return rows;
// 1. Trouver les valeurs maximales (les "Top 1") pour indexer 0-100
const maxQty = Math.max(...rows.map((r) => r.totalQuantite ?? 0));
const maxCa = Math.max(...rows.map((r) => r.totalCa ?? 0));
const maxMarge = Math.max(...rows.map((r) => r.totalMarge ?? 0));
// 2. Pour chaque produit, calculer le score relatif au Top 1
for (const row of rows) {
// Score de 0 à 100 sur chaque axe (le Top 1 a 100)
const scoreQty = maxQty > 0 ? ((row.totalQuantite ?? 0) / maxQty) * 100 : 0;
const scoreCa = maxCa > 0 ? ((row.totalCa ?? 0) / maxCa) * 100 : 0;
const scoreMarge = maxMarge > 0 ? ((row.totalMarge ?? 0) / maxMarge) * 100 : 0;
// Base = meilleur score d'axe
const base = Math.max(scoreQty, scoreCa, scoreMarge);
// Bonus polyvalence : combien d'axes dépassent le seuil demandé (en %, c.a.d en score d'axe)
// Attention : settings.seuilAxeFort était pensé pour un % du Total (~2%).
// Maintenant le score max étant 100, un "axe fort" devrait plutôt être un paramètre indexé (ex: > 30% du max).
// On va garder la règle, mais l'utilisateur devra probablement ajuster le seuil dans les paramètres.
let axesForts = 0;
if (scoreQty > settings.seuilAxeFort) axesForts++;
if (scoreCa > settings.seuilAxeFort) axesForts++;
if (scoreMarge > settings.seuilAxeFort) axesForts++;
const bonus = axesForts * settings.bonusParAxe;
row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10;
}
return rows;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+8
View File
@@ -0,0 +1,8 @@
export { auth as middleware } from "@/lib/auth";
export const config = {
// Next.js 16+: use 'matcher' with App Router routes only
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$).*)",
],
};
+71
View File
@@ -0,0 +1,71 @@
/**
* CollectFlow - Core Grid Types
* Maps to the `ventes_produits` table via Drizzle ORM (camelCase).
*/
/** One row in the data grid, representing a product at a store for one period. */
export interface VenteProduit {
id: number;
codein: string;
codeFournisseur: string | null;
nomFournisseur: string | null;
libelle1: string | null;
gtin: string | null;
reference: string | null;
codeGamme: GammeCode | null;
code3: string | null;
libelle3: string | null;
magasin: string;
codeMagasin: string | null;
annee: number | null;
mois: number | null;
periode: string;
quantite: number | null;
montantMvt: number | null;
margeMvt: number | null;
}
/** A/B/C/Z status codes for product categories */
export type GammeCode = "A" | "B" | "C" | "Z";
/** A product row with its 12-month sales history and computed KPIs */
export interface ProductRow {
codein: string;
codeFournisseur: string;
nomFournisseur: string;
libelle1: string;
gtin: string;
reference: string;
code3: string;
libelle3: string;
codeGamme: GammeCode | null;
/** Draft gamme value before saving */
codeGammeDraft: GammeCode | null;
/** 12-month sales quantities, indexed by YYYYMM key */
sales12m: Record<string, number>;
totalQuantite: number;
totalCa: number;
totalMarge: number;
tauxMarge: number;
/** Combined performance score (0-10) */
score: number;
aiRecommendation?: string | null;
}
/** Summary bar totals for the currently visible/filtered rows */
export interface GridSummary {
totalRows: number;
totalQuantite: number;
totalCa: number;
totalMarge: number;
tauxMargeGlobal: number;
}
/** Filters applied to the grid */
export interface GridFilters {
magasin: string | null;
codeFournisseur: string | null;
code3: string | null;
codeGamme: GammeCode | null;
search: string;
}