"use client"; /** * CollectFlow — CA par nomenclature : nos magasins face au réseau. * * On part des lignes AFFICHÉES dans la Grille (filtres de nomenclature, de * gamme et recherche compris) et on les regroupe par nomenclature (`code3`). * La période est celle de la Grille : les 12 mois complets glissants, sur * laquelle sont calés à la fois les totaux FF par magasin et les chiffres * réseau Qlik. * * Comparer des montants bruts n'aurait pas de sens — le réseau compte ~270 * magasins, nous deux. La comparaison passe donc par deux rapports : * - le poids de la nomenclature dans le CA du périmètre, chez nous et dans * le réseau (écart en points : où notre mix diverge de celui du réseau) ; * - l'indice de chaque magasin face au magasin moyen du réseau * (CA réseau ÷ 270) : 100 = au niveau du magasin moyen. */ import React, { useMemo, useState } from "react"; import { ChevronDown, ChevronUp, ChevronsUpDown, FileSpreadsheet, Layers, Loader2, Search } from "lucide-react"; import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { TuileStat } from "@/features/grid/components/stat-tile"; import { formatMonthLabel } from "@/features/grid/lib/months"; import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format"; import { MAGASINS, nomMagasin } from "@/lib/magasins"; import type { ProductRow } from "@/types/grid"; /** Une nomenclature et ses chiffres sur la période. */ interface LigneNomenclature { code: string; libelle: string; nbProduits: number; /** Produits vendus dans au moins un de nos magasins. */ nbVendus: number; /** Produits sans chiffre réseau (non rapprochés dans Qlik). */ nbSansReseau: number; caParSite: Record; caNous: number; caReseau: number; partNous: number | null; partReseau: number | null; /** Écart de poids, en points : partNous − partReseau. */ ecartPoints: number | null; /** CA du magasin moyen du réseau (CA réseau ÷ nombre de magasins du réseau). */ caMagasinMoyenReseau: number; } type CleTri = "libelle" | "caNous" | "caReseau" | "partNous" | "partReseau" | "ecartPoints" | `site:${string}`; const SANS_NOMENCLATURE = "—"; const fmtPct = (v: number | null) => (v == null ? "—" : `${fmtDecimal1(v * 100)} %`); const fmtPoints = (v: number | null) => (v == null ? "—" : `${v >= 0 ? "+" : ""}${fmtDecimal1(v * 100)} pts`); const fmtEurOuTiret = (v: number) => (v !== 0 ? fmtEur2(v) : "—"); /** Indice d'un magasin face au magasin moyen du réseau (100 = au niveau). */ function indice(ca: number, caMagasinMoyen: number): number | null { if (caMagasinMoyen <= 0) return null; return (ca / caMagasinMoyen) * 100; } function couleurIndice(v: number | null): string { if (v == null) return "var(--text-muted)"; return v >= 100 ? "var(--accent-success)" : "var(--accent-error)"; } function couleurEcart(v: number | null): string { if (v == null || Math.abs(v) < 0.005) return "var(--text-muted)"; return v > 0 ? "var(--accent-success)" : "var(--accent-error)"; } /** Magasins présents dans les données, à défaut nos magasins connus — dans l'ordre de `MAGASINS`. */ function sitesDe(rows: ProductRow[]): string[] { const trouves = new Set(); for (const r of rows) for (const s of Object.keys(r.caByStore ?? {})) trouves.add(s); const connus = MAGASINS.map((m) => m.code); const autres = [...trouves].filter((s) => !connus.includes(s)).sort(); return [...connus, ...autres]; } function regrouper(rows: ProductRow[], sites: string[]): { lignes: LigneNomenclature[]; total: LigneNomenclature } { const groupes = new Map(); const vide = (code: string, libelle: string): LigneNomenclature => ({ code, libelle, nbProduits: 0, nbVendus: 0, nbSansReseau: 0, caParSite: Object.fromEntries(sites.map((s) => [s, 0])), caNous: 0, caReseau: 0, partNous: null, partReseau: null, ecartPoints: null, caMagasinMoyenReseau: 0, }); const total = vide("", "Total"); for (const r of rows) { const code = r.code3?.trim() || SANS_NOMENCLATURE; let g = groupes.get(code); if (!g) { g = vide(code, r.libelle3?.trim() || (code === SANS_NOMENCLATURE ? "Sans nomenclature" : code)); groupes.set(code, g); } let caProduit = 0; for (const s of sites) { const ca = r.caByStore?.[s] ?? 0; g.caParSite[s] += ca; total.caParSite[s] += ca; caProduit += ca; } // Lignes sans détail par magasin : le total du produit reste compté. if (!r.caByStore && r.totalCa) caProduit = r.totalCa; g.caNous += caProduit; total.caNous += caProduit; for (const cible of [g, total]) { cible.nbProduits += 1; if (caProduit > 0) cible.nbVendus += 1; if (r.caReseau == null) cible.nbSansReseau += 1; else cible.caReseau += r.caReseau; } } const lignes = [...groupes.values()].filter((g) => g.caNous !== 0 || g.caReseau !== 0); for (const g of [...lignes, total]) { g.partNous = total.caNous > 0 ? g.caNous / total.caNous : null; g.partReseau = total.caReseau > 0 ? g.caReseau / total.caReseau : null; g.ecartPoints = g.partNous != null && g.partReseau != null ? g.partNous - g.partReseau : null; g.caMagasinMoyenReseau = g.caReseau / NB_MAGASINS_RESEAU; } return { lignes, total }; } function valeurTri(l: LigneNomenclature, cle: CleTri): number | string { if (cle === "libelle") return l.libelle.toLowerCase(); if (cle.startsWith("site:")) return l.caParSite[cle.slice(5)] ?? 0; const v = l[cle as Exclude]; // Valeur absente : toujours en bas, quel que soit le sens. return v ?? Number.NaN; } /** * Classeur Excel de la liste affichée — valeurs numériques brutes pour que le * tableur puisse trier et sommer. `exceljs` est chargé à la demande. */ async function construireClasseur( lignes: LigneNomenclature[], total: LigneNomenclature, sites: string[], contexte: { fournisseur?: string; periode: string; nbLignesGrille: number }, ): Promise { const { Workbook } = await import("exceljs"); const classeur = new Workbook(); classeur.creator = "CollectFlow"; classeur.created = new Date(); const feuille = classeur.addWorksheet("CA par nomenclature", { views: [{ state: "frozen", ySplit: 1 }] }); feuille.columns = [ { header: "Code", key: "code", width: 12 }, { header: "Nomenclature", key: "libelle", width: 36 }, { header: "Produits", key: "nbProduits", width: 10 }, { header: "Produits vendus", key: "nbVendus", width: 15 }, ...sites.flatMap((s) => [ { header: `CA ${nomMagasin(s, { court: true })}`, key: `ca_${s}`, width: 16 }, { header: `Indice ${nomMagasin(s, { court: true })}`, key: `ind_${s}`, width: 14 }, ]), { header: "CA nos magasins", key: "caNous", width: 16 }, { header: "Poids chez nous", key: "partNous", width: 15 }, { header: "CA réseau", key: "caReseau", width: 16 }, { header: "CA magasin moyen réseau", key: "caMoyen", width: 22 }, { header: "Poids réseau", key: "partReseau", width: 13 }, { header: "Écart de poids (pts)", key: "ecart", width: 18 }, { header: "Produits sans chiffre réseau", key: "nbSansReseau", 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; const ajouter = (l: LigneNomenclature) => feuille.addRow({ code: l.code, libelle: l.libelle, nbProduits: l.nbProduits, nbVendus: l.nbVendus, ...Object.fromEntries(sites.flatMap((s) => { const ind = indice(l.caParSite[s] ?? 0, l.caMagasinMoyenReseau); return [[`ca_${s}`, l.caParSite[s] ?? 0], [`ind_${s}`, ind == null ? "" : Math.round(ind)]]; })), caNous: l.caNous, partNous: l.partNous ?? "", caReseau: l.caReseau, caMoyen: l.caMagasinMoyenReseau, partReseau: l.partReseau ?? "", ecart: l.ecartPoints == null ? "" : Math.round(l.ecartPoints * 1000) / 10, nbSansReseau: l.nbSansReseau, }); for (const l of lignes) ajouter(l); const ligneTotal = ajouter(total); ligneTotal.font = { bold: true }; const eur = '#,##0.00 "€"'; for (const cle of ["caNous", "caReseau", "caMoyen", ...sites.map((s) => `ca_${s}`)]) feuille.getColumn(cle).numFmt = eur; feuille.getColumn("partNous").numFmt = "0.0 %"; feuille.getColumn("partReseau").numFmt = "0.0 %"; feuille.getColumn("ecart").numFmt = '+0.0;-0.0;0.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: 80 }]; for (const [cle, valeur] of [ ["Extraction", "CA par nomenclature — nos magasins et réseau"], ["Fournisseur", contexte.fournisseur ?? "—"], ["Période", contexte.periode], ["Périmètre", `${contexte.nbLignesGrille} produits affichés dans la Grille (filtres compris)`], ["Poids", "part de la nomenclature dans le CA du périmètre (chez nous / dans le réseau)"], ["Indice magasin", `CA du magasin ÷ CA du magasin moyen du réseau (CA réseau ÷ ${NB_MAGASINS_RESEAU}) × 100`], ["Généré le", new Date().toLocaleString("fr-FR")], ]) { contexteFeuille.addRow([cle, valeur]).getCell(1).font = { bold: true }; } const buffer = await classeur.xlsx.writeBuffer(); return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); } function EnTete({ label, cle, tri, onTri, align = "right", title }: { label: React.ReactNode; cle: CleTri; tri: { cle: CleTri; desc: boolean }; onTri: (cle: CleTri) => void; align?: "left" | "right"; title?: string; }) { const actif = tri.cle === cle; const Icone = !actif ? ChevronsUpDown : tri.desc ? ChevronDown : ChevronUp; return ( ); } export function NomenclatureCaModal({ rows, mois, nomFournisseur, onClose }: { /** Les produits affichés dans la Grille (filtres et recherche compris). */ rows: ProductRow[]; /** Les 12 clés de mois de la Grille, du plus ancien au plus récent. */ mois: string[]; nomFournisseur?: string; onClose: () => void; }) { const sites = useMemo(() => sitesDe(rows), [rows]); const { lignes, total } = useMemo(() => regrouper(rows, sites), [rows, sites]); const [tri, setTri] = useState<{ cle: CleTri; desc: boolean }>({ cle: "caNous", desc: true }); const [recherche, setRecherche] = useState(""); const [exportEnCours, setExportEnCours] = useState(false); const [exportErreur, setExportErreur] = useState(null); const changerTri = (cle: CleTri) => setTri((t) => (t.cle === cle ? { cle, desc: !t.desc } : { cle, desc: cle !== "libelle" })); const affichees = useMemo(() => { const q = recherche.trim().toLowerCase(); const filtrees = q ? lignes.filter((l) => l.libelle.toLowerCase().includes(q) || l.code.toLowerCase().includes(q)) : lignes; const sens = tri.desc ? -1 : 1; return [...filtrees].sort((a, b) => { const va = valeurTri(a, tri.cle); const vb = valeurTri(b, tri.cle); if (typeof va === "string" || typeof vb === "string") return String(va).localeCompare(String(vb), "fr") * sens; if (Number.isNaN(va)) return Number.isNaN(vb) ? 0 : 1; if (Number.isNaN(vb)) return -1; return (va - vb) * sens; }); }, [lignes, recherche, tri]); const periode = mois.length > 0 ? `${formatMonthLabel(mois[0])} → ${formatMonthLabel(mois[mois.length - 1])}` : "12 derniers mois"; const absentesChezNous = lignes.filter((l) => l.caNous <= 0 && l.caReseau > 0).length; const indicesTotal = sites.map((s) => indice(total.caParSite[s] ?? 0, total.caMagasinMoyenReseau)); const exporter = async () => { if (affichees.length === 0 || exportEnCours) return; setExportEnCours(true); setExportErreur(null); try { const blob = await construireClasseur(affichees, total, sites, { fournisseur: nomFournisseur, periode, nbLignesGrille: rows.length, }); const url = URL.createObjectURL(blob); const lien = document.createElement("a"); lien.href = url; lien.download = [ "CA_nomenclatures", nomFournisseur?.replace(/[^\w]+/g, "_"), new Date().toISOString().slice(0, 10), ].filter(Boolean).join("_") + ".xlsx"; lien.click(); URL.revokeObjectURL(url); } catch (e) { console.error("[CA par nomenclature] export Excel :", e); setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste."); } finally { setExportEnCours(false); } }; const celluleSite = (l: LigneNomenclature, s: string, gras = false) => { const ca = l.caParSite[s] ?? 0; const ind = indice(ca, l.caMagasinMoyenReseau); return (
{fmtEurOuTiret(ca)}
{ind == null ? "—" : `indice ${fmtEntier(ind)}`}
); }; return ( CA par nomenclature

{nomFournisseur ? <>{nomFournisseur} — : null} {periode} {` — ${fmtEntier(rows.length)} produits affichés dans la Grille, par magasin et face au réseau.`}

{sites.map((s, i) => ( ))} 0 ? "var(--accent-warning)" : undefined} />
setRecherche(e.target.value)} className="apple-input w-60" style={{ paddingLeft: "32px" }} />
{exportErreur && (
{exportErreur}
)} {affichees.length === 0 ? (
{lignes.length === 0 ? "Aucune nomenclature avec du CA sur la période pour les produits affichés." : "Aucune nomenclature ne correspond à ce filtre."}
) : (
{sites.map((s) => ( ))} {affichees.map((l) => ( {sites.map((s) => celluleSite(l, s))} ))} {sites.map((s) => celluleSite(total, s, true))}
{l.libelle}
{l.code !== SANS_NOMENCLATURE && <>{l.code} · } {fmtEntier(l.nbVendus)} / {fmtEntier(l.nbProduits)} produits vendus {l.nbSansReseau > 0 && ( {" "}· {fmtEntier(l.nbSansReseau)} sans réseau )}
{fmtEurOuTiret(l.caNous)} {fmtPct(l.partNous)}
{fmtEurOuTiret(l.caReseau)}
{l.caReseau !== 0 ? `${fmtEur2(l.caMagasinMoyenReseau)} / mag.` : ""}
{fmtPct(l.partReseau)} {fmtPoints(l.ecartPoints)}
Total
{fmtEntier(total.nbVendus)} / {fmtEntier(total.nbProduits)} produits vendus
{fmtEur2(total.caNous)} {fmtPct(total.partNous)}
{fmtEur2(total.caReseau)}
{fmtEur2(total.caMagasinMoyenReseau)} / mag.
{fmtPct(total.partReseau)}
)}

Période : les 12 mois complets de la Grille, communs à nos magasins et au réseau. Le poids est la part de la nomenclature dans le CA des produits affichés ; l'écart compare notre mix à celui du réseau. L'indice rapporte le CA d'un magasin à celui du magasin moyen du réseau (CA réseau ÷ {NB_MAGASINS_RESEAU}) : 100 = au niveau. {total.nbSansReseau > 0 && ` ${fmtEntier(total.nbSansReseau)} produits n'ont pas de chiffre réseau : le CA réseau des nomenclatures concernées est sous-estimé.`}

); }