mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 05:28:11 +02:00
feat(produits): recherche Qlik d'abord + fiche réseau complète, tendance 12 mois glissants
Recherche produit - La page /produits interroge désormais Qlik Sense EN PREMIER (src/lib/qlik-search.ts) : recherche globale Engine sur les champs article (code + libellé détectés dynamiquement via FieldList), sélection des valeurs trouvées, puis liste des Article Code correspondants. - Les métriques réseau des articles trouvés sont extraites sur 12 mois glissants et mises en cache (qlik_network_metrics), puis rapprochées du catalogue FF Nancy par code centrale (pgGetProduitsByCodeCentrale). - Repli propre sur la recherche catalogue local si Qlik est injoignable, signalé dans l'UI. Résultats mis en cache mémoire 10 min, requête dédupliquée. - Nouvelle route GET /api/produits/search (runtime nodejs, 300 s) ; la liste est chargée côté client avec état de chargement, l'extraction Qlik prenant plusieurs secondes. Informations affichées - Résultats : magasins vendeurs (+ % du réseau), quantité réseau, quantité par magasin, prix moyen réseau, CA par magasin, marge %, sparkline de tendance, fournisseur, et présence ou non au catalogue Nancy. - Fiche : carte « Performance réseau · 12 mois glissants » en tête (8 indicateurs + détail mensuel qté / magasins / qté par magasin / CA / prix moyen / marge %), puis carte « Fournisseur » dédiée, puis identité, stock, nos ventes 12 mois. - La fiche s'ouvre aussi par code centrale (?cc=) pour les produits que le réseau travaille et que Nancy ne référence pas — le libellé et le fournisseur Qlik sont persistés dans le cache réseau pour ce cas. Tendance réseau - computeNetworkTrend reconstruit sa fenêtre depuis la date du jour : 12 mois complets, mois en cours EXCLU (il est partiel et écrasait la pente). Les mois absents du cache valent 0 ; ceux antérieurs à la première extraction réelle sont écartés au lieu d'être inventés à 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
16 files changed
+1550
-329
No files matched your search
@@ -4,15 +4,15 @@ import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers,
|
||||
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid,
|
||||
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, 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 } from "@/features/produits/lib/compare-reseau";
|
||||
import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitFiche } from "@/features/produits/types";
|
||||
import { OpportunitesFamille } from "./opportunites";
|
||||
|
||||
@@ -20,6 +20,8 @@ import { OpportunitesFamille } from "./opportunites";
|
||||
|
||||
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);
|
||||
|
||||
@@ -72,6 +74,29 @@ function Stat({ label, value, hint, color }: { label: string; value: string; hin
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 }) {
|
||||
@@ -96,13 +121,13 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title="Extraire les données réseau Qlik pour ce seul produit"
|
||||
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…" : "Charger les données réseau"}
|
||||
{status === "running" ? "Extraction…" : "Actualiser depuis Qlik"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -111,7 +136,7 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
|
||||
// ─── Fiche ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
|
||||
const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
|
||||
const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
|
||||
const [magasin, setMagasin] = useState<string>("TOTAL");
|
||||
|
||||
const sitesDisponibles = useMemo(() => {
|
||||
@@ -128,6 +153,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
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">
|
||||
@@ -143,12 +176,12 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
Retour aux résultats
|
||||
</Link>
|
||||
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
|
||||
{detail.libelle1 || "Sans libellé"}
|
||||
{titre}
|
||||
</h2>
|
||||
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
<span className="font-mono">{detail.codein}</span>
|
||||
{detail.code_centrale && <> · centrale <span className="font-mono">{detail.code_centrale}</span></>}
|
||||
{fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}</>}
|
||||
{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 && (
|
||||
@@ -163,197 +196,28 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Identité */}
|
||||
<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="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 ? fmtEur(detail.pa) : "—"} />
|
||||
<Field label="PV central" value={detail.pv_central != null ? fmtEur(detail.pv_central) : "—"} />
|
||||
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{fournisseurs.length > 0 && (
|
||||
<div className="mt-4 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)" }}>
|
||||
Fournisseurs référencés
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{fournisseurs.map((f) => (
|
||||
<span
|
||||
key={f.codefou}
|
||||
className="rounded-lg px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
|
||||
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
|
||||
color: f.principal ? "var(--accent)" : "var(--text-secondary)",
|
||||
}}
|
||||
title={f.reference ? `Réf. ${f.reference}` : undefined}
|
||||
>
|
||||
{f.nomfou}
|
||||
{f.principal && <span className="ml-1 font-semibold">· principal</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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 */}
|
||||
<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 ? fmtEur(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>
|
||||
|
||||
{/* Ventes 12 mois glissants */}
|
||||
{/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */}
|
||||
<Card
|
||||
title="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>
|
||||
|
||||
{/* Réseau Qlik */}
|
||||
<Card
|
||||
title="Réseau Qlik"
|
||||
title="Performance réseau · 12 mois glissants"
|
||||
icon={Globe}
|
||||
action={detail.code_centrale ? <SyncProduitButton codeCentrale={detail.code_centrale} /> : undefined}
|
||||
action={codeCentrale ? <SyncProduitButton codeCentrale={codeCentrale} /> : undefined}
|
||||
>
|
||||
{!detail.code_centrale ? (
|
||||
{!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>
|
||||
@@ -369,18 +233,29 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
|
||||
<Stat label="Qté réseau" value={fmtQte(reseau.qteReseau)} />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat
|
||||
label="Magasins"
|
||||
value={`${reseau.nbMagasinsReseau}`}
|
||||
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} %`}
|
||||
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={comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}
|
||||
value={margeReseau != null ? `${fmtDec(margeReseau)} %` : "—"}
|
||||
color={margeReseau != null && margeReseau >= 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined}
|
||||
/>
|
||||
<Stat
|
||||
label="Tendance 12 m"
|
||||
@@ -390,8 +265,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
/>
|
||||
</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)}
|
||||
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} ·
|
||||
{" "}mois en cours exclu (incomplet).
|
||||
</p>
|
||||
|
||||
{trend.hasData && (
|
||||
@@ -421,18 +302,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{([
|
||||
{ key: "qte", label: "Quantité", fmt: (v: number) => fmtQte(v) },
|
||||
{ key: "ca", label: "CA", fmt: (v: number) => Math.round(v).toLocaleString("fr-FR") },
|
||||
{ key: "nbMag", label: "Magasins", fmt: (v: number) => fmtQte(v) },
|
||||
{ key: "margePct", label: "Marge %", fmt: (v: number) => `${fmtDec(Math.abs(v) <= 1 ? v * 100 : v)} %` },
|
||||
] as const).map((row) => (
|
||||
<tr key={row.key}>
|
||||
{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 v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key];
|
||||
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) : "—"}
|
||||
@@ -452,8 +329,222 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Comparatif local vs réseau */}
|
||||
{comparatif.hasReseau && (
|
||||
{/* ─── 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
|
||||
@@ -523,8 +614,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Opportunités de la même famille */}
|
||||
{detail.nom_no_id != null && (
|
||||
{/* ─── 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}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { pgSearchProduits } from "@/lib/pg-ff-client";
|
||||
import { getProduitFiche } from "@/features/produits/api/get-produit-fiche";
|
||||
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
|
||||
import { ProduitSearchBar } from "./search-bar";
|
||||
import { ProduitResults } from "./results";
|
||||
import { ProduitFicheView } from "./fiche";
|
||||
@@ -7,22 +6,28 @@ import { ProduitFicheView } from "./fiche";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Recherche produit : par libellé ou par code centrale.
|
||||
* Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite.
|
||||
*
|
||||
* - `?q=…` → liste de résultats
|
||||
* - `?codein=…` → fiche complète (URL partageable)
|
||||
* - `?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 }>;
|
||||
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) : null;
|
||||
// On ne relance pas la recherche quand une fiche est ouverte : la liste
|
||||
// reste accessible via le lien retour, qui conserve `q`.
|
||||
const results = !codein && q.length >= 3 ? await pgSearchProduits(q) : [];
|
||||
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">
|
||||
@@ -31,27 +36,34 @@ export default async function ProduitsPage(props: {
|
||||
Recherche produit
|
||||
</h1>
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik.
|
||||
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} />
|
||||
|
||||
{codein && !fiche && (
|
||||
{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 le code article <span className="font-mono">{codein}</span>.
|
||||
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} />}
|
||||
|
||||
{!codein && (
|
||||
<ProduitResults rows={results} query={q} />
|
||||
)}
|
||||
{!ficheDemandee && <ProduitResults query={q} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PackageSearch, Wifi, WifiOff } from "lucide-react";
|
||||
import type { PgProduitSearchRow } from "@/lib/pg-ff-client";
|
||||
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";
|
||||
|
||||
function fmtQte(v: number): string {
|
||||
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).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);
|
||||
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);
|
||||
|
||||
/**
|
||||
* Résultats de recherche produit.
|
||||
*
|
||||
* La recherche interroge **Qlik d'abord** (`/api/produits/search`) : plusieurs
|
||||
* secondes, parfois plus. On la lance donc côté client avec un état de
|
||||
* chargement explicite, plutôt que de bloquer le rendu serveur de la page.
|
||||
*/
|
||||
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;
|
||||
} | 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 loading = !tropCourt && resultat?.cle !== cle;
|
||||
|
||||
useEffect(() => {
|
||||
if (tropCourt) return;
|
||||
let annule = false;
|
||||
const url = `/api/produits/search?q=${encodeURIComponent(query)}${force ? "&force=1" : ""}`;
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
return json as ProduitRechercheResultat;
|
||||
})
|
||||
.then((data) => { if (!annule) setResultat({ cle, data, error: null }); })
|
||||
.catch((e) => {
|
||||
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e) });
|
||||
});
|
||||
return () => { annule = true; };
|
||||
}, [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;
|
||||
|
||||
/** Liste des résultats de recherche. */
|
||||
export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; query: string }) {
|
||||
if (!query) {
|
||||
return (
|
||||
<div
|
||||
@@ -16,111 +70,242 @@ export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; qu
|
||||
>
|
||||
<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
|
||||
Recherchez un produit dans le réseau
|
||||
</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
<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 (rows.length === 0) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
Aucun produit ne correspond à « {query} ».
|
||||
<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)" }}>
|
||||
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 jusqu'à une minute.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""}
|
||||
{rows.length >= 50 && " (50 premiers — affinez la recherche)"}
|
||||
</p>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
<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 article</Th>
|
||||
<Th>Code centrale</Th>
|
||||
<Th>Famille</Th>
|
||||
<Th align="right">Stock</Th>
|
||||
<Th align="right">Qté réseau</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.codein}
|
||||
className="transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
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]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
Articles trouvés, mais l'extraction des mesures réseau a échoué : {qlikError}
|
||||
</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(r.codein)}&q=${encodeURIComponent(query)}`}
|
||||
className="font-medium hover:underline"
|
||||
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{r.libelle1 || "—"}
|
||||
{l.libelle1 || l.codein}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.fournisseur || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.codein}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.code_centrale || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.nomenclature || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtQte(r.stock_total)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{r.has_reseau ? (
|
||||
<span className="inline-flex items-center gap-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||
<Wifi className="w-3 h-3" style={{ color: "var(--accent-success)" }} />
|
||||
{fmtQte(r.qte_reseau)}
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
/ {r.nb_magasins_reseau} mag.
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title="Données réseau Qlik non synchronisées pour ce produit"
|
||||
>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
non synchro
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) {
|
||||
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);
|
||||
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
|
||||
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
|
||||
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
|
||||
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Search, X } from "lucide-react";
|
||||
/**
|
||||
* Champ de recherche produit.
|
||||
*
|
||||
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : la requête
|
||||
* fait un ILIKE sans index sur `articles.libelle1`, on ne veut pas la déclencher
|
||||
* à chaque caractère. Minimum 3 caractères, aligné sur `pgSearchProduits`.
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { rechercherProduits } from "@/features/produits/api/search-produits";
|
||||
|
||||
// L'extraction Qlik (Playwright + Engine) prend plusieurs secondes, parfois
|
||||
// plus d'une minute quand le serveur Qlik est chargé.
|
||||
export const maxDuration = 300;
|
||||
// Playwright/Chromium exige le runtime Node.js (pas edge).
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/produits/search?q=<terme>[&force=1]
|
||||
*
|
||||
* Recherche produit **Qlik d'abord** : les articles du réseau qui correspondent
|
||||
* au terme, leurs métriques réseau sur 12 mois glissants, puis le rapprochement
|
||||
* avec le catalogue FF Nancy.
|
||||
*
|
||||
* `force=1` ignore le cache mémoire de 10 min (bouton « relancer »).
|
||||
*
|
||||
* 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 q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||
if (q.length < 3) {
|
||||
return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
}
|
||||
const force = req.nextUrl.searchParams.get("force") === "1";
|
||||
|
||||
try {
|
||||
const result = await rechercherProduits(q, { force });
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
const message = (e as Error).message ?? String(e);
|
||||
console.error("[api/produits/search]", message.slice(0, 300));
|
||||
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user