mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(grid): ajout algorithme score produit avec parametrage dynamique et page d'aide
This commit is contained in:
1 parent
f540e4f2b3
commit
3544c0c898
67 files changed
+19476
-15
No files matched your search
@@ -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'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">« Exporter »</strong>.</p>
|
||||
<p className="text-slate-600 text-xs mt-1">L'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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>< 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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">« Valider »</strong> dans la grille pour créer votre premier snapshot.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/grid");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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" },
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 })} €
|
||||
</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 })} €
|
||||
</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'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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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$).*)",
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user