feat(reseau): page Recherche réseau — chercher dans le catalogue Qlik

La Grille part de NOTRE catalogue : on extrait nos codes centraux puis on
demande à Qlik les métriques de ces codes. Un produit vendu par le réseau
mais absent de FF Nancy était donc invisible par construction.

Cette page inverse le sens : elle interroge Qlik d'abord, par libellé ou par
code centrale, et affiche aussi les produits que nous ne référençons pas.

- searchNetworkProductsPlaywright() : sélection par SelectValues sur
  « Article Code » (mode code) ou par SearchListObjectFor +
  AcceptListObjectSearch sur la dimension « Article » (mode plein texte,
  avec repli sur des noms de champs si la master dimension échoue).
  Cube [Article Code, Article, Mois] × 6 mesures, passe N-1 incluse pour
  obtenir 12 mois glissants contigus. Garde-fou à 300 produits/recherche.
- /api/qlik/search : job asynchrone (POST + polling GET) comme la sync,
  une seule recherche à la fois pour ménager le moteur Qlik. Met les
  résultats en cache dans qlik_network_metrics et enrichit avec le
  catalogue local (badge « Chez moi » / « Réseau uniquement », stock).
- Page /recherche-reseau : tableau triable, filtres, fiche produit avec la
  courbe 12 mois, export Excel.
- Composants de tendance extraits de heatmap-grid vers features/network
  pour être partagés (comportement inchangé).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-05 11:22:53 +00:00
1 parent 4b229acc9b
commit c565672259
10 files changed
+1514 -132

No files matched your search

+8 -130
View File
@@ -13,7 +13,7 @@ import {
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -26,6 +26,13 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import {
computeNetworkTrend,
TrendSparkline,
NetworkLineChart,
TREND_COLOR,
TREND_STRONG,
} from "@/features/network/components/network-trend";
function getLast12Months(): string[] {
const months: string[] = [];
@@ -238,135 +245,6 @@ interface CellDetailData {
receptions: number | null;
}
// ─── Tendance réseau (à partir de qteReseauByMonth) ───────────────────────────
type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM"
direction: "up" | "down" | "flat";
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
hasData: boolean;
};
/**
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/
function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length;
let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat";
if (n >= 3) {
const mx = (n - 1) / 2;
const my = values.reduce((s, v) => s + v, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
const slope = den ? num / den : 0;
if (my > 0) {
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
} else if (slope > 0) {
direction = "up";
}
} else if (n === 2 && values[0] > 0) {
pct = (values[1] - values[0]) / values[0];
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
}
return { values, labels, direction, pct, hasData: true };
}
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
const TREND_STRONG = 0.25;
const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
up: "#22c55e",
down: "#ef4444",
flat: "#94a3b8",
};
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
function TrendSparkline({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct } = trend;
const color = TREND_COLOR[direction];
const W = 46, H = 18, n = values.length;
const max = Math.max(...values, 1), min = Math.min(...values, 0);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
const y = H - 1 - ((v - min) / range) * (H - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
return (
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
<div className="flex items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
</svg>
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
</div>
{pct != null && (
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
</div>
);
}
/** "2026-01" → "01/26" pour un axe compact. */
function fmtMonthShort(lab: string): string {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
/** Courbe unique des ventes réseau mensuelles (SVG). */
function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
const W = 460, H = 200;
const padL = 10, padR = 10, padT = 22, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = values.length;
const maxV = Math.max(...values, 1);
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
return (
<div className="mt-3 w-full overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
{/* ligne de base */}
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{/* aire douce sous la courbe */}
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
{/* courbe */}
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
{values.map((v, i) => (
<g key={labels[i]}>
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
{/* quantité au-dessus du point */}
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
{Math.round(v).toLocaleString("fr-FR")}
</text>
{/* mois sous l'axe */}
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(labels[i])}
</text>
</g>
))}
</svg>
</div>
);
}
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth);
@@ -0,0 +1,152 @@
"use client";
/**
* CollectFlow — Tendance réseau Qlik (composants partagés).
*
* Extrait de `features/grid/components/heatmap-grid.tsx` pour être réutilisé par
* la page Recherche réseau. Le comportement est identique : seule la localisation
* du code change.
*
* Source des données : `qteByMonth` ({ "YYYY-MM": quantité }) produit par la
* synchronisation Qlik (mesure « Quantité N », complétée par « Quantité COMP »
* et une passe année N-1 pour obtenir 12 mois glissants contigus).
*/
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
export type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM"
direction: "up" | "down" | "flat";
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
hasData: boolean;
};
/**
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length;
let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat";
if (n >= 3) {
const mx = (n - 1) / 2;
const my = values.reduce((s, v) => s + v, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
const slope = den ? num / den : 0;
if (my > 0) {
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
} else if (slope > 0) {
direction = "up";
}
} else if (n === 2 && values[0] > 0) {
pct = (values[1] - values[0]) / values[0];
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
}
return { values, labels, direction, pct, hasData: true };
}
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
export const TREND_STRONG = 0.25;
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
up: "#22c55e",
down: "#ef4444",
flat: "#94a3b8",
};
/** Libellé français de la tendance (« Forte hausse », « Stable »…). */
export function trendLabel(pct: number): string {
if (pct > TREND_STRONG) return "Forte hausse";
if (pct > 0.08) return "Hausse";
if (pct < -TREND_STRONG) return "Forte baisse";
if (pct < -0.08) return "Baisse";
return "Stable";
}
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct } = trend;
const color = TREND_COLOR[direction];
const W = 46, H = 18, n = values.length;
const max = Math.max(...values, 1), min = Math.min(...values, 0);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
const y = H - 1 - ((v - min) / range) * (H - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
return (
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
<div className="flex items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
</svg>
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
</div>
{pct != null && (
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
</div>
);
}
/** "2026-01" → "01/26" pour un axe compact. */
export function fmtMonthShort(lab: string): string {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
/** Courbe unique des ventes réseau mensuelles (SVG). */
export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
const W = 460, H = 200;
const padL = 10, padR = 10, padT = 22, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = values.length;
const maxV = Math.max(...values, 1);
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
return (
<div className="mt-3 w-full overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
{/* ligne de base */}
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{/* aire douce sous la courbe */}
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
{/* courbe */}
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
{values.map((v, i) => (
<g key={labels[i]}>
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
{/* quantité au-dessus du point */}
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
{Math.round(v).toLocaleString("fr-FR")}
</text>
{/* mois sous l'axe */}
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(labels[i])}
</text>
</g>
))}
</svg>
</div>
);
}