"use client"; /** * CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas. * * On part du CLASSEMENT AFFICHÉ, pas d'un classement maison : le tri en cours * dans la Grille — CA réseau, quantité, marge, peu importe — fixe l'ordre et * donc ce que « le haut du classement » veut dire. Recalculer un ordre ici * répondrait à une autre question que celle qu'on vient de poser à l'écran. * * « Non travaillé » = aucune vente sur 12 mois ET aucun stock au dernier mois * connu. Les deux conditions comptent : un article sans vente mais en stock est * détenu par le magasin, ce n'est pas un trou d'assortiment mais un invendu. */ import React, { useMemo, useState } from "react"; import { PackageX, Store, FileSpreadsheet, Loader2 } from "lucide-react"; import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { TuileStat } from "@/features/grid/components/stat-tile"; import { SITE_LABELS } from "@/features/grid/lib/months"; import type { ProductRow } from "@/types/grid"; /** Ligne du classement affiché : le produit et la valeur qui l'a classé. */ export interface LigneClassee { row: ProductRow; valeurCritere: unknown; } const PROFONDEURS = [100, 200, 500] as const; export const PROFONDEUR_MAX = PROFONDEURS[PROFONDEURS.length - 1]; const nb = (v: number) => Math.round(v).toLocaleString("fr-FR"); const eur0 = (v: number) => `${Math.round(v).toLocaleString("fr-FR")} €`; const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; /** * Libellé et mise en forme de la colonne de tri. * * Le tri porte sur des grandeurs sans commune mesure — des euros, des unités, * des pourcentages. Afficher la valeur brute rendrait la colonne illisible, et * ne rien afficher priverait le classement de sa justification. */ interface Critere { label: string; format: (v: number) => string; /** Format de nombre Excel — la valeur part BRUTE dans le tableur, qui doit * pouvoir la trier et la sommer ; seule sa présentation est reprise ici. */ xlsx: string; /** Normalisation avant export, quand la donnée stockée n'est pas l'unité affichée. */ brut?: (v: number) => number; } const CRITERES: Record = { caReseau: { label: "CA réseau", format: eur0, xlsx: '#,##0 "€"' }, qteReseau: { label: "Qté réseau", format: nb, xlsx: "#,##0" }, nbMagasinsReseau: { label: "Magasins réseau", format: nb, xlsx: "#,##0" }, tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %`, xlsx: "0 %" }, prixMoyenReseau: { label: "PV moyen réseau", format: eur2, xlsx: '#,##0.00 "€"' }, prixVente: { label: "PV magasin", format: eur2, xlsx: '#,##0.00 "€"' }, caParMagasinReseau: { label: "CA / magasin réseau", format: eur0, xlsx: '#,##0 "€"' }, margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %`, xlsx: '0.0 "%"', brut: (v) => (Math.abs(v) <= 1 ? v * 100 : v) }, totalQuantite: { label: "Qté 12 m", format: nb, xlsx: "#,##0" }, totalCa: { label: "CA 12 m", format: eur0, xlsx: '#,##0 "€"' }, totalMarge: { label: "Marge 12 m", format: eur0, xlsx: '#,##0 "€"' }, }; function libelleCritere(id: string | null): string { if (!id) return "ordre d'affichage"; return CRITERES[id]?.label ?? id; } function formatCritere(id: string | null, valeur: unknown): string { if (valeur == null) return "—"; if (typeof valeur !== "number") return String(valeur); // Les sentinelles de tri (valeurs manquantes, produit « nouveau ») ne sont // pas des mesures : les écrire donnerait un nombre qui n'existe pas. if (!Number.isFinite(valeur)) return "—"; return (id && CRITERES[id]?.format(valeur)) ?? nb(valeur); } /** Ce que le magasin fait de ce produit, et ce que les autres en font. */ interface Constat { ventesMagasin: number; stockMagasin: number; /** Le magasin a déjà détenu ce produit sur la période (stock ou réception). */ dejaDetenu: boolean; ventesAilleurs: number; stockAilleurs: number; } function constater(row: ProductRow, site: string, mois: string[]): Constat { const ventesParSite = row.sales12mByStore ?? {}; const stockParSite = row.stock12mByStore ?? {}; const receptionsParSite = row.receptions12mByStore ?? {}; const somme = (serie?: Record) => mois.reduce((total, m) => total + (serie?.[m] ?? 0), 0); const dernier = (serie?: Record) => serie?.[mois[mois.length - 1]] ?? 0; const ventesMagasin = row.quantiteByStore?.[site] ?? somme(ventesParSite[site]); const stockMagasin = dernier(stockParSite[site]); const maxStock = mois.reduce((max, m) => Math.max(max, stockParSite[site]?.[m] ?? 0), 0); let ventesAilleurs = 0; let stockAilleurs = 0; for (const autre of new Set([...Object.keys(ventesParSite), ...Object.keys(stockParSite)])) { if (autre === site) continue; ventesAilleurs += row.quantiteByStore?.[autre] ?? somme(ventesParSite[autre]); stockAilleurs += dernier(stockParSite[autre]); } return { ventesMagasin, stockMagasin, dejaDetenu: maxStock > 0 || somme(receptionsParSite[site]) > 0, ventesAilleurs, stockAilleurs, }; } /** Ligne prête pour le tableur : le rang, le produit, le constat. */ interface TrouClasse { row: ProductRow; valeurCritere: unknown; rang: number; constat: Constat; } /** * Classeur Excel de la liste affichée. * * Deux feuilles : les données seules d'un côté — en-têtes ligne 1, filtre * automatique, valeurs NUMÉRIQUES pour que le tableur puisse trier et sommer — * et le contexte de l'extraction de l'autre. Mêler les deux dans une même * feuille condamnerait le filtre et le tri, alors que ce fichier a vocation à * être retravaillé. * * `exceljs` est chargé à la demande : il pèse lourd, et la Grille n'a pas à le * transporter pour tous ceux qui n'exportent jamais. */ async function construireClasseur( trous: TrouClasse[], contexte: { fournisseur?: string; magasin: string; critere: string; profondeur: number; examines: number }, critereId: string | null, ): Promise { const { Workbook } = await import("exceljs"); const classeur = new Workbook(); classeur.creator = "CollectFlow"; classeur.created = new Date(); const critere = critereId ? CRITERES[critereId] : undefined; const feuille = classeur.addWorksheet("Non travaillés", { views: [{ state: "frozen", ySplit: 1 }] }); feuille.columns = [ { header: "Rang", key: "rang", width: 8 }, { header: "Code interne", key: "codein", width: 14 }, { header: "Référence", key: "reference", width: 18 }, { header: "EAN / GTIN", key: "gtin", width: 16 }, { header: "Désignation", key: "libelle", width: 45 }, { header: "Famille", key: "famille", width: 30 }, { header: contexte.critere, key: "critere", width: 18 }, { header: "Ventes ailleurs 12 m", key: "ventesAilleurs", width: 20 }, { header: "Stock ailleurs", key: "stockAilleurs", width: 15 }, { header: "Constat", key: "constat", width: 24 }, ]; feuille.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } }; feuille.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } }; feuille.getRow(1).height = 20; for (const { row, valeurCritere, rang, constat } of trous) { feuille.addRow({ rang, codein: row.codein, reference: row.reference || "", gtin: row.gtin || "", libelle: row.libelle1 || "", famille: [row.code3, row.libelle3].filter(Boolean).join(" — "), critere: typeof valeurCritere === "number" && Number.isFinite(valeurCritere) ? (critere?.brut?.(valeurCritere) ?? valeurCritere) : (valeurCritere == null ? "" : String(valeurCritere)), ventesAilleurs: constat.ventesAilleurs, stockAilleurs: constat.stockAilleurs, constat: constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu", }); } if (critere) feuille.getColumn("critere").numFmt = critere.xlsx; feuille.getColumn("ventesAilleurs").numFmt = "#,##0"; feuille.getColumn("stockAilleurs").numFmt = "#,##0"; feuille.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: feuille.columns.length } }; const contexteFeuille = classeur.addWorksheet("Contexte"); contexteFeuille.columns = [{ width: 26 }, { width: 60 }]; for (const [cle, valeur] of [ ["Extraction", "Produits non travaillés par un magasin"], ["Fournisseur", contexte.fournisseur ?? "—"], ["Magasin examiné", contexte.magasin], ["Classement", `${contexte.critere} — ${contexte.profondeur} premiers (${contexte.examines} examinés)`], ["Critère retenu", "aucune vente sur 12 mois glissants ET aucun stock au dernier mois connu"], ["Non travaillés", String(trous.length)], ["Généré le", new Date().toLocaleString("fr-FR")], ]) { const ligne = contexteFeuille.addRow([cle, valeur]); ligne.getCell(1).font = { bold: true }; } const buffer = await classeur.xlsx.writeBuffer(); return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); } export function AssortmentGapsModal({ classement, critereId, mois, magasinInitial, nomFournisseur, onClose }: { /** Le classement AFFICHÉ, dans son ordre — déjà tronqué à PROFONDEUR_MAX. */ classement: LigneClassee[]; critereId: string | null; /** Les 12 clés de mois, du plus ancien au plus récent. */ mois: string[]; magasinInitial: string; /** Sert à nommer le fichier exporté et à le documenter. */ nomFournisseur?: string; onClose: () => void; }) { // Magasins réellement présents dans les données, à défaut les deux sites connus. const sites = useMemo(() => { const trouves = new Set(); for (const { row } of classement) { for (const s of Object.keys(row.sales12mByStore ?? {})) trouves.add(s); for (const s of Object.keys(row.stock12mByStore ?? {})) trouves.add(s); } return trouves.size > 0 ? [...trouves].sort() : Object.keys(SITE_LABELS); }, [classement]); const [site, setSite] = useState(() => (sites.includes(magasinInitial) ? magasinInitial : sites[0])); const [profondeur, setProfondeur] = useState(200); const examine = useMemo(() => classement.slice(0, profondeur), [classement, profondeur]); const trous = useMemo(() => examine .map((ligne, index) => ({ ...ligne, rang: index + 1, constat: constater(ligne.row, site, mois) })) .filter(({ constat }) => constat.ventesMagasin <= 0 && constat.stockMagasin <= 0), [examine, site, mois]); const [exportEnCours, setExportEnCours] = useState(false); const [exportErreur, setExportErreur] = useState(null); const vendusAilleurs = trous.filter((t) => t.constat.ventesAilleurs > 0).length; const dejaTentes = trous.filter((t) => t.constat.dejaDetenu).length; const nomMagasin = SITE_LABELS[site]?.nom ?? site; const autresMagasins = sites.filter((s) => s !== site).map((s) => SITE_LABELS[s]?.nom ?? s).join(", ") || "les autres"; const exporter = async () => { if (trous.length === 0 || exportEnCours) return; setExportEnCours(true); setExportErreur(null); try { const blob = await construireClasseur( trous, { fournisseur: nomFournisseur, magasin: nomMagasin, critere: libelleCritere(critereId), profondeur, examines: examine.length, }, critereId, ); const url = URL.createObjectURL(blob); const lien = document.createElement("a"); lien.href = url; lien.download = [ "Non_travailles", nomFournisseur?.replace(/[^\w]+/g, "_"), nomMagasin.replace(/[^\w]+/g, "_"), `Top${profondeur}`, new Date().toISOString().slice(0, 10), ].filter(Boolean).join("_") + ".xlsx"; lien.click(); URL.revokeObjectURL(url); } catch (e) { // Un export qui échoue en silence laisse croire au téléchargement. console.error("[trous d'assortiment] export Excel :", e); setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste."); } finally { setExportEnCours(false); } }; return ( Produits non travaillés par un magasin

Sur le classement affiché — trié par{" "} {libelleCritere(critereId)} {" — ni vente ni stock sur 12 mois."}

{/* Réglages : quel magasin, et jusqu'où dans le classement */}
Magasin
{sites.map((s) => ( ))}
Profondeur
{PROFONDEURS.map((p) => ( ))}
{classement.length < profondeur && ( ({classement.length} lignes affichées seulement) )}
{/* L'export reprend exactement la liste affichée : même magasin, même profondeur, même classement. */}
{exportErreur && (
{exportErreur}
)}
0 ? "var(--accent-warning)" : undefined} icone={PackageX} />
{trous.length === 0 ? (
Aucun trou dans les {examine.length} premiers : {nomMagasin} travaille tout le haut de ce classement.
) : (
{trous.map(({ row, valeurCritere, rang, constat }) => ( ))}
# Code Référence Désignation {libelleCritere(critereId)} Ventes ailleurs Stock ailleurs Constat
{rang} {row.codein} {row.reference || "—"} {row.libelle1} {row.libelle3 && ( {row.libelle3} )} {formatCritere(critereId, valeurCritere)} 0 ? "var(--accent-success)" : "var(--text-muted)" }}> {constat.ventesAilleurs > 0 ? nb(constat.ventesAilleurs) : "—"} {constat.stockAilleurs > 0 ? nb(constat.stockAilleurs) : "—"} {constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu"}
)}

Non travaillé = aucune vente sur les 12 mois glissants ET aucun stock au dernier mois connu, à {nomMagasin}. Un produit sans vente mais en stock n'apparaît pas ici : le magasin le détient, c'est un invendu, pas un trou d'assortiment.

); }