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