mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-15 11:18:42 +02:00
Le code interne identifie l'article chez nous, la référence l'identifie chez le fournisseur : c'est elle qu'on recopie sur une commande. La fenêtre ne donnait que le premier, obligeant à revenir à la Grille pour retrouver le second — l'export, lui, portait déjà les deux. Référence absente affichée « — » plutôt que laissée vide : une cellule vide se lit comme une colonne qui n'a pas fini de charger. Vérifié sur build de production, avec des références longues et absentes : colonne lisible, aucun débordement horizontal de la fenêtre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
459 lines
24 KiB
TypeScript
459 lines
24 KiB
TypeScript
"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<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 {
|
|
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<string, number>) =>
|
|
mois.reduce((total, m) => total + (serie?.[m] ?? 0), 0);
|
|
const dernier = (serie?: Record<string, number>) => 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<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.
|
|
const sites = useMemo(() => {
|
|
const trouves = new Set<string>();
|
|
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<number>(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<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"
|
|
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
|
onInteractOutside={onClose}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg leading-snug pr-6 flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
|
<PackageX className="w-5 h-5 shrink-0" style={{ color: "var(--accent-warning)" }} />
|
|
Produits non travaillés par un magasin
|
|
</DialogTitle>
|
|
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
|
Sur le classement affiché — trié par{" "}
|
|
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleCritere(critereId)}</span>
|
|
{" — ni vente ni stock sur 12 mois."}
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
{/* Réglages : quel magasin, et jusqu'où dans le classement */}
|
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasin</span>
|
|
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
|
{sites.map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setSite(s)}
|
|
aria-pressed={site === s}
|
|
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors flex items-center gap-1.5"
|
|
style={site === s
|
|
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
|
|
: { color: "var(--text-muted)" }}
|
|
>
|
|
<Store className="w-3 h-3" />
|
|
{SITE_LABELS[s]?.nom ?? s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Profondeur</span>
|
|
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
|
{PROFONDEURS.map((p) => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => setProfondeur(p)}
|
|
aria-pressed={profondeur === p}
|
|
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors tabular-nums"
|
|
style={profondeur === p
|
|
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
|
|
: { color: "var(--text-muted)" }}
|
|
>
|
|
Top {p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{classement.length < profondeur && (
|
|
<span className="text-[11.5px]" style={{ color: "var(--text-muted)" }}>
|
|
({classement.length} lignes affichées seulement)
|
|
</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"
|
|
valeur={`${trous.length} / ${examine.length}`}
|
|
indice={`à ${nomMagasin}`}
|
|
couleur={trous.length > 0 ? "var(--accent-warning)" : undefined}
|
|
icone={PackageX}
|
|
/>
|
|
<TuileStat
|
|
label="Vendus ailleurs"
|
|
valeur={nb(vendusAilleurs)}
|
|
indice={`déjà vendus chez ${autresMagasins} — le gisement le plus sûr`}
|
|
/>
|
|
<TuileStat
|
|
label="Déjà détenus"
|
|
valeur={nb(dejaTentes)}
|
|
indice="stock ou réception sur la période, sans vente : essayés, pas oubliés"
|
|
/>
|
|
</div>
|
|
|
|
{trous.length === 0 ? (
|
|
<div className="rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
|
Aucun trou dans les {examine.length} premiers : {nomMagasin} travaille tout le haut de ce classement.
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
|
<table className="w-full text-[12px]" style={{ minWidth: 820 }}>
|
|
<thead>
|
|
<tr style={{ background: "var(--bg-elevated)" }}>
|
|
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>#</th>
|
|
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Code</th>
|
|
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Référence</th>
|
|
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Désignation</th>
|
|
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
|
{libelleCritere(critereId)}
|
|
</th>
|
|
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Ventes ailleurs</th>
|
|
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Stock ailleurs</th>
|
|
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Constat</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{trous.map(({ row, valeurCritere, rang, constat }) => (
|
|
<tr key={row.codein} style={{ borderTop: "1px solid var(--border)" }}>
|
|
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: "var(--text-muted)" }}>{rang}</td>
|
|
<td className="px-2.5 py-1.5 tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>{row.codein}</td>
|
|
<td className="px-2.5 py-1.5 font-mono text-[11.5px] whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
|
{row.reference || "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }} title={row.libelle1}>
|
|
<span className="font-semibold">{row.libelle1}</span>
|
|
{row.libelle3 && (
|
|
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--text-muted)" }}>{row.libelle3}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
|
{formatCritere(critereId, valeurCritere)}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap"
|
|
style={{ color: constat.ventesAilleurs > 0 ? "var(--accent-success)" : "var(--text-muted)" }}>
|
|
{constat.ventesAilleurs > 0 ? nb(constat.ventesAilleurs) : "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
|
{constat.stockAilleurs > 0 ? nb(constat.stockAilleurs) : "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
|
{constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[11.5px] leading-snug" style={{ color: "var(--text-muted)" }}>
|
|
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.
|
|
</p>
|
|
</DialogContent>
|
|
);
|
|
}
|