mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
merge: intègre main — main prioritaire, abandon de la page en doublon
main a beaucoup avancé (53 commits) pendant que cette branche construisait l'API. La fusion a révélé que /produits, sur main, fait la même chose que la page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités, URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits). Résolution, main prioritaire : - sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son travail sur la tendance et Qlik est postérieur et plus complet. - get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir. - settings : les deux côtés (ServerLogs de main + les sections API d'ici). Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et composants. searchNetworkProductsPlaywright disparaît avec la version main de qlik-playwright ; rien d'autre ne l'utilisait. api-enrich se branche désormais sur le module de tendance de main (features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue « vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et nouveau. Apport conservé de la branche : l'API /api/v1, absente de main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
50 files changed
+8488
-2017
No files matched your search
@@ -15,10 +15,10 @@ import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
|
||||
import { upsertGridRows } from "@/lib/grid-store";
|
||||
|
||||
const NB_MAGASINS_RESEAU = 270;
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
magasin?: string;
|
||||
@@ -507,6 +507,29 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
|
||||
*
|
||||
* `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la
|
||||
* deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide
|
||||
* pour que l'UI distingue « pas de donnée » de « zéro magasin ».
|
||||
*/
|
||||
function nbMagParMois(
|
||||
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
|
||||
): Record<string, number> | null {
|
||||
if (!metricsByMonth) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const [mois] of Object.entries(metricsByMonth)) {
|
||||
// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de
|
||||
// `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme
|
||||
// « manquant » amputait la série et faisait disparaître toute la courbe
|
||||
// (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente).
|
||||
const nb = Number(metricsByMonth[mois]?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
|
||||
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
|
||||
@@ -537,6 +560,7 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } fro
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
|
||||
import { SuccessModal } from "@/components/shared/success-modal";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -37,7 +38,13 @@ export function FloatingSummaryBar() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({
|
||||
const [modal, setModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: "success" | "error";
|
||||
action?: { label: string; onClick: () => void };
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: "",
|
||||
message: ""
|
||||
@@ -118,12 +125,26 @@ export function FloatingSummaryBar() {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!labelOverride) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Erreur Snapshot",
|
||||
message: `Impossible de créer le snapshot : ${msg}`
|
||||
});
|
||||
// Onglet resté ouvert pendant un déploiement : les identifiants de
|
||||
// Server Action ont changé côté serveur. Rien à réparer côté
|
||||
// métier, il faut recharger le bundle client.
|
||||
if (isStaleServerActionError(err)) {
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Page à recharger",
|
||||
message: STALE_ACTION_MESSAGE,
|
||||
variant: "error",
|
||||
action: { label: "Recharger la page", onClick: () => window.location.reload() },
|
||||
});
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Erreur Snapshot",
|
||||
message: `Impossible de créer le snapshot : ${msg}`,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSavingSnapshot(false);
|
||||
@@ -327,6 +348,8 @@ export function FloatingSummaryBar() {
|
||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
||||
title={modal.title}
|
||||
message={modal.message}
|
||||
variant={modal.variant}
|
||||
action={modal.action}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -26,71 +26,21 @@ 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 {
|
||||
getLast12Months,
|
||||
formatMonthLabel,
|
||||
formatDate,
|
||||
SITE_LABELS,
|
||||
getStoreConfig,
|
||||
} from "@/features/grid/lib/months";
|
||||
import {
|
||||
computeNetworkTrend,
|
||||
TrendSparkline,
|
||||
NetworkLineChart,
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
TREND_STRONG,
|
||||
} from "@/features/network/components/network-trend";
|
||||
|
||||
function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
||||
// pour exclure le mois en cours (i=0)
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module)
|
||||
|
||||
function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns premium styling and color for stores based on the store name's hash.
|
||||
* This guarantees a consistent color for a given store, regardless of its position in the list.
|
||||
*/
|
||||
const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
function getStoreConfig(name: string) {
|
||||
const known = SITE_LABELS[name];
|
||||
const words = (known?.nom ?? name).trim().split(/\s+/);
|
||||
let label = known?.label ?? words[0].charAt(0).toUpperCase();
|
||||
if (!known && words.length > 1) {
|
||||
label += words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colorInt = Math.abs(hash) % 5;
|
||||
|
||||
switch (colorInt) {
|
||||
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
|
||||
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
|
||||
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
|
||||
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
|
||||
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
|
||||
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
|
||||
}
|
||||
}
|
||||
NB_MAGASINS_RESEAU,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
@@ -125,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 <span className={className} style={style}>-</span>;
|
||||
}
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(texte);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors min-w-0"
|
||||
title={copied ? "Copié !" : titre}
|
||||
>
|
||||
<span className={cn("truncate", className)} style={style}>{texte}</span>
|
||||
<span className="shrink-0 transition-all duration-200">
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -245,34 +245,102 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
|
||||
/**
|
||||
* Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels.
|
||||
*
|
||||
* Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font
|
||||
* paraître un nombre court anormalement lâche. Les chiffres tabulaires sont
|
||||
* réservés aux colonnes qui s'alignent verticalement (tableau, axes).
|
||||
*/
|
||||
function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
|
||||
label: string;
|
||||
valeur: string;
|
||||
indice?: string;
|
||||
couleur?: string;
|
||||
icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl px-3 py-2.5 flex-1 min-w-[140px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{Icone && <Icone className="w-4 h-4 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
|
||||
<span className="text-[20px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
|
||||
{valeur}
|
||||
</span>
|
||||
</div>
|
||||
{indice && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{indice}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
|
||||
* mensuelles en petits multiples.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de
|
||||
* verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale
|
||||
* jamais par la couleur seule. Les courbes, elles, portent des teintes
|
||||
* d'identité — sans quoi la même couleur voudrait dire deux choses.
|
||||
*/
|
||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||
const trend = computeNetworkTrend(row.qteReseauByMonth);
|
||||
const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
|
||||
const dernier = values.length - 1;
|
||||
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const verdict = trend.nouveau
|
||||
? "Nouveau"
|
||||
: pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %`
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-md" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogContent className="max-w-xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}
|
||||
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
|
||||
{pct != null && (
|
||||
<> · <span className="font-bold" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "}
|
||||
{pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"}
|
||||
</span></>
|
||||
)}
|
||||
Réseau · 12 mois glissants, mois en cours exclu
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{!trend.hasData ? (
|
||||
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
|
||||
</div>
|
||||
) : (
|
||||
<NetworkLineChart labels={labels} values={values} color={color} />
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={trend.surQteParMagasin
|
||||
? `${trendLabel(pct, trend.nouveau)} · sur la qté/magasin`
|
||||
: `${trendLabel(pct, trend.nouveau)} · sur les volumes (magasins inconnus)`}
|
||||
couleur={color}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && (
|
||||
<TuileStat
|
||||
label="Qté / magasin"
|
||||
valeur={fmt(trend.perStore[dernier], trend.perStore[dernier] < 10 ? 1 : 0)}
|
||||
indice={`dernier mois · ${fmt(trend.perStore.reduce((t, v) => t + v, 0) / trend.perStore.length, 1)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={magasins ? fmt(magasins.values[dernier]) : fmt(row.nbMagasinsReseau ?? 0)}
|
||||
indice={`sur ${NB_MAGASINS_RESEAU} du réseau`}
|
||||
/>
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
@@ -443,63 +511,40 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
accessorKey: "codein",
|
||||
header: "Code interne",
|
||||
size: 90,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue<string>();
|
||||
// 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 (
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors"
|
||||
title="Copier le code interne"
|
||||
>
|
||||
<span className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" style={{ color: "var(--text-muted)" }}>
|
||||
{value}
|
||||
</span>
|
||||
<div className="shrink-0 transition-all duration-200">
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
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<string>();
|
||||
return (
|
||||
<span className="text-[12px] font-mono opacity-80" style={{ color: "var(--text-secondary)" }}>
|
||||
{val || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
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<string>();
|
||||
return (
|
||||
<span className="text-[12px] font-mono tracking-tight opacity-70" style={{ color: "var(--text-secondary)" }}>
|
||||
{val || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
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",
|
||||
@@ -612,13 +657,18 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
{
|
||||
id: "tendanceReseau",
|
||||
accessorFn: (row) => {
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth);
|
||||
return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY;
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
if (!t.hasData) return Number.NEGATIVE_INFINITY;
|
||||
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus
|
||||
// forte progression possible : il doit remonter en tête du tri, pas
|
||||
// tomber au fond avec les lignes sans données.
|
||||
if (t.nouveau) return Number.POSITIVE_INFINITY;
|
||||
return t.pct ?? Number.NEGATIVE_INFINITY;
|
||||
},
|
||||
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
|
||||
size: 78,
|
||||
cell: ({ row }) => {
|
||||
const trend = computeNetworkTrend(row.original.qteReseauByMonth);
|
||||
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
|
||||
* produit (`/produits`). Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { fmtMonthShort } from "@/features/grid/lib/months";
|
||||
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
|
||||
/** 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, nouveau } = 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)}% : 4 derniers mois vs 4 premiers — cliquer pour le détail`
|
||||
: nouveau
|
||||
? "Nouveau : aucune vente sur les 4 premiers mois de la fenêtre — 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>
|
||||
)}
|
||||
{pct == null && nouveau && (
|
||||
<span className="text-[9px] font-bold leading-none" style={{ color }}>Nouveau</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
|
||||
* variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
|
||||
* non un éclaircissement automatique (cf. `globals.css`).
|
||||
*/
|
||||
const SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"] as const;
|
||||
|
||||
/** Une facette du graphique : une série, son échelle, son titre. */
|
||||
interface Facette {
|
||||
titre: string;
|
||||
valeurs: number[];
|
||||
couleur: string;
|
||||
hauteur: number;
|
||||
/** Lavis sous la courbe : réservé à la facette principale. */
|
||||
aire?: boolean;
|
||||
decimales?: number;
|
||||
unite?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ventes réseau mensuelles — petits multiples empilés sur un axe des mois commun.
|
||||
*
|
||||
* ⚠️ PAS DE DOUBLE AXE. Superposer qté/magasin, volumes et magasins sur un même
|
||||
* cadre demanderait deux ou trois échelles verticales : l'alignement entre elles
|
||||
* serait arbitraire et inventerait des corrélations absentes des données. C'est
|
||||
* l'erreur de graphique la plus commune. Trois mesures d'ordres de grandeur
|
||||
* différents ⇒ trois facettes, une échelle chacune, le même axe horizontal.
|
||||
*
|
||||
* ⚠️ LA FACETTE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN. La quantité brute confond
|
||||
* la performance du produit et sa diffusion : elle monte dès qu'on référence le
|
||||
* produit ailleurs, sans qu'il se vende mieux nulle part. Les deux autres
|
||||
* facettes expliquent les mouvements de la première, elles ne les jugent pas.
|
||||
*
|
||||
* ⚠️ ÉTIQUETAGE SÉLECTIF. Une valeur à côté de chaque point, c'est 36 nombres :
|
||||
* illisible, et personne ne les lit. Seuls l'extrême et le dernier mois sont
|
||||
* écrits ; le survol donne le mois exact et la vue tableau porte tout le reste,
|
||||
* de sorte qu'aucune valeur n'est accessible uniquement au survol.
|
||||
*
|
||||
* Le texte ne porte jamais la couleur d'une série (une teinte claire est
|
||||
* illisible en texte) : l'identité vient du repère coloré posé à côté.
|
||||
*/
|
||||
export function NetworkLineChart({
|
||||
labels,
|
||||
values,
|
||||
stores,
|
||||
perStore,
|
||||
}: {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
/** Couleur héritée de la tendance : conservée pour l'API, plus utilisée ici —
|
||||
* la teinte « tendance » reste au badge d'en-tête, les séries ont la leur. */
|
||||
color?: string;
|
||||
stores?: number[] | null;
|
||||
perStore?: number[] | null;
|
||||
}) {
|
||||
const [survol, setSurvol] = useState<number | null>(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);
|
||||
const avecMagasins = memeTaille(stores);
|
||||
|
||||
const fmt = (v: number, d = 0) =>
|
||||
v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
|
||||
const facettes: Facette[] = [];
|
||||
if (avecParMag) {
|
||||
facettes.push({
|
||||
titre: "Qté / magasin",
|
||||
valeurs: perStore!,
|
||||
couleur: SERIE_COULEURS[0],
|
||||
hauteur: 92,
|
||||
aire: true,
|
||||
decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0,
|
||||
});
|
||||
facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[1], hauteur: 58 });
|
||||
} else {
|
||||
facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true });
|
||||
}
|
||||
if (avecMagasins) {
|
||||
facettes.push({ titre: "Magasins vendeurs", valeurs: stores!, 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: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
/** Aux extrémités, l'étiquette est ancrée vers l'intérieur : sinon elle sort du cadre. */
|
||||
const ancrage = (i: number): "start" | "middle" | "end" =>
|
||||
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;
|
||||
// Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un mois) :
|
||||
// l'échelle part du minimum réel, sinon le point sort du cadre par le bas.
|
||||
const min = Math.min(0, ...f.valeurs);
|
||||
const max = Math.max(...f.valeurs, min + 1);
|
||||
const etendue = max - min || 1;
|
||||
const y = (v: number) => 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 (
|
||||
<div className="mt-3 w-full">
|
||||
{/* Bascule : la même surface sert au dessin ou aux chiffres. */}
|
||||
<div className="flex justify-end mb-1.5">
|
||||
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
{(["graphique", "tableau"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setVue(v)}
|
||||
className="px-2.5 py-0.5 text-[10px] font-semibold rounded-md transition-colors capitalize"
|
||||
style={vue === v
|
||||
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
|
||||
: { color: "var(--text-muted)" }}
|
||||
aria-pressed={vue === v}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{vue === "graphique" ? (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width="100%"
|
||||
height={H}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
|
||||
// base (6 px mesurés), et la carte changeait de hauteur en basculant
|
||||
// vers le tableau.
|
||||
style={{ minWidth: 460, display: "block" }}
|
||||
role="img"
|
||||
aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")}
|
||||
onMouseLeave={() => setSurvol(null)}
|
||||
>
|
||||
{disposees.map((f) => {
|
||||
const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
|
||||
// L'extrême et le dernier mois seulement : deux repères qui
|
||||
// situent la courbe, sans noyer le dessin sous les nombres.
|
||||
const aEtiqueter = [...new Set([f.iMax, n - 1])];
|
||||
return (
|
||||
<g key={f.titre}>
|
||||
{/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */}
|
||||
<rect x={padL} y={f.hautEntete + 3} width={12} height={3} rx={1.5} fill={f.couleur} />
|
||||
<text x={padL + 18} y={f.hautEntete + 7} fontSize={9.5} fontWeight={600} fill="var(--text-secondary)" dominantBaseline="middle">
|
||||
{f.titre}
|
||||
</text>
|
||||
<text x={padL + plotW} y={f.hautEntete + 7} textAnchor="end" fontSize={9} fill="var(--text-muted)" dominantBaseline="middle">
|
||||
max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""}
|
||||
</text>
|
||||
|
||||
{/* Ligne de base : filet plein, une nuance au-dessus du fond */}
|
||||
<line x1={padL} y1={f.base} x2={padL + plotW} y2={f.base} stroke="var(--border)" strokeWidth={1} />
|
||||
{f.aire && n > 1 && (
|
||||
<polygon
|
||||
points={`${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`}
|
||||
fill={f.couleur}
|
||||
opacity={0.1}
|
||||
/>
|
||||
)}
|
||||
{n > 1 && (
|
||||
<polyline points={pts} fill="none" stroke={f.couleur} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
|
||||
{/* Points repères : extrême et dernier mois, cerclés de la couleur du fond */}
|
||||
{aEtiqueter.map((i) => (
|
||||
<circle
|
||||
key={`pt-${f.titre}-${i}`}
|
||||
cx={x(i)}
|
||||
cy={f.y(f.valeurs[i])}
|
||||
r={4}
|
||||
fill={f.couleur}
|
||||
stroke="var(--bg-surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
{aEtiqueter.map((i) => {
|
||||
// Un point proche du sommet verrait son étiquette empiéter sur le
|
||||
// bandeau de la facette (constaté au rendu : « 58 » par-dessus
|
||||
// « Qté / magasin »). Dans ce cas elle bascule SOUS le point.
|
||||
const yPoint = f.y(f.valeurs[i]);
|
||||
const dessus = yPoint - 8 >= f.haut + 8;
|
||||
return (
|
||||
<text
|
||||
key={`lab-${f.titre}-${i}`}
|
||||
x={x(i)}
|
||||
y={dessus ? yPoint - 8 : yPoint + 13}
|
||||
textAnchor={ancrage(i)}
|
||||
fontSize={9}
|
||||
fontWeight={700}
|
||||
fill="var(--text-primary)"
|
||||
>
|
||||
{fmt(f.valeurs[i], f.decimales ?? 0)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Point mis en avant au survol */}
|
||||
{survol != null && (
|
||||
<circle
|
||||
cx={x(survol)}
|
||||
cy={f.y(f.valeurs[survol])}
|
||||
r={3.5}
|
||||
fill={f.couleur}
|
||||
stroke="var(--bg-surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Réticule : un seul trait vertical relie les trois facettes au même mois */}
|
||||
{survol != null && (
|
||||
<line x1={x(survol)} y1={0} x2={x(survol)} y2={basPlots} stroke="var(--border-strong)" strokeWidth={1} />
|
||||
)}
|
||||
|
||||
{/* Axe des mois, commun aux facettes */}
|
||||
{labels.map((lab, i) => (
|
||||
<text
|
||||
key={`mois-${lab}`}
|
||||
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}
|
||||
>
|
||||
{fmtMonthShort(lab)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<g pointerEvents="none">
|
||||
<rect
|
||||
x={bx} y={4} width={largeur} height={hauteur} rx={8}
|
||||
fill="var(--bg-elevated)" stroke="var(--border)" strokeWidth={1}
|
||||
/>
|
||||
<text x={bx + 9} y={17} fontSize={9.5} fontWeight={700} fill="var(--text-primary)">
|
||||
{fmtMonthShort(labels[survol])}
|
||||
</text>
|
||||
{disposees.map((f, k) => (
|
||||
<g key={`bulle-${f.titre}`}>
|
||||
<rect x={bx + 9} y={26 + k * 13} width={8} height={3} rx={1.5} fill={f.couleur} />
|
||||
<text x={bx + 22} y={30 + k * 13} fontSize={9} fill="var(--text-secondary)">
|
||||
{f.titre}
|
||||
</text>
|
||||
<text
|
||||
x={bx + largeur - 9} y={30 + k * 13}
|
||||
textAnchor="end" fontSize={9} fontWeight={700} fill="var(--text-primary)"
|
||||
>
|
||||
{fmt(f.valeurs[survol], f.decimales ?? 0)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Zones de survol : larges cibles, une par mois */}
|
||||
{labels.map((lab, i) => (
|
||||
<rect
|
||||
key={`survol-${lab}`}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
/* 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. */
|
||||
<div style={{ height: H }} className="overflow-y-auto overflow-x-auto">
|
||||
<table className="w-full text-[11px] tabular-nums" style={{ borderCollapse: "collapse" }}>
|
||||
<thead className="sticky top-0" style={{ background: "var(--bg-surface)" }}>
|
||||
<tr style={{ color: "var(--text-muted)" }}>
|
||||
<th className="text-left font-medium py-1 pr-2">Mois</th>
|
||||
{disposees.map((f) => (
|
||||
<th key={`th-${f.titre}`} className="text-right font-medium py-1 pl-2 whitespace-nowrap">
|
||||
{f.titre}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{labels.map((lab, i) => (
|
||||
<tr key={`tr-${lab}`} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="py-1 pr-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtMonthShort(lab)}
|
||||
</td>
|
||||
{disposees.map((f) => (
|
||||
<td key={`td-${f.titre}-${lab}`} className="text-right py-1 pl-2" style={{ color: "var(--text-primary)" }}>
|
||||
{fmt(f.valeurs[i], f.decimales ?? 0)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
|
||||
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
|
||||
*/
|
||||
export function DualLineChart({
|
||||
labels,
|
||||
series,
|
||||
}: {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; values: number[]; color: string }>;
|
||||
}) {
|
||||
const W = 620, H = 220;
|
||||
const padL = 10, padR = 10, padT = 26, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = labels.length;
|
||||
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="flex items-center gap-4 mb-1">
|
||||
{series.map((s) => (
|
||||
<span key={s.name} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 460 }} role="img" aria-label="Comparatif mensuel local vs réseau">
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{series.map((s) => {
|
||||
const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
return (
|
||||
<g key={s.name}>
|
||||
{n > 1 && (
|
||||
<polyline points={pts} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
{s.values.map((v, i) => (
|
||||
<circle key={`${s.name}-${labels[i]}`} cx={x(i)} cy={y(v)} r={2.4} fill={s.color} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{labels.map((lab, i) => (
|
||||
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(lab)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
|
||||
interface SyncQlikButtonProps {
|
||||
/** Code fournisseur affiché (pour sync ciblée) */
|
||||
@@ -11,24 +12,8 @@ interface SyncQlikButtonProps {
|
||||
lastUpdate?: string | null;
|
||||
}
|
||||
|
||||
/** État serveur renvoyé par GET/POST /api/qlik/sync */
|
||||
interface SyncJobState {
|
||||
jobId: string | null;
|
||||
fournisseur: string;
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
requested: number;
|
||||
fetched: number;
|
||||
upserted: number;
|
||||
periode?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
/** Date + heure de dernière synchro, ou "jamais". */
|
||||
function formatLastUpdate(iso?: string | null): string {
|
||||
if (!iso) return "jamais";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "jamais";
|
||||
@@ -39,55 +24,15 @@ function formatDate(iso?: string | null): string {
|
||||
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
||||
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
||||
*
|
||||
* UX : le POST démarre un job serveur en arrière-plan et revient immédiatement.
|
||||
* Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle
|
||||
* GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de
|
||||
* la page) et continuer à afficher le spinner correctement.
|
||||
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
|
||||
* dans `useQlikSyncJob`, partagée avec la fiche produit.
|
||||
*/
|
||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||
const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
|
||||
const trackingRef = useRef<boolean>(false);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Met à jour le label visible à droite du bouton selon l'état courant.
|
||||
*/
|
||||
const applyJobToUi = useCallback((j: SyncJobState | null) => {
|
||||
if (!j) return;
|
||||
if (j.status === "running") {
|
||||
setStatus("running");
|
||||
// Texte informatif pendant l'extraction
|
||||
const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : "";
|
||||
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
|
||||
} else if (j.status === "success") {
|
||||
setStatus("success");
|
||||
if (j.message) setMessage(j.message);
|
||||
else setMessage(`${j.upserted} produits réseau synchronisés`);
|
||||
} else if (j.status === "error") {
|
||||
setStatus("error");
|
||||
setMessage(j.error || "Erreur sync Qlik");
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Force le refresh de la grille (avec cache-buster) et de la barre de résumé.
|
||||
* C'est l'équivalent de ce que faisait l'ancien code après un POST réussi.
|
||||
*/
|
||||
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
|
||||
const refreshGrid = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
@@ -95,163 +40,10 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
router.refresh();
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
/**
|
||||
* Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job
|
||||
* passe en success/error, ou après un timeout de sécurité.
|
||||
*/
|
||||
const pollOnce = useCallback(async (): Promise<boolean> => {
|
||||
if (!codeFournisseur) return false;
|
||||
if (!trackingRef.current) return false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "GET", cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
// En cas d'erreur HTTP transitoire on retente au tour suivant.
|
||||
return true;
|
||||
}
|
||||
const data: SyncJobState = await res.json();
|
||||
applyJobToUi(data);
|
||||
|
||||
if (data.status === "running") {
|
||||
// Continue à poller
|
||||
return true;
|
||||
}
|
||||
if (data.status === "success" || data.status === "error") {
|
||||
// Terminé : on rafraîchit la grille, on coupe le polling.
|
||||
trackingRef.current = false;
|
||||
if (data.status === "success") {
|
||||
refreshGrid();
|
||||
}
|
||||
// Petit délai pour laisser l'utilisateur lire le message vert/rouge
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
return false;
|
||||
}
|
||||
// idle : rien à faire
|
||||
trackingRef.current = false;
|
||||
return false;
|
||||
} catch {
|
||||
// Erreur réseau transitoire, on laisse le polling réessayer.
|
||||
return true;
|
||||
}
|
||||
}, [applyJobToUi, codeFournisseur, refreshGrid]);
|
||||
|
||||
/**
|
||||
* Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut
|
||||
* continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas
|
||||
* marteler le serveur pendant une extraction qui dure plusieurs minutes.
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
clearPollTimer();
|
||||
const tick = async () => {
|
||||
const keepGoing = await pollOnce();
|
||||
if (keepGoing && trackingRef.current) {
|
||||
pollTimerRef.current = setTimeout(tick, 3000);
|
||||
} else {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// Premier appel quasi immédiat
|
||||
pollTimerRef.current = setTimeout(tick, 200);
|
||||
}, [clearPollTimer, pollOnce]);
|
||||
|
||||
/**
|
||||
* Au mount : si on a un fournisseur, on interroge l'API pour savoir si
|
||||
* un job tourne déjà (ex. reload de la page pendant l'extraction). Si
|
||||
* oui, on reprend l'état et on relance le polling.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!codeFournisseur) return;
|
||||
let cancelled = false;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "GET", cache: "no-store" },
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) return;
|
||||
const data: SyncJobState = await res.json();
|
||||
if (cancelled) return;
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
} else if (data.status === "success" || data.status === "error") {
|
||||
// Job terminé en amont (avant le mount) : on l'affiche brièvement
|
||||
applyJobToUi(data);
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
}
|
||||
} catch {
|
||||
/* ignore : on n'empêche pas l'UI d'afficher le bouton */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
};
|
||||
// On veut interroger une seule fois au mount (et quand le fournisseur change).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [codeFournisseur, clearPollTimer]);
|
||||
|
||||
/**
|
||||
* Cleanup : annule un timer de polling éventuel au démontage.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer();
|
||||
};
|
||||
}, [clearPollTimer]);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!codeFournisseur) {
|
||||
setStatus("error");
|
||||
setMessage("Aucun fournisseur sélectionné");
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
return;
|
||||
}
|
||||
// Empêche un double-clic pendant qu'un job tourne déjà.
|
||||
if (status === "running") return;
|
||||
|
||||
setStatus("running");
|
||||
setMessage("Démarrage de l'extraction Qlik…");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const data: SyncJobState & { success?: boolean } = await res.json();
|
||||
if (!res.ok || (data as { success?: boolean }).success === false) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
// Si le serveur nous dit que c'est déjà en cours (jobId existant),
|
||||
// on reprend juste le suivi.
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
// Status terminal direct (cas rare : 0 codes → success immédiat)
|
||||
applyJobToUi(data);
|
||||
if (data.status === "success") {
|
||||
refreshGrid();
|
||||
}
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
};
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||
onSuccess: refreshGrid,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
? message
|
||||
: status === "running"
|
||||
? (message || `Extraction Qlik…`)
|
||||
: `MAJ Qlik · ${formatDate(lastUpdate)}`}
|
||||
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatDate(lastUpdate)}`}
|
||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* CollectFlow — Helpers de fenêtre mensuelle et de libellés magasin.
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la
|
||||
* fiche produit (`/produits`). Aucun changement de comportement.
|
||||
*
|
||||
* ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
|
||||
* - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
|
||||
* - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
|
||||
* Utiliser `ffMonthToQlik` / `qlikMonthToFf` pour passer de l'une à l'autre.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Les 12 mois complets glissants, du plus ancien au plus récent, au format
|
||||
* `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
|
||||
*
|
||||
* ⚠️ À n'appeler qu'à l'intérieur d'un `useMemo` / d'un rendu, jamais au niveau
|
||||
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
|
||||
* entre le rendu serveur et le rendu client.
|
||||
*/
|
||||
export function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
||||
// pour exclure le mois en cours (i=0)
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** `"202601"` → `"Jan 26"`. */
|
||||
export function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
/** `"2026-01"` → `"01/26"` — axe compact des graphiques. */
|
||||
export function fmtMonthShort(lab: string): string {
|
||||
const [y, m] = lab.split("-");
|
||||
return `${m ?? lab}/${(y ?? "").slice(2)}`;
|
||||
}
|
||||
|
||||
/** Date ISO → `jj/mm/aaaa`, `"—"` si absente ou invalide. */
|
||||
export function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/** Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. */
|
||||
export function ffMonthToQlik(key: string): string {
|
||||
return `${key.slice(0, 4)}-${key.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
/** Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. */
|
||||
export function qlikMonthToFf(key: string): string {
|
||||
return key.replace("-", "");
|
||||
}
|
||||
|
||||
/** Les deux magasins FF Nancy suivis par l'application. */
|
||||
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
|
||||
* nom, donc identique quel que soit l'ordre d'affichage.
|
||||
*/
|
||||
export function getStoreConfig(name: string) {
|
||||
const known = SITE_LABELS[name];
|
||||
const words = (known?.nom ?? name).trim().split(/\s+/);
|
||||
let label = known?.label ?? words[0].charAt(0).toUpperCase();
|
||||
if (!known && words.length > 1) {
|
||||
label += words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colorInt = Math.abs(hash) % 5;
|
||||
|
||||
switch (colorInt) {
|
||||
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
|
||||
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
|
||||
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
|
||||
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
|
||||
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
|
||||
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* CollectFlow — Tendance des ventes réseau Qlik sur 12 mois.
|
||||
*
|
||||
* Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de
|
||||
* la colonne « Tendance / Réseau ») et la fiche produit (`/produits`).
|
||||
* Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
|
||||
* de présence. Constante métier : source unique pour la Grille et la fiche produit.
|
||||
*/
|
||||
export const NB_MAGASINS_RESEAU = 270;
|
||||
|
||||
export type NetworkTrend = {
|
||||
values: number[]; // quantités mensuelles, ordre chronologique
|
||||
labels: string[]; // "YYYY-MM"
|
||||
/**
|
||||
* Quantité moyenne par magasin vendeur, mois par mois. `null` si le nombre
|
||||
* de magasins n'est pas connu sur les 12 mois.
|
||||
*/
|
||||
perStore: number[] | null;
|
||||
/**
|
||||
* `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se
|
||||
* rabattre sur les quantités brutes faute de nombre de magasins.
|
||||
*/
|
||||
surQteParMagasin: boolean;
|
||||
direction: "up" | "down" | "flat";
|
||||
/** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */
|
||||
pct: number | null;
|
||||
/** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */
|
||||
nouveau: boolean;
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nombre de mois comparés à chaque bout de la fenêtre.
|
||||
*
|
||||
* Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute
|
||||
* la tendance de deux points, donc du hasard d'un réassort ou d'une opération.
|
||||
*/
|
||||
export const TREND_WINDOW = 4;
|
||||
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
|
||||
export const TREND_STRONG = 0.25;
|
||||
|
||||
/**
|
||||
* Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
|
||||
* plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
|
||||
* écraserait systématiquement la tendance vers le bas.
|
||||
*
|
||||
* Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
|
||||
* `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
|
||||
* rester alignés.
|
||||
*/
|
||||
export function buildRolling12QlikMonths(now: Date = new Date()): string[] {
|
||||
const months: string[] = [];
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
|
||||
export const TREND_FLAT = 0.08;
|
||||
|
||||
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
down: "#ef4444",
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tendance réseau : variation entre les **4 premiers** et les **4 derniers**
|
||||
* mois de la fenêtre de 12 mois glissants, mois en cours exclu.
|
||||
*
|
||||
* La fenêtre est reconstruite à partir de la date du jour (et non des clés
|
||||
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
|
||||
* réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois,
|
||||
* présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
|
||||
* mois tronqué.
|
||||
*
|
||||
* Une série n'est affichée que si les **12 clés sont explicitement présentes**.
|
||||
* L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé
|
||||
* la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle
|
||||
* ne peut donc plus être présentée comme une tendance réelle.
|
||||
*
|
||||
* Cette exigence du « tout ou rien » évite deux écueils observés en production :
|
||||
* une tendance calculée sur deux points (« +4 100 % · Forte hausse » pour un
|
||||
* article vendu depuis mai), et des mois non extraits comptés comme des mois
|
||||
* sans vente.
|
||||
*
|
||||
* ⚠️ CHANGEMENT D'INDICATEUR. La version précédente rapportait la pente d'une
|
||||
* régression linéaire à la moyenne de la série. Mathématiquement défendable,
|
||||
* mais illisible en pratique — constaté en production :
|
||||
*
|
||||
* - « −124 % », alors qu'une baisse ne peut pas dépasser −100 % ;
|
||||
* - « +508 % » sur des dizaines de lignes d'affilée, toujours la même valeur :
|
||||
* c'est la signature arithmétique d'une série nulle partout sauf le dernier
|
||||
* mois, où le rapport pente/moyenne ne dépend même plus des quantités ;
|
||||
* - « +1 062 % » pour 2 unités vendues une seule fois dans l'année.
|
||||
*
|
||||
* On compare donc désormais deux moyennes de 4 mois, ce qui donne une variation
|
||||
* qui se lit comme telle, plancher à −100 %, et reste insensible au bruit d'un
|
||||
* mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois),
|
||||
* aucun pourcentage n'est inventé : le produit est signalé « nouveau ».
|
||||
*
|
||||
* ⚠️ LA TENDANCE PORTE SUR LA QUANTITÉ PAR MAGASIN, PAS SUR LA QUANTITÉ BRUTE.
|
||||
*
|
||||
* La quantité brute confond deux phénomènes distincts : un produit peut vendre
|
||||
* davantage simplement parce qu'il est référencé dans plus de magasins, sans
|
||||
* mieux se vendre nulle part. Inversement, un produit retiré de 60 magasins peut
|
||||
* s'effondrer en volume tout en performant mieux là où il reste.
|
||||
*
|
||||
* C'est donc `quantité / magasins vendeurs` qui dit si un produit marche — et
|
||||
* c'est sur elle que l'évolution est calculée. La courbe des quantités reste
|
||||
* affichée, mais comme contexte, pas comme indicateur.
|
||||
*
|
||||
* Repli : sans nombre de magasins sur les 12 mois, on retombe sur les quantités
|
||||
* brutes et `surQteParMagasin` vaut `false`, pour que l'UI puisse le dire.
|
||||
*
|
||||
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
|
||||
*/
|
||||
export function computeNetworkTrend(
|
||||
qteByMonth?: Record<string, number> | null,
|
||||
nbMagByMonth?: Record<string, number> | null,
|
||||
now: Date = new Date(),
|
||||
): NetworkTrend {
|
||||
const empty: NetworkTrend = {
|
||||
values: [], labels: [], perStore: null, surQteParMagasin: false,
|
||||
direction: "flat", pct: null, nouveau: false, hasData: false,
|
||||
};
|
||||
if (!qteByMonth) return empty;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(qteByMonth, label) &&
|
||||
Number.isFinite(Number(qteByMonth[label])),
|
||||
);
|
||||
if (!complet) return empty;
|
||||
|
||||
const values = labels.map((l) => Number(qteByMonth[l]));
|
||||
const n = values.length;
|
||||
|
||||
// Quantité par magasin vendeur : l'indicateur qui isole la performance du
|
||||
// produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le
|
||||
// seul choix cohérent avec une quantité elle-même nulle.
|
||||
const magasins = computeStoresSeries(nbMagByMonth, now);
|
||||
const perStore = magasins
|
||||
? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0))
|
||||
: null;
|
||||
const serie = perStore ?? values;
|
||||
|
||||
const taille = Math.min(TREND_WINDOW, Math.floor(n / 2));
|
||||
const moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0);
|
||||
const debut = moyenne(serie.slice(0, taille));
|
||||
const fin = moyenne(serie.slice(n - taille));
|
||||
|
||||
let pct: number | null = null;
|
||||
let nouveau = false;
|
||||
let direction: NetworkTrend["direction"] = "flat";
|
||||
|
||||
if (debut > 0) {
|
||||
// Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans
|
||||
// retours massifs, et la division par une base positive garde le sens.
|
||||
pct = (fin - debut) / debut;
|
||||
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
|
||||
} else if (fin > 0) {
|
||||
// Rien au départ, des ventes à l'arrivée : la variation relative n'existe
|
||||
// pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé.
|
||||
nouveau = true;
|
||||
direction = "up";
|
||||
}
|
||||
|
||||
return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Série « nombre de magasins vendeurs » sur la MÊME fenêtre que
|
||||
* `computeNetworkTrend`, pour la superposer à la courbe des quantités.
|
||||
*
|
||||
* Pourquoi cette deuxième courbe : une quantité qui monte parce que le produit
|
||||
* est diffusé dans plus de magasins ne raconte pas la même histoire qu'une
|
||||
* quantité qui monte à diffusion constante. Sans elle, la tendance seule ne
|
||||
* permet pas de distinguer un succès produit d'un simple élargissement.
|
||||
*
|
||||
* Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent
|
||||
* être explicitement présentes, sinon `null` — un mois non extrait ne doit pas
|
||||
* se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion.
|
||||
*/
|
||||
export function computeStoresSeries(
|
||||
nbMagByMonth?: Record<string, number> | null,
|
||||
now: Date = new Date(),
|
||||
): { values: number[]; labels: string[] } | null {
|
||||
if (!nbMagByMonth) return null;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(nbMagByMonth, label) &&
|
||||
Number.isFinite(Number(nbMagByMonth[label])),
|
||||
);
|
||||
if (!complet) return null;
|
||||
return { values: labels.map((l) => Number(nbMagByMonth[l])), labels };
|
||||
}
|
||||
|
||||
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
|
||||
export function trendLabel(pct: number | null, nouveau = false): string {
|
||||
// Un produit sans base de comparaison n'est pas « stable » : il est nouveau.
|
||||
if (nouveau) return "Nouveau";
|
||||
if (pct == null) return "Stable";
|
||||
if (pct > TREND_STRONG) return "Forte hausse";
|
||||
if (pct > TREND_FLAT) return "Hausse";
|
||||
if (pct < -TREND_STRONG) return "Forte baisse";
|
||||
if (pct < -TREND_FLAT) return "Baisse";
|
||||
return "Stable";
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"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";
|
||||
|
||||
// Le calcul vit dans `@/lib/network-trend` (module pur, sans React) pour que
|
||||
// l'API `/api/v1` expose exactement la même tendance que la Grille. On le
|
||||
// réexporte ici pour ne pas changer les imports existants.
|
||||
import { computeNetworkTrend, trendLabel, TREND_STRONG, type NetworkTrend } from "@/lib/network-trend";
|
||||
|
||||
export { computeNetworkTrend, trendLabel, TREND_STRONG };
|
||||
export type { NetworkTrend };
|
||||
|
||||
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
down: "#ef4444",
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
pgGetProduitDetail,
|
||||
pgGetCodeinByCodeCentrale,
|
||||
pgGetFournisseursByCodein,
|
||||
pgGetStockByCodein,
|
||||
pgGetCommandesByCodein,
|
||||
pgGetGammeHistoryByCodein,
|
||||
pgGetMensuelByCodein,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types";
|
||||
|
||||
/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */
|
||||
function buildMonthKeys(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function emptyMois(mois: string): ProduitMois {
|
||||
return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 };
|
||||
}
|
||||
|
||||
function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
|
||||
const qte = rows.reduce((s, r) => s + r.qte, 0);
|
||||
const ca = rows.reduce((s, r) => s + r.ca, 0);
|
||||
const marge = rows.reduce((s, r) => s + r.marge, 0);
|
||||
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche d'un produit **à partir de son code centrale** : c'est la
|
||||
* clé du réseau Qlik, seule identité disponible pour un produit que le réseau
|
||||
* travaille mais que Nancy ne référence pas.
|
||||
*
|
||||
* Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie
|
||||
* locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes).
|
||||
* Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal
|
||||
* depuis la recherche Qlik.
|
||||
*
|
||||
* Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code.
|
||||
*/
|
||||
export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codeCentrale.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const codein = await pgGetCodeinByCodeCentrale(cleaned);
|
||||
if (codein) return getProduitFiche(codein);
|
||||
|
||||
// Produit réseau pur : aucune donnée locale à assembler.
|
||||
const map = await getNetworkMetricsByCodeCentrale([cleaned]);
|
||||
const reseau = map.get(cleaned) ?? null;
|
||||
if (!reseau) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const months = buildMonthKeys();
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
codeCentrale: cleaned,
|
||||
libelleReseau: reseau.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau.fournisseurReseau ?? "",
|
||||
fournisseurs: [],
|
||||
stock: [],
|
||||
commandesEnCours: 0,
|
||||
gammes: [],
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite: {},
|
||||
totaux: totauxFrom(months.map((m) => mensuelTotal[m])),
|
||||
totauxParSite: {},
|
||||
sitesActifs: [],
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
|
||||
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
|
||||
* réseau Qlik.
|
||||
*
|
||||
* Renvoie `null` si le codein est inconnu du catalogue FF.
|
||||
*/
|
||||
export async function getProduitFiche(codein: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codein.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const detail = await pgGetProduitDetail(cleaned);
|
||||
if (!detail) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
|
||||
const [fournisseurs, stock, commandesEnCours, gammes, mensuelRows] = await Promise.all([
|
||||
pgGetFournisseursByCodein(cleaned),
|
||||
pgGetStockByCodein(cleaned),
|
||||
pgGetCommandesByCodein(cleaned),
|
||||
pgGetGammeHistoryByCodein(cleaned),
|
||||
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
|
||||
]);
|
||||
|
||||
const months = buildMonthKeys();
|
||||
const allowed = new Set(months);
|
||||
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
const mensuelParSite: Record<string, Record<string, ProduitMois>> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
// Mois réellement présents dans les mouvements. Indispensable pour distinguer
|
||||
// « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock
|
||||
// effectivement nul » — c'est la convention de la Grille.
|
||||
const moisAvecDonnees = new Set<string>();
|
||||
const moisAvecDonneesParSite = new Map<string, Set<string>>();
|
||||
|
||||
for (const row of mensuelRows) {
|
||||
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
|
||||
const mois = row.mois.replace("-", "");
|
||||
if (!allowed.has(mois)) continue;
|
||||
const site = String(row.site).trim();
|
||||
|
||||
const qte = Number(row.qte_vendue) || 0;
|
||||
const ca = Number(row.ca_ht) || 0;
|
||||
const marge = Number(row.marge) || 0;
|
||||
const stockFinMois = Number(row.stock_fin_mois) || 0;
|
||||
const qteRecue = Number(row.qte_recue) || 0;
|
||||
|
||||
moisAvecDonnees.add(mois);
|
||||
const tot = mensuelTotal[mois];
|
||||
tot.qte += qte;
|
||||
tot.ca += ca;
|
||||
tot.marge += marge;
|
||||
tot.stockFinMois += stockFinMois;
|
||||
tot.qteRecue += qteRecue;
|
||||
|
||||
mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)]));
|
||||
if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set());
|
||||
moisAvecDonneesParSite.get(site)!.add(mois);
|
||||
const cell = mensuelParSite[site][mois];
|
||||
cell.qte += qte;
|
||||
cell.ca += ca;
|
||||
cell.marge += marge;
|
||||
// Le SQL donne déjà le stock du dernier mouvement du mois pour ce site.
|
||||
cell.stockFinMois = stockFinMois;
|
||||
cell.qteRecue += qteRecue;
|
||||
}
|
||||
|
||||
// Report du stock de fin de mois sur les mois SANS mouvement (carry-forward),
|
||||
// même convention que la Grille : un mois sans mouvement conserve le stock du
|
||||
// mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0.
|
||||
const carryForward = (serie: Record<string, ProduitMois>, avecDonnees: Set<string>) => {
|
||||
let last = 0;
|
||||
for (const m of months) {
|
||||
if (avecDonnees.has(m)) last = serie[m].stockFinMois;
|
||||
else serie[m].stockFinMois = last;
|
||||
}
|
||||
};
|
||||
carryForward(mensuelTotal, moisAvecDonnees);
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set());
|
||||
}
|
||||
|
||||
const totaux = totauxFrom(months.map((m) => mensuelTotal[m]));
|
||||
const totauxParSite: Record<string, ProduitTotaux> = {};
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
totauxParSite[site] = totauxFrom(months.map((m) => bySite[m]));
|
||||
}
|
||||
|
||||
const sitesActifs = Object.entries(totauxParSite)
|
||||
.filter(([, t]) => t.qte > 0)
|
||||
.map(([site]) => site)
|
||||
.sort();
|
||||
|
||||
// Métriques réseau Qlik (jointure par code centrale).
|
||||
let reseau = null;
|
||||
if (detail.code_centrale) {
|
||||
const map = await getNetworkMetricsByCodeCentrale([detail.code_centrale]);
|
||||
reseau = map.get(detail.code_centrale) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
codeCentrale: detail.code_centrale ?? "",
|
||||
libelleReseau: reseau?.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau?.fournisseurReseau ?? "",
|
||||
fournisseurs,
|
||||
stock,
|
||||
commandesEnCours,
|
||||
gammes,
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite,
|
||||
totaux,
|
||||
totauxParSite,
|
||||
sitesActifs,
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import "server-only";
|
||||
|
||||
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
import {
|
||||
getNetworkMetricsByCodeCentrale,
|
||||
upsertNetworkMetrics,
|
||||
type NetworkMetricCached,
|
||||
} from "@/lib/qlik-network-cache";
|
||||
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
|
||||
import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
||||
|
||||
/**
|
||||
* CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite.
|
||||
*
|
||||
* Déroulé :
|
||||
* 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET
|
||||
* leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`).
|
||||
* 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
|
||||
* 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà
|
||||
* extrait (le cube de recherche est agrégé, il ne le fournit pas).
|
||||
*
|
||||
* Une seconde session Qlik pour les mesures faisait abandonner le moteur
|
||||
* (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app,
|
||||
* dont une avec une grosse sélection, et l'Engine coupe les requêtes.
|
||||
*
|
||||
* Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne
|
||||
* pas laisser l'utilisateur sans résultat — la réponse le signale explicitement
|
||||
* (`source: "db"`).
|
||||
*/
|
||||
|
||||
/** Durée de validité d'un résultat de recherche en cache mémoire. */
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */
|
||||
const JOB_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Série « magasins vendeurs » extraite du détail mensuel du cache.
|
||||
*
|
||||
* Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est
|
||||
* qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la
|
||||
* série et priverait la tendance de son dénominateur.
|
||||
*/
|
||||
function nbMagParMois(
|
||||
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
|
||||
): Record<string, number> | null {
|
||||
if (!metricsByMonth) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const mois of Object.keys(metricsByMonth)) {
|
||||
const nb = Number(metricsByMonth[mois]?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */
|
||||
function cacheReseauComplet(
|
||||
cache: NetworkMetricCached | undefined,
|
||||
periode: string | null,
|
||||
now: Date = new Date(),
|
||||
): cache is NetworkMetricCached {
|
||||
const parMois = cache?.qteByMonth;
|
||||
if (!parMois || !periode || cache.periode !== periode) return false;
|
||||
return buildRolling12QlikMonths(now).every((mois) =>
|
||||
Object.prototype.hasOwnProperty.call(parMois, mois) &&
|
||||
Number.isFinite(Number(parMois[mois])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre maximum de recherches Qlik simultanées.
|
||||
*
|
||||
* Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on
|
||||
* plafonne plutôt que de lui envoyer N extractions en parallèle.
|
||||
*/
|
||||
const MAX_RECHERCHES_SIMULTANEES = 1;
|
||||
|
||||
/**
|
||||
* Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik
|
||||
* (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur
|
||||
* ou un rafraîchissement relancerait toute l'extraction.
|
||||
*/
|
||||
const cache = new Map<string, { at: number; result: ProduitRechercheResultat }>();
|
||||
|
||||
/**
|
||||
* Jobs de recherche, indexés par terme normalisé.
|
||||
*
|
||||
* Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours
|
||||
* Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps
|
||||
* se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le
|
||||
* client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail
|
||||
* tourne donc en tâche de fond et le client interroge l'état.
|
||||
*/
|
||||
const jobs = new Map<string, RechercheJob>();
|
||||
|
||||
export interface RechercheJob {
|
||||
jobId: string;
|
||||
terme: string;
|
||||
status: "running" | "success" | "error";
|
||||
/** Étape en cours, affichée pendant l'attente. */
|
||||
etape: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
result?: ProduitRechercheResultat;
|
||||
}
|
||||
|
||||
/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */
|
||||
export type RechercheEtat = RechercheJob | { status: "idle"; terme: string };
|
||||
|
||||
function cacheKey(term: string): string {
|
||||
return term.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
|
||||
function purge(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) {
|
||||
if (now - v.at > CACHE_TTL_MS) cache.delete(k);
|
||||
}
|
||||
for (const [k, j] of jobs) {
|
||||
if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
function jobsEnCours(): number {
|
||||
let n = 0;
|
||||
for (const j of jobs.values()) if (j.status === "running") n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob {
|
||||
return {
|
||||
jobId: `cache_${cacheKey(terme)}`,
|
||||
terme,
|
||||
status: "success",
|
||||
etape: "Terminé",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre (ou réutilise) une recherche et rend la main **immédiatement**.
|
||||
*
|
||||
* - résultat déjà en cache et pas de `force` → job `success` directement ;
|
||||
* - recherche déjà en cours sur le même terme → on renvoie ce job ;
|
||||
* - sinon on lance le travail en tâche de fond.
|
||||
*/
|
||||
export function demarrerRecherche(
|
||||
term: string,
|
||||
options: { force?: boolean } = {},
|
||||
): RechercheJob {
|
||||
const cleaned = term.trim();
|
||||
const key = cacheKey(cleaned);
|
||||
purge();
|
||||
|
||||
if (!options.force) {
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`);
|
||||
return jobTermine(cleaned, hit.result);
|
||||
}
|
||||
}
|
||||
|
||||
const existant = jobs.get(key);
|
||||
if (existant && existant.status === "running") return existant;
|
||||
if (existant && !options.force && existant.status === "success" && existant.result) return existant;
|
||||
|
||||
if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) {
|
||||
return {
|
||||
jobId: `refus_${Date.now().toString(36)}`,
|
||||
terme: cleaned,
|
||||
status: "error",
|
||||
etape: "Refusé",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.",
|
||||
};
|
||||
}
|
||||
|
||||
const job: RechercheJob = {
|
||||
jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
terme: cleaned,
|
||||
status: "running",
|
||||
etape: "Recherche des articles dans Qlik…",
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(key, job);
|
||||
console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`);
|
||||
|
||||
// Fire-and-forget : le client suivra l'avancement via `etatRecherche`.
|
||||
void executerRecherche(cleaned, (etape) => { job.etape = etape; })
|
||||
.then((result) => {
|
||||
job.result = result;
|
||||
job.status = "success";
|
||||
job.etape = "Terminé";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
// On ne met en cache qu'un résultat Qlik exploitable : mémoriser un
|
||||
// repli catalogue (panne Qlik passagère) le figerait 10 minutes
|
||||
// alors qu'un simple « Relancer » aurait suffi.
|
||||
if (result.source === "qlik" && !result.qlikError) {
|
||||
cache.set(key, { at: Date.now(), result });
|
||||
}
|
||||
console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`);
|
||||
})
|
||||
.catch((e) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
job.status = "error";
|
||||
job.etape = "Échec";
|
||||
job.error = messageQlikLisible(message);
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300));
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/** État courant de la recherche pour ce terme (pour le polling client). */
|
||||
export function etatRecherche(term: string): RechercheEtat {
|
||||
const cleaned = term.trim();
|
||||
const key = cacheKey(cleaned);
|
||||
purge();
|
||||
const job = jobs.get(key);
|
||||
if (job) return job;
|
||||
const hit = cache.get(key);
|
||||
if (hit) return jobTermine(cleaned, hit.result);
|
||||
return { status: "idle", terme: cleaned };
|
||||
}
|
||||
|
||||
async function executerRecherche(
|
||||
term: string,
|
||||
onEtape: (etape: string) => void = () => {},
|
||||
): Promise<ProduitRechercheResultat> {
|
||||
const started = Date.now();
|
||||
|
||||
// La recherche catalogue local tourne en parallèle : elle sert à la fois de
|
||||
// repli si Qlik échoue, et de complément (produits Nancy sans code centrale,
|
||||
// donc invisibles côté réseau).
|
||||
const localPromise = pgSearchProduits(term).catch((e) => {
|
||||
console.error("[produits/search] recherche locale échouée:", (e as Error).message);
|
||||
return [] as PgProduitSearchRow[];
|
||||
});
|
||||
|
||||
let qlik;
|
||||
try {
|
||||
onEtape("Recherche des articles dans Qlik…");
|
||||
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error("[produits/search] Qlik indisponible, repli catalogue local:", message);
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: locaux.map(rowDepuisCatalogueSeul),
|
||||
source: "db",
|
||||
qlikError: messageQlikLisible(message),
|
||||
tronque: locaux.length >= 50,
|
||||
champUtilise: null,
|
||||
locauxHorsReseau: [],
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const codes = qlik.matches.map((m) => m.codeCentrale);
|
||||
if (codes.length === 0) {
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: [],
|
||||
source: "qlik",
|
||||
// `avertissement` n'est renseigné que si Qlik a répondu sans appliquer
|
||||
// le filtre : l'utilisateur doit savoir que ce n'est pas « pas de
|
||||
// produit » mais « recherche non appliquée ».
|
||||
qlikError: qlik.avertissement,
|
||||
tronque: false,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau: locaux,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
// Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik).
|
||||
// Les extraire dans une seconde session concurrente faisait abandonner le
|
||||
// moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la
|
||||
// recherche alors que la sync de la Grille, elle seule sur le serveur,
|
||||
// fonctionnait.
|
||||
//
|
||||
// Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit
|
||||
// dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche
|
||||
// produit propose son bouton « Actualiser depuis Qlik ».
|
||||
onEtape("Rapprochement avec notre catalogue…");
|
||||
const [cacheReseau, catalogue, locaux] = await Promise.all([
|
||||
getNetworkMetricsByCodeCentrale(codes).catch((e) => {
|
||||
console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message);
|
||||
return new Map<string, NetworkMetricCached>();
|
||||
}),
|
||||
// Deux clés candidates : la dimension « Article Code » et le champ
|
||||
// `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et
|
||||
// une seule joint avec `articles.artcentrale`.
|
||||
pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]),
|
||||
localPromise,
|
||||
]);
|
||||
|
||||
// Le cube de recherche utilise encore les master measures annuelles pour
|
||||
// classer rapidement les résultats. Il ne doit jamais écraser une extraction
|
||||
// 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec
|
||||
// leur mensuel. Sans cache complet, les valeurs de découverte restent
|
||||
// temporaires jusqu'au bouton « Actualiser depuis Qlik ».
|
||||
try {
|
||||
const aPersister: NetworkMetric[] = qlik.matches.map((m) => {
|
||||
const cache = cacheReseau.get(m.codeCentrale);
|
||||
const fiable = cacheReseauComplet(cache, qlik.periode);
|
||||
return {
|
||||
codeCentrale: m.codeCentrale,
|
||||
caReseau: fiable ? cache.caReseau : m.caReseau,
|
||||
qteReseau: fiable ? cache.qteReseau : m.qteReseau,
|
||||
nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
|
||||
caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
|
||||
margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau,
|
||||
periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined,
|
||||
qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined,
|
||||
metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined,
|
||||
libelleReseau: m.libelle || undefined,
|
||||
fournisseurReseau: m.fournisseur || undefined,
|
||||
};
|
||||
});
|
||||
const n = await upsertNetworkMetrics(aPersister);
|
||||
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
|
||||
} catch (e) {
|
||||
console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message);
|
||||
}
|
||||
|
||||
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
|
||||
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
|
||||
const cache = cacheReseau.get(m.codeCentrale);
|
||||
const fiable = cacheReseauComplet(cache, qlik.periode);
|
||||
const qteReseau = fiable ? cache.qteReseau : m.qteReseau;
|
||||
const caReseau = fiable ? cache.caReseau : m.caReseau;
|
||||
const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau;
|
||||
const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau;
|
||||
const margePct = fiable ? cache.margePctReseau : m.margePctReseau;
|
||||
return {
|
||||
codeCentrale: m.codeCentrale,
|
||||
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
|
||||
libelleReseau: m.libelle,
|
||||
fournisseur: local?.fournisseur || m.fournisseur || "",
|
||||
codein: local?.codein ?? null,
|
||||
nomenclature: local?.nomenclature ?? "",
|
||||
stockLocal: local ? Number(local.stock_total) || 0 : null,
|
||||
qteReseau,
|
||||
caReseau,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: caParMagasin,
|
||||
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
|
||||
margePctReseau: normalizeMargePct(margePct),
|
||||
// Le mensuel ne vient que du cache : le cube de recherche est agrégé.
|
||||
qteByMonth: fiable ? cache.qteByMonth : null,
|
||||
nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null,
|
||||
periode: qlik.periode,
|
||||
};
|
||||
});
|
||||
|
||||
// Les plus vendus du réseau d'abord : c'est l'information utile pour
|
||||
// arbitrer, un produit que le réseau ne vend pas n'intéresse personne.
|
||||
rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"));
|
||||
|
||||
// Les produits Nancy déjà couverts par un résultat réseau ne doivent pas
|
||||
// réapparaître dans la liste secondaire. On compare sur les deux clés
|
||||
// candidates, la jointure pouvant se faire par l'une ou par l'autre.
|
||||
const codesTrouves = new Set<string>(codes);
|
||||
for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt);
|
||||
const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale));
|
||||
|
||||
// Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format
|
||||
// de clé différent entre Qlik et `articles.artcentrale`.
|
||||
if (catalogue.size === 0 && codes.length > 0) {
|
||||
console.warn(
|
||||
`[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` +
|
||||
`échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` +
|
||||
`article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
source: "qlik",
|
||||
qlikError: qlik.avertissement,
|
||||
tronque: qlik.tronque,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */
|
||||
function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
|
||||
const qteReseau = Number(r.qte_reseau) || 0;
|
||||
const nbMag = Number(r.nb_magasins_reseau) || 0;
|
||||
return {
|
||||
codeCentrale: r.code_centrale,
|
||||
libelle: r.libelle1 || r.codein,
|
||||
libelleReseau: "",
|
||||
fournisseur: r.fournisseur,
|
||||
codein: r.codein,
|
||||
nomenclature: r.nomenclature,
|
||||
stockLocal: Number(r.stock_total) || 0,
|
||||
qteReseau,
|
||||
caReseau: 0,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: 0,
|
||||
prixMoyenReseau: null,
|
||||
margePctReseau: null,
|
||||
qteByMonth: null,
|
||||
nbMagByMonth: null,
|
||||
periode: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit les pannes Qlik récurrentes en message actionnable. Les autres
|
||||
* erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables.
|
||||
*/
|
||||
function messageQlikLisible(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.";
|
||||
}
|
||||
if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) {
|
||||
return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez.";
|
||||
}
|
||||
if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) {
|
||||
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
|
||||
}
|
||||
if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) {
|
||||
return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).";
|
||||
}
|
||||
return message.slice(0, 300);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* CollectFlow — Comparatif local vs réseau pour un produit.
|
||||
*
|
||||
* Principe : rapporter les deux périmètres à un **magasin moyen**, sinon on
|
||||
* compare 2 magasins Nancy à ~270 magasins réseau et le chiffre ne veut rien dire.
|
||||
*
|
||||
* ⚠️ La comparaison de référence porte sur les **quantités**. Le CA local vient
|
||||
* de `mvtart.mntmvtttc` (TTC) alors que la base de la mesure Qlik « CA N » n'est
|
||||
* pas documentée : les montants sont donc affichés côte à côte à titre indicatif,
|
||||
* sans indice calculé.
|
||||
*/
|
||||
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { ffMonthToQlik } from "@/features/grid/lib/months";
|
||||
import type { ProduitFiche } from "@/features/produits/types";
|
||||
|
||||
export interface ComparatifMois {
|
||||
/** Clé de mois FF "YYYYMM". */
|
||||
mois: string;
|
||||
/** Quantité vendue par magasin, chez nous. */
|
||||
local: number;
|
||||
/** Quantité vendue par magasin, au réseau. */
|
||||
reseau: number;
|
||||
}
|
||||
|
||||
export interface ComparatifReseau {
|
||||
/** false si le produit n'a pas encore de données réseau en cache. */
|
||||
hasReseau: boolean;
|
||||
/** Nombre de nos magasins ayant vendu le produit (min. 1 pour éviter /0). */
|
||||
nbMagasinsLocaux: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** Taux de présence réseau (0..1), ou null sans données. */
|
||||
tauxPresence: number | null;
|
||||
|
||||
/** Quantité 12 mois, par magasin. */
|
||||
qteLocaleParMagasin: number;
|
||||
qteReseauParMagasin: number;
|
||||
/**
|
||||
* Indice de performance en % : 100 = on vend autant qu'un magasin moyen du
|
||||
* réseau, 150 = 50 % de mieux. `null` si le réseau ne vend rien.
|
||||
*/
|
||||
indiceQte: number | null;
|
||||
|
||||
/** CA 12 mois par magasin — indicatif (bases de calcul non alignées). */
|
||||
caLocalParMagasin: number;
|
||||
caReseauParMagasin: number | null;
|
||||
|
||||
/** Taux de marge en %, local et réseau (indicatif). */
|
||||
tauxMargeLocal: number;
|
||||
tauxMargeReseau: number | null;
|
||||
|
||||
/** Série mensuelle par magasin, alignée sur les 12 mois de la fiche. */
|
||||
monthly: ComparatifMois[];
|
||||
/** true si au moins un mois réseau est disponible. */
|
||||
hasMonthly: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines
|
||||
* configurations renvoient déjà des points de pourcentage. Même normalisation
|
||||
* que la colonne « Marge % / Réseau » de la Grille.
|
||||
*/
|
||||
export function normalizeMargePct(raw: number | null | undefined): number | null {
|
||||
if (raw == null) return null;
|
||||
const v = Number(raw);
|
||||
if (!Number.isFinite(v) || v === 0) return null;
|
||||
return Math.abs(v) <= 1 ? v * 100 : v;
|
||||
}
|
||||
|
||||
/** Calcule le comparatif local vs réseau à partir d'une fiche produit. */
|
||||
export function computeComparatif(fiche: ProduitFiche): ComparatifReseau {
|
||||
const { reseau, totaux, sitesActifs, months, mensuelTotal } = fiche;
|
||||
|
||||
// Nos magasins actifs sur la période. Si aucun n'a vendu, on garde 1 pour
|
||||
// que la division reste définie — l'indice vaudra alors 0, ce qui est le
|
||||
// message voulu : « le réseau vend, nous non ».
|
||||
const nbMagasinsLocaux = Math.max(sitesActifs.length, 1);
|
||||
const qteLocaleParMagasin = totaux.qte / nbMagasinsLocaux;
|
||||
const caLocalParMagasin = totaux.ca / nbMagasinsLocaux;
|
||||
|
||||
if (!reseau || !reseau.nbMagasinsReseau) {
|
||||
return {
|
||||
hasReseau: false,
|
||||
nbMagasinsLocaux,
|
||||
nbMagasinsReseau: 0,
|
||||
tauxPresence: null,
|
||||
qteLocaleParMagasin,
|
||||
qteReseauParMagasin: 0,
|
||||
indiceQte: null,
|
||||
caLocalParMagasin,
|
||||
caReseauParMagasin: null,
|
||||
tauxMargeLocal: totaux.tauxMarge,
|
||||
tauxMargeReseau: null,
|
||||
monthly: [],
|
||||
hasMonthly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nbMagasinsReseau = reseau.nbMagasinsReseau;
|
||||
const qteReseauParMagasin = reseau.qteReseau / nbMagasinsReseau;
|
||||
const caReseauParMagasin = reseau.caReseau / nbMagasinsReseau;
|
||||
|
||||
// Série mensuelle : quantité par magasin de part et d'autre.
|
||||
// Le nb de magasins réseau du mois est utilisé quand il est disponible
|
||||
// (`metricsByMonth`), sinon on retombe sur le total de la période.
|
||||
const monthly: ComparatifMois[] = [];
|
||||
let hasMonthly = false;
|
||||
for (const mois of months) {
|
||||
const qlikKey = ffMonthToQlik(mois);
|
||||
const mensuel = reseau.metricsByMonth?.[qlikKey];
|
||||
const qteReseauMois = mensuel?.qte ?? reseau.qteByMonth?.[qlikKey];
|
||||
const nbMagMois = mensuel?.nbMag && mensuel.nbMag > 0 ? mensuel.nbMag : nbMagasinsReseau;
|
||||
|
||||
if (qteReseauMois != null) hasMonthly = true;
|
||||
monthly.push({
|
||||
mois,
|
||||
local: (mensuelTotal[mois]?.qte ?? 0) / nbMagasinsLocaux,
|
||||
reseau: qteReseauMois != null ? qteReseauMois / nbMagMois : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasReseau: true,
|
||||
nbMagasinsLocaux,
|
||||
nbMagasinsReseau,
|
||||
tauxPresence: nbMagasinsReseau / NB_MAGASINS_RESEAU,
|
||||
qteLocaleParMagasin,
|
||||
qteReseauParMagasin,
|
||||
indiceQte: qteReseauParMagasin > 0 ? (qteLocaleParMagasin / qteReseauParMagasin) * 100 : null,
|
||||
caLocalParMagasin,
|
||||
caReseauParMagasin,
|
||||
tauxMargeLocal: totaux.tauxMarge,
|
||||
tauxMargeReseau: normalizeMargePct(reseau.margePctReseau),
|
||||
monthly,
|
||||
hasMonthly,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lecture métier de l'indice de performance. */
|
||||
export function indiceVerdict(indice: number | null): { label: string; color: string } {
|
||||
if (indice == null) return { label: "Non comparable", color: "var(--text-muted)" };
|
||||
if (indice >= 150) return { label: "Forte surperformance", color: "#22c55e" };
|
||||
if (indice >= 110) return { label: "Surperformance", color: "#22c55e" };
|
||||
if (indice >= 90) return { label: "Dans la moyenne réseau", color: "var(--text-secondary)" };
|
||||
if (indice >= 50) return { label: "Sous-performance", color: "#f59e0b" };
|
||||
return { label: "Forte sous-performance", color: "#ef4444" };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* CollectFlow — Types de la fiche produit (page /produits).
|
||||
*/
|
||||
|
||||
import type {
|
||||
PgProduitDetailRow,
|
||||
PgProduitFournisseurRow,
|
||||
PgProduitSearchRow,
|
||||
PgProduitStockRow,
|
||||
PgGammeHistoryRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
|
||||
/**
|
||||
* Une ligne de résultat de recherche produit.
|
||||
*
|
||||
* La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau
|
||||
* (identifié par son code centrale), éventuellement rapproché du catalogue FF
|
||||
* Nancy. `codein === null` = le réseau travaille ce produit, nous ne le
|
||||
* référençons pas.
|
||||
*/
|
||||
export interface ProduitRechercheRow {
|
||||
/** Dimension Qlik « Article Code » = `articles.artcentrale`. */
|
||||
codeCentrale: string;
|
||||
/** Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. */
|
||||
libelle: string;
|
||||
/** Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). */
|
||||
libelleReseau: string;
|
||||
/** Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. */
|
||||
fournisseur: string;
|
||||
/** `null` si le produit n'est pas au catalogue Nancy. */
|
||||
codein: string | null;
|
||||
nomenclature: string;
|
||||
/** Stock Nancy tous sites, `null` si produit non référencé. */
|
||||
stockLocal: number | null;
|
||||
|
||||
// ─── Réseau Qlik, sur 12 mois glissants ─────────────────────────────────
|
||||
qteReseau: number;
|
||||
caReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** Part des magasins du réseau travaillant le produit (0..1). */
|
||||
tauxPresence: number;
|
||||
/** Quantité vendue par magasin qui travaille le produit. */
|
||||
qteParMagasinReseau: number;
|
||||
caParMagasinReseau: number;
|
||||
/** CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. */
|
||||
prixMoyenReseau: number | null;
|
||||
/** Taux de marge réseau en points de % (déjà normalisé), `null` si absent. */
|
||||
margePctReseau: number | null;
|
||||
/** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */
|
||||
qteByMonth: Record<string, number> | null;
|
||||
/**
|
||||
* Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la
|
||||
* qté/magasin : sans cette série, elle se rabat sur les quantités brutes.
|
||||
*/
|
||||
nbMagByMonth: Record<string, number> | null;
|
||||
periode: string | null;
|
||||
}
|
||||
|
||||
export interface ProduitRechercheResultat {
|
||||
rows: ProduitRechercheRow[];
|
||||
/** `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). */
|
||||
source: "qlik" | "db";
|
||||
/** Message d'erreur Qlik quand on a dû se rabattre sur la base locale. */
|
||||
qlikError: string | null;
|
||||
/** true si Qlik a renvoyé plus d'articles que la limite affichée. */
|
||||
tronque: boolean;
|
||||
/** Champ Qlik réellement interrogé (diagnostic). */
|
||||
champUtilise: string | null;
|
||||
/**
|
||||
* Produits du catalogue Nancy qui matchent le terme mais qu'aucun article
|
||||
* Qlik ne couvre (pas de code centrale, ou non vendus par le réseau).
|
||||
*/
|
||||
locauxHorsReseau: PgProduitSearchRow[];
|
||||
dureeMs: number;
|
||||
}
|
||||
|
||||
/** Les métriques d'un mois, pour un périmètre donné (total ou un site). */
|
||||
export interface ProduitMois {
|
||||
/** Clé de mois FF, format "YYYYMM". */
|
||||
mois: string;
|
||||
qte: number;
|
||||
/** Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. */
|
||||
ca: number;
|
||||
marge: number;
|
||||
stockFinMois: number;
|
||||
qteRecue: number;
|
||||
}
|
||||
|
||||
export interface ProduitTotaux {
|
||||
qte: number;
|
||||
ca: number;
|
||||
marge: number;
|
||||
/** Taux de marge en % (marge / CA × 100). */
|
||||
tauxMarge: number;
|
||||
}
|
||||
|
||||
export interface ProduitFiche {
|
||||
/**
|
||||
* Identité catalogue FF Nancy — `null` pour un produit que le réseau
|
||||
* travaille mais que Nancy ne référence pas (fiche 100 % réseau).
|
||||
*/
|
||||
detail: PgProduitDetailRow | null;
|
||||
/** Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. */
|
||||
codeCentrale: string;
|
||||
/** Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). */
|
||||
libelleReseau: string;
|
||||
/** Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). */
|
||||
fournisseurReseau: string;
|
||||
fournisseurs: PgProduitFournisseurRow[];
|
||||
stock: PgProduitStockRow[];
|
||||
commandesEnCours: number;
|
||||
gammes: PgGammeHistoryRow[];
|
||||
/** Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. */
|
||||
months: string[];
|
||||
/** Cumul tous sites, par mois. */
|
||||
mensuelTotal: Record<string, ProduitMois>;
|
||||
/** site → mois → métriques. */
|
||||
mensuelParSite: Record<string, Record<string, ProduitMois>>;
|
||||
totaux: ProduitTotaux;
|
||||
totauxParSite: Record<string, ProduitTotaux>;
|
||||
/** Sites ayant vendu au moins une unité sur les 12 mois. */
|
||||
sitesActifs: string[];
|
||||
/** Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. */
|
||||
reseau: NetworkMetricCached | null;
|
||||
/** Fenêtre analysée, pour l'affichage. */
|
||||
periode: { dateDebut: string; dateFin: string };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** État serveur renvoyé par GET/POST /api/qlik/sync. */
|
||||
export interface QlikSyncJobState {
|
||||
jobId: string | null;
|
||||
fournisseur: string;
|
||||
codeCentrale?: string;
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
requested: number;
|
||||
fetched: number;
|
||||
upserted: number;
|
||||
periode?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type QlikSyncStatus = QlikSyncJobState["status"];
|
||||
|
||||
/**
|
||||
* Cible de la synchro : un fournisseur entier (réservé aux admins) ou un seul
|
||||
* code centrale (ouvert à tout utilisateur connecté).
|
||||
*/
|
||||
export type QlikSyncTarget =
|
||||
| { mode: "fournisseur"; fournisseur: string | undefined }
|
||||
| { mode: "produit"; codeCentrale: string | undefined };
|
||||
|
||||
interface UseQlikSyncJobOptions {
|
||||
target: QlikSyncTarget;
|
||||
/** Appelé une fois quand un job se termine avec succès. */
|
||||
onSuccess?: () => void;
|
||||
/** Intervalle de polling en ms (défaut 3 s — une extraction peut durer plusieurs minutes). */
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
/** Construit la query string de la route selon la cible. */
|
||||
function targetQuery(target: QlikSyncTarget): string | null {
|
||||
if (target.mode === "fournisseur") {
|
||||
return target.fournisseur ? `fournisseur=${encodeURIComponent(target.fournisseur)}` : null;
|
||||
}
|
||||
return target.codeCentrale ? `codeCentrale=${encodeURIComponent(target.codeCentrale)}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pilote un job de synchronisation Qlik : démarrage (POST), suivi (polling GET),
|
||||
* reprise d'un job déjà en cours au montage, et nettoyage des timers.
|
||||
*
|
||||
* Extrait de `SyncQlikButton` pour être partagé avec la fiche produit. Le
|
||||
* protocole est inchangé : le POST démarre un job serveur en arrière-plan et
|
||||
* revient immédiatement, le client poll ensuite GET jusqu'à l'état terminal.
|
||||
*/
|
||||
export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: UseQlikSyncJobOptions) {
|
||||
const [status, setStatus] = useState<QlikSyncStatus>("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
|
||||
const query = targetQuery(target);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
|
||||
const trackingRef = useRef<boolean>(false);
|
||||
// Gardé dans une ref : le callback peut changer d'identité à chaque rendu
|
||||
// sans qu'on veuille relancer le polling pour autant.
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Met à jour le libellé visible selon l'état courant. */
|
||||
const applyJobToUi = useCallback((j: QlikSyncJobState | null) => {
|
||||
if (!j) return;
|
||||
if (j.status === "running") {
|
||||
setStatus("running");
|
||||
const requestedTxt = j.requested > 0 ? ` (${j.requested} code${j.requested > 1 ? "s" : ""})` : "";
|
||||
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
|
||||
} else if (j.status === "success") {
|
||||
setStatus("success");
|
||||
if (j.message) setMessage(j.message);
|
||||
else setMessage(`${j.upserted} produits réseau synchronisés`);
|
||||
} else if (j.status === "error") {
|
||||
setStatus("error");
|
||||
setMessage(j.error || "Erreur sync Qlik");
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Un tour de polling. Renvoie true s'il faut continuer.
|
||||
*/
|
||||
const pollOnce = useCallback(async (): Promise<boolean> => {
|
||||
if (!query || !trackingRef.current) return false;
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
// Erreur HTTP transitoire : on retente au tour suivant.
|
||||
return true;
|
||||
}
|
||||
const data: QlikSyncJobState = await res.json();
|
||||
applyJobToUi(data);
|
||||
|
||||
if (data.status === "running") return true;
|
||||
|
||||
if (data.status === "success" || data.status === "error") {
|
||||
trackingRef.current = false;
|
||||
if (data.status === "success") onSuccessRef.current?.();
|
||||
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
return false;
|
||||
}
|
||||
trackingRef.current = false;
|
||||
return false;
|
||||
} catch {
|
||||
// Erreur réseau transitoire : on laisse le polling réessayer.
|
||||
return true;
|
||||
}
|
||||
}, [applyJobToUi, query]);
|
||||
|
||||
/** Boucle de polling auto-réarmée tant que pollOnce le demande. */
|
||||
const startPolling = useCallback(() => {
|
||||
clearPollTimer();
|
||||
const tick = async () => {
|
||||
const keepGoing = await pollOnce();
|
||||
if (keepGoing && trackingRef.current) {
|
||||
pollTimerRef.current = setTimeout(tick, pollIntervalMs);
|
||||
} else {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// Premier appel quasi immédiat.
|
||||
pollTimerRef.current = setTimeout(tick, 200);
|
||||
}, [clearPollTimer, pollOnce, pollIntervalMs]);
|
||||
|
||||
/**
|
||||
* Au montage (et quand la cible change) : reprend l'état d'un éventuel job
|
||||
* déjà en cours, par exemple après un reload pendant l'extraction.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!query) return;
|
||||
let cancelled = false;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
|
||||
if (cancelled || !res.ok) return;
|
||||
const data: QlikSyncJobState = await res.json();
|
||||
if (cancelled) return;
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
} else if (data.status === "success" || data.status === "error") {
|
||||
// Job terminé avant le montage : on l'affiche brièvement.
|
||||
applyJobToUi(data);
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
}
|
||||
} catch {
|
||||
/* ignore : on n'empêche pas l'UI de s'afficher */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
};
|
||||
// On veut interroger une seule fois au montage (et quand la cible change).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, clearPollTimer]);
|
||||
|
||||
/** Cleanup : annule un timer de polling éventuel au démontage. */
|
||||
useEffect(() => clearPollTimer, [clearPollTimer]);
|
||||
|
||||
/** Démarre l'extraction. */
|
||||
const start = useCallback(async () => {
|
||||
if (!query) {
|
||||
setStatus("error");
|
||||
setMessage(target.mode === "fournisseur" ? "Aucun fournisseur sélectionné" : "Aucun code centrale");
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
return;
|
||||
}
|
||||
// Empêche un double-clic pendant qu'un job tourne déjà.
|
||||
if (status === "running") return;
|
||||
|
||||
setStatus("running");
|
||||
setMessage("Démarrage de l'extraction Qlik…");
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "POST" });
|
||||
const data: QlikSyncJobState & { success?: boolean } = await res.json();
|
||||
if (!res.ok || data.success === false) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
// Statut terminal direct (cas rare : 0 code → success immédiat).
|
||||
applyJobToUi(data);
|
||||
if (data.status === "success") onSuccessRef.current?.();
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
}, [applyJobToUi, query, startPolling, status, target.mode]);
|
||||
|
||||
return { status, message, start };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Download, Loader2, RefreshCw, FileText, AlertCircle } from "lucide-react";
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
octets: number;
|
||||
modifieLe: string;
|
||||
enCours: boolean;
|
||||
}
|
||||
|
||||
function formatTaille(octets: number): string {
|
||||
if (octets < 1024) return `${octets} o`;
|
||||
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
|
||||
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("fr-FR", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Appelle l'API. Volontairement sans état React : réutilisable tel quel. */
|
||||
async function recupererLogs(): Promise<LogEntry[]> {
|
||||
const res = await fetch("/api/logs", { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(res.status === 403 ? "Réservé aux administrateurs" : `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return Array.isArray(data.logs) ? data.logs : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Journaux serveur des extractions Qlik — consultation et téléchargement.
|
||||
*
|
||||
* Une extraction produit plusieurs milliers de lignes de diagnostic. Un
|
||||
* terminal les tronque et n'en laisse que la fin, alors que l'information
|
||||
* décisive (carte du modèle Qlik, champs retenus, choix des périodes) se trouve
|
||||
* en tête. Le journal complet est donc capturé côté serveur et récupérable ici
|
||||
* en un fichier.
|
||||
*/
|
||||
export function ServerLogs() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
// « chargement » dès le départ : le premier appel part au montage, et aucun
|
||||
// setState ne doit être fait de façon synchrone dans un effet.
|
||||
const [etat, setEtat] = useState<"idle" | "chargement" | "erreur">("chargement");
|
||||
const [erreur, setErreur] = useState<string>("");
|
||||
|
||||
/** Applique un résultat (succès ou échec) à l'état local. */
|
||||
const appliquer = useCallback((entrees: LogEntry[] | null, message: string) => {
|
||||
if (entrees) {
|
||||
setLogs(entrees);
|
||||
setErreur("");
|
||||
setEtat("idle");
|
||||
} else {
|
||||
setErreur(message);
|
||||
setEtat("erreur");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Chargement initial. L'état n'est touché que depuis les callbacks de la
|
||||
// promesse, jamais dans le corps de l'effet.
|
||||
useEffect(() => {
|
||||
recupererLogs()
|
||||
.then((entrees) => appliquer(entrees, ""))
|
||||
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
|
||||
}, [appliquer]);
|
||||
|
||||
/** Rechargement explicite (bouton) : là, l'état « chargement » est immédiat. */
|
||||
const recharger = useCallback(() => {
|
||||
setEtat("chargement");
|
||||
recupererLogs()
|
||||
.then((entrees) => appliquer(entrees, ""))
|
||||
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
|
||||
}, [appliquer]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Journal complet de chaque extraction Qlik (le plus récent en premier).
|
||||
Conservés le temps de la vie du serveur, 20 au maximum.
|
||||
</p>
|
||||
<button
|
||||
onClick={recharger}
|
||||
disabled={etat === "chargement"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0 disabled:opacity-60"
|
||||
>
|
||||
{etat === "chargement"
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{etat === "erreur" && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-[var(--accent-error)]">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{erreur}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etat !== "erreur" && logs.length === 0 && (
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
Aucun journal pour l'instant — lancez une synchronisation Qlik depuis la Grille.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)]">
|
||||
{logs.map((log) => (
|
||||
<li key={log.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<FileText className="w-4 h-4 shrink-0 text-[var(--text-muted)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate">
|
||||
{log.id}
|
||||
</span>
|
||||
{log.enCours && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--brand-solid)] text-white shrink-0">
|
||||
en cours
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--text-muted)]">
|
||||
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/logs?id=${encodeURIComponent(log.id)}`}
|
||||
download
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Télécharger
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { SuccessModal } from "@/components/shared/success-modal";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
import { ConfirmModal } from "@/components/shared/confirm-modal";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
@@ -36,12 +37,34 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||
const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({
|
||||
const [modal, setModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: "success" | "error";
|
||||
action?: { label: string; onClick: () => void };
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: "",
|
||||
message: ""
|
||||
});
|
||||
|
||||
/**
|
||||
* Onglet resté ouvert pendant un déploiement : les identifiants de Server
|
||||
* Action ont changé côté serveur. On propose le rechargement au lieu
|
||||
* d'afficher une erreur technique incompréhensible.
|
||||
*/
|
||||
const modalErreur = (err: unknown, titre: string, message: string) =>
|
||||
isStaleServerActionError(err)
|
||||
? {
|
||||
isOpen: true,
|
||||
title: "Page à recharger",
|
||||
message: STALE_ACTION_MESSAGE,
|
||||
variant: "error" as const,
|
||||
action: { label: "Recharger la page", onClick: () => window.location.reload() },
|
||||
}
|
||||
: { isOpen: true, title: titre, message, variant: "error" as const };
|
||||
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
@@ -59,7 +82,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
const data = await getSnapshots(type);
|
||||
setSnapshots(data);
|
||||
} catch (err) {
|
||||
setError("Impossible de charger les données.");
|
||||
setError(isStaleServerActionError(err) ? STALE_ACTION_MESSAGE : "Impossible de charger les données.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -83,10 +106,10 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
setSnapshots(prev => prev.filter(s => s.id !== id));
|
||||
setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." });
|
||||
} else {
|
||||
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément." });
|
||||
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément.", variant: "error" });
|
||||
}
|
||||
} catch (err) {
|
||||
setModal({ isOpen: true, title: "Erreur Technique", message: "Une erreur est survenue lors de la suppression." });
|
||||
setModal(modalErreur(err, "Erreur Technique", "Une erreur est survenue lors de la suppression."));
|
||||
} finally {
|
||||
setIsDeleting(null);
|
||||
}
|
||||
@@ -215,6 +238,8 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
||||
title={modal.title}
|
||||
message={modal.message}
|
||||
variant={modal.variant}
|
||||
action={modal.action}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
Reference in new issue
Block a user