mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 10:48:09 +02:00
feat: add no-sales-6-months tab to stock management page
Adds a second tab "Sans vente 6 mois" on the grid page listing all products with no sale in the last 6 months, sorted by oldest last sale first. Resolves merge conflicts from upstream refactor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
dc16beeda4
51 files changed
+4889
-863
No files matched your search
@@ -41,6 +41,10 @@ export function BulkAiAnalyzer() {
|
||||
const { rows } = useGridStore.getState();
|
||||
isCancelledRef.current = false;
|
||||
|
||||
// Show immediate visual feedback before any async work
|
||||
setIsAnalyzing(true);
|
||||
setProgress({ current: 0, total: 0, message: "Chargement du contexte...", errors: 0 });
|
||||
|
||||
// 1. Fetch AI Context for the Supplier
|
||||
let supplierContext = "";
|
||||
if (supplierCode) {
|
||||
@@ -166,7 +170,6 @@ export function BulkAiAnalyzer() {
|
||||
|
||||
// 2c. Context profiling en micro-batches asynchrones
|
||||
// Le score est déjà calculé par score-engine.ts (champ row.score sur chaque ProductRow).
|
||||
setIsAnalyzing(true);
|
||||
setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du contexte...", errors: 0 });
|
||||
|
||||
const SCORING_BATCH_SIZE = 25;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown } from "lucide-react";
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown, Table2 } from "lucide-react";
|
||||
import * as ExcelJS from "exceljs";
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
@@ -11,6 +11,52 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { rows, draftChanges } = useGridStore();
|
||||
|
||||
const handleExportAllProducts = async () => {
|
||||
setIsOpen(false);
|
||||
if (rows.length === 0) {
|
||||
alert("Aucune donnée à exporter.");
|
||||
return;
|
||||
}
|
||||
|
||||
const supplier = nomFournisseur || rows[0]?.nomFournisseur || "Fournisseur";
|
||||
|
||||
const payload = {
|
||||
nomFournisseur: supplier,
|
||||
rows: rows.map(r => ({
|
||||
codein: r.codein,
|
||||
libelle1: r.libelle1 || "",
|
||||
code3: r.code3 || "",
|
||||
libelle3: r.libelle3 || "",
|
||||
codeGamme: (draftChanges[r.codein] ?? r.codeGamme) as string | null,
|
||||
totalQuantite: r.totalQuantite,
|
||||
totalCa: r.totalCa,
|
||||
tauxMarge: r.tauxMarge,
|
||||
gammeAvant: r.codeGammeInit ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/export/excel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Erreur lors de l'export");
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Assortiment_Complet_${supplier.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Une erreur s'est produite lors de l'export Excel.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsOpen(false);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
@@ -157,6 +203,19 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
|
||||
<div className="my-1 h-px bg-[var(--border)] mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handleExportAllProducts}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
|
||||
>
|
||||
<Table2 className="w-4 h-4 text-blue-400" />
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">Tous les produits (Excel)</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Gamme avant/après, delta, stats 12m</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-[var(--border)] mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handlePrintPDF}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { BulkAiAnalyzer } from "./bulk-ai-analyzer";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
@@ -27,6 +28,8 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
|
||||
}
|
||||
|
||||
export function FloatingSummaryBar() {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
const { summary, resetDrafts, rows, filters, draftChanges } = useGridStore();
|
||||
const { resetInsights } = useAiCopilotStore();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -175,7 +178,7 @@ export function FloatingSummaryBar() {
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 items-center">
|
||||
<BulkAiAnalyzer />
|
||||
{isAdmin && <BulkAiAnalyzer />}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition, useCallback, useMemo } from "react";
|
||||
import { useEffect, useRef, useState, useTransition, useMemo } 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";
|
||||
@@ -9,15 +9,15 @@ import { ExportDropdown } from "@/features/grid/components/export-dropdown";
|
||||
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
import { useInfiniteGrid } from "@/features/grid/hooks/use-infinite-grid";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { CheckCircle, AlertCircle, Loader2, StopCircle } from "lucide-react";
|
||||
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { computeProductScores } from "@/lib/score-engine";
|
||||
|
||||
interface GridClientProps {
|
||||
initialRows: ProductRow[];
|
||||
initialTotal: number;
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string;
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
@@ -25,11 +25,10 @@ interface GridClientProps {
|
||||
magasin: string;
|
||||
}
|
||||
|
||||
export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string })?.role === "admin";
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
const setRows = useGridStore((s) => s.setRows);
|
||||
const appendRows = useGridStore((s) => s.appendRows);
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
@@ -48,20 +47,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// ─── Chargement progressif des pages suivantes ─────────────────────────────
|
||||
const handlePageLoaded = useCallback((newRows: ProductRow[]) => {
|
||||
// Ajout O(n_nouveaux_items) au lieu de O(N_total)
|
||||
appendRows(newRows);
|
||||
}, [appendRows]);
|
||||
|
||||
const { total, isLoadingMore, hasMore, loadNextPage, stopLoading, progress } = useInfiniteGrid({
|
||||
codeFournisseur,
|
||||
magasin: magasin || "TOTAL",
|
||||
initialTotal,
|
||||
pageSize: 10000,
|
||||
onPageLoaded: handlePageLoaded
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
@@ -70,6 +55,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const currentQueryString = searchParams.toString();
|
||||
// Set the active query, ensuring it starts with ? if not empty
|
||||
setActiveGridQuery(currentQueryString ? `?${currentQueryString}` : "");
|
||||
}, [searchParams, setActiveGridQuery, isMounted]);
|
||||
|
||||
@@ -82,24 +68,16 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
setFilter("code3", null);
|
||||
prevFournisseurRef.current = codeFournisseur;
|
||||
}
|
||||
// Reset du store pour ce fournisseur
|
||||
setRows(initialRows);
|
||||
const scoredRows = computeProductScores([...initialRows]);
|
||||
setRows(scoredRows);
|
||||
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
setActiveMagasin(magasin || "TOTAL");
|
||||
}, [magasin, setActiveMagasin, isMounted]);
|
||||
|
||||
// Chargement automatique des pages suivantes en arrière-plan
|
||||
useEffect(() => {
|
||||
if (!isMounted || !hasMore || isLoadingMore) return;
|
||||
// Délai court pour ne pas bloquer le rendu
|
||||
const timer = setTimeout(loadNextPage, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isMounted, hasMore, isLoadingMore, loadNextPage]);
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
const result = await save();
|
||||
@@ -123,7 +101,7 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
<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>
|
||||
{" "}• {total.toLocaleString("fr-FR")} références
|
||||
{" "}• {initialRows.length} références
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -154,32 +132,6 @@ export function GridClient({ initialRows, initialTotal, codeFournisseur, nomFour
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barre de progression chargement en arrière-plan */}
|
||||
{(hasMore || isLoadingMore) && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-medium"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin shrink-0" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
Téléchargement : {rows.length.toLocaleString("fr-FR")} / {total.toLocaleString("fr-FR")} références
|
||||
</span>
|
||||
<div className="flex-1 h-1 w-[100px] rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%`, background: "var(--accent)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={stopLoading}
|
||||
className="ml-2 flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-red-500/10 text-red-600 transition-colors"
|
||||
title="Arrêter le téléchargement du reste des références"
|
||||
>
|
||||
<StopCircle className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] uppercase font-bold">Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="shrink-0 print:hidden">
|
||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -268,39 +268,27 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
|
||||
// =========================================================================
|
||||
|
||||
export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
// L'abonnement doit être ultra-minimal ! JAMAIS de destructuring brut sur useGridStore().
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
|
||||
const { rows, filters, displayDensity, draftChanges, activeMagasin } = useGridStore();
|
||||
|
||||
// Filtre client-side par code3 (famille) et codeGamme sans abonnement réactif aux brouillons pour éviter le freeze.
|
||||
// Filtre client-side par code3 (famille) et codeGamme
|
||||
const filteredData = useMemo(() => {
|
||||
const { code3, codeGamme } = filters;
|
||||
if (!code3 && !codeGamme) return rows;
|
||||
|
||||
// On récupère le state de façon non-réactive pour ne pas trigger 130k renders
|
||||
const currentDrafts = useGridStore.getState().draftChanges;
|
||||
|
||||
return rows.filter(r => {
|
||||
if (code3 && r.code3 !== code3) return false;
|
||||
if (codeGamme) {
|
||||
const g = currentDrafts[r.codein] ?? r.codeGamme ?? "";
|
||||
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
|
||||
const norm = g.trim() === "" ? "Aucune" : g;
|
||||
if (norm !== codeGamme) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, filters]); // ❌ Surtout pas draftChanges ici
|
||||
}, [rows, filters, draftChanges]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Selectors granulaires
|
||||
const columnVisibility = useGridStore((s) => s.columnVisibility);
|
||||
const setColumnVisibility = useGridStore((s) => s.setColumnVisibility);
|
||||
const columnSizing = useGridStore((s) => s.columnSizing);
|
||||
const setColumnSizing = useGridStore((s) => s.setColumnSizing);
|
||||
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
@@ -442,33 +430,45 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
accessorKey: "libelle1",
|
||||
header: "Désignation",
|
||||
size: 280,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 overflow-hidden w-full pr-1">
|
||||
<span className="text-[13px] font-bold truncate flex-1" title={row.original.libelle1} style={{ color: "var(--text-primary)" }}>
|
||||
{row.original.libelle1}
|
||||
</span>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{row.original.workingStores.map((magasin) => {
|
||||
const config = getStoreConfig(magasin);
|
||||
return (
|
||||
<div
|
||||
key={magasin}
|
||||
title={`Travaillé par : ${SITE_LABELS[magasin]?.nom ?? magasin}`}
|
||||
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
|
||||
style={{
|
||||
background: config.bg,
|
||||
borderColor: config.border,
|
||||
color: config.text,
|
||||
}}
|
||||
>
|
||||
<Store className="w-2.5 h-2.5" strokeWidth={3} />
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const isDifferentSupplier = r.fournisseurPrincipalCode && r.fournisseurPrincipalCode !== r.codeFournisseur;
|
||||
const warnTitle = isDifferentSupplier
|
||||
? `Dernier fournisseur enregistré : ${r.fournisseurPrincipalNom ?? r.fournisseurPrincipalCode} (${r.fournisseurPrincipalCode}) — les ventes sont à attribuer à ce fournisseur`
|
||||
: undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-hidden w-full pr-1">
|
||||
{isDifferentSupplier && (
|
||||
<span title={warnTitle} className="shrink-0 flex items-center">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] font-bold truncate flex-1" title={r.libelle1} style={{ color: "var(--text-primary)" }}>
|
||||
{r.libelle1}
|
||||
</span>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{r.workingStores.map((magasin) => {
|
||||
const config = getStoreConfig(magasin);
|
||||
return (
|
||||
<div
|
||||
key={magasin}
|
||||
title={`Travaillé par : ${SITE_LABELS[magasin]?.nom ?? magasin}`}
|
||||
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
|
||||
style={{
|
||||
background: config.bg,
|
||||
borderColor: config.border,
|
||||
color: config.text,
|
||||
}}
|
||||
>
|
||||
<Store className="w-2.5 h-2.5" strokeWidth={3} />
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "libelle3",
|
||||
|
||||
Reference in new issue
Block a user