diff --git a/.viz-bundle.mjs b/.viz-bundle.mjs new file mode 100644 index 0000000..be35014 --- /dev/null +++ b/.viz-bundle.mjs @@ -0,0 +1,229 @@ +// +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +// src/features/grid/components/network-charts.tsx +import { useState } from "react"; + +// src/features/grid/lib/months.ts +function fmtMonthShort(lab) { + const [y, m] = lab.split("-"); + return `${m ?? lab}/${(y ?? "").slice(2)}`; +} + +// src/features/grid/components/network-charts.tsx +import { jsx, jsxs } from "react/jsx-runtime"; +var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"]; +function NetworkLineChart({ + labels: labels2, + values: values2, + stores: stores2, + perStore: perStore2 +}) { + const [survol, setSurvol] = useState(null); + const n = values2.length; + const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0; + const avecParMag = memeTaille(perStore2); + const avecMagasins = memeTaille(stores2); + const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); + const facettes = []; + if (avecParMag) { + facettes.push({ + titre: "Qt\xE9 / magasin", + valeurs: perStore2, + couleur: SERIE_COULEURS[0], + hauteur: 92, + aire: true, + decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0 + }); + facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 }); + } else { + facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true }); + } + if (avecMagasins) { + facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, couleur: SERIE_COULEURS[2], hauteur: 46 }); + } + const W = 520; + const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12; + const plotW = W - padL - padR; + const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH; + const x = (i) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2; + const ancrage = (i) => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; + let curseur = 0; + const disposees = facettes.map((f) => { + const hautEntete = curseur; + const haut = curseur + enteteH; + curseur = haut + f.hauteur + ecart; + const min = Math.min(0, ...f.valeurs); + const max = Math.max(...f.valeurs, min + 1); + const etendue = max - min || 1; + const y = (v) => haut + f.hauteur - (v - min) / etendue * f.hauteur; + const iMax = f.valeurs.indexOf(Math.max(...f.valeurs)); + return { ...f, hautEntete, haut, base: y(min), max, y, iMax }; + }); + const basPlots = curseur - ecart; + return /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [ + /* @__PURE__ */ jsxs( + "svg", + { + viewBox: `0 0 ${W} ${H}`, + className: "w-full", + style: { minWidth: 420 }, + role: "img", + "aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "), + onMouseLeave: () => setSurvol(null), + children: [ + disposees.map((f) => { + const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" "); + const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])]; + return /* @__PURE__ */ jsxs("g", { children: [ + /* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }), + /* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }), + /* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [ + "max ", + fmt(f.max, f.decimales ?? 0), + f.unite ?? "" + ] }), + /* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }), + f.aire && n > 1 && /* @__PURE__ */ jsx( + "polygon", + { + points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`, + fill: f.couleur, + opacity: 0.1 + } + ), + n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }), + aEtiqueter.map((i) => /* @__PURE__ */ jsx( + "circle", + { + cx: x(i), + cy: f.y(f.valeurs[i]), + r: 4, + fill: f.couleur, + stroke: "var(--bg-surface)", + strokeWidth: 2 + }, + `pt-${f.titre}-${i}` + )), + aEtiqueter.map((i) => { + const yPoint = f.y(f.valeurs[i]); + const dessus = yPoint - 8 >= f.haut + 8; + return /* @__PURE__ */ jsx( + "text", + { + x: x(i), + y: dessus ? yPoint - 8 : yPoint + 13, + textAnchor: ancrage(i), + fontSize: 9, + fontWeight: 700, + fill: "var(--text-primary)", + children: fmt(f.valeurs[i], f.decimales ?? 0) + }, + `lab-${f.titre}-${i}` + ); + }), + survol != null && /* @__PURE__ */ jsx( + "circle", + { + cx: x(survol), + cy: f.y(f.valeurs[survol]), + r: 3.5, + fill: f.couleur, + stroke: "var(--bg-surface)", + strokeWidth: 2 + } + ) + ] }, f.titre); + }), + survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }), + labels2.map((lab, i) => /* @__PURE__ */ jsx( + "text", + { + x: x(i), + y: H - 5, + textAnchor: ancrage(i), + fontSize: 8.5, + fill: survol === i ? "var(--text-primary)" : "var(--text-muted)", + fontWeight: survol === i ? 700 : 400, + children: fmtMonthShort(lab) + }, + `mois-${lab}` + )), + survol != null && (() => { + const largeur = 158; + const hauteur = 16 + disposees.length * 13 + 8; + const gauche = x(survol) + 10 + largeur > W - 4; + const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10; + return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [ + /* @__PURE__ */ jsx( + "rect", + { + x: bx, + y: 4, + width: largeur, + height: hauteur, + rx: 8, + fill: "var(--bg-elevated)", + stroke: "var(--border)", + strokeWidth: 1 + } + ), + /* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }), + disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [ + /* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }), + /* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }), + /* @__PURE__ */ jsx( + "text", + { + x: bx + largeur - 9, + y: 30 + k * 13, + textAnchor: "end", + fontSize: 9, + fontWeight: 700, + fill: "var(--text-primary)", + children: fmt(f.valeurs[survol], f.decimales ?? 0) + } + ) + ] }, `bulle-${f.titre}`)) + ] }); + })(), + labels2.map((lab, i) => /* @__PURE__ */ jsx( + "rect", + { + x: x(i) - plotW / (2 * Math.max(1, n - 1)), + y: 0, + width: plotW / Math.max(1, n - 1), + height: H, + fill: "transparent", + onMouseEnter: () => setSurvol(i) + }, + `survol-${lab}` + )) + ] + } + ), + /* @__PURE__ */ jsxs("details", { className: "mt-2", children: [ + /* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }), + /* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [ + /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [ + /* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }), + disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`)) + ] }) }), + /* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [ + /* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }), + disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`)) + ] }, `tr-${lab}`)) }) + ] }) }) + ] }) + ] }); +} + +// +var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"]; +var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470]; +var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170]; +var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0); +globalThis.__HTML__ = renderToStaticMarkup( + React.createElement(NetworkLineChart, { labels, values, stores, perStore }) +); diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 19e955d..09ca1a3 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -75,6 +75,56 @@ function getQlikNetworkSortValue(value: number | null | undefined, columnId: str // ========================================================================= // 1. Composant isolé pour la Cellule Gamme (évite le re-render des colonnes) +/** + * Cellule dont la valeur se copie d'un clic. + * + * Extraite de la colonne « Code interne », qui la portait en ligne avec un + * `eslint-disable react-hooks/rules-of-hooks` : un `useState` dans le corps d'un + * `cell()` de TanStack n'est pas rendu au même endroit d'un rendu à l'autre. Un + * vrai composant supprime la dérogation et rend la copie réutilisable — les + * références et les gencodes se recopient autant que les codes internes. + * + * `stopPropagation` est indispensable : sans lui, le clic sélectionne aussi la + * ligne. + */ +const CopiableCell = React.memo(function CopiableCell({ value, titre, className, style }: { + value: string | null | undefined; + titre: string; + className?: string; + style?: React.CSSProperties; +}) { + const [copied, setCopied] = useState(false); + const texte = (value ?? "").trim(); + + if (!texte) { + return -; + } + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(texte); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {texte} + + {copied ? ( + + ) : ( + + )} + +
+ ); +}); + const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boolean }) => { // Abonnement ultra-ciblé : la cellule ne re-render que si SA valeur change const codein = row.codein; @@ -461,63 +511,40 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { accessorKey: "codein", header: "Code interne", size: 90, - cell: ({ getValue }) => { - const value = getValue(); - // eslint-disable-next-line react-hooks/rules-of-hooks - const [copied, setCopied] = useState(false); - - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- - {value} - -
- {copied ? ( - - ) : ( - - )} -
-
- ); - }, + cell: ({ getValue }) => ( + ()} + titre="Copier le code interne" + className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" + style={{ color: "var(--text-muted)" }} + /> + ), }, { accessorKey: "reference", header: "Référence", size: 110, - cell: ({ getValue }) => { - const val = getValue(); - return ( - - {val || "-"} - - ); - }, + cell: ({ getValue }) => ( + ()} + titre="Copier la référence" + className="text-[12px] font-mono opacity-80 group-hover:opacity-100" + style={{ color: "var(--text-secondary)" }} + /> + ), }, { accessorKey: "gtin", header: "EAN / GTIN", size: 120, - cell: ({ getValue }) => { - const val = getValue(); - return ( - - {val || "-"} - - ); - }, + cell: ({ getValue }) => ( + ()} + titre="Copier l'EAN / GTIN" + className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100" + style={{ color: "var(--text-secondary)" }} + /> + ), }, { accessorKey: "libelle1", diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 73ce6c7..4ae2fce 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -107,6 +107,7 @@ export function NetworkLineChart({ perStore?: number[] | null; }) { const [survol, setSurvol] = useState(null); + const [vue, setVue] = useState<"graphique" | "tableau">("graphique"); const n = values.length; const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0; const avecParMag = memeTaille(perStore); @@ -160,11 +161,37 @@ export function NetworkLineChart({ const basPlots = curseur - ecart; return ( -
+
+ {/* Bascule : la même surface sert au dessin ou aux chiffres. */} +
+
+ {(["graphique", "tableau"] as const).map((v) => ( + + ))} +
+
+ {vue === "graphique" ? ( +
f.titre).join(", ")} onMouseLeave={() => setSurvol(null)} @@ -266,6 +293,45 @@ export function NetworkLineChart({ ))} + {/* Infobulle DANS le dessin. + Elle vivait sous le SVG, en HTML : selon la longueur des valeurs elle + passait sur deux lignes et la carte changeait de hauteur en cours de + survol. Ici la géométrie est figée par le viewBox — la carte ne bouge + plus jamais. `pointerEvents=none` laisse passer le survol vers les + cibles en dessous. */} + {survol != null && (() => { + const largeur = 158; + const hauteur = 16 + disposees.length * 13 + 8; + // La bulle bascule à gauche du réticule quand elle déborderait à droite. + const gauche = x(survol) + 10 + largeur > W - 4; + const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10; + return ( + + + + {fmtMonthShort(labels[survol])} + + {disposees.map((f, k) => ( + + + + {f.titre} + + + {fmt(f.valeurs[survol], f.decimales ?? 0)} + + + ))} + + ); + })()} + {/* Zones de survol : larges cibles, une par mois */} {labels.map((lab, i) => ( ))} - - {/* Lecture du mois survolé, en encre neutre : la couleur reste aux repères */} -
- {survol != null ? ( - <> - {fmtMonthShort(labels[survol])} - {disposees.map((f) => ( - - - {f.titre} - - {fmt(f.valeurs[survol], f.decimales ?? 0)} - - - ))} - - ) : ( - Survolez un mois pour le détail. - )}
- - {/* Vue tableau : l'équivalent accessible, et le recours quand une teinte - passe sous le seuil de contraste. Aucune valeur n'est réservée au survol. */} -
- - Voir les valeurs mois par mois - -
- - - - + ) : ( + /* Vue tableau : même panneau, MÊME HAUTEUR. + Un dépliant aurait fait grandir la carte à l'ouverture — exactement ce + qu'on venait de corriger sur le survol. Le tableau occupe donc la place + du graphique et défile à l'intérieur : la carte garde sa taille, et + aucune valeur n'est réservée au survol. */ +
+
Mois
+ + + + {disposees.map((f) => ( + + ))} + + + + {labels.map((lab, i) => ( + + {disposees.map((f) => ( - + ))} - - - {labels.map((lab, i) => ( - - - {disposees.map((f) => ( - - ))} - - ))} - -
Mois + {f.titre} +
+ {fmtMonthShort(lab)} + {f.titre} + {fmt(f.valeurs[i], f.decimales ?? 0)} +
{fmtMonthShort(lab)} - {fmt(f.valeurs[i], f.decimales ?? 0)} -
-
-
+ ))} + + +
+ )}
); }