"use client"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck, Scale, RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info, type LucideIcon, } 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, NB_MAGASINS_RESEAU, type NetworkTrend, } from "@/features/grid/lib/network-trend"; import { formatMonthLabel, formatDate, 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 { Card, CardContent, CardHeader, Field } from "@/components/ui/card"; import { StatCard } from "@/components/ui/stat-card"; import { Badge, GammeBadge, StoreBadge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Segmented } from "@/components/ui/tabs"; import { EmptyState } from "@/components/ui/states"; import { Terme, Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/feedback"; import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format"; import { couleurMarge } from "@/lib/marge"; import { cn } from "@/lib/utils"; import { OpportunitesFamille } from "./opportunites"; // ─── Briques de présentation ───────────────────────────────────────────────── /** Bloc de la fiche : carte du thème, titre avec icône, actions à droite. */ function Bloc({ titre, icone: Icone, description, actions, children }: { titre: ReactNode; icone: LucideIcon; description?: ReactNode; actions?: ReactNode; children: ReactNode; }) { return ( {titre} } description={description} actions={actions} /> {children} ); } /** Libellé avec une explication libre au survol (comme ``, hors glossaire). */ function Aide({ texte, children }: { texte: ReactNode; children: ReactNode }) { return ( {children} ); } /** Sous-titre d'une partie de bloc. */ function SousTitre({ titre, description }: { titre: ReactNode; description?: ReactNode }) { return (

{titre}

{description &&

{description}

}
); } /** Classes des petits tableaux mensuels (en-tête et première colonne figée). */ const TH_COL = "whitespace-nowrap px-2 py-2 text-right text-[13px] font-semibold text-[var(--text-secondary)]"; const TH_COIN = "sticky left-0 z-[1] whitespace-nowrap bg-[var(--bg-elevated)] px-3 py-2 text-left text-[13px] font-semibold text-[var(--text-secondary)]"; const TH_LIGNE = "sticky left-0 z-[1] whitespace-nowrap bg-[var(--bg-surface)] px-3 py-2 text-left font-medium text-[var(--text-secondary)]"; const TD = "whitespace-nowrap px-2 py-2 text-right tabular-nums text-[var(--text-primary)]"; /** Couleur de la tendance, prise dans le thème (lisible en clair comme en sombre). */ const COULEUR_TENDANCE: Record = { up: "var(--accent-success)", down: "var(--accent-error)", flat: "var(--text-secondary)", }; /** Couleur du verdict de l'indice de performance, prise dans le thème. */ const COULEUR_VERDICT: Record = { "Forte surperformance": "var(--accent-success)", Surperformance: "var(--accent-success)", "Dans la moyenne réseau": "var(--text-primary)", "Sous-performance": "var(--accent-warning)", "Forte sous-performance": "var(--accent-error)", }; /** Période Qlik `"2025-09_2026-08"` → « de septembre 2025 à août 2026 ». */ function libellePeriode(periode: string | null | undefined): string { const m = /^(\d{4})-(\d{2})_(\d{4})-(\d{2})$/.exec(periode ?? ""); if (!m) return periode ? `période ${periode}` : "12 derniers mois"; const mois = (annee: string, num: string) => new Date(Number(annee), Number(num) - 1, 1).toLocaleDateString("fr-FR", { month: "long", year: "numeric" }); return `de ${mois(m[1], m[2])} à ${mois(m[3], m[4])}`; } // ─── 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é vendue", get: (v) => v.qte, fmt: fmtEntier }, { label: "Magasins vendeurs", get: (v) => v.nbMag, fmt: fmtEntier }, { label: "Quantité par magasin", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: fmtDecimal1 }, { label: "Chiffre d'affaires (€)", get: (v) => v.ca, fmt: fmtEntier }, { label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: fmtEur2 }, { label: "Taux de marge", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDecimal1(n)} %` }, ]; // ─── Mise à jour des données du réseau 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(), }); // Un échec n'est notifié que s'il suit un clic ici : la reprise d'un ancien // job au chargement de la fiche ne doit pas afficher de message d'erreur. const demandeRef = useRef(false); useEffect(() => { if (status === "error" && demandeRef.current) { demandeRef.current = false; toast.erreur(`La mise à jour des données du réseau a échoué. ${message}`.trim()); } else if (status === "success") { demandeRef.current = false; } }, [status, message]); const lancer = () => { demandeRef.current = true; void start(); }; return (
{status === "running" && ( Mise à jour en cours, quelques secondes… )} {status === "success" && ( Données du réseau mises à jour )} {status === "error" && ( La mise à jour a échoué )}
); } // ─── 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(CODE_TOUS_MAGASINS); const sitesDisponibles = useMemo(() => { const sites = new Set([...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 tous = magasin === CODE_TOUS_MAGASINS; const serie = tous ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {}); const totauxAffiches = tous ? 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 = {}; 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 : notre catalogue d'abord, sinon celui du réseau. */ 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); const lienGrille = (codefou: string) => `/grid?fournisseur=${encodeURIComponent(codefou)}&magasin=TOTAL`; return (
{/* En-tête de la fiche */}
{backQuery ? "Retour aux résultats" : "Retour à la recherche"}

{titre}

{detail && ( Code article {detail.codein} )} {codeCentrale && ( Code centrale {codeCentrale} )} {fournisseurAffiche && Fournisseur : {fournisseurAffiche}}

{fournisseurPrincipal?.codefou && ( )}
{!detail && (
Ce produit est vendu dans le réseau mais n'est pas dans notre catalogue.
Seules les données du réseau sont disponibles : pas de stock, de ventes ni de gamme chez nous.
)} {/* ─── Réseau — la donnée qui motive la recherche ─────────────── */} Ventes dans le réseau · 12 derniers mois} icone={Globe} actions={codeCentrale ? : undefined} > {!codeCentrale ? (

Ce produit n'a pas de code centrale : il n'est pas suivi par la centrale d'achat, il n'y a donc pas de données du réseau.

) : !reseau ? ( ) : ( <>
} value={fmtEntier(reseau.nbMagasinsReseau)} hint={`sur ${NB_MAGASINS_RESEAU}, soit ${fmtEntier((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100)} % du réseau`} /> } value={fmtEur0(reseau.caParMagasinReseau)} /> Taux de marge} value={ margeReseau != null ? {fmtDecimal1(margeReseau)} % : "—" } /> Tendance } value={ trend.pct != null ? ( {trend.pct >= 0 ? "+" : ""}{fmtEntier(trend.pct * 100)} % ) : "—" } hint={trendLabel(trend.pct, trend.nouveau)} />
{libelleReseau || "—"} {fournisseurReseau || "—"}

Ventes {libellePeriode(reseau.periode)} · données du réseau mises à jour le {formatDate(reseau.fetchedAt)} · {" "}le mois en cours n'est pas compté (incomplet).

{trend.hasData && (
)} {/* Détail mensuel complet (CA, marge, magasins) si disponible */} {reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && (
{months.map((m) => ( ))} {LIGNES_MENSUELLES_RESEAU.map((row) => ( {months.map((m) => { const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)]; const v = cell ? row.get(cell) : undefined; return ( ); })} ))}
Mesure{formatMonthLabel(m)}
{row.label} {v != null ? row.fmt(v) : —}

Pour certains mois, les données du réseau ne donnent que la quantité : le chiffre d'affaires et la marge y sont alors absents.{" "} Pourquoi ?

)} )}
{/* ─── Fournisseur ─────────────────────────────────────────────── */} {fournisseurs.length === 0 ? (

{fournisseurReseau ? <>Aucun fournisseur dans notre catalogue. Dans les données du réseau, le fournisseur est {fournisseurReseau}. : "Aucun fournisseur connu pour ce produit."}

) : (
    {fournisseurs.map((f) => (
  • {f.nomfou} {f.principal && Fournisseur principal}
    Code fournisseur {f.codefou}
    Référence : {f.reference || "—"} : {f.pcb != null ? fmtEntier(f.pcb) : "—"} Réviser l'assortiment
  • ))}
{fournisseurReseau && (

Fournisseur indiqué par les données du réseau : {fournisseurReseau}

)}
)}
{/* ─── Identité catalogue (produits référencés uniquement) ──────── */} {detail && (
}>{detail.codein} }>{detail.code_centrale || "—"} {fournisseurAffiche ?? "—"} }>{detail.gtin || "—"} {detail.reference || "—"} }>{detail.pcb != null ? fmtEntier(detail.pcb) : "—"} }> {detail.libelle1nom ?? "—"} {detail.libelle2 ?? "—"} {detail.libelle3 ?? "—"} {detail.pa != null ? fmtEur2(detail.pa) : "—"} Prix de vente minimum (centrale) } > {detail.pv_central != null ? fmtEur2(detail.pv_central) : "—"} Quantité en commande} > {commandesEnCours > 0 ? fmtEntier(commandesEnCours) : "—"}
{gammes.length > 1 && (
    {gammes.slice(0, 10).map((g, i) => (
  • {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
  • ))}
)}
)} {/* ─── Stock ───────────────────────────────────────────────────── */} {detail && ( {stock.length === 0 ? (

Aucun stock enregistré pour ce produit dans nos magasins.

) : (
{stock.map((s) => (
Stock
{fmtEntier(s.qte)}
{s.qte < 0 && }
{fmtEntier(s.stockdispo)} {fmtEur0(s.valstock)} }>{s.prmp > 0 ? fmtEur2(s.prmp) : "—"} {formatDate(s.dernierevente)} {formatDate(s.dernierereception)}
))}
)}
)} {/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */} {detail && ( ({ value: code, label: nomMagasin(code, { court: true }) })), ]} value={magasin} onChange={setMagasin} /> } >
} value={fmtEur0(totauxAffiches.marge)} /> 0 ? {fmtDecimal1(totauxAffiches.tauxMarge)} % : "—" } />
{months.map((m) => ( ))} {months.map((m) => ( ))} {months.map((m) => ( ))} {months.map((m) => ( ))}
Mois{formatMonthLabel(m)}
Quantité vendue
Chiffre d'affaires TTC (€) {serie[m]?.ca ? fmtEntier(serie[m].ca) : —}
Stock en fin de mois {serie[m]?.stockFinMois ? fmtEntier(serie[m].stockFinMois) : —}
serie[m]?.qte ?? 0)} />
)} {/* ─── Comparatif local vs réseau ──────────────────────────────── */} {detail && comparatif.hasReseau && (
Indice de performance } value={ comparatif.indiceQte != null ? {fmtEntier(comparatif.indiceQte)} % : "—" } hint={verdict.label} /> 1 ? "s" : ""} · 12 mois`} /> Présence dans le réseau} value={comparatif.tauxPresence != null ? `${fmtEntier(comparatif.tauxPresence * 100)} %` : "—"} hint={`${fmtEntier(comparatif.nbMagasinsReseau)} magasins vendeurs sur ${NB_MAGASINS_RESEAU}`} />
Chiffre d'affaires par magasin} nous={fmtEur0(comparatif.caLocalParMagasin)} reseau={comparatif.caReseauParMagasin != null ? fmtEur0(comparatif.caReseauParMagasin) : "—"} /> Taux de marge} nous={{fmtDecimal1(comparatif.tauxMargeLocal)} %} reseau={ comparatif.tauxMargeReseau != null ? {fmtDecimal1(comparatif.tauxMargeReseau)} % : "—" } />

L'indice compare les quantités vendues par magasin, seule base commune fiable. Notre chiffre d'affaires est TTC (caisses) ; la base de celui du réseau n'est pas connue : montants et marges sont donc donnés à titre indicatif.

{comparatif.hasMonthly && (
ffMonthToQlik(m.mois))} series={[ { name: "Nous, par magasin", values: comparatif.monthly.map((m) => m.local), color: "var(--viz-1)" }, { name: "Réseau, par magasin", values: comparatif.monthly.map((m) => m.reseau), color: "var(--viz-2)" }, ]} />
)}
)} {/* ─── Opportunités de la même famille ─────────────────────────── */} {detail?.nom_no_id != null && ( )} {/* Sparkline discrète en pied de fiche, cohérente avec la Grille */} {trend.hasData && (
Tendance dans le réseau :
)}
); } /** Deux valeurs côte à côte, nous et le réseau, à titre indicatif. */ function Comparaison({ titre, nous, reseau }: { titre: ReactNode; nous: ReactNode; reseau: ReactNode }) { return (
{titre} · indicatif
Nous : {nous} Réseau : {reseau}
); }