mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 10:17:33 +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
@@ -0,0 +1,661 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck,
|
||||
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info,
|
||||
} from "lucide-react";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitFiche } from "@/features/produits/types";
|
||||
import { OpportunitesFamille } from "./opportunites";
|
||||
|
||||
// ─── Formatteurs partagés ────────────────────────────────────────────────────
|
||||
|
||||
const fmtEur = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
||||
const fmtEur2 = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number, d = 1) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
|
||||
|
||||
// ─── Briques de présentation ─────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, icon: Icon, children, action }: {
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="apple-card">
|
||||
<div className="apple-card-header flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
{title}
|
||||
</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="apple-card-content">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-[13px] truncate ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
||||
{value ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl p-3" 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="text-[19px] font-black tabular-nums leading-tight mt-0.5" style={{ color: color ?? "var(--text-primary)" }}>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Détail mensuel réseau ───────────────────────────────────────────────────
|
||||
|
||||
/** Les mesures Qlik d'un mois, telles que stockées dans `metricsByMonth`. */
|
||||
type MoisReseau = { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number };
|
||||
|
||||
/**
|
||||
* Lignes du tableau « Détail mensuel réseau ». Deux d'entre elles sont dérivées
|
||||
* (qté/magasin, prix moyen) : le cube Qlik ne les renvoie pas, mais ce sont les
|
||||
* chiffres qu'on lit réellement pour arbitrer.
|
||||
*/
|
||||
const LIGNES_MENSUELLES_RESEAU: Array<{
|
||||
label: string;
|
||||
get: (v: MoisReseau) => number | undefined;
|
||||
fmt: (n: number) => string;
|
||||
}> = [
|
||||
{ label: "Quantité", get: (v) => v.qte, fmt: (n) => fmtQte(n) },
|
||||
{ label: "Magasins", get: (v) => v.nbMag, fmt: (n) => fmtQte(n) },
|
||||
{ label: "Qté / mag.", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: (n) => fmtDec(n) },
|
||||
{ label: "CA", get: (v) => v.ca, fmt: (n) => Math.round(n).toLocaleString("fr-FR") },
|
||||
{ label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: (n) => fmtEur2(n) },
|
||||
{ label: "Marge %", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDec(n)} %` },
|
||||
];
|
||||
|
||||
// ─── Bouton d'extraction Qlik pour un seul code centrale ─────────────────────
|
||||
|
||||
function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
|
||||
const router = useRouter();
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "produit", codeCentrale },
|
||||
onSuccess: () => router.refresh(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{message && (
|
||||
<span
|
||||
className="text-[10px] leading-tight text-right max-w-[280px]"
|
||||
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
||||
title={message}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title="Ré-extraire les données réseau Qlik pour ce seul produit"
|
||||
>
|
||||
{status === "running" ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: status === "success" ? <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||
: status === "error" ? <AlertCircle className="w-3.5 h-3.5 text-rose-500" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{status === "running" ? "Extraction…" : "Actualiser depuis Qlik"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fiche ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
|
||||
const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
|
||||
const [magasin, setMagasin] = useState<string>("TOTAL");
|
||||
|
||||
const sitesDisponibles = useMemo(() => {
|
||||
const sites = new Set<string>([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]);
|
||||
return [...sites].filter(Boolean).sort();
|
||||
}, [fiche.mensuelParSite, stock]);
|
||||
|
||||
// Série affichée selon le magasin sélectionné.
|
||||
const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {});
|
||||
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
|
||||
|
||||
/** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */
|
||||
const nbMagByMonth = useMemo(() => {
|
||||
const detailMensuel = reseau?.metricsByMonth;
|
||||
if (!detailMensuel) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const [mois, mesures] of Object.entries(detailMensuel)) {
|
||||
// Mois présent sans `nbMag` = mois extrait sans vente = zéro magasin
|
||||
// (cf. get-product-rows.ts) : sinon la série est amputée et la courbe
|
||||
// disparaît sur les anciens caches.
|
||||
const nb = Number(mesures?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return parMois;
|
||||
}, [reseau]);
|
||||
const trend = useMemo(
|
||||
() => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth),
|
||||
[reseau, nbMagByMonth],
|
||||
);
|
||||
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]);
|
||||
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
|
||||
const verdict = indiceVerdict(comparatif.indiceQte);
|
||||
|
||||
const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0];
|
||||
/** Nom de fournisseur à afficher : catalogue Nancy d'abord, sinon champ Qlik. */
|
||||
const fournisseurAffiche = fournisseurPrincipal?.nomfou || fournisseurReseau || null;
|
||||
const titre = detail?.libelle1 || libelleReseau || codeCentrale || "Sans libellé";
|
||||
|
||||
// Indicateurs réseau dérivés (le cache ne stocke que les mesures brutes Qlik).
|
||||
const qteParMagasinReseau = reseau && reseau.nbMagasinsReseau > 0 ? reseau.qteReseau / reseau.nbMagasinsReseau : null;
|
||||
const prixMoyenReseau = reseau && reseau.qteReseau > 0 ? reseau.caReseau / reseau.qteReseau : null;
|
||||
const margeReseau = normalizeMargePct(reseau?.margePctReseau);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* En-tête */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={backQuery ? `/produits?q=${encodeURIComponent(backQuery)}` : "/produits"}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] mb-1.5 hover:underline"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
Retour aux résultats
|
||||
</Link>
|
||||
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
|
||||
{titre}
|
||||
</h2>
|
||||
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
{detail && <><span className="font-mono">{detail.codein}</span>{" · "}</>}
|
||||
{codeCentrale && <>centrale <span className="font-mono">{codeCentrale}</span></>}
|
||||
{fournisseurAffiche && <> · <span style={{ color: "var(--text-secondary)" }}>{fournisseurAffiche}</span></>}
|
||||
</p>
|
||||
</div>
|
||||
{fournisseurPrincipal?.codefou && (
|
||||
<Link
|
||||
href={`/grid?fournisseur=${encodeURIComponent(fournisseurPrincipal.codefou)}&magasin=TOTAL`}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5"
|
||||
title="Ouvrir la grille d'arbitrage de ce fournisseur"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
Voir dans la Grille
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!detail && (
|
||||
<div
|
||||
className="rounded-xl p-4 flex items-start gap-3 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent)" }} />
|
||||
<div>
|
||||
Ce produit est travaillé par le réseau mais <strong>n'est pas référencé dans notre catalogue</strong>.
|
||||
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */}
|
||||
<Card
|
||||
title="Performance réseau · 12 mois glissants"
|
||||
icon={Globe}
|
||||
action={codeCentrale ? <SyncProduitButton codeCentrale={codeCentrale} /> : undefined}
|
||||
>
|
||||
{!codeCentrale ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik.
|
||||
</p>
|
||||
) : !reseau ? (
|
||||
<div className="flex items-start gap-3 rounded-xl p-4" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--text-muted)" }} />
|
||||
<div className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
Aucune donnée réseau en cache pour ce produit.
|
||||
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat
|
||||
label="Magasins vendeurs"
|
||||
value={fmtQte(reseau.nbMagasinsReseau)}
|
||||
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} % du réseau`}
|
||||
/>
|
||||
<Stat label="Quantité réseau" value={fmtQte(reseau.qteReseau)} hint="12 mois glissants" />
|
||||
<Stat
|
||||
label="Qté / magasin"
|
||||
value={qteParMagasinReseau != null ? fmtDec(qteParMagasinReseau) : "—"}
|
||||
hint="par magasin qui le travaille"
|
||||
/>
|
||||
<Stat
|
||||
label="Prix moyen réseau"
|
||||
value={prixMoyenReseau != null ? fmtEur2(prixMoyenReseau) : "—"}
|
||||
hint="CA réseau / quantité"
|
||||
/>
|
||||
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
|
||||
<Stat label="CA / magasin" value={fmtEur(reseau.caParMagasinReseau)} />
|
||||
<Stat
|
||||
label="Marge % réseau"
|
||||
value={margeReseau != null ? `${fmtDec(margeReseau)} %` : "—"}
|
||||
color={margeReseau != null && margeReseau >= 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined}
|
||||
/>
|
||||
<Stat
|
||||
label="Tendance 12 m"
|
||||
value={trend.pct != null ? `${trend.pct >= 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"}
|
||||
hint={trendLabel(trend.pct, trend.nouveau)}
|
||||
color={TREND_COLOR[trend.direction]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<Field label="Libellé réseau" value={libelleReseau || "—"} />
|
||||
<Field label="Fournisseur réseau" value={fournisseurReseau || "—"} />
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
|
||||
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} ·
|
||||
{" "}mois en cours exclu (incomplet).
|
||||
</p>
|
||||
|
||||
{trend.hasData && (
|
||||
<div className="mt-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Tendance réseau — la qté/magasin d'abord, les volumes en contexte
|
||||
</div>
|
||||
<NetworkLineChart
|
||||
labels={trend.labels}
|
||||
values={trend.values}
|
||||
color={TREND_COLOR[trend.direction]}
|
||||
stores={magasinsParMois?.values}
|
||||
perStore={trend.perStore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Détail mensuel complet (CA, marge, magasins) si disponible */}
|
||||
{reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Détail mensuel réseau
|
||||
</div>
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold" style={{ color: "var(--text-muted)" }}>Mesure</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} className="px-1 py-1.5 text-center font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{LIGNES_MENSUELLES_RESEAU.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<td className="px-2 py-1 font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.label}
|
||||
</td>
|
||||
{months.map((m) => {
|
||||
const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)];
|
||||
const v = cell ? row.get(cell) : undefined;
|
||||
return (
|
||||
<td key={m} className="px-1 py-1 text-center tabular-nums" style={{ color: "var(--text-muted)" }}>
|
||||
{v != null ? row.fmt(v) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-[10px] mt-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ─── Fournisseur ─────────────────────────────────────────────── */}
|
||||
<Card title="Fournisseur" icon={Truck}>
|
||||
{fournisseurs.length === 0 ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
{fournisseurReseau
|
||||
? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique <strong>{fournisseurReseau}</strong>.</>
|
||||
: "Aucun fournisseur référencé pour ce produit."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{fournisseurs.map((f) => (
|
||||
<div
|
||||
key={f.codefou}
|
||||
className="rounded-xl p-3 flex flex-wrap items-center justify-between gap-3"
|
||||
style={{
|
||||
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
|
||||
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-semibold" style={{ color: f.principal ? "var(--accent)" : "var(--text-primary)" }}>
|
||||
{f.nomfou}
|
||||
{f.principal && <span className="ml-2 text-[11px] font-medium">· principal</span>}
|
||||
</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: "var(--text-muted)" }}>{f.codefou}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Réf. <span className="font-mono">{f.reference || "—"}</span></span>
|
||||
<span>PCB <span className="tabular-nums">{f.pcb != null ? fmtQte(f.pcb) : "—"}</span></span>
|
||||
<Link
|
||||
href={`/grid?fournisseur=${encodeURIComponent(f.codefou)}&magasin=TOTAL`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
Grille
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{fournisseurReseau && (
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Fournisseur côté Qlik : {fournisseurReseau}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ─── Identité catalogue (produits référencés uniquement) ──────── */}
|
||||
{detail && (
|
||||
<Card title="Identité" icon={Package}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<Field label="Code article" value={detail.codein} mono />
|
||||
<Field label="Code centrale" value={detail.code_centrale || "—"} mono />
|
||||
<Field label="Fournisseur" value={fournisseurAffiche ?? "—"} />
|
||||
<Field label="GTIN" value={detail.gtin || "—"} mono />
|
||||
<Field label="Référence fou." value={detail.reference || "—"} mono />
|
||||
<Field label="PCB" value={detail.pcb != null ? fmtQte(detail.pcb) : "—"} />
|
||||
<Field label="Gamme actuelle" value={gammes[0]?.gamme_code ?? "—"} />
|
||||
<Field label="Secteur" value={detail.libelle1nom ?? "—"} />
|
||||
<Field label="Famille" value={detail.libelle2 ?? "—"} />
|
||||
<Field label="Sous-famille" value={detail.libelle3 ?? "—"} />
|
||||
<Field label="Prix d'achat" value={detail.pa != null ? fmtEur2(detail.pa) : "—"} />
|
||||
<Field label="PV central" value={detail.pv_central != null ? fmtEur2(detail.pv_central) : "—"} />
|
||||
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
|
||||
</div>
|
||||
|
||||
{gammes.length > 1 && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Historique de gamme
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gammes.slice(0, 10).map((g, i) => (
|
||||
<span
|
||||
key={`${g.saison_no_id}-${i}`}
|
||||
className="rounded-lg px-2 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
{g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
|
||||
<span className="ml-1.5 font-bold" style={{ color: "var(--text-primary)" }}>{g.gamme_code}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Stock ───────────────────────────────────────────────────── */}
|
||||
{detail && (
|
||||
<Card title="Stock temps réel" icon={Warehouse}>
|
||||
{stock.length === 0 ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>Aucune ligne de stock pour ce produit.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{stock.map((s) => (
|
||||
<div key={s.site} className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{SITE_LABELS[s.site]?.nom ?? s.site}
|
||||
</span>
|
||||
<span
|
||||
className="text-[16px] font-black tabular-nums"
|
||||
style={{ color: s.qte < 0 ? "var(--accent-error)" : "var(--text-primary)" }}
|
||||
>
|
||||
{fmtQte(s.qte)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<div>Dispo : <span className="tabular-nums">{fmtQte(s.stockdispo)}</span></div>
|
||||
<div>Valeur : <span className="tabular-nums">{fmtEur(s.valstock)}</span></div>
|
||||
<div>PRMP : <span className="tabular-nums">{s.prmp > 0 ? fmtEur2(s.prmp) : "—"}</span></div>
|
||||
<div>Dern. vente : {formatDate(s.dernierevente)}</div>
|
||||
<div className="col-span-2">Dern. réception : {formatDate(s.dernierereception)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */}
|
||||
{detail && (
|
||||
<Card
|
||||
title="Nos ventes · 12 mois glissants"
|
||||
icon={ShoppingCart}
|
||||
action={
|
||||
<div className="segment-control">
|
||||
{["TOTAL", ...sitesDisponibles].map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => setMagasin(code)}
|
||||
className={`segment-btn ${magasin === code ? "active" : ""}`}
|
||||
>
|
||||
{code === "TOTAL" ? "Total" : (SITE_LABELS[code]?.label ?? code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<Stat label="Quantité vendue" value={fmtQte(totauxAffiches.qte)} />
|
||||
<Stat label="CA (TTC)" value={fmtEur(totauxAffiches.ca)} />
|
||||
<Stat label="Marge" value={fmtEur(totauxAffiches.marge)} />
|
||||
<Stat
|
||||
label="Taux de marge"
|
||||
value={`${fmtDec(totauxAffiches.tauxMarge)} %`}
|
||||
color={totauxAffiches.tauxMarge >= 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left text-[10px] font-semibold uppercase" style={{ color: "var(--text-muted)" }}>
|
||||
Mois
|
||||
</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} className="px-1 py-1.5 text-center text-[10px] font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
Ventes
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 min-w-[46px]">
|
||||
<HeatmapCell
|
||||
value={serie[m]?.qte ?? 0}
|
||||
tooltipStock={serie[m]?.stockFinMois ?? null}
|
||||
tooltipReceptions={serie[m]?.qteRecue ?? null}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
CA (TTC)
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
Stock fin de mois
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<NetworkLineChart
|
||||
labels={months.map(ffMonthToQlik)}
|
||||
values={months.map((m) => serie[m]?.qte ?? 0)}
|
||||
color="#0ea5e9"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Comparatif local vs réseau ──────────────────────────────── */}
|
||||
{detail && comparatif.hasReseau && (
|
||||
<Card title="Nous vs. un magasin moyen du réseau" icon={Globe}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat
|
||||
label="Indice de performance"
|
||||
value={comparatif.indiceQte != null ? `${Math.round(comparatif.indiceQte)} %` : "—"}
|
||||
hint={verdict.label}
|
||||
color={verdict.color}
|
||||
/>
|
||||
<Stat
|
||||
label="Nos ventes / magasin"
|
||||
value={fmtQte(comparatif.qteLocaleParMagasin)}
|
||||
hint={`${comparatif.nbMagasinsLocaux} magasin${comparatif.nbMagasinsLocaux > 1 ? "s" : ""} · 12 mois`}
|
||||
/>
|
||||
<Stat
|
||||
label="Réseau / magasin"
|
||||
value={fmtQte(comparatif.qteReseauParMagasin)}
|
||||
hint={`${comparatif.nbMagasinsReseau} magasins · 12 mois`}
|
||||
/>
|
||||
<Stat
|
||||
label="Présence réseau"
|
||||
value={comparatif.tauxPresence != null ? `${fmtDec(comparatif.tauxPresence * 100, 0)} %` : "—"}
|
||||
hint={`${comparatif.nbMagasinsReseau} / ${NB_MAGASINS_RESEAU} magasins`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
CA par magasin · indicatif
|
||||
</div>
|
||||
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtEur(comparatif.caLocalParMagasin)}</span></span>
|
||||
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Taux de marge · indicatif
|
||||
</div>
|
||||
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtDec(comparatif.tauxMargeLocal)} %</span></span>
|
||||
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-2" style={{ color: "var(--text-muted)" }}>
|
||||
L'indice compare les <strong>quantités</strong> par magasin, seule base commune fiable.
|
||||
Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas
|
||||
documentée — montants et marges sont donc donnés à titre indicatif.
|
||||
</p>
|
||||
|
||||
{comparatif.hasMonthly && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Quantité par magasin, mois par mois
|
||||
</div>
|
||||
<DualLineChart
|
||||
labels={comparatif.monthly.map((m) => ffMonthToQlik(m.mois))}
|
||||
series={[
|
||||
{ name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" },
|
||||
{ name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Opportunités de la même famille ─────────────────────────── */}
|
||||
{detail?.nom_no_id != null && (
|
||||
<Card title="Opportunités · même famille" icon={Layers}>
|
||||
<OpportunitesFamille
|
||||
nomNoId={detail.nom_no_id}
|
||||
currentCodein={detail.codein}
|
||||
familleLabel={detail.libelle3 ?? detail.libelle2 ?? ""}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sparkline discrète en pied de fiche, cohérente avec la Grille */}
|
||||
{trend.hasData && (
|
||||
<div className="flex items-center justify-end gap-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<span>Tendance réseau :</span>
|
||||
<TrendSparkline trend={trend} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function ProduitsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
|
||||
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
|
||||
|
||||
/**
|
||||
* Produits de la même famille que le produit affiché, classés par écart de
|
||||
* performance réseau vs locale (quantité **par magasin**, pour neutraliser
|
||||
* l'écart d'échelle entre ~270 magasins et nos 2 sites).
|
||||
*
|
||||
* Chargement à la demande : la requête agrège 12 mois de mouvements pour toute
|
||||
* la famille, elle ne doit pas ralentir l'ouverture de la fiche.
|
||||
*/
|
||||
export function OpportunitesFamille({
|
||||
nomNoId,
|
||||
currentCodein,
|
||||
familleLabel,
|
||||
}: {
|
||||
nomNoId: number;
|
||||
currentCodein: string;
|
||||
familleLabel: string;
|
||||
}) {
|
||||
const [rows, setRows] = useState<PgOpportuniteRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/produits/opportunites?nomNoId=${nomNoId}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setRows((data.rows ?? []) as PgOpportuniteRow[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows === null) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
|
||||
lesquelles le réseau vend bien et que nous vendons peu ?
|
||||
</p>
|
||||
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
|
||||
{loading ? "Analyse…" : "Analyser la famille"}
|
||||
</button>
|
||||
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On retire le produit courant : il est déjà détaillé au-dessus.
|
||||
const others = rows.filter((r) => r.codein !== currentCodein);
|
||||
|
||||
if (others.length === 0) {
|
||||
return (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Aucune autre référence de cette famille n'a de données réseau en cache.
|
||||
Synchronisez d'autres fournisseurs pour enrichir la comparaison.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{others.map((r) => {
|
||||
const opportunite = r.ecart_par_magasin > 0;
|
||||
return (
|
||||
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{r.libelle1 || r.codein}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_reseau_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_locale_par_magasin)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums font-semibold"
|
||||
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
|
||||
title={opportunite
|
||||
? "Le réseau vend plus que nous sur ce produit"
|
||||
: "Nous vendons autant ou plus que le réseau"}
|
||||
>
|
||||
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{fmtQte(r.nb_magasins_reseau)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
|
||||
Un écart positif signale une référence sous-exploitée chez nous.
|
||||
Périmètre : les articles <strong>de notre catalogue</strong> ayant déjà des données réseau —
|
||||
Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
|
||||
import { ProduitSearchBar } from "./search-bar";
|
||||
import { ProduitResults } from "./results";
|
||||
import { ProduitFicheView } from "./fiche";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite.
|
||||
*
|
||||
* - `?q=…` → liste de résultats (chargée côté client : l'extraction Qlik
|
||||
* prend plusieurs secondes, on ne bloque pas le rendu serveur)
|
||||
* - `?codein=…` → fiche d'un produit de notre catalogue (URL partageable)
|
||||
* - `?cc=…` → fiche d'un produit du réseau par son code centrale, qu'il
|
||||
* soit référencé chez nous ou non
|
||||
*/
|
||||
export default async function ProduitsPage(props: {
|
||||
searchParams: Promise<{ q?: string; codein?: string; cc?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const q = (searchParams.q ?? "").trim();
|
||||
const codein = (searchParams.codein ?? "").trim();
|
||||
const codeCentrale = (searchParams.cc ?? "").trim();
|
||||
|
||||
const fiche = codein
|
||||
? await getProduitFiche(codein)
|
||||
: codeCentrale
|
||||
? await getProduitFicheByCodeCentrale(codeCentrale)
|
||||
: null;
|
||||
const ficheDemandee = Boolean(codein || codeCentrale);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
|
||||
Recherche produit
|
||||
</h1>
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue —
|
||||
performance réseau et ventes locales sur 12 mois glissants.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
|
||||
<ProduitSearchBar key={q} initialQuery={q} />
|
||||
|
||||
{ficheDemandee && !fiche && (
|
||||
<div
|
||||
className="rounded-xl p-6 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun produit trouvé pour{" "}
|
||||
{codein
|
||||
? <>le code article <span className="font-mono">{codein}</span></>
|
||||
: <>le code centrale <span className="font-mono">{codeCentrale}</span></>}.
|
||||
{codeCentrale && !codein && (
|
||||
<div className="mt-1">
|
||||
Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
|
||||
|
||||
{!ficheDemandee && <ProduitResults query={q} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
|
||||
import { TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
||||
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number, d = 1) =>
|
||||
new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
|
||||
const fmtEur = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
||||
const fmtEur2 = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
|
||||
|
||||
/** Intervalle d'interrogation de l'état du job de recherche. */
|
||||
const POLL_MS = 2000;
|
||||
|
||||
/** Réponse du job de recherche, telle que la renvoie l'API. */
|
||||
interface JobRecherche {
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
etape?: string;
|
||||
error?: string;
|
||||
result?: ProduitRechercheResultat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit une réponse HTTP en **ne présumant pas** qu'elle est du JSON.
|
||||
*
|
||||
* Un reverse proxy qui coupe la requête répond une page HTML : `res.json()`
|
||||
* échouait alors sur « Unexpected token '<' … is not valid JSON », message
|
||||
* inexploitable pour l'utilisateur. On traduit ici le statut HTTP.
|
||||
*/
|
||||
async function lireJson(res: Response): Promise<Record<string, unknown>> {
|
||||
const texte = await res.text();
|
||||
let data: Record<string, unknown> | null = null;
|
||||
try {
|
||||
data = texte ? (JSON.parse(texte) as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (data == null) {
|
||||
if (res.status === 504 || res.status === 408) {
|
||||
throw new Error("Le serveur a mis trop de temps à répondre (délai dépassé côté proxy). Réessayez.");
|
||||
}
|
||||
if (res.status === 502 || res.status === 503) {
|
||||
throw new Error("Application indisponible ou en cours de redémarrage. Réessayez dans un instant.");
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error("Session expirée. Rechargez la page pour vous reconnecter.");
|
||||
}
|
||||
throw new Error(`Réponse inattendue du serveur (HTTP ${res.status}).`);
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.error ?? `HTTP ${res.status}`));
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultats de recherche produit.
|
||||
*
|
||||
* La recherche interroge **Qlik d'abord** et peut dépasser la minute : elle est
|
||||
* lancée par un `POST` qui rend la main tout de suite, puis on interroge son
|
||||
* avancement toutes les 2 s. Une requête HTTP maintenue pendant toute
|
||||
* l'extraction se faisait couper par le reverse proxy.
|
||||
*/
|
||||
export function ProduitResults({ query }: { query: string }) {
|
||||
// `relance` mémorise la dernière demande explicite de rafraîchissement, pour
|
||||
// ne forcer le contournement du cache serveur que sur CE terme-là.
|
||||
const [relance, setRelance] = useState<{ n: number; q: string }>({ n: 0, q: "" });
|
||||
const [resultat, setResultat] = useState<{
|
||||
cle: string;
|
||||
data: ProduitRechercheResultat | null;
|
||||
error: string | null;
|
||||
etape: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const force = relance.n > 0 && relance.q === query;
|
||||
const cle = `${query}|${force ? relance.n : 0}`;
|
||||
const tropCourt = query.trim().length < 3;
|
||||
|
||||
// L'état de chargement est **dérivé** (résultat pas encore aligné sur la
|
||||
// requête courante) plutôt que posé en début d'effet : appeler setState
|
||||
// synchronement dans un effet provoque un rendu en cascade.
|
||||
const enAttente = resultat?.cle !== cle || (!resultat?.data && !resultat?.error);
|
||||
const loading = !tropCourt && enAttente;
|
||||
|
||||
useEffect(() => {
|
||||
if (tropCourt) return;
|
||||
let annule = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const url = (methode: "POST" | "GET") =>
|
||||
`/api/produits/search?q=${encodeURIComponent(query)}${methode === "POST" && force ? "&force=1" : ""}`;
|
||||
|
||||
const appliquer = (job: JobRecherche): boolean => {
|
||||
if (annule) return true;
|
||||
if (job.status === "success" && job.result) {
|
||||
setResultat({ cle, data: job.result, error: null, etape: null });
|
||||
return true;
|
||||
}
|
||||
if (job.status === "error") {
|
||||
setResultat({ cle, data: null, error: job.error ?? "La recherche a échoué.", etape: null });
|
||||
return true;
|
||||
}
|
||||
// `idle` = le process a redémarré et a perdu le job : on relance.
|
||||
setResultat({ cle, data: null, error: null, etape: job.etape ?? null });
|
||||
return false;
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const job = (await lireJson(await fetch(url("GET"), { cache: "no-store" }))) as unknown as JobRecherche;
|
||||
if (job.status === "idle") {
|
||||
await demarrer();
|
||||
return;
|
||||
}
|
||||
if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS);
|
||||
} catch (e) {
|
||||
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null });
|
||||
}
|
||||
};
|
||||
|
||||
const demarrer = async () => {
|
||||
try {
|
||||
const job = (await lireJson(
|
||||
await fetch(url("POST"), { method: "POST", cache: "no-store" }),
|
||||
)) as unknown as JobRecherche;
|
||||
if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS);
|
||||
} catch (e) {
|
||||
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null });
|
||||
}
|
||||
};
|
||||
|
||||
void demarrer();
|
||||
return () => { annule = true; if (timer) clearTimeout(timer); };
|
||||
}, [cle, query, force, tropCourt]);
|
||||
|
||||
const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query }));
|
||||
const data = resultat?.cle === cle ? resultat.data : null;
|
||||
const error = resultat?.cle === cle ? resultat.error : null;
|
||||
const etape = resultat?.cle === cle ? resultat.etape : null;
|
||||
|
||||
if (!query) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Recherchez un produit dans le réseau
|
||||
</p>
|
||||
<p className="text-[12px] mt-1 max-w-lg mx-auto" style={{ color: "var(--text-muted)" }}>
|
||||
La recherche interroge <strong>Qlik Sense</strong> (les ~{NB_MAGASINS_RESEAU} magasins du réseau),
|
||||
puis rapproche chaque produit trouvé de notre catalogue.
|
||||
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
{etape ?? "Interrogation de Qlik Sense…"}
|
||||
</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants.
|
||||
Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl p-6" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
La recherche a échoué
|
||||
</p>
|
||||
<p className="text-[12px] mt-0.5 break-words" style={{ color: "var(--text-muted)" }}>{error}</p>
|
||||
<button onClick={relancer} className="btn-action btn-action-secondary mt-3 flex items-center gap-1.5">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
{source === "qlik" ? (
|
||||
<Globe className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} />
|
||||
) : (
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{rows.length} produit{rows.length > 1 ? "s" : ""}
|
||||
{source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"}
|
||||
{tronque && " (liste tronquée — affinez la recherche)"}
|
||||
{" · "}{(dureeMs / 1000).toFixed(1)} s
|
||||
</p>
|
||||
<button onClick={relancer} className="btn-action btn-action-secondary flex items-center gap-1.5">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Relancer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{source === "db" && (
|
||||
<div
|
||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<span>
|
||||
Qlik n'a pas pu être interrogé, la recherche s'est repliée sur le catalogue local
|
||||
(les chiffres réseau affichés viennent du dernier cache).
|
||||
{qlikError && <span className="block mt-0.5" style={{ color: "var(--text-muted)" }}>{qlikError}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === "qlik" && qlikError && (
|
||||
<div
|
||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<span>
|
||||
{rows.length === 0
|
||||
? qlikError
|
||||
: `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun article du réseau ne correspond à « {query} ».
|
||||
</div>
|
||||
) : (
|
||||
<ResultTable rows={rows} query={query} />
|
||||
)}
|
||||
|
||||
{locauxHorsReseau.length > 0 && (
|
||||
<details className="rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<summary className="cursor-pointer px-3 py-2.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
|
||||
sans correspondance réseau (pas de code centrale, ou non vendu par le réseau)
|
||||
</summary>
|
||||
<div className="px-3 pb-3">
|
||||
<ul className="space-y-1">
|
||||
{locauxHorsReseau.slice(0, 30).map((l) => (
|
||||
<li key={l.codein} className="text-[12px]">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{l.libelle1 || l.codein}
|
||||
</Link>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[13px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<Th>Libellé</Th>
|
||||
<Th>Fournisseur</Th>
|
||||
<Th>Code centrale</Th>
|
||||
<Th align="right">Magasins</Th>
|
||||
<Th align="right">Qté réseau</Th>
|
||||
<Th align="right">Qté / mag.</Th>
|
||||
<Th align="right">Prix moyen</Th>
|
||||
<Th align="right">CA / mag.</Th>
|
||||
<Th align="right">Marge %</Th>
|
||||
<Th align="center">Tendance</Th>
|
||||
<Th align="right">Nous</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth);
|
||||
const href = r.codein
|
||||
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
|
||||
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
|
||||
return (
|
||||
<tr
|
||||
key={r.codeCentrale}
|
||||
className="transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2 max-w-[280px]">
|
||||
<Link href={href} className="font-medium hover:underline" style={{ color: "var(--accent)" }}>
|
||||
{r.libelle || "—"}
|
||||
</Link>
|
||||
{r.nomenclature && (
|
||||
<div className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>{r.nomenclature}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.fournisseur || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.codeCentrale}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtQte(r.nbMagasinsReseau)}
|
||||
<span className="text-[11px] ml-1" style={{ color: "var(--text-muted)" }}>
|
||||
{fmtDec(r.tauxPresence * 100, 0)} %
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{fmtQte(r.qteReseau)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qteParMagasinReseau)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
{trend.hasData ? <TrendSparkline trend={trend} /> : <span style={{ color: "var(--text-muted)" }}>—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-[12px]">
|
||||
{r.codein ? (
|
||||
<span style={{ color: "var(--text-secondary)" }} title={`Code article ${r.codein}`}>
|
||||
stock {fmtQte(r.stockLocal ?? 0)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="rounded-md px-1.5 py-0.5 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
|
||||
title="Le réseau travaille ce produit, notre catalogue ne le référence pas"
|
||||
>
|
||||
non référencé
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) {
|
||||
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
|
||||
const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
|
||||
return (
|
||||
<th
|
||||
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Champ de recherche produit.
|
||||
*
|
||||
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : chaque
|
||||
* recherche déclenche deux allers-retours Qlik Sense (recherche des articles
|
||||
* puis extraction de leurs mesures sur 12 mois), soit plusieurs secondes.
|
||||
* Minimum 3 caractères.
|
||||
*
|
||||
* L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL
|
||||
* (retour arrière, lien partagé) par remontage, plutôt que par un effet qui
|
||||
* appellerait setState en cascade.
|
||||
*/
|
||||
export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
|
||||
const router = useRouter();
|
||||
const [term, setTerm] = useState(initialQuery);
|
||||
|
||||
const tooShort = term.trim().length > 0 && term.trim().length < 3;
|
||||
|
||||
function submit() {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return;
|
||||
router.push(`/produits?q=${encodeURIComponent(cleaned)}`);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setTerm("");
|
||||
router.push("/produits");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xl">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
|
||||
placeholder="Libellé produit ou code centrale…"
|
||||
aria-label="Rechercher un produit par libellé ou code centrale"
|
||||
className="apple-input h-10 text-[14px]"
|
||||
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
|
||||
/>
|
||||
{term && (
|
||||
<button
|
||||
onClick={clear}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={term.trim().length < 3}
|
||||
className="btn-action btn-action-primary disabled:opacity-50"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
{tooShort && (
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Saisissez au moins 3 caractères.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Search, Loader2, AlertTriangle, PackageSearch, FileDown, Info,
|
||||
ChevronUp, ChevronDown, ChevronsUpDown, Store, Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
computeNetworkTrend, TrendSparkline, NetworkLineChart, TREND_COLOR, trendLabel,
|
||||
} from "@/features/network/components/network-trend";
|
||||
import type { NetworkSearchJobState, NetworkSearchRow } from "@/types/network-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Formatage ────────────────────────────────────────────────────────────────
|
||||
const fmtEur = (n?: number | null) =>
|
||||
n == null ? "-" : n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||
const fmtNum = (n?: number | null) =>
|
||||
n == null ? "-" : Math.round(n).toLocaleString("fr-FR");
|
||||
/** Qlik renvoie soit un ratio (0.32) soit déjà des points (32) — même règle que la Grille. */
|
||||
const toPct = (v: number) => (Math.abs(v) <= 1 ? v * 100 : v);
|
||||
|
||||
// ─── Tri ──────────────────────────────────────────────────────────────────────
|
||||
type SortKey = "libelle" | "codeCentrale" | "fournisseur" | "caReseau" | "qteReseau"
|
||||
| "nbMagasinsReseau" | "caParMagasinReseau" | "margePctReseau" | "trend" | "stockTotal";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
/** Filtres : tout / seulement nos références / seulement les produits inconnus. */
|
||||
type Filter = "all" | "mine" | "network";
|
||||
|
||||
export function RechercheReseauClient() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [job, setJob] = useState<NetworkSearchJobState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("caReseau");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [modalRow, setModalRow] = useState<NetworkSearchRow | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isRunning = job?.status === "running";
|
||||
|
||||
// ── Lancement de la recherche ─────────────────────────────────────────────
|
||||
const startSearch = useCallback(async () => {
|
||||
const q = query.trim();
|
||||
if (!q || isRunning) return;
|
||||
setError(null);
|
||||
setElapsed(0);
|
||||
try {
|
||||
const res = await fetch("/api/qlik/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: q }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? `Erreur ${res.status}`);
|
||||
return;
|
||||
}
|
||||
setJob(data as NetworkSearchJobState);
|
||||
setFilter("all");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [query, isRunning]);
|
||||
|
||||
// ── Polling tant que le job tourne ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isRunning || !job?.jobId) return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/search?jobId=${encodeURIComponent(job.jobId!)}`);
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (res.ok) setJob(data as NetworkSearchJobState);
|
||||
} catch {
|
||||
// Réseau instable : on retentera au prochain tick.
|
||||
}
|
||||
if (!cancelled) pollRef.current = setTimeout(tick, 2000);
|
||||
};
|
||||
pollRef.current = setTimeout(tick, 1500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollRef.current) clearTimeout(pollRef.current);
|
||||
};
|
||||
}, [isRunning, job?.jobId]);
|
||||
|
||||
// Chronomètre d'attente (l'extraction Qlik prend typiquement 20-40 s).
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [isRunning]);
|
||||
|
||||
const results = useMemo(() => job?.results ?? [], [job?.results]);
|
||||
const counts = useMemo(() => ({
|
||||
all: results.length,
|
||||
mine: results.filter((r) => r.chezMoi).length,
|
||||
network: results.filter((r) => !r.chezMoi).length,
|
||||
}), [results]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const filtered = results.filter((r) =>
|
||||
filter === "all" ? true : filter === "mine" ? r.chezMoi : !r.chezMoi);
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
const val = (r: NetworkSearchRow): number | string => {
|
||||
switch (sortKey) {
|
||||
case "libelle": return r.libelle.toLowerCase();
|
||||
case "codeCentrale": return r.codeCentrale;
|
||||
case "fournisseur": return (r.fournisseur ?? r.nomfou ?? "").toLowerCase();
|
||||
case "trend": return computeNetworkTrend(r.qteByMonth).pct ?? -Infinity;
|
||||
case "stockTotal": return r.stockTotal ?? -1;
|
||||
default: return r[sortKey] ?? 0;
|
||||
}
|
||||
};
|
||||
return [...filtered].sort((a, b) => {
|
||||
const va = val(a), vb = val(b);
|
||||
if (typeof va === "string" || typeof vb === "string") {
|
||||
return String(va).localeCompare(String(vb), "fr") * dir;
|
||||
}
|
||||
return (va - vb) * dir;
|
||||
});
|
||||
}, [results, filter, sortKey, sortDir]);
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir(key === "libelle" || key === "codeCentrale" || key === "fournisseur" ? "asc" : "desc"); }
|
||||
};
|
||||
|
||||
// ── Export Excel ──────────────────────────────────────────────────────────
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!rows.length || exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Recherche réseau");
|
||||
// Colonnes mensuelles = union des mois présents, triée chronologiquement.
|
||||
const months = [...new Set(rows.flatMap((r) => Object.keys(r.qteByMonth ?? {})))].sort().slice(-12);
|
||||
ws.columns = [
|
||||
{ header: "Libellé", key: "libelle", width: 46 },
|
||||
{ header: "Code centrale", key: "code", width: 15 },
|
||||
{ header: "Fournisseur", key: "fou", width: 24 },
|
||||
{ header: "Famille", key: "fam", width: 20 },
|
||||
{ header: "Statut", key: "statut", width: 18 },
|
||||
{ header: "CA réseau", key: "ca", width: 14 },
|
||||
{ header: "Qté réseau", key: "qte", width: 12 },
|
||||
{ header: "Nb magasins", key: "nbmag", width: 12 },
|
||||
{ header: "CA / magasin", key: "camag", width: 14 },
|
||||
{ header: "Marge %", key: "marge", width: 10 },
|
||||
{ header: "Code interne", key: "codein", width: 14 },
|
||||
{ header: "Stock 292", key: "s292", width: 10 },
|
||||
{ header: "Stock 579", key: "s579", width: 10 },
|
||||
...months.map((m) => ({ header: m, key: `m_${m}`, width: 9 })),
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
for (const r of rows) {
|
||||
const monthCells: Record<string, number> = {};
|
||||
for (const m of months) monthCells[`m_${m}`] = r.qteByMonth?.[m] ?? 0;
|
||||
ws.addRow({
|
||||
libelle: r.libelle,
|
||||
code: r.codeCentrale,
|
||||
fou: r.fournisseur ?? r.nomfou ?? "",
|
||||
fam: r.famille ?? "",
|
||||
statut: r.chezMoi ? "Chez moi" : "Réseau uniquement",
|
||||
ca: Math.round(r.caReseau),
|
||||
qte: Math.round(r.qteReseau),
|
||||
nbmag: r.nbMagasinsReseau,
|
||||
camag: Math.round(r.caParMagasinReseau),
|
||||
marge: Number(toPct(r.margePctReseau).toFixed(1)),
|
||||
codein: r.codein ?? "",
|
||||
s292: r.stock292 ?? "",
|
||||
s579: r.stock579 ?? "",
|
||||
...monthCells,
|
||||
});
|
||||
}
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `recherche-reseau-${(job?.query ?? "").replace(/[^\w-]+/g, "_").slice(0, 40)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [rows, exporting, job?.query]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
{/* ── En-tête ── */}
|
||||
<div className="mb-5">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<PackageSearch className="w-6 h-6" style={{ color: "var(--accent)" }} />
|
||||
Recherche réseau
|
||||
</h1>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Cherche dans le catalogue <strong>Qlik du réseau</strong> — y compris les produits que nous ne
|
||||
référençons pas. Ventes mois par mois sur 12 mois glissants.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Barre de recherche ── */}
|
||||
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex flex-col sm:flex-row gap-2.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none" style={{ color: "var(--text-muted)" }} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") startSearch(); }}
|
||||
placeholder="Nom du produit (ex. tapis anti) ou code centrale (ex. 10000459340)"
|
||||
disabled={isRunning}
|
||||
className="w-full rounded-xl pl-9 pr-3 py-2.5 text-[14px] outline-none disabled:opacity-60"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={startSearch}
|
||||
disabled={isRunning || !query.trim()}
|
||||
className="rounded-xl px-5 py-2.5 text-[14px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-2 shrink-0"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{isRunning ? <><Loader2 className="w-4 h-4 animate-spin" /> Recherche… {elapsed}s</> : <><Search className="w-4 h-4" /> Rechercher</>}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] mt-2 flex items-start gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Info className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
L'interrogation de Qlik prend en général 20 à 40 secondes. Plusieurs codes centraux peuvent être
|
||||
collés d'un coup (séparés par des espaces ou des virgules).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Erreurs ── */}
|
||||
{error && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "error" && job.error && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{job.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "success" && job.tooMany && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>
|
||||
Recherche trop large : <strong>{job.matchCount}</strong> produits correspondent
|
||||
{job.maxProducts ? <> (maximum {job.maxProducts})</> : null}. Précisez les termes pour
|
||||
protéger le moteur Qlik.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "success" && !job.tooMany && results.length === 0 && (
|
||||
<div className="rounded-xl p-6 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Aucun produit trouvé dans Qlik pour « {job.query} ».
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Résultats ── */}
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
{/* Filtres + résumé + export */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{([
|
||||
{ key: "all" as Filter, label: "Tous", n: counts.all },
|
||||
{ key: "mine" as Filter, label: "Chez moi", n: counts.mine },
|
||||
{ key: "network" as Filter, label: "Réseau uniquement", n: counts.network },
|
||||
]).map(({ key, label, n }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={cn("rounded-full px-3.5 py-1.5 text-[12px] font-semibold transition-colors")}
|
||||
style={{
|
||||
background: filter === key ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: filter === key ? "#fff" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{label} <span className="opacity-70">({n})</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{job?.periode && (
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Période Qlik : {job.periode}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={exportExcel}
|
||||
disabled={exporting}
|
||||
className="rounded-xl px-3 py-1.5 text-[12px] font-semibold flex items-center gap-1.5 disabled:opacity-50"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
{exporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileDown className="w-3.5 h-3.5" />}
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bandeau opportunités : produits forts au réseau que nous n'avons pas */}
|
||||
{counts.network > 0 && filter !== "mine" && (
|
||||
<div className="rounded-xl p-3 mb-3 flex items-start gap-2.5 text-[12px]"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", color: "var(--text-secondary)" }}>
|
||||
<Sparkles className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
<strong>{counts.network}</strong> produit{counts.network > 1 ? "s" : ""} vendu
|
||||
{counts.network > 1 ? "s" : ""} par le réseau que nous ne référençons pas — trier par
|
||||
« CA / Mag » ou « Nb mag » fait remonter les meilleures opportunités d'assortiment.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResultsTable
|
||||
rows={rows}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
onRowClick={setModalRow}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={modalRow !== null} onOpenChange={(open) => { if (!open) setModalRow(null); }}>
|
||||
{modalRow !== null && <ProductDetailModal row={modalRow} />}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tableau des résultats ────────────────────────────────────────────────────
|
||||
|
||||
const COLUMNS: Array<{ key: SortKey; label: string; sub?: string; align: "left" | "center" | "right" }> = [
|
||||
{ key: "libelle", label: "Produit", align: "left" },
|
||||
{ key: "codeCentrale", label: "Code centrale", align: "left" },
|
||||
{ key: "fournisseur", label: "Fournisseur", align: "left" },
|
||||
{ key: "caReseau", label: "CA", sub: "Réseau", align: "right" },
|
||||
{ key: "qteReseau", label: "Qté", sub: "Réseau", align: "right" },
|
||||
{ key: "nbMagasinsReseau", label: "Nb mag", sub: "Réseau", align: "right" },
|
||||
{ key: "caParMagasinReseau", label: "CA / Mag", sub: "Réseau", align: "right" },
|
||||
{ key: "margePctReseau", label: "Marge %", sub: "Réseau", align: "right" },
|
||||
{ key: "trend", label: "Tendance", sub: "12 mois", align: "center" },
|
||||
{ key: "stockTotal", label: "Stock", sub: "292 + 579", align: "right" },
|
||||
];
|
||||
|
||||
function ResultsTable({ rows, sortKey, sortDir, onSort, onRowClick }: {
|
||||
rows: NetworkSearchRow[];
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
onSort: (k: SortKey) => void;
|
||||
onRowClick: (r: NetworkSearchRow) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
{COLUMNS.map((c) => {
|
||||
const active = sortKey === c.key;
|
||||
const Icon = !active ? ChevronsUpDown : sortDir === "asc" ? ChevronUp : ChevronDown;
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => onSort(c.key)}
|
||||
className="px-3 py-2.5 font-semibold cursor-pointer select-none whitespace-nowrap"
|
||||
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)", textAlign: c.align }}
|
||||
>
|
||||
<span className={cn("inline-flex items-center gap-1", c.align === "right" && "flex-row-reverse")}>
|
||||
<Icon className="w-3 h-3 shrink-0" style={{ opacity: active ? 1 : 0.35 }} />
|
||||
<span>
|
||||
{c.label}
|
||||
{c.sub && <><br /><span className="text-[9px] opacity-60 font-normal">{c.sub}</span></>}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const trend = computeNetworkTrend(r.qteByMonth);
|
||||
const margePct = toPct(r.margePctReseau);
|
||||
return (
|
||||
<tr
|
||||
key={r.codeCentrale}
|
||||
onClick={() => onRowClick(r)}
|
||||
className="cursor-pointer hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2 max-w-[380px]">
|
||||
<div className="font-medium truncate" style={{ color: "var(--text-primary)" }} title={r.libelle}>
|
||||
{r.libelle}
|
||||
</div>
|
||||
<span
|
||||
className="inline-block mt-0.5 rounded px-1.5 py-px text-[9px] font-bold uppercase tracking-wide"
|
||||
style={r.chezMoi
|
||||
? { background: "var(--accent-bg)", color: "var(--accent)" }
|
||||
: { background: "rgba(245,158,11,0.15)", color: "#d97706" }}
|
||||
>
|
||||
{r.chezMoi ? `Chez moi${r.codein ? ` · ${r.codein}` : ""}` : "Réseau uniquement"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums" style={{ color: "var(--text-secondary)" }}>{r.codeCentrale}</td>
|
||||
<td className="px-3 py-2 max-w-[180px] truncate" style={{ color: "var(--text-secondary)" }} title={r.fournisseur ?? r.nomfou ?? ""}>
|
||||
{r.fournisseur ?? r.nomfou ?? "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>{fmtEur(r.caReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{fmtNum(r.qteReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{fmtNum(r.nbMagasinsReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-secondary)" }}>{fmtEur(r.caParMagasinReseau)}</td>
|
||||
<td className={cn("px-3 py-2 text-right tabular-nums font-bold",
|
||||
margePct >= 30 ? "text-emerald-500" : margePct >= 15 ? "text-amber-500" : "text-rose-500")}>
|
||||
{margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
|
||||
</td>
|
||||
<td className="px-3 py-2"><TrendSparkline trend={trend} /></td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.chezMoi ? fmtNum(r.stockTotal ?? 0) : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fiche produit ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Détail d'un produit : courbe des ventes réseau 12 mois + informations Qlik / locales. */
|
||||
function ProductDetailModal({ row }: { row: NetworkSearchRow }) {
|
||||
const trend = computeNetworkTrend(row.qteByMonth);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const margePct = toPct(row.margePctReseau);
|
||||
|
||||
const infos: Array<{ label: string; value: string }> = [
|
||||
{ label: "Code centrale", value: row.codeCentrale },
|
||||
{ label: "Fournisseur", value: row.fournisseur ?? row.nomfou ?? "-" },
|
||||
...(row.famille ? [{ label: "Famille", value: row.famille }] : []),
|
||||
{ label: "CA réseau", value: fmtEur(row.caReseau) },
|
||||
{ label: "Qté réseau", value: fmtNum(row.qteReseau) },
|
||||
{ label: "Nb magasins", value: fmtNum(row.nbMagasinsReseau) },
|
||||
{ label: "CA / magasin", value: fmtEur(row.caParMagasinReseau) },
|
||||
{ label: "Marge % réseau", value: `${margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%` },
|
||||
];
|
||||
if (row.chezMoi) {
|
||||
infos.push({ label: "Code interne", value: row.codein ?? "-" });
|
||||
if (row.pa != null) infos.push({ label: "PA", value: fmtEur(row.pa) });
|
||||
if (row.pvCentral != null) infos.push({ label: "PV central", value: fmtEur(row.pvCentral) });
|
||||
infos.push({ label: "Stock 292", value: fmtNum(row.stock292 ?? 0) });
|
||||
infos.push({ label: "Stock 579", value: fmtNum(row.stock579 ?? 0) });
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-lg" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle}
|
||||
</DialogTitle>
|
||||
<p className="text-[12px] mt-0.5 flex flex-wrap items-center gap-x-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<span
|
||||
className="rounded px-1.5 py-px text-[9px] font-bold uppercase tracking-wide"
|
||||
style={row.chezMoi
|
||||
? { background: "var(--accent-bg)", color: "var(--accent)" }
|
||||
: { background: "rgba(245,158,11,0.15)", color: "#d97706" }}
|
||||
>
|
||||
{row.chezMoi ? "Chez moi" : "Réseau uniquement"}
|
||||
</span>
|
||||
<span>Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}</span>
|
||||
{pct != null && (
|
||||
<span className="font-bold" style={{ color }}>
|
||||
· {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
|
||||
</span>
|
||||
)}
|
||||
</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 de détail mensuel renvoyé par Qlik pour ce produit.
|
||||
</div>
|
||||
) : (
|
||||
<NetworkLineChart labels={labels} values={values} color={color} />
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{infos.map((i) => (
|
||||
<div key={i.label} className="flex items-baseline justify-between gap-2 text-[12px]">
|
||||
<span style={{ color: "var(--text-muted)" }}>{i.label}</span>
|
||||
<span className="font-semibold tabular-nums text-right" style={{ color: "var(--text-primary)" }}>{i.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!row.chezMoi && (
|
||||
<div className="mt-3 rounded-xl p-3 flex items-start gap-2 text-[12px]"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", color: "var(--text-secondary)" }}>
|
||||
<Store className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
Ce produit est vendu dans <strong>{fmtNum(row.nbMagasinsReseau)}</strong> magasin
|
||||
{row.nbMagasinsReseau > 1 ? "s" : ""} du réseau et génère <strong>{fmtEur(row.caParMagasinReseau)}</strong> de
|
||||
CA par magasin, alors que nous ne le référençons pas.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { RechercheReseauClient } from "./client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Recherche réseau — CollectFlow",
|
||||
};
|
||||
|
||||
/**
|
||||
* Recherche dans le catalogue réseau Qlik.
|
||||
*
|
||||
* Contrairement à la Grille (qui part de NOTRE catalogue), cette page interroge
|
||||
* Qlik en premier : elle affiche donc aussi les produits vendus par le réseau
|
||||
* que FF Nancy ne référence pas. Tout le travail est fait côté client via
|
||||
* /api/qlik/search (job asynchrone), l'extraction Qlik prenant ~20-40 s.
|
||||
*/
|
||||
export default function RechercheReseauPage() {
|
||||
return <RechercheReseauClient />;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { UserManagement } from "@/features/admin/components/user-management";
|
||||
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
|
||||
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
|
||||
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
|
||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||
|
||||
@@ -670,6 +671,14 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Journaux serveur — diagnostic des extractions Qlik */}
|
||||
<Section
|
||||
title="Journaux serveur"
|
||||
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
|
||||
>
|
||||
<ServerLogs />
|
||||
</Section>
|
||||
|
||||
{/* Gestion des Utilisateurs */}
|
||||
<Section
|
||||
title="Gestion des Utilisateurs"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CollectFlow — Journaux serveur des jobs (liste + téléchargement).
|
||||
*
|
||||
* `GET /api/logs` → liste des journaux disponibles (JSON)
|
||||
* `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
|
||||
*
|
||||
* Réservé aux admins : un journal d'extraction contient les diagnostics internes
|
||||
* du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite —
|
||||
* l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur,
|
||||
* et jamais le mot de passe Qlik.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { listCaptures, readCapture } from "@/lib/log-capture";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const refus = await requireAdmin();
|
||||
if (refus) return refus;
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: true, logs: await listCaptures() });
|
||||
}
|
||||
|
||||
const contenu = await readCapture(id);
|
||||
if (contenu == null) {
|
||||
return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans
|
||||
// un en-tête HTTP.
|
||||
const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
|
||||
return new NextResponse(contenu, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${nom}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/produits/opportunites?nomNoId=123
|
||||
*
|
||||
* Produits de la même nomenclature, classés par écart entre performance réseau
|
||||
* et performance locale (quantités par magasin). Chargé à la demande depuis la
|
||||
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
|
||||
* on ne veut pas la payer au chargement de la page.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const raw = req.nextUrl.searchParams.get("nomNoId");
|
||||
const nomNoId = Number(raw);
|
||||
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
|
||||
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
|
||||
return NextResponse.json({ success: true, rows });
|
||||
} catch (e) {
|
||||
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
|
||||
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
|
||||
|
||||
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
|
||||
// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état
|
||||
// des jobs vit dans la mémoire du process.
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Recherche produit **Qlik d'abord** — API asynchrone.
|
||||
*
|
||||
* `POST /api/produits/search?q=<terme>[&force=1]` démarre la recherche et rend la
|
||||
* main tout de suite. `GET /api/produits/search?q=<terme>` renvoie l'avancement,
|
||||
* puis le résultat.
|
||||
*
|
||||
* Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une
|
||||
* extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue
|
||||
* aussi longtemps se fait couper par le reverse proxy, qui répond une page
|
||||
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
|
||||
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
function lireTerme(req: NextRequest): string | null {
|
||||
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||
return q.length >= 3 ? q : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
const force = req.nextUrl.searchParams.get("force") === "1";
|
||||
try {
|
||||
const job = demarrerRecherche(q, { force });
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
} catch (e) {
|
||||
const message = (e as Error).message ?? String(e);
|
||||
console.error("[api/produits/search] POST", message.slice(0, 300));
|
||||
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
try {
|
||||
return NextResponse.json({ success: true, ...etatRecherche(q) });
|
||||
} catch (e) {
|
||||
const message = (e as Error).message ?? String(e);
|
||||
console.error("[api/produits/search] GET", message.slice(0, 300));
|
||||
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { searchNetworkProductsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByCodeCentrale, pgGetStockForCodeins, type StockBySite } from "@/lib/pg-ff-client";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import type { NetworkSearchJobState, NetworkSearchRow } from "@/types/network-search";
|
||||
|
||||
// Comme la sync : l'ouverture de l'app Qlik + les cubes peuvent prendre plusieurs
|
||||
// dizaines de secondes. On rend la main immédiatement et le client polle GET.
|
||||
export const maxDuration = 300;
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface SearchJob extends NetworkSearchJobState {
|
||||
jobId: string;
|
||||
status: "running" | "success" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Store en mémoire (même parti pris que /api/qlik/sync : simple, perdu au restart).
|
||||
*
|
||||
* `activeJobId` sérialise les recherches : le moteur Qlik sature facilement, on ne
|
||||
* lance donc **qu'une extraction à la fois** pour toute l'application.
|
||||
*/
|
||||
const jobs = new Map<string, SearchJob>();
|
||||
let activeJobId: string | null = null;
|
||||
|
||||
/** Ne garde que les N derniers jobs pour éviter une fuite mémoire. */
|
||||
function pruneJobs(keep = 20): void {
|
||||
if (jobs.size <= keep) return;
|
||||
const ordered = [...jobs.values()].sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
||||
for (const j of ordered.slice(0, jobs.size - keep)) {
|
||||
if (j.jobId !== activeJobId) jobs.delete(j.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
function newJobId(): string {
|
||||
return `qsearch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** Vérifie qu'une session est ouverte. Renvoie une 401 sinon. */
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit une erreur Qlik en message actionnable. La saturation mémoire du serveur
|
||||
* Qlik est le cas le plus fréquent et n'est PAS un bug CollectFlow : l'application
|
||||
* Qlik ne se charge même pas.
|
||||
*/
|
||||
function humanizeQlikError(msg: string): string {
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Exécute la recherche Qlik puis enrichit avec le catalogue local. */
|
||||
async function runJob(job: SearchJob, maxProducts: number): Promise<void> {
|
||||
try {
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
console.log(`[api/qlik/search] job=${job.jobId} query="${job.query}" fenêtre=${dateFilter.label}`);
|
||||
|
||||
const res = await searchNetworkProductsPlaywright(job.query, { maxProducts }, undefined, dateFilter);
|
||||
job.mode = res.mode;
|
||||
job.matchCount = res.matchCount;
|
||||
job.periode = res.periode;
|
||||
|
||||
if (res.tooMany) {
|
||||
job.tooMany = true;
|
||||
job.results = [];
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/search] job=${job.jobId} recherche trop large (${res.matchCount} > ${maxProducts})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const products = res.products;
|
||||
console.log(`[api/qlik/search] job=${job.jobId} ← Qlik: ${products.length} produits`);
|
||||
|
||||
// Mise en cache : accélère les recherches suivantes ET enrichit la Grille
|
||||
// pour les codes que nous référençons.
|
||||
if (products.length) {
|
||||
try {
|
||||
const n = await upsertNetworkMetrics(products);
|
||||
console.log(`[api/qlik/search] job=${job.jobId} ${n} lignes mises en cache`);
|
||||
} catch (e) {
|
||||
// Non fatal : la recherche reste exploitable sans cache.
|
||||
console.error(`[api/qlik/search] job=${job.jobId} cache KO:`, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Enrichissement local : qui connaît-on déjà, et avec quel stock ?
|
||||
const locaux = await pgGetArticlesByCodeCentrale(products.map((p) => p.codeCentrale));
|
||||
const codeins = [...locaux.values()].map((a) => a.codein).filter(Boolean);
|
||||
const stocks: Map<string, StockBySite> = codeins.length
|
||||
? await pgGetStockForCodeins(codeins)
|
||||
: new Map<string, StockBySite>();
|
||||
|
||||
const rows: NetworkSearchRow[] = products.map((p) => {
|
||||
const local = locaux.get(p.codeCentrale);
|
||||
const stock = local?.codein ? stocks.get(local.codein) : undefined;
|
||||
return {
|
||||
codeCentrale: p.codeCentrale,
|
||||
libelle: p.libelle || local?.libelle1 || p.codeCentrale,
|
||||
fournisseur: p.fournisseur,
|
||||
famille: p.famille,
|
||||
caReseau: p.caReseau,
|
||||
qteReseau: p.qteReseau,
|
||||
nbMagasinsReseau: p.nbMagasinsReseau,
|
||||
caParMagasinReseau: p.caParMagasinReseau,
|
||||
margePctReseau: p.margePctReseau,
|
||||
qteByMonth: p.qteByMonth ?? null,
|
||||
chezMoi: Boolean(local),
|
||||
codein: local?.codein,
|
||||
libelleLocal: local?.libelle1,
|
||||
codefou: local?.codefou,
|
||||
nomfou: local?.nomfou,
|
||||
pa: local?.pa != null ? Number(local.pa) : undefined,
|
||||
pvCentral: local?.pv_central != null ? Number(local.pv_central) : undefined,
|
||||
stock292: stock?.stock292,
|
||||
stock579: stock?.stock579,
|
||||
stockTotal: stock?.stockTotal,
|
||||
};
|
||||
});
|
||||
|
||||
job.results = rows;
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/search] job=${job.jobId} terminé — ${rows.length} lignes (${rows.filter((r) => r.chezMoi).length} déjà référencées)`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/search] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
job.error = humanizeQlikError(msg);
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} finally {
|
||||
if (activeJobId === job.jobId) activeJobId = null;
|
||||
pruneJobs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/search?jobId=XXX
|
||||
* État + résultats du job. Sans jobId, renvoie le job actif s'il y en a un.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const jobId = req.nextUrl.searchParams.get("jobId");
|
||||
const job = jobId ? jobs.get(jobId) : (activeJobId ? jobs.get(activeJobId) : undefined);
|
||||
if (!job) {
|
||||
const idle: NetworkSearchJobState = { jobId: null, status: "idle", query: "" };
|
||||
return NextResponse.json({ success: true, ...idle });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/qlik/search body: { query: string, maxProducts?: number }
|
||||
* Démarre une recherche en arrière-plan et retourne immédiatement le job.
|
||||
* Refuse (409) si une recherche tourne déjà — le moteur Qlik ne supporte pas
|
||||
* plusieurs extractions concurrentes.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
let body: { query?: unknown; maxProducts?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = String(body.query ?? "").trim();
|
||||
if (!query) return NextResponse.json({ error: "Param 'query' requis" }, { status: 400 });
|
||||
if (query.length > 120) return NextResponse.json({ error: "Recherche trop longue (120 caractères max)" }, { status: 400 });
|
||||
|
||||
const rawMax = Number(body.maxProducts);
|
||||
const maxProducts = Number.isFinite(rawMax) ? Math.min(2000, Math.max(1, Math.trunc(rawMax))) : 300;
|
||||
|
||||
const active = activeJobId ? jobs.get(activeJobId) : undefined;
|
||||
if (active && active.status === "running") {
|
||||
return NextResponse.json(
|
||||
{ error: `Une recherche est déjà en cours ("${active.query}"). Attendez qu'elle se termine.`, ...active },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const job: SearchJob = {
|
||||
jobId: newJobId(),
|
||||
status: "running",
|
||||
query,
|
||||
maxProducts,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(job.jobId, job);
|
||||
activeJobId = job.jobId;
|
||||
console.log(`[api/qlik/search] job=${job.jobId} démarré — query="${query}" max=${maxProducts}`);
|
||||
|
||||
// Fire-and-forget : le client polle GET. runJob capture ses propres erreurs.
|
||||
void runJob(job, maxProducts).catch((e) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/search] job=${job.jobId} crash non géré`, msg);
|
||||
job.status = "error";
|
||||
job.error = msg;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
if (activeJobId === job.jobId) activeJobId = null;
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
}
|
||||
+191
-29
@@ -5,11 +5,9 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
import {
|
||||
buildGridNetworkQlikDateFilter,
|
||||
envMonthsBack,
|
||||
QLIK_MONTHS_BACK_DEFAULT,
|
||||
QLIK_MONTHS_BACK_MAX,
|
||||
QLIK_MONTHS_BACK_MIN,
|
||||
} from "@/lib/qlik-date-range";
|
||||
import { startCapture, stopCapture } from "@/lib/log-capture";
|
||||
|
||||
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
|
||||
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
|
||||
@@ -26,7 +24,10 @@ type QlikSyncStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
interface QlikSyncJob {
|
||||
jobId: string;
|
||||
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
|
||||
fournisseur: string;
|
||||
/** Code centrale (mode produit) — absent en mode fournisseur. */
|
||||
codeCentrale?: string;
|
||||
status: QlikSyncStatus;
|
||||
requested: number;
|
||||
fetched: number;
|
||||
@@ -48,6 +49,33 @@ interface QlikSyncJob {
|
||||
*/
|
||||
const jobs = new Map<string, QlikSyncJob>();
|
||||
|
||||
/**
|
||||
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
|
||||
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
|
||||
*/
|
||||
function jobKeyForProduct(codeCentrale: string): string {
|
||||
return `cc:${codeCentrale.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre maximum d'extractions produit simultanées.
|
||||
*
|
||||
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
|
||||
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
|
||||
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
|
||||
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
|
||||
*/
|
||||
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
|
||||
|
||||
/** Compte les extractions produit actuellement en cours. */
|
||||
function runningProductJobs(): number {
|
||||
let n = 0;
|
||||
for (const [key, job] of jobs) {
|
||||
if (key.startsWith("cc:") && job.status === "running") n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Construit un jobId court et lisible. */
|
||||
function newJobId(): string {
|
||||
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -58,6 +86,7 @@ function publicJob(job: QlikSyncJob) {
|
||||
return {
|
||||
jobId: job.jobId,
|
||||
fournisseur: job.fournisseur,
|
||||
codeCentrale: job.codeCentrale,
|
||||
status: job.status,
|
||||
requested: job.requested,
|
||||
fetched: job.fetched,
|
||||
@@ -72,11 +101,12 @@ function publicJob(job: QlikSyncJob) {
|
||||
};
|
||||
}
|
||||
|
||||
/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */
|
||||
function idleJob(fournisseur: string) {
|
||||
/** État "idle" sérialisé (pas de job connu pour cette cible). */
|
||||
function idleJob(fournisseur: string, codeCentrale?: string) {
|
||||
return {
|
||||
jobId: null,
|
||||
fournisseur,
|
||||
codeCentrale,
|
||||
status: "idle" as const,
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
@@ -105,6 +135,20 @@ async function requireAdmin(): Promise<NextResponse | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie qu'une session existe (sans exiger le rôle admin).
|
||||
*
|
||||
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
|
||||
* `/api/*`, le contrôle doit donc être fait ici explicitement.
|
||||
*/
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex de validation d'un code centrale "raisonnable".
|
||||
*
|
||||
@@ -172,6 +216,10 @@ function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
|
||||
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
|
||||
*/
|
||||
async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
// Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du
|
||||
// job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) —
|
||||
// c'est précisément la partie qu'un terminal tronque.
|
||||
startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`);
|
||||
try {
|
||||
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
|
||||
// Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects).
|
||||
@@ -189,18 +237,20 @@ async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via
|
||||
// QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective
|
||||
// pour audit.
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
// Contrat métier strict : toujours 12 mois complets glissants.
|
||||
// Une variable d'environnement ne doit pas pouvoir raccourcir la série.
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
job.periode = dateFilter.label;
|
||||
job.dateDebut = dateFilter.dateDebut;
|
||||
job.dateFin = dateFilter.dateFin;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`,
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||
);
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
||||
// Le code fournisseur est transmis en plus des codes : s'il désigne dans
|
||||
// Qlik le même périmètre (couverture vérifiée côté extracteur), une seule
|
||||
// sélection remplace les dizaines de milliers de `SelectValues` par code.
|
||||
// Les codes restent le repli et le contrôle de couverture.
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur);
|
||||
job.fetched = metrics.size;
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
@@ -209,34 +259,99 @@ async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
// Message explicite quand le serveur Qlik est saturé (OOM à l'ouverture de
|
||||
// l'app) : ce n'est PAS un bug CollectFlow, l'app Qlik ne se charge même pas.
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
|
||||
} else {
|
||||
job.error = msg;
|
||||
}
|
||||
job.finishedAt = new Date().toISOString();
|
||||
applyJobError(job, e);
|
||||
} finally {
|
||||
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/sync?fournisseur=XXX
|
||||
* Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est
|
||||
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
|
||||
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
|
||||
* l'app Qlik ne se charge même pas).
|
||||
*/
|
||||
function applyJobError(job: QlikSyncJob, e: unknown): void {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
|
||||
} else {
|
||||
job.error = msg;
|
||||
}
|
||||
job.finishedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extraction Qlik pour UN seul code centrale (fiche produit).
|
||||
*
|
||||
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
|
||||
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
|
||||
* exactement le même extracteur, donc le même détail mensuel.
|
||||
*/
|
||||
async function runProductJob(job: QlikSyncJob): Promise<void> {
|
||||
startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`);
|
||||
try {
|
||||
const code = job.codeCentrale!;
|
||||
job.requested = 1;
|
||||
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
job.periode = dateFilter.label;
|
||||
job.dateDebut = dateFilter.dateDebut;
|
||||
job.dateFin = dateFilter.dateFin;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||
);
|
||||
|
||||
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
|
||||
job.fetched = metrics.size;
|
||||
if (metrics.size === 0) {
|
||||
job.status = "success";
|
||||
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
job.upserted = count;
|
||||
job.status = "success";
|
||||
job.message = "Données réseau récupérées";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
|
||||
} catch (e) {
|
||||
applyJobError(job, e);
|
||||
} finally {
|
||||
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
|
||||
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
|
||||
* connu, renvoie un état "idle" (avec un message explicite). Permet au
|
||||
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
|
||||
// Mode produit : ouvert à tout utilisateur connecté.
|
||||
if (codeCentrale) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const job = jobs.get(jobKeyForProduct(codeCentrale));
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
// Mode fournisseur : admin uniquement (inchangé).
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
}
|
||||
const job = jobs.get(fournisseur);
|
||||
if (!job) {
|
||||
@@ -252,12 +367,59 @@ export async function GET(req: NextRequest) {
|
||||
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
|
||||
|
||||
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
|
||||
if (codeCentraleParam) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
|
||||
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
|
||||
const { accepted } = filterCentralCodes([codeCentraleParam]);
|
||||
if (accepted.length === 0) {
|
||||
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
|
||||
}
|
||||
const code = accepted[0];
|
||||
const key = jobKeyForProduct(code);
|
||||
|
||||
const existingProduct = jobs.get(key);
|
||||
if (existingProduct && existingProduct.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
|
||||
}
|
||||
|
||||
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
|
||||
return NextResponse.json(
|
||||
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const productJob: QlikSyncJob = {
|
||||
jobId: newJobId(),
|
||||
fournisseur: "",
|
||||
codeCentrale: code,
|
||||
status: "running",
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(key, productJob);
|
||||
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
|
||||
|
||||
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(productJob) });
|
||||
}
|
||||
|
||||
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = jobs.get(fournisseur);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
|
||||
*
|
||||
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
|
||||
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
|
||||
* référençons pas), c'est `/api/qlik/search` — bien plus lent, et volontairement
|
||||
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
|
||||
* séparé de cette API.
|
||||
*/
|
||||
export async function GET(
|
||||
|
||||
@@ -25,6 +25,16 @@
|
||||
/* Slate 900 with opacity */
|
||||
--border: #334155;
|
||||
/* Slate 700 */
|
||||
|
||||
/* ── Palette data-viz (3 séries) ──
|
||||
Slots 1-3 de la palette catégorielle de référence, pas-de-teinte propre au
|
||||
fond sombre. Validée sur ce fond : bande de clarté, plancher de chroma,
|
||||
séparation daltonienne (pire paire ΔE 9,4) et contraste ≥ 3:1 — toutes
|
||||
paires, pas seulement les adjacentes. Ne pas retoucher une valeur isolément :
|
||||
c'est l'ENSEMBLE qui passe les contrôles. */
|
||||
--viz-1: #3987e5;
|
||||
--viz-2: #d95926;
|
||||
--viz-3: #199e70;
|
||||
--border-strong: #475569;
|
||||
/* Slate 600 */
|
||||
|
||||
@@ -70,6 +80,13 @@
|
||||
--border: rgba(0, 0, 0, 0.07);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Mêmes teintes, pas choisis pour le fond clair (et non un simple éclaircissement
|
||||
des valeurs sombres). L'aqua passe sous 3:1 sur blanc : la « règle de relief »
|
||||
s'applique — étiquettes directes visibles + vue tableau, toutes deux présentes. */
|
||||
--viz-1: #2a78d6;
|
||||
--viz-2: #eb6834;
|
||||
--viz-3: #1baf7a;
|
||||
|
||||
--text-primary: #1d1d1f;
|
||||
/* Apple dark */
|
||||
--text-secondary: #515154;
|
||||
|
||||
Reference in new issue
Block a user