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

@@ -0,0 +1,69 @@
"use client";
import { Bot, Loader2, AlertCircle, Sparkles } from "lucide-react";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import type { ProductRow } from "@/types/grid";
import { cn } from "@/lib/utils";
interface AiInsightBlockProps {
row: ProductRow;
}
export function AiInsightBlock({ row }: AiInsightBlockProps) {
const { insights, analyzeProduct } = useAiCopilotStore();
const insight = insights[row.codein];
const status = insight?.status ?? "idle";
const handleAnalyze = () => {
if (status === "loading") return;
analyzeProduct({
codein: row.codein,
libelle1: row.libelle1,
totalCa: row.totalCa,
tauxMarge: row.tauxMarge,
totalQuantite: row.totalQuantite,
sales12m: row.sales12m,
codeGamme: row.codeGamme,
});
};
if (status === "idle") {
return (
<button
onClick={handleAnalyze}
className="flex items-center gap-1.5 text-[11px] font-medium transition-colors group"
style={{ color: "var(--text-secondary)" }}
>
<Sparkles className="w-3 h-3 text-indigo-500 dark:text-indigo-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors" />
<span className="group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors">Analyser</span>
</button>
);
}
if (status === "loading") {
return (
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-3 h-3 animate-spin" />
<span className="italic">Analyse IA...</span>
</div>
);
}
if (status === "error") {
return (
<div className="flex items-center gap-1.5 text-[11px] text-rose-600 dark:text-rose-500">
<AlertCircle className="w-3 h-3" />
<span>Erreur</span>
</div>
);
}
return (
<div className="flex items-start gap-1.5 group cursor-pointer" onClick={handleAnalyze} title="Cliquer pour re-analyser">
<Bot className="w-3.5 h-3.5 mt-0.5 text-indigo-600 dark:text-indigo-400 shrink-0" />
<span className={cn("text-[11px] italic line-clamp-2 leading-[1.3]")} style={{ color: "var(--text-secondary)" }}>
{insight?.insight}
</span>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import { create } from "zustand";
interface AiInsight {
codein: string;
insight: string;
status: "idle" | "loading" | "done" | "error";
}
interface AiCopilotState {
insights: Record<string, AiInsight>;
analyzeProduct: (payload: {
codein: string;
libelle1: string;
totalCa: number;
tauxMarge: number;
totalQuantite: number;
sales12m: Record<string, number>;
codeGamme: string | null;
}) => Promise<void>;
}
export const useAiCopilotStore = create<AiCopilotState>((set) => ({
insights: {},
analyzeProduct: async (payload) => {
const { codein } = payload;
// Mark as loading
set((s) => ({
insights: { ...s.insights, [codein]: { codein, insight: "", status: "loading" } },
}));
try {
const res = await fetch("/api/ai/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: data.insight, status: "done" },
},
}));
} catch {
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: "Erreur lors de l'analyse.", status: "error" },
},
}));
}
},
}));
+41
View File
@@ -0,0 +1,41 @@
"use server";
import { FOURNISSEURS, generateMockRows } from "@/lib/mock-data";
import type { ProductRow } from "@/types/grid";
/**
* Get the list of all unique suppliers (Mocked for now).
*/
export async function getFournisseurs() {
// Simulating a database latency
await new Promise((r) => setTimeout(r, 100));
return FOURNISSEURS;
}
/**
* Get the list of all magasins (Mocked).
*/
export async function getMagasins() {
await new Promise((r) => setTimeout(r, 50));
return [
{ code: "M001", nom: "BORDEAUX LAC" },
{ code: "M002", nom: "TOULOUSE BLAGNAC" },
{ code: "M003", nom: "NANTES BEAUJOIRE" },
{ code: "M004", nom: "LILLE FLANDRES" },
{ code: "M005", nom: "MARSEILLE VALENTINE" },
];
}
/**
* Get grid data for a specific supplier and store (Mocked for now).
* In SQL:
* - If magasin === "TOTAL": SELECT SUM(quantite), AVG(marge)... GROUP BY produit_id
* - If magasin === "M001": SELECT quantite, marge... WHERE magasin_id = 'M001'
*/
export async function getGridData(codeFournisseur: string, magasin: string = "TOTAL"): Promise<ProductRow[]> {
// Simulating a database latency
await new Promise((r) => setTimeout(r, 300));
// Use the existing mock generator - passing magasin to see how it affects data
return generateMockRows(codeFournisseur, 85);
}
+69
View File
@@ -0,0 +1,69 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and, ne, sql } from "drizzle-orm";
import type { ProductRow, GammeCode } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
interface GetProductRowsInput {
codeFournisseur: string;
magasin?: string;
}
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL" } = input;
// Get all rows for this supplier (excluding the synthetic TOTAL period)
const rows = await db
.select()
.from(ventesProduits)
.where(
and(
eq(ventesProduits.codeFournisseur, codeFournisseur),
ne(ventesProduits.periode, "TOTAL"),
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
)
);
// Group by codein and aggregate monthly sales
const productMap = new Map<string, ProductRow>();
for (const row of rows) {
const existing = productMap.get(row.codein);
const monthKey = row.periode;
const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0"));
const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0"));
const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0"));
if (existing) {
existing.sales12m[monthKey] = (existing.sales12m[monthKey] ?? 0) + qty;
existing.totalQuantite += qty;
existing.totalCa += ca;
existing.totalMarge += marge;
existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0;
} else {
productMap.set(row.codein, {
codein: row.codein,
codeFournisseur: row.codeFournisseur ?? "",
nomFournisseur: row.nomFournisseur ?? "",
libelle1: row.libelle1 ?? "",
gtin: row.gtin ?? "",
reference: row.reference ?? "",
code3: row.code3 ?? "",
libelle3: row.libelle3 ?? "",
codeGamme: (row.codeGamme as GammeCode | null),
codeGammeDraft: null,
sales12m: { [monthKey]: qty },
totalQuantite: qty,
totalCa: ca,
totalMarge: marge,
tauxMarge: ca > 0 ? (marge / ca) * 100 : 0,
score: 0,
aiRecommendation: null,
});
}
}
return computeProductScores(Array.from(productMap.values()));
}
@@ -0,0 +1,49 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
const SaveDraftsSchema = z.object({
changes: z.array(
z.object({
codein: z.string(),
magasin: z.string(),
codeGamme: z.enum(["A", "B", "C", "Z"]),
})
),
});
export async function saveDraftChanges(
raw: unknown
): Promise<{ success: boolean; saved: number; error?: string }> {
const parsed = SaveDraftsSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
}
const { changes } = parsed.data;
try {
// Batch update all rows for given codein × magasin
await Promise.all(
changes.map(({ codein, magasin, codeGamme }) =>
db
.update(ventesProduits)
.set({ codeGamme })
.where(
and(
eq(ventesProduits.codein, codein),
eq(ventesProduits.magasin, magasin)
)
)
)
);
return { success: true, saved: changes.length };
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return { success: false, saved: 0, error: msg };
}
}
@@ -0,0 +1,56 @@
"use client";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
const GAMME_OPTIONS: { label: string; code: GammeCode; color: string }[] = [
{ label: "A — Cœur", code: "A", color: "border-emerald-600 text-emerald-400 hover:bg-emerald-900/20" },
{ label: "B — Complémentaire", code: "B", color: "border-blue-600 text-blue-400 hover:bg-blue-900/20" },
{ label: "C — Saisonnier", code: "C", color: "border-amber-600 text-amber-400 hover:bg-amber-900/20" },
{ label: "Z — Sortie", code: "Z", color: "border-rose-600 text-rose-400 hover:bg-rose-900/20" },
];
interface BulkActionToolbarProps {
selectedCodeins: string[];
onClearSelection: () => void;
}
export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) {
const { setDraftGamme } = useGridStore();
if (selectedCodeins.length === 0) return null;
const applyBulk = (code: GammeCode) => {
selectedCodeins.forEach((codein) => setDraftGamme(codein, code));
onClearSelection();
};
return (
<div className="flex items-center gap-3 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
</span>
<div className="h-4 w-px bg-slate-700/50" />
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-tight">Appliquer en masse :</span>
{GAMME_OPTIONS.map(({ label, code, color }) => (
<button
key={code}
onClick={() => applyBulk(code)}
className={cn(
"px-3 py-1 text-xs font-bold border rounded transition-colors",
color
)}
>
{label}
</button>
))}
<button
onClick={onClearSelection}
className="ml-auto text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Annuler
</button>
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
interface FinancialCellProps {
totalCa: number;
totalMarge: number;
tauxMarge: number;
}
function getMarginColor(pct: number): string {
if (pct >= 45) return "#10b981"; // emerald-500
if (pct >= 20) return "#f59e0b"; // amber-500
return "#f43f5e"; // rose-500
}
export function FinancialCell({ totalCa, totalMarge, tauxMarge }: FinancialCellProps) {
const isHighMargin = tauxMarge >= 45;
const isMedMargin = tauxMarge >= 20 && tauxMarge < 45;
let textAccent = "var(--accent-error)";
let bgAccent = "var(--accent-error-bg)";
if (isHighMargin) {
textAccent = "var(--accent-success)";
bgAccent = "var(--accent-success-bg)";
} else if (isMedMargin) {
textAccent = "var(--accent-warning)";
bgAccent = "var(--accent-warning-bg)";
}
return (
<div
className="flex flex-col items-center justify-center rounded-lg py-1 shadow-sm w-full max-w-[130px] mx-auto transition-colors border-apple"
style={{
background: bgAccent,
borderColor: "transparent"
}}
>
<span
className="font-mono-nums text-[13px] font-bold whitespace-nowrap"
style={{ color: textAccent }}
>
{totalCa.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
<div className="flex items-center gap-1.5 mt-0.5 opacity-80">
<span
className="font-mono-nums text-[10px] px-1 py-0.5 rounded font-bold whitespace-nowrap bg-white/20 dark:bg-black/20"
style={{ color: textAccent }}
>
{tauxMarge.toFixed(1)}%
</span>
<span className="font-mono-nums text-[10px] font-semibold text-slate-500 dark:text-slate-400 whitespace-nowrap">
{totalMarge.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useGridStore } from "@/features/grid/store/use-grid-store";
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
// Determine color based on label to match the prototype
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
return (
<div className="flex flex-col items-center">
<span className="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-xl ${valColor}`}>{value}</span>
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
</div>
);
}
export function FloatingSummaryBar() {
const { summary, draftChanges } = useGridStore();
const hasDrafts = Object.keys(draftChanges).length > 0;
return (
<div
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
style={{
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
}}
>
<div className="text-xs font-bold px-4 py-2 rounded-full border flex items-center gap-2 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-500">
{hasDrafts && (
<span className="ml-2 text-[10px] font-black uppercase text-amber-600 dark:text-amber-500 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded border border-amber-200 dark:border-amber-800">
{Object.keys(draftChanges).length} modif.
</span>
)}
</div>
<div className="flex space-x-12 items-center">
<Stat
label="Volume Total Vendu"
value={Math.round(summary.totalQuantite).toLocaleString("fr-FR")}
/>
<Stat
label="CA Global Généré"
value={summary.totalCa.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
/>
<Stat
label="Taux de Marge Moyen"
value={`${summary.tauxMargeGlobal.toFixed(1)}%`}
sub={summary.totalMarge.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
/>
</div>
<div className="flex space-x-3">
<button
className="px-6 py-3 rounded-xl text-sm font-bold transition-all active:scale-95 border hover:brightness-105"
style={{
background: "var(--action-secondary-bg)",
color: "var(--action-secondary-text)",
borderColor: "var(--action-secondary-border)"
}}
>
Export Excel
</button>
<button
className="px-8 py-3 rounded-xl text-sm font-black transition-all shadow-lg active:scale-95 text-white hover:brightness-110"
style={{
background: "var(--brand-solid)",
}}
>
Valider ce Fournisseur
</button>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
interface GammeSelectProps {
value: GammeCode | null;
isDraft: boolean;
onChange: (gamme: GammeCode) => void;
}
const GAMME_STYLES: Record<GammeCode, string> = {
A: "border-[2px] border-emerald-500 bg-emerald-50 text-emerald-900 dark:border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-100",
B: "border-[2px] border-blue-500 bg-blue-50 text-blue-900 dark:border-blue-400 dark:bg-blue-900/40 dark:text-blue-100",
C: "border-[2px] border-amber-500 bg-amber-50 text-amber-900 dark:border-amber-400 dark:bg-amber-900/40 dark:text-amber-100",
Z: "border-[2px] border-rose-600 bg-rose-50 text-rose-900 dark:border-rose-500 dark:bg-rose-900/50 dark:text-rose-100",
};
export function GammeSelect({ value, isDraft, onChange }: GammeSelectProps) {
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value as GammeCode)}
className={cn(
"w-full text-xs font-black rounded-lg py-2 px-2 outline-none cursor-pointer shadow-sm transition-all text-center",
"focus:ring-2 focus:ring-brand-500/20",
value
? GAMME_STYLES[value]
: "border-2 border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-500",
isDraft && "shadow-[0_0_10px_rgba(251,191,36,0.5)] border-amber-400"
)}
>
<option value="" disabled>—</option>
<option value="A">A - Cœur</option>
<option value="B">B - Standard</option>
<option value="C">C - Optionnel</option>
<option value="Z">Z - Sortie</option>
</select>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store";
import { computeProductScores } from "@/lib/score-engine";
interface GridClientProps {
initialRows: ProductRow[];
codeFournisseur: string;
nomFournisseur: string;
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
magasin: string;
}
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const setRows = useGridStore((s) => s.setRows);
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const { save, hasDrafts, count } = useSaveDrafts(magasin);
useEffect(() => {
// Deep clone to avoid mutating the React prop directly across re-renders
const rowsCopy = JSON.parse(JSON.stringify(initialRows));
const scoredRows = computeProductScores(rowsCopy, { seuilAxeFort, bonusParAxe });
setRows(scoredRows);
}, [initialRows, setRows, seuilAxeFort, bonusParAxe]);
const handleSave = () => {
startTransition(async () => {
const result = await save();
setSaveStatus(result.success ? "success" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
});
};
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
return (
<div className="space-y-3 pb-20">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Révision d&apos;Assortiment</h1>
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {initialRows.length} références
</p>
</div>
{hasDrafts && (
<button
onClick={handleSave}
disabled={isPending}
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "success" ? (
<CheckCircle className="w-4 h-4" />
) : saveStatus === "error" ? (
<AlertCircle className="w-4 h-4" />
) : null}
{isPending
? "Sauvegarde..."
: saveStatus === "success"
? "Sauvegardé !"
: saveStatus === "error"
? "Erreur"
: `Valider ${count} changement${count > 1 ? "s" : ""}`}
</button>
)}
</div>
{/* Filters */}
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
{/* Bulk toolbar (contextual) */}
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
{/* Main grid */}
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
/>
{/* Summary bar */}
<FloatingSummaryBar />
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import { Search, X, ChevronDown } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
const GAMME_FILTERS: { label: string; value: GammeCode | null }[] = [
{ label: "Tous", value: null },
{ label: "A", value: "A" },
{ label: "B", value: "B" },
{ label: "C", value: "C" },
{ label: "Z", value: "Z" },
];
import { SupplierCombobox } from "./supplier-combobox";
import { StoreCombobox } from "./store-combobox";
import { useRouter, useSearchParams } from "next/navigation";
interface GridFilterBarProps {
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
}
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const { filters, setFilter } = useGridStore();
const router = useRouter();
const searchParams = useSearchParams();
const handleSupplierSelect = (code: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("fournisseur", code);
router.push(`/grid?${params.toString()}`);
};
const handleStoreSelect = (code: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("magasin", code);
router.push(`/grid?${params.toString()}`);
};
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Supplier Selector */}
<SupplierCombobox
fournisseurs={fournisseurs}
selectedCode={searchParams.get("fournisseur")}
onSelect={handleSupplierSelect}
className="w-[280px]"
/>
{/* Store Selector */}
<StoreCombobox
magasins={magasins}
selectedCode={searchParams.get("magasin")}
onSelect={handleStoreSelect}
className="w-[220px]"
/>
{/* Search */}
<div className="relative">
<Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5"
style={{ color: "var(--text-muted)" }}
/>
<input
type="search"
placeholder="Rechercher (code, désignation...)"
value={filters.search}
onChange={(e) => setFilter("search", e.target.value)}
className="apple-input w-72 pr-8"
style={{ paddingLeft: "32px" }}
/>
{filters.search && (
<button
onClick={() => setFilter("search", "")}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
</button>
)}
</div>
{/* Gamme quick filter */}
<div
className="flex items-center gap-1 p-0.5 rounded-md"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
{GAMME_FILTERS.map(({ label, value }) => (
<button
key={label}
onClick={() => setFilter("codeGamme", value)}
className={cn(
"px-2.5 py-1 text-[11px] font-semibold rounded-[4px] transition-colors",
filters.codeGamme === value
? "shadow-sm"
: "hover:bg-[var(--bg-surface)]"
)}
style={{
background: filters.codeGamme === value ? "var(--bg-surface)" : "transparent",
color: filters.codeGamme === value ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{label}
</button>
))}
</div>
{/* Nomenclature filter */}
<div className="relative">
<select
value={filters.code3 ?? ""}
onChange={(e) => setFilter("code3", e.target.value || null)}
className="apple-input pr-8 appearance-none"
style={{ width: "180px" }}
>
<option value="">Toutes nomenclatures</option>
</select>
<ChevronDown
className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 pointer-events-none"
style={{ color: "var(--text-muted)" }}
/>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
interface HeatmapCellProps {
value: number | null;
}
function getHeatmapStyle(value: number | null): { background: string; color: string } {
if (value === null || value === 0) {
return { background: "var(--bg-elevated)", color: "var(--text-muted)" };
}
const abs = Math.abs(value);
if (abs > 100) return { background: "rgba(45,212,191,0.22)", color: "var(--accent)" };
if (abs > 30) return { background: "rgba(45,212,191,0.14)", color: "var(--accent)" };
if (abs > 10) return { background: "rgba(45,212,191,0.08)", color: "var(--accent)" };
return { background: "var(--bg-elevated)", color: "var(--text-secondary)" };
}
export function HeatmapCell({ value }: HeatmapCellProps) {
const style = getHeatmapStyle(value);
const display = value === null || value === 0 ? "—" : Math.round(Math.abs(value)).toString();
return (
<div
className="rounded text-center text-[12px] tabular-nums px-0.5 py-0.5 font-bold tracking-tighter transition-colors min-h-[24px] flex items-center justify-center font-mono-nums"
style={style}
title={value !== null ? `${Math.abs(value).toLocaleString("fr-FR")} unités` : "Aucune vente"}
>
{display}
</div>
);
}
@@ -0,0 +1,341 @@
"use client";
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
SortingState,
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { FinancialCell } from "@/features/grid/components/financial-cell";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import { AiInsightBlock } from "@/features/ai-copilot/components/ai-insight-block";
function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
const MONTHS_12 = getLast12Months();
function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
}
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
const { rows, draftChanges, setDraftGamme, filters, displayDensity } = useGridStore();
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const tableContainerRef = useRef<HTMLDivElement>(null);
const rowHeight = displayDensity === "compact" ? 32 : displayDensity === "normal" ? 40 : 48;
const cellPadding = displayDensity === "compact" ? "py-1" : displayDensity === "normal" ? "py-1.5" : "py-2.5";
const handleRowSelectionChange = useCallback(
(updater: React.SetStateAction<RowSelectionState>) => {
setRowSelection(updater);
},
[]
);
// Reset selection when rows change (e.g. supplier change)
useEffect(() => {
setRowSelection({});
}, [rows]);
const prevSelectionRef = useRef<string[]>([]);
// Propagate selection changes via useEffect to avoid "update during render" error
useEffect(() => {
if (!onSelectionChange) return;
const selectedIdxs = Object.keys(rowSelection).filter((k) => rowSelection[k]);
const selectedCodeins = selectedIdxs
.map((idx) => rows[parseInt(idx)]?.codein ?? "")
.filter(Boolean);
// Only update if selection actually changed to avoid re-render loops & console warnings
const currentString = JSON.stringify(selectedCodeins);
const prevString = JSON.stringify(prevSelectionRef.current);
if (currentString !== prevString) {
prevSelectionRef.current = selectedCodeins;
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [rowSelection, onSelectionChange, rows]);
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
{
id: "select",
size: 36,
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={(el) => { if (el) el.indeterminate = table.getIsSomeRowsSelected(); }}
onChange={table.getToggleAllRowsSelectedHandler()}
className="accent-emerald-500 cursor-pointer"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => e.stopPropagation()}
className="accent-emerald-500 cursor-pointer"
/>
),
},
{
accessorKey: "codein",
header: "Code",
size: 90,
cell: ({ getValue }) => <span className="font-mono-nums font-bold text-[12px] tracking-tight opacity-70" style={{ color: "var(--text-muted)" }}>{getValue<string>()}</span>,
},
{
accessorKey: "libelle1",
header: "Désignation",
size: 280,
cell: ({ getValue }) => (
<span className="text-[13px] font-bold truncate block max-w-[270px]" title={getValue<string>()} style={{ color: "var(--text-primary)" }}>
{getValue<string>()}
</span>
),
},
{
accessorKey: "libelle3",
header: "Famille",
size: 160,
cell: ({ getValue }) => (
<span className="text-[12px] truncate block text-left w-full opacity-70" style={{ color: "var(--text-secondary)" }}>
{getValue<string>()}
</span>
),
},
{
accessorKey: "score",
header: "Score",
size: 60,
cell: ({ getValue }) => {
const val = getValue<number>();
const color = val >= 80 ? "text-emerald-500" : val >= 50 ? "text-amber-500" : "text-rose-500";
return (
<div className={cn("text-center font-black text-[13px]", color)}>
{val}
</div>
);
},
},
...MONTHS_12.map((monthKey) => ({
id: `month_${monthKey}`,
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
size: 68,
enableSorting: false,
cell: ({ row }: { row: { original: ProductRow } }) => (
<HeatmapCell value={row.original.sales12m[monthKey] ?? null} />
),
})),
{
accessorKey: "totalQuantite",
header: () => <div className="text-center w-full">Tot. 12m</div>,
size: 90,
cell: ({ getValue }) => (
<div className="mx-auto w-[60px] text-center px-1 font-mono-nums text-sm font-black py-1.5 rounded-md border" style={{
background: "var(--bg-elevated)",
color: "var(--text-primary)",
borderColor: "var(--border-strong)"
}}>
{Math.round(getValue<number>()).toLocaleString("fr-FR")}
</div>
),
},
{
id: "financial",
header: () => <div className="text-center w-full">CA / Marge</div>,
size: 160,
cell: ({ row }) => (
<FinancialCell
totalCa={row.original.totalCa}
totalMarge={row.original.totalMarge}
tauxMarge={row.original.tauxMarge}
/>
),
},
{
id: "gammeInitial",
header: () => <div className="text-center w-full">Init.</div>,
size: 56,
cell: ({ row }) => (
<div className="text-center font-bold text-[12px]" style={{ color: "var(--text-secondary)" }}>
{row.original.codeGamme || "-"}
</div>
),
},
{
id: "gamme",
header: "Gamme",
size: 110,
cell: ({ row }) => {
const effectiveGamme = (draftChanges[row.original.codein] ?? row.original.codeGamme) as GammeCode | null;
return (
<GammeSelect
value={effectiveGamme}
isDraft={!!draftChanges[row.original.codein]}
onChange={(g: GammeCode) => setDraftGamme(row.original.codein, g)}
/>
);
},
},
{
id: "ai",
header: "Recommandation IA",
size: 230,
enableSorting: false,
cell: ({ row }) => <AiInsightBlock row={row.original} />,
},
], [draftChanges, setDraftGamme]);
const table = useReactTable({
data: rows,
columns,
state: { sorting, globalFilter: filters.search, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
});
const { rows: tableRows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => rowHeight,
overscan: 20,
});
const totalWidth = columns.reduce((s, c) => s + ((c as { size?: number }).size ?? 150), 0);
return (
<div
ref={tableContainerRef}
className="overflow-auto rounded-[12px]"
style={{
height: "calc(100vh - 240px)",
background: "var(--bg-surface)",
border: "1px solid var(--border)",
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
<thead className="sticky top-0 z-10 block" style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{headerGroup.headers.map((header) => {
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "financial" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score";
const size = header.getSize();
return (
<th
key={header.id}
className="px-2 py-2 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap select-none flex items-center"
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-muted)",
justifyContent: isCenter ? "center" : "flex-start"
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className={`flex items-center gap-1 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3" />
: <ChevronsUpDown className="w-3 h-3 opacity-30" />
)}
</div>
</th>
);
})}
</tr>
))}
</thead>
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const effectiveGamme = draftChanges[row.original.codein] ?? row.original.codeGamme;
const isSelected = row.getIsSelected();
return (
<tr
key={row.id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
onClick={() => row.toggleSelected()}
className={cn(
"absolute w-full flex items-center cursor-pointer transition-colors",
effectiveGamme === "Z" && "opacity-40"
)}
style={{
height: `${rowHeight}px`,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: "1px solid var(--border)",
background: isSelected ? "var(--accent-bg)" : undefined,
borderLeft: isSelected ? "2px solid var(--accent)" : undefined,
}}
>
{row.getVisibleCells().map((cell) => {
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || cell.column.id === "libelle3";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "financial" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "score";
const size = cell.column.getSize();
return (
<td
key={cell.id}
className={`px-2 overflow-hidden flex items-center`}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
height: `${rowHeight}px`,
justifyContent: isCenter ? "center" : "flex-start"
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Search, Building2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface Store {
code: string;
nom: string;
}
interface StoreComboboxProps {
magasins: Store[];
selectedCode: string | null;
onSelect: (code: string) => void;
className?: string;
}
export function StoreCombobox({ magasins, selectedCode, onSelect, className }: StoreComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const options = React.useMemo(() => [
{ code: "TOTAL", nom: "Tous les magasins (Total)" },
...magasins
], [magasins]);
const selectedStore = options.find((s) => s.code === (selectedCode || "TOTAL"));
const filtered = React.useMemo(() => {
if (!search) return options;
const s = search.toLowerCase();
return options.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50);
}, [options, search]);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className={cn("relative w-[280px]", className)} ref={containerRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-blue-500/50 transition-all shadow-sm group"
>
<div className="flex items-center gap-2 min-w-0">
<Building2 className="w-3.5 h-3.5 text-slate-400 group-hover:text-blue-500 transition-colors" />
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedStore ? selectedStore.nom : "TOTAL"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<input
autoFocus
placeholder="Rechercher un magasin..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
</div>
) : (
filtered.map((s) => (
<button
key={s.code}
onClick={() => {
onSelect(s.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
s.code === (selectedCode || "TOTAL")
? "bg-blue-500/10 text-blue-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
)}
>
<div className="flex flex-col min-w-0 pr-2">
<span className="truncate">{s.nom}</span>
<span className="text-[10px] opacity-50 font-mono italic">{s.code === "TOTAL" ? "Agrégation nationale" : `Magasin ID: ${s.code}`}</span>
</div>
{s.code === (selectedCode || "TOTAL") && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
interface Supplier {
code: string;
nom: string;
}
interface SupplierComboboxProps {
fournisseurs: Supplier[];
selectedCode: string | null;
onSelect: (code: string) => void;
className?: string;
}
export function SupplierCombobox({ fournisseurs, selectedCode, onSelect, className }: SupplierComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const selectedSupplier = fournisseurs.find((f) => f.code === selectedCode);
const filtered = React.useMemo(() => {
if (!search) return fournisseurs;
const s = search.toLowerCase();
return fournisseurs.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50); // Limit display for performance
}, [fournisseurs, search]);
// Handle click outside to close
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className={cn("relative w-[300px]", className)} ref={containerRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-emerald-500/50 transition-all shadow-sm group"
>
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedSupplier ? selectedSupplier.nom : "Sélectionner un fournisseur..."}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<input
autoFocus
placeholder="Rechercher..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
</div>
) : (
filtered.map((f) => (
<button
key={f.code}
onClick={() => {
onSelect(f.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
f.code === selectedCode
? "bg-emerald-500/10 text-emerald-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
)}
>
<div className="flex flex-col min-w-0 pr-2">
<span className="truncate">{f.nom}</span>
<span className="text-[10px] opacity-50 font-mono">Code: {f.code}</span>
</div>
{f.code === selectedCode && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))
)}
{fournisseurs.length > 50 && !search && (
<div className="px-4 py-2 text-[10px] text-center text-slate-400 uppercase tracking-widest font-bold bg-slate-50/50 dark:bg-slate-800/50">
Saisissez pour filtrer les {fournisseurs.length} entrées
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
import { SupplierCombobox } from "./supplier-combobox";
import { useRouter } from "next/navigation";
interface Supplier {
code: string;
nom: string;
}
interface SupplierSelectionLandingProps {
fournisseurs: Supplier[];
}
export function SupplierSelectionLanding({ fournisseurs }: SupplierSelectionLandingProps) {
const router = useRouter();
const handleSelect = (code: string) => {
router.push(`/grid?fournisseur=${code}`);
};
return (
<div className="h-full flex flex-col items-center justify-center p-6 text-center animate-in fade-in zoom-in duration-500">
<div className="max-w-md space-y-6">
<div className="space-y-4">
<div className="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center mx-auto mb-2 text-emerald-600">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2" />
</svg>
</div>
<h1 className="text-4xl font-black tracking-tight text-slate-900 dark:text-white">CollectFlow</h1>
<p className="text-lg text-slate-500 dark:text-slate-400">
Optimisez votre assortiment en quelques clics. Sélectionnez un fournisseur pour commencer.
</p>
</div>
<div className="flex justify-center pt-4">
<SupplierCombobox
fournisseurs={fournisseurs}
selectedCode={null}
onSelect={handleSelect}
className="w-[400px] !h-12 text-lg shadow-xl"
/>
</div>
<div className="pt-8 text-[11px] text-slate-400 uppercase tracking-[0.2em] font-bold">
Accès Rapide • {fournisseurs.length} Fournisseurs Disponibles
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, Building2, ChevronRight } from "lucide-react";
interface Supplier {
code: string;
nom: string;
}
export function SupplierSelection({ fournisseurs }: { fournisseurs: Supplier[] }) {
const router = useRouter();
const [search, setSearch] = useState("");
const filtered = fournisseurs.filter((f) =>
f.nom.toLowerCase().includes(search.toLowerCase()) ||
f.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="max-w-4xl mx-auto py-12 px-6">
<div className="mb-10 text-center">
<h1 className="text-[32px] font-bold tracking-tight mb-2">Choisir un fournisseur</h1>
<p className="text-[15px]" style={{ color: "var(--text-secondary)" }}>
Sélectionnez un fournisseur pour commencer la révision de l'assortiment.
</p>
</div>
<div className="relative mb-8 max-w-xl mx-auto">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Rechercher par nom ou code..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="apple-input pl-12 h-14 text-lg w-full"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((f) => (
<button
key={f.code}
onClick={() => router.push(`/grid?fournisseur=${f.code}`)}
className="flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 hover:border-emerald-500/50 hover:bg-emerald-50/10 transition-all text-left shadow-sm group active:scale-[0.98]"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-500 group-hover:bg-emerald-500/10 group-hover:text-emerald-500 transition-colors">
<Building2 className="w-6 h-6" />
</div>
<div>
<div className="text-[17px] font-bold tracking-tight text-slate-900 dark:text-white">
{f.nom}
</div>
<div className="text-[13px] font-medium text-slate-500">
Code: {f.code}
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-emerald-500 transition-colors" />
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-20 bg-slate-50/50 dark:bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<p className="text-slate-500 font-medium">Aucun fournisseur trouvé pour "{search}"</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
"use client";
import { useCallback } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
import { GammeCode } from "@/types/grid";
/**
* Builds the payload from Zustand draftChanges and calls the Server Action.
* Returns a cleanup function called after successful save.
*/
export function useSaveDrafts(magasin: string) {
const { draftChanges, resetDrafts } = useGridStore();
const save = useCallback(async () => {
const changes = Object.entries(draftChanges).map(([codein, codeGamme]) => ({
codein,
magasin,
codeGamme: codeGamme as GammeCode,
}));
if (changes.length === 0) return { success: true, saved: 0 };
const result = await saveDraftChanges({ changes });
if (result.success) resetDrafts();
return result;
}, [draftChanges, magasin, resetDrafts]);
return { save, hasDrafts: Object.keys(draftChanges).length > 0, count: Object.keys(draftChanges).length };
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
interface GridState {
/** Source data from server */
rows: ProductRow[];
/** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>;
filters: GridFilters;
summary: GridSummary;
displayDensity: "compact" | "normal" | "comfortable";
// Actions
setRows: (rows: ProductRow[]) => void;
setDraftGamme: (codein: string, gamme: GammeCode) => void;
resetDrafts: () => void;
setFilter: (key: keyof GridFilters, value: string | null) => void;
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
}
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
const active = rows.filter((r) => {
const g = drafts[r.codein] ?? r.codeGamme;
return g !== "Z";
});
const totalCa = active.reduce((s, r) => s + (r.totalCa ?? 0), 0);
const totalMarge = active.reduce((s, r) => s + (r.totalMarge ?? 0), 0);
return {
totalRows: active.length,
totalQuantite: active.reduce((s, r) => s + (r.totalQuantite ?? 0), 0),
totalCa,
totalMarge,
tauxMargeGlobal: totalCa > 0 ? (totalMarge / totalCa) * 100 : 0,
};
}
export const useGridStore = create<GridState>()(
persist(
(set, get) => ({
rows: [],
draftChanges: {},
filters: {
magasin: null,
codeFournisseur: null,
code3: null,
codeGamme: null,
search: "",
},
summary: {
totalRows: 0,
totalQuantite: 0,
totalCa: 0,
totalMarge: 0,
tauxMargeGlobal: 0,
},
displayDensity: "normal",
setRows: (rows) => {
set({ rows, summary: computeSummary(rows, get().draftChanges) });
},
setDraftGamme: (codein, gamme) => {
const draftChanges = { ...get().draftChanges, [codein]: gamme };
set({ draftChanges, summary: computeSummary(get().rows, draftChanges) });
},
resetDrafts: () => {
set({ draftChanges: {}, summary: computeSummary(get().rows, {}) });
},
setFilter: (key, value) => {
set((state) => ({ filters: { ...state.filters, [key]: value } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
}),
{
name: "collectflow-grid-storage",
// Only persist filters and display density, do not persist data rows and drafts
partialize: (state) => ({
filters: state.filters,
displayDensity: state.displayDensity,
}),
}
)
);
@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { DEFAULT_SCORE_SETTINGS, type ScoreSettings } from "@/lib/score-engine";
interface ScoreSettingsState extends ScoreSettings {
setSeuilAxeFort: (v: number) => void;
setBonusParAxe: (v: number) => void;
resetDefaults: () => void;
}
export const useScoreSettingsStore = create<ScoreSettingsState>()(
persist(
(set) => ({
...DEFAULT_SCORE_SETTINGS,
setSeuilAxeFort: (v) => set({ seuilAxeFort: v }),
setBonusParAxe: (v) => set({ bonusParAxe: v }),
resetDefaults: () => set({ ...DEFAULT_SCORE_SETTINGS }),
}),
{
name: "collectflow-score-settings",
}
)
);
@@ -0,0 +1,45 @@
"use server";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { z } from "zod";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
nomFournisseur: z.string().optional(),
magasin: z.string(),
label: z.string().optional(),
changes: z.record(z.string(), z.object({
before: z.string().nullable(),
after: z.string(),
})),
summary: z.object({
totalRows: z.number(),
totalCa: z.number(),
totalMarge: z.number(),
tauxMargeGlobal: z.number(),
}).optional(),
});
export async function saveSnapshot(raw: unknown) {
const parsed = SaveSnapshotSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: "Validation failed: " + parsed.error.message };
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary } = parsed.data;
const [created] = await db
.insert(sessionSnapshots)
.values({
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: label ?? `Snapshot — ${new Date().toLocaleDateString("fr-FR")}`,
changes,
summaryJson: summary ?? null,
})
.returning({ id: sessionSnapshots.id });
return { success: true, snapshotId: created?.id };
}