"use client"; import { useEffect, useMemo, useState, type ReactNode } 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, type NetworkTrend } from "@/features/grid/lib/network-trend"; import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { DataTable, type DataColumn } from "@/components/ui/data-table"; import { EmptyState, ErrorState } from "@/components/ui/states"; import { Terme } from "@/components/ui/tooltip"; import { GLOSSAIRE } from "@/lib/glossaire"; import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format"; import { couleurMarge } from "@/lib/marge"; /** 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> { const texte = await res.text(); let data: Record | null = null; try { data = texte ? (JSON.parse(texte) as Record) : 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; } /** * Les messages du serveur nomment l'outil source (Qlik) : l'utilisateur lit * « données du réseau », le message d'origine reste dans le détail technique. */ const mentionneOutil = (texte: string | null | undefined) => /qlik/i.test(texte ?? ""); function libelleEtape(etape: string | null): string { if (!etape || mentionneOutil(etape)) return "Recherche dans les données du réseau…"; return etape; } /** Bandeau d'avertissement : explication en clair, détail technique replié. */ function Avertissement({ children, detail }: { children: ReactNode; detail?: string | null }) { return (
{children} {detail && (
Détail technique

{detail}

)}
); } /** * 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 | 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 ( Saisissez le nom d'un produit (ex. « poêle 28 ») ou son code centrale (ex. « 10000167303 »). Vous verrez ses ventes dans les {NB_MAGASINS_RESEAU} magasins du réseau, puis chez nous s'il est dans notre catalogue. } /> ); } if (loading) { return (

{libelleEtape(etape)}

Recherche des produits puis de leurs ventes sur les 12 derniers mois. Cela peut prendre plus d'une minute : vous pouvez laisser la page ouverte.

); } if (error) { return ( Réessayer } /> ); } if (!data) return null; const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data; return (

{source === "qlik" ? : } {fmtEntier(rows.length)}{" "} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""} {source === "qlik" ? " dans le réseau" : " dans notre catalogue"} {tronque && " (liste limitée : précisez la recherche pour tout voir)"} · recherche en {fmtDecimal1(dureeMs / 1000)} s

{source === "db" && ( Les données du réseau n'ont pas pu être interrogées : seuls les produits de notre catalogue sont affichés, avec les chiffres du réseau de la dernière mise à jour. )} {source === "qlik" && qlikError && ( {rows.length === 0 ? "La recherche n'a pas pu être appliquée aux données du réseau. Essayez un terme plus précis (deux mots, ou le code centrale)." : "Produits trouvés, mais leurs ventes dans le réseau n'ont pas pu être chargées."} )} {rows.length === 0 ? ( Aucun produit ne correspond à « {query} »} description="Vérifiez l'orthographe, essayez un mot plus court ou le code centrale du produit." /> ) : ( <>

Chiffres des magasins du réseau sur les 12 derniers mois (mois en cours exclu). Cliquez sur un produit pour ouvrir sa fiche.

)} {locauxHorsReseau.length > 0 && (
{fmtEntier(locauxHorsReseau.length)} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue sans ventes connues dans le réseau (pas de code centrale, ou pas vendu par le réseau)
    {locauxHorsReseau.slice(0, 30).map((l) => (
  • {l.libelle1 || l.codein} {" · "}{l.fournisseur}{" · "}code article {l.codein}
  • ))}
)}
); } // ─── Tableau des résultats ────────────────────────────────────────────────── type Ligne = ProduitRechercheRow & { trend: NetworkTrend }; const rechercherDans = (r: Ligne) => [r.libelle, r.fournisseur, r.codeCentrale, r.codein, r.nomenclature]; function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) { // Tendance calculée une fois par ligne (et non à chaque tri ou rendu). const lignes = useMemo( () => rows.map((r) => ({ ...r, trend: computeNetworkTrend(r.qteByMonth, r.nbMagByMonth) })), [rows], ); const colonnes = useMemo[]>(() => [ { id: "produit", header: "Produit", sortValue: (r) => r.libelle, className: "max-w-[320px]", cell: (r) => { const href = r.codein ? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}` : `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`; return ( <> {r.libelle || "—"} {r.nomenclature && (
{r.nomenclature}
)} ); }, }, { id: "fournisseur", header: "Fournisseur", sortValue: (r) => r.fournisseur, cell: (r) => {r.fournisseur || "—"}, }, { id: "codeCentrale", header: "Code centrale", hint: GLOSSAIRE.codeCentrale.definition, cell: (r) => {r.codeCentrale}, }, { id: "magasins", header: "Magasins vendeurs", hint: GLOSSAIRE.presenceReseau.definition, align: "right", sortValue: (r) => r.nbMagasinsReseau, cell: (r) => ( <> {fmtEntier(r.nbMagasinsReseau)} ({fmtEntier(r.tauxPresence * 100)} %) ), }, { id: "qteReseau", header: "Quantité vendue", hint: "Quantité vendue par l'ensemble des magasins du réseau sur les 12 derniers mois.", align: "right", sortValue: (r) => r.qteReseau, cell: (r) => {fmtEntier(r.qteReseau)}, }, { id: "qteParMagasin", header: "Quantité par magasin", hint: "Quantité moyenne vendue sur 12 mois par un magasin du réseau qui a ce produit.", align: "right", sortValue: (r) => r.qteParMagasinReseau, cell: (r) => fmtDecimal1(r.qteParMagasinReseau), }, { id: "prixMoyen", header: "Prix moyen", hint: "Prix de vente moyen constaté dans le réseau (chiffre d'affaires divisé par la quantité).", align: "right", sortValue: (r) => r.prixMoyenReseau, cell: (r) => (r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"), }, { id: "caParMagasin", header: "CA par magasin", hint: GLOSSAIRE.caParMagasin.definition, align: "right", sortValue: (r) => (r.caParMagasinReseau > 0 ? r.caParMagasinReseau : null), cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur2(r.caParMagasinReseau) : "—"), }, { id: "marge", header: "Taux de marge", hint: GLOSSAIRE.marge.definition, align: "right", sortValue: (r) => r.margePctReseau, cell: (r) => r.margePctReseau != null ? ( {fmtDecimal1(r.margePctReseau)} % ) : "—", }, { id: "tendance", header: "Tendance", hint: "Ventes par magasin des 4 derniers mois comparées aux 4 premiers mois de la période de 12 mois.", align: "center", sortValue: (r) => (r.trend.hasData ? r.trend.pct : null), cell: (r) => (r.trend.hasData ? : —), }, { id: "nous", header: "Chez nous", hint: "Notre stock (tous nos magasins) si le produit est dans notre catalogue.", align: "right", sortValue: (r) => (r.codein ? (r.stockLocal ?? 0) : null), cell: (r) => r.codein ? ( Stock {fmtEntier(r.stockLocal ?? 0)} ) : ( Non référencé ), }, ], [query]); return ( r.codeCentrale} searchIn={rechercherDans} searchPlaceholder="Filtrer les résultats…" pageSize={50} unite={lignes.length > 1 ? "produits" : "produit"} emptyTitle="Aucun produit" /> ); }