mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user