mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
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:
10 files changed
+1514
-132
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user