Trous d'assortiment : extraction Excel de la liste affichée

Un bouton « Export Excel » dans la fenêtre des produits non travaillés. Il
reprend EXACTEMENT ce qui est à l'écran — même magasin, même profondeur,
même classement — plutôt que de recalculer une liste : un fichier qui ne
correspond pas à la fenêtre d'où il sort ne se vérifie plus.

Deux feuilles. Les données seules d'abord : en-têtes en ligne 1, filtre
automatique, volet figé, et surtout des valeurs NUMÉRIQUES — le tableur doit
pouvoir trier et sommer, ce qu'une chaîne « 89 863 € » lui interdit. La mise
en forme des nombres suit le critère de tri (euros, unités, pourcentage). Le
contexte ensuite, sur sa propre feuille : fournisseur, magasin examiné,
classement retenu, définition du « non travaillé », date. Mêler les deux
dans une feuille unique aurait condamné filtre et tri.

`exceljs` est chargé à la demande plutôt qu'importé en tête : il pèse lourd,
et la Grille n'a pas à le transporter pour tous ceux qui n'exportent jamais.
Un échec de génération s'affiche dans la fenêtre — un export qui échoue en
silence laisse croire au téléchargement.

Le nom du fournisseur descend jusqu'à la fenêtre pour nommer et documenter
le fichier.

Vérifié sur build de production, en téléchargeant le fichier puis en le
relisant : « Non_travailles_Fournisseur_Test_Houdemont_Top200_2026-08-14 »,
deux feuilles, 15 lignes conformes au décompte affiché, filtre automatique
sur A1:J1, valeurs lues comme des nombres et format « #,##0 "€" » sur la
colonne du critère.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
This commit is contained in:
Claude committed 2026-08-14 08:36:20 +00:00
1 parent beefb07ba0
commit f75d51f6f4
3 files changed
+185 -15

No files matched your search

@@ -14,7 +14,7 @@
*/
import React, { useMemo, useState } from "react";
import { PackageX, Store } from "lucide-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";
@@ -40,18 +40,29 @@ const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2
* des pourcentages. Afficher la valeur brute rendrait la colonne illisible, et
* ne rien afficher priverait le classement de sa justification.
*/
const CRITERES: Record<string, { label: string; format: (v: number) => string }> = {
caReseau: { label: "CA réseau", format: eur0 },
qteReseau: { label: "Qté réseau", format: nb },
nbMagasinsReseau: { label: "Magasins réseau", format: nb },
tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %` },
prixMoyenReseau: { label: "PV moyen réseau", format: eur2 },
prixVente: { label: "PV magasin", format: eur2 },
caParMagasinReseau: { label: "CA / magasin réseau", format: eur0 },
margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %` },
totalQuantite: { label: "Qté 12 m", format: nb },
totalCa: { label: "CA 12 m", format: eur0 },
totalMarge: { label: "Marge 12 m", format: eur0 },
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<string, Critere> = {
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 {
@@ -108,13 +119,106 @@ function constater(row: ProductRow, site: string, mois: string[]): Constat {
};
}
export function AssortmentGapsModal({ classement, critereId, mois, magasinInitial, onClose }: {
/** 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<Blob> {
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.
@@ -137,11 +241,51 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
.filter(({ constat }) => constat.ventesMagasin <= 0 && constat.stockMagasin <= 0),
[examine, site, mois]);
const [exportEnCours, setExportEnCours] = useState(false);
const [exportErreur, setExportErreur] = useState<string | null>(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 (
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-5xl grid-cols-1 max-h-[88vh] overflow-y-auto"
@@ -206,8 +350,30 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
</span>
)}
</div>
{/* L'export reprend exactement la liste affichée : même magasin,
même profondeur, même classement. */}
<button
type="button"
onClick={exporter}
disabled={trous.length === 0 || exportEnCours}
title={trous.length === 0
? "Rien à exporter : aucun trou dans ce périmètre"
: `Exporter les ${trous.length} produits non travaillés au format Excel`}
className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-md text-[12px] font-semibold border transition-all disabled:opacity-50"
style={{ background: "var(--bg-elevated)", borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}
>
{exportEnCours ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5" />}
{exportEnCours ? "Génération…" : "Export Excel"}
</button>
</div>
{exportErreur && (
<div className="rounded-lg px-3 py-2 text-[12px]" style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
{exportErreur}
</div>
)}
<div className="flex flex-wrap gap-2">
<TuileStat
label="Non travaillés"
@@ -307,6 +307,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
nomFournisseur={nomFournisseur}
/>
</div>
@@ -49,6 +49,8 @@ import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/net
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
isAdmin?: boolean;
/** Sert à nommer et documenter les extractions issues de la Grille. */
nomFournisseur?: string;
}
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
@@ -512,7 +514,7 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
// =========================================================================
export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
@@ -1320,6 +1322,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
critereId={sorting[0]?.id ?? null}
mois={MONTHS_12}
magasinInitial={activeMagasin}
nomFournisseur={nomFournisseur}
onClose={() => setGapsOpen(false)}
/>
)}