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

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

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
export default function ExportsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Exports</h1>
<p className="text-sm text-slate-400 mt-1">Génération des fichiers Excel différentiels pour l&apos;ERP.</p>
</div>
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
<p className="text-slate-500 text-sm">Sélectionnez un fournisseur dans la grille, puis cliquez sur <strong className="text-slate-400">«&nbsp;Exporter&nbsp;»</strong>.</p>
<p className="text-slate-600 text-xs mt-1">L&apos;export génère un fichier <code className="text-slate-500">.xlsx</code> avec le delta avant/après des gammes révisées.</p>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions";
import { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
import { Suspense } from "react";
interface GridPageProps {
searchParams: Promise<{ fournisseur?: string; magasin?: string }>;
}
export default async function GridPage({ searchParams }: GridPageProps) {
const params = await searchParams;
const codeFournisseur = params.fournisseur;
const magasin = params.magasin ?? "TOTAL";
// 1. Fetch available suppliers & stores
const [fournisseurs, magasins] = await Promise.all([
getFournisseurs(),
getMagasins()
]);
// 2. If no supplier selected, show compact selection UI
if (!codeFournisseur) {
return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
}
// 3. Load real product data for the selected supplier
const rows = await getGridData(codeFournisseur, magasin);
const selectedFournisseur = fournisseurs.find((f) => f.code === codeFournisseur);
return (
<GridClient
initialRows={rows}
codeFournisseur={codeFournisseur}
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
fournisseurs={fournisseurs}
magasins={magasins}
magasin={magasin}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Sidebar } from "@/components/shared/sidebar";
import { Header } from "@/components/shared/header";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: "var(--bg-base)" }}>
<Sidebar />
<main className="flex-1 ml-60 flex flex-col h-full overflow-hidden">
<Header />
<div className="flex-1 overflow-hidden">
{children}
</div>
</main>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { BarChart3, ArrowRight, TrendingUp, Award, Layers } from "lucide-react";
import Link from "next/link";
function InfoCard({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
return (
<div
className="rounded-[14px] overflow-hidden"
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
>
<div className="px-5 py-4 flex items-center gap-2.5" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={2} />
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
</div>
<div className="px-5 py-5" style={{ background: "var(--bg-surface)" }}>
{children}
</div>
</div>
);
}
function FormulaStep({ step, title, formula }: { step: number; title: string; formula: string }) {
return (
<div className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold flex-shrink-0 mt-0.5"
style={{ background: "var(--accent-bg)", color: "var(--accent)", border: "1px solid var(--accent-border)" }}
>
{step}
</div>
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>{title}</p>
<code className="text-[12px] mt-1 block px-3 py-2 rounded-[8px] font-mono" style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)", border: "1px solid var(--border)" }}>
{formula}
</code>
</div>
</div>
);
}
function ScoreBadge({ value, label }: { value: string; label: string }) {
const num = parseFloat(value);
const color = num >= 80 ? "text-emerald-500" : num >= 50 ? "text-amber-500" : "text-rose-500";
const bg = num >= 80 ? "rgba(16, 185, 129, 0.1)" : num >= 50 ? "rgba(245, 158, 11, 0.1)" : "rgba(244, 63, 94, 0.1)";
const borderColor = num >= 80 ? "rgba(16, 185, 129, 0.2)" : num >= 50 ? "rgba(245, 158, 11, 0.2)" : "rgba(244, 63, 94, 0.2)";
return (
<div className="text-center">
<div
className={`text-[18px] font-black ${color} inline-flex items-center justify-center w-12 h-12 rounded-[10px]`}
style={{ background: bg, border: `1px solid ${borderColor}` }}
>
{value}
</div>
<p className="text-[11px] mt-1.5 font-medium" style={{ color: "var(--text-muted)" }}>{label}</p>
</div>
);
}
export default function ScorePage() {
return (
<div className="p-6 space-y-6 max-w-2xl overflow-y-auto h-full">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
Score Produit
</h1>
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>
Comprenez comment chaque produit est évalué pour le référencement.
</p>
</div>
{/* Principe fondamental */}
<InfoCard icon={Award} title="Principe fondamental">
<div
className="rounded-[10px] px-4 py-3"
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}
>
<p className="text-[13px] font-medium" style={{ color: "var(--accent)" }}>
Un produit excellent sur <strong>une seule dimension</strong> suffit à en faire un bon produit.
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-secondary)" }}>
Un produit qui fait 30% du volume du fournisseur est un bon produit même si sa marge est faible.
</p>
</div>
</InfoCard>
{/* Les 3 axes */}
<InfoCard icon={Layers} title="Les 3 axes d'évaluation">
<div className="space-y-3">
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
Chaque produit est évalué sur son <strong>poids relatif par rapport au meilleur produit</strong> :
</p>
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Quantité", desc: "Volume vendu", icon: "📦" },
{ label: "Chiffre d'Affaires", desc: "Montant total", icon: "💰" },
{ label: "Marge", desc: "Profit généré", icon: "📈" },
].map(({ label, desc, icon }) => (
<div
key={label}
className="px-3 py-3 rounded-[10px] text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<div className="text-lg">{icon}</div>
<p className="text-[12px] font-semibold mt-1" style={{ color: "var(--text-primary)" }}>{label}</p>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{desc}</p>
</div>
))}
</div>
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
Poids = (métrique du produit ÷ métrique du Produit N°1) × 100
</p>
</div>
</InfoCard>
{/* Formule */}
<InfoCard icon={TrendingUp} title="Formule de calcul">
<div className="space-y-4">
<FormulaStep
step={1}
title="Calculer les 3 scores de base (indexés sur le TOP 100)"
formula="score_axe = produit.métrique / top1_fournisseur × 100"
/>
<FormulaStep
step={2}
title="Prendre le meilleur axe (MAX)"
formula="base = MAX(poids_qty, poids_ca, poids_marge)"
/>
<FormulaStep
step={3}
title="Ajouter le bonus polyvalence"
formula="bonus = nb_axes_forts × bonus_par_axe"
/>
<FormulaStep
step={4}
title="Score final"
formula="score = MIN(base + bonus, 100)"
/>
<div
className="rounded-[10px] px-4 py-3 mt-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
<strong>Axe fort</strong> = un axe dont le poids dépasse le seuil (configurable dans Paramètres).
Un produit peut obtenir un bonus s'il excelle sur 2 ou 3 axes à la fois.
</p>
</div>
</div>
</InfoCard>
{/* Signification des couleurs */}
<InfoCard icon={BarChart3} title="Lecture du score">
<div className="flex items-center justify-center gap-6 py-2">
<ScoreBadge value="85" label="Excellent" />
<ScoreBadge value="55" label="Correct" />
<ScoreBadge value="12" label="Faible" />
</div>
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>≥ 80</strong> — Produit stratégique, très forte contribution</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>50 – 79</strong> — Contribution significative, à conserver</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>&lt; 50</strong> — Contribution faible, à réévaluer</p>
</div>
</div>
</InfoCard>
{/* Lien vers les paramètres */}
<Link
href="/settings"
className="flex items-center gap-2 px-4 py-3 rounded-[10px] transition-all hover:gap-3"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--accent)" }}
>
<span className="text-[13px] font-medium">Ajuster les paramètres du score</span>
<ArrowRight className="w-4 h-4 ml-auto" />
</Link>
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
"use client";
import { useState, useCallback } from "react";
import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, BarChart3, RotateCcw } from "lucide-react";
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store";
interface OpenRouterModel { id: string; name: string; free: boolean; }
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section
className="rounded-[14px] overflow-hidden"
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
>
<div className="px-5 py-4" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
{subtitle && <p className="text-[12px] mt-0.5" style={{ color: "var(--text-secondary)" }}>{subtitle}</p>}
</div>
<div className="px-5 py-5 space-y-4" style={{ background: "var(--bg-surface)" }}>
{children}
</div>
</section>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium" style={{ color: "var(--text-secondary)" }}>{label}</label>
{children}
{hint && <p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{hint}</p>}
</div>
);
}
export default function SettingsPage() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
const setSeuilAxeFort = useScoreSettingsStore((s) => s.setSeuilAxeFort);
const setBonusParAxe = useScoreSettingsStore((s) => s.setBonusParAxe);
const resetScoreDefaults = useScoreSettingsStore((s) => s.resetDefaults);
const [showKey, setShowKey] = useState(false);
const [apiKey, setApiKey] = useState("");
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [saveStatus, setSaveStatus] = useState<"idle" | "saved">("idle");
const [models, setModels] = useState<OpenRouterModel[]>([]);
const [selectedModel, setSelectedModel] = useState("google/gemini-flash-1.5");
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const fetchModels = useCallback(async (key: string) => {
if (!key.trim()) return;
setModelsStatus("loading");
try {
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
if (!res.ok) throw new Error();
const data = await res.json();
setModels(data.models ?? []);
setModelsStatus("ok");
} catch {
setModelsStatus("error");
}
}, []);
const testConnection = async () => {
setDbStatus("testing");
await new Promise((r) => setTimeout(r, 1200));
setDbStatus("error");
};
return (
<div className="space-y-6 max-w-xl">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>Paramètres</h1>
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>Configuration de la connexion et des services.</p>
</div>
{/* PostgreSQL */}
<Section title="PostgreSQL" subtitle="Connexion à votre instance Docker ou distante">
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
<input type="text" placeholder="localhost" className="apple-input font-mono" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Port">
<input type="text" placeholder="5432" defaultValue="5432" className="apple-input font-mono" />
</Field>
<Field label="Base de données">
<input type="text" placeholder="collectflow" className="apple-input font-mono" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Utilisateur">
<input type="text" placeholder="postgres" className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<input type="password" placeholder="••••••••" className="apple-input font-mono" />
</Field>
</div>
<div className="flex items-center gap-2.5">
<input type="checkbox" id="ssl" className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer" />
<label htmlFor="ssl" className="text-[12px] cursor-pointer" style={{ color: "var(--text-secondary)" }}>Connexion SSL</label>
</div>
<div className="flex items-center gap-3">
<button onClick={testConnection} disabled={dbStatus === "testing"} className="apple-btn-secondary">
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
Tester la connexion
</button>
{dbStatus === "error" && (
<span className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Connexion refusée — vérifiez les paramètres</span>
)}
</div>
</Section>
{/* OpenRouter */}
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
placeholder="sk-or-v1-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
>
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
</button>
</div>
<button
onClick={() => fetchModels(apiKey)}
disabled={!apiKey || modelsStatus === "loading"}
className="apple-btn-secondary whitespace-nowrap"
>
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Charger les modèles
</button>
</div>
</Field>
<Field
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={models.length === 0}
className="apple-input"
>
{models.length === 0
? <option>— Modèles non chargés —</option>
: models.map((m) => (
<option key={m.id} value={m.id}>
{m.free ? "🆓 " : ""}{m.name}
</option>
))
}
</select>
</Field>
</Section>
{/* Apparence */}
<Section title="Apparence">
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Thème</p>
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Interface claire ou sombre</p>
</div>
{/* Segment control */}
<div className="segment-control">
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
<Sun className="w-3.5 h-3.5" /> Clair
</button>
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
<Moon className="w-3.5 h-3.5" /> Sombre
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Densité de la grille</p>
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Hauteur de ligne par défaut</p>
</div>
<select
className="apple-input w-auto"
value={displayDensity}
onChange={(e) => setDisplayDensity(e.target.value as any)}
>
<option value="compact">Compact (32px)</option>
<option value="normal">Normal (40px)</option>
<option value="comfortable">Spacieux (48px)</option>
</select>
</div>
</Section>
{/* Score Produit */}
<Section title="Score Produit" subtitle="Paramètres de l'algorithme d'évaluation des performances">
<Field
label={`Seuil d'axe fort : ${seuilAxeFort.toFixed(1)}%`}
hint="Pourcentage minimum du poids fournisseur pour qu'une dimension (Quantité, CA, Marge) soit considérée comme un point fort du produit."
>
<div className="flex items-center gap-4">
<input
type="range"
min="5"
max="100"
step="5"
value={seuilAxeFort}
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value))}
className="flex-1 accent-emerald-500 cursor-pointer"
/>
<div className="relative w-20">
<input
type="number"
min="5"
max="100"
step="5"
value={seuilAxeFort}
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value) || 30.0)}
className="apple-input font-mono-nums text-right pr-6"
/>
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
</div>
</div>
</Field>
<Field
label={`Bonus par axe : +${bonusParAxe} pts`}
hint="Points supplémentaires accordés au score final pour chaque axe dépassant le seuil."
>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="20"
step="1"
value={bonusParAxe}
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10))}
className="flex-1 accent-emerald-500 cursor-pointer"
/>
<div className="w-20">
<input
type="number"
min="0"
max="20"
step="1"
value={bonusParAxe}
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10) || 0)}
className="apple-input font-mono-nums text-right"
/>
</div>
</div>
</Field>
<div className="pt-2">
<button
onClick={resetScoreDefaults}
className="flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-emerald-500"
style={{ color: "var(--text-secondary)" }}
>
<RotateCcw className="w-3.5 h-3.5" />
Rétablir les valeurs par défaut
</button>
</div>
</Section>
{/* Save */}
<div className="flex items-center gap-3">
<button
onClick={() => { setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2500); }}
className="apple-btn-primary"
style={{ background: "var(--accent)" }}
>
{saveStatus === "saved" ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{saveStatus === "saved" ? "Sauvegardé !" : "Sauvegarder"}
</button>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export default function SnapshotsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Snapshots</h1>
<p className="text-sm text-slate-400 mt-1">Historique de vos sessions d&apos;arbitrage sauvegardées.</p>
</div>
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
<p className="text-slate-500 text-sm">Aucun snapshot enregistré.</p>
<p className="text-slate-600 text-xs mt-1">Utilisez le bouton <strong className="text-slate-500">«&nbsp;Valider&nbsp;»</strong> dans la grille pour créer votre premier snapshot.</p>
</div>
</div>
);
}