diff --git a/scripts/db-init.js b/scripts/db-init.js index 9c2cecc..3fa350d 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -113,7 +113,8 @@ async function main() { ALTER TABLE "qlik_network_metrics" ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2), ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4), - ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb; + ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb, + ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb; ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau"; ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau"; `); diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx new file mode 100644 index 0000000..3a47948 --- /dev/null +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -0,0 +1,546 @@ +"use client"; + +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, +} 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 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 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 ( +
+
+

+ + {title} +

+ {action} +
+
{children}
+
+ ); +} + +function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { + return ( +
+
+ {label} +
+
+ {value ?? "—"} +
+
+ ); +} + +function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+ {hint &&
{hint}
} +
+ ); +} + +// ─── 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 ( +
+ {message && ( + + {message} + + )} + +
+ ); +} + +// ─── Fiche ─────────────────────────────────────────────────────────────────── + +export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) { + const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche; + const [magasin, setMagasin] = useState("TOTAL"); + + 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 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 }); + + const trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]); + const comparatif = useMemo(() => computeComparatif(fiche), [fiche]); + const verdict = indiceVerdict(comparatif.indiceQte); + + const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0]; + + return ( +
+ {/* En-tête */} +
+
+ + + Retour aux résultats + +

+ {detail.libelle1 || "Sans libellé"} +

+

+ {detail.codein} + {detail.code_centrale && <> · centrale {detail.code_centrale}} + {fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}} +

+
+ {fournisseurPrincipal?.codefou && ( + + + Voir dans la Grille + + )} +
+ + {/* Identité */} + +
+ + + + + + + + + + + + 0 ? fmtQte(commandesEnCours) : "—"} /> +
+ + {fournisseurs.length > 0 && ( +
+
+ Fournisseurs référencés +
+
+ {fournisseurs.map((f) => ( + + {f.nomfou} + {f.principal && · principal} + + ))} +
+
+ )} + + {gammes.length > 1 && ( +
+
+ Historique de gamme +
+
+ {gammes.slice(0, 10).map((g, i) => ( + + {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`} + {g.gamme_code} + + ))} +
+
+ )} +
+ + {/* Stock */} + + {stock.length === 0 ? ( +

Aucune ligne de stock pour ce produit.

+ ) : ( +
+ {stock.map((s) => ( +
+
+ + {SITE_LABELS[s.site]?.nom ?? s.site} + + + {fmtQte(s.qte)} + +
+
+
Dispo : {fmtQte(s.stockdispo)}
+
Valeur : {fmtEur(s.valstock)}
+
PRMP : {s.prmp > 0 ? fmtEur(s.prmp) : "—"}
+
Dern. vente : {formatDate(s.dernierevente)}
+
Dern. réception : {formatDate(s.dernierereception)}
+
+
+ ))} +
+ )} +
+ + {/* Ventes 12 mois glissants */} + + {["TOTAL", ...sitesDisponibles].map((code) => ( + + ))} +
+ } + > +
+ + + + = 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined} + /> +
+ +
+ + + + + {months.map((m) => ( + + ))} + + + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + +
+ Mois + + {formatMonthLabel(m)} +
+ Ventes + + +
+ CA (TTC) + + {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} +
+ Stock fin de mois + + {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} +
+
+ + serie[m]?.qte ?? 0)} + color="#0ea5e9" + /> + + + {/* Réseau Qlik */} + : undefined} + > + {!detail.code_centrale ? ( +

+ Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik. +

+ ) : !reseau ? ( +
+ +
+ Aucune donnée réseau en cache pour ce produit. +
+ Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes. +
+
+
+ ) : ( + <> +
+ + + + + + = 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"} + hint={trendLabel(trend.pct)} + color={TREND_COLOR[trend.direction]} + /> +
+ +

+ Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} +

+ + {trend.hasData && ( +
+
+ Quantités vendues réseau, mois par mois +
+ +
+ )} + + {/* Détail mensuel complet (CA, marge, magasins) si disponible */} + {reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && ( +
+
+ Détail mensuel réseau +
+ + + + + {months.map((m) => ( + + ))} + + + + {([ + { 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) => ( + + + {months.map((m) => { + const v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key]; + return ( + + ); + })} + + ))} + +
Mesure + {formatMonthLabel(m)} +
+ {row.label} + + {v != null ? row.fmt(v) : "—"} +
+

+ Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité. +

+
+ )} + + )} +
+ + {/* Comparatif local vs réseau */} + {comparatif.hasReseau && ( + +
+ + 1 ? "s" : ""} · 12 mois`} + /> + + +
+ +
+
+
+ CA par magasin · indicatif +
+
+ Nous : {fmtEur(comparatif.caLocalParMagasin)} + Réseau : {comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"} +
+
+
+
+ Taux de marge · indicatif +
+
+ Nous : {fmtDec(comparatif.tauxMargeLocal)} % + Réseau : {comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"} +
+
+
+ +

+ L'indice compare les quantités 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. +

+ + {comparatif.hasMonthly && ( +
+
+ Quantité par magasin, mois par mois +
+ 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" }, + ]} + /> +
+ )} +
+ )} + + {/* 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 réseau : + +
+ )} + + ); +} diff --git a/src/app/(dashboard)/produits/loading.tsx b/src/app/(dashboard)/produits/loading.tsx new file mode 100644 index 0000000..64ecef1 --- /dev/null +++ b/src/app/(dashboard)/produits/loading.tsx @@ -0,0 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + +export default function ProduitsLoading() { + return ; +} diff --git a/src/app/(dashboard)/produits/opportunites.tsx b/src/app/(dashboard)/produits/opportunites.tsx new file mode 100644 index 0000000..f1ef7e3 --- /dev/null +++ b/src/app/(dashboard)/produits/opportunites.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+

+ 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 ? +

+ + {error &&

{error}

} +
+ ); + } + + // 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 ( +

+ 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. +

+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {others.map((r) => { + const opportunite = r.ecart_par_magasin > 0; + return ( + + + + + + + + + ); + })} + +
ProduitFournisseurRéseau / mag.Nous / mag.ÉcartMagasins
+ + {r.libelle1 || r.codein} + + {r.fournisseur} + {fmtDec(r.qte_reseau_par_magasin)} + + {fmtDec(r.qte_locale_par_magasin)} + + {r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)} + + {fmtQte(r.nb_magasins_reseau)} +
+
+

+ É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 de notre catalogue 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. +

+
+ ); +} diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx new file mode 100644 index 0000000..4bc1f1c --- /dev/null +++ b/src/app/(dashboard)/produits/page.tsx @@ -0,0 +1,57 @@ +import { pgSearchProduits } from "@/lib/pg-ff-client"; +import { getProduitFiche } 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 : par libellé ou par code centrale. + * + * - `?q=…` → liste de résultats + * - `?codein=…` → fiche complète (URL partageable) + */ +export default async function ProduitsPage(props: { + searchParams: Promise<{ q?: string; codein?: string }>; +}) { + const searchParams = await props.searchParams; + const q = (searchParams.q ?? "").trim(); + const codein = (searchParams.codein ?? "").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) : []; + + return ( +
+
+

+ Recherche produit +

+

+ Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik. +

+
+ + {/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} + + + {codein && !fiche && ( +
+ Aucun produit trouvé pour le code article {codein}. +
+ )} + + {fiche && } + + {!codein && ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx new file mode 100644 index 0000000..1d9a889 --- /dev/null +++ b/src/app/(dashboard)/produits/results.tsx @@ -0,0 +1,129 @@ +import Link from "next/link"; +import { PackageSearch, Wifi, WifiOff } from "lucide-react"; +import type { PgProduitSearchRow } from "@/lib/pg-ff-client"; + +function fmtQte(v: number): string { + return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v); +} + +/** Liste des résultats de recherche. */ +export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; query: string }) { + if (!query) { + return ( +
+ +

+ Recherchez un produit +

+

+ Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »). +

+
+ ); + } + + if (rows.length === 0) { + return ( +
+ Aucun produit ne correspond à « {query} ». +
+ ); + } + + return ( +
+

+ {rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""} + {rows.length >= 50 && " (50 premiers — affinez la recherche)"} +

+ +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
LibelléFournisseurCode articleCode centraleFamilleStockQté réseau
+ + {r.libelle1 || "—"} + + + {r.fournisseur || "—"} + + {r.codein} + + {r.code_centrale || "—"} + + {r.nomenclature || "—"} + + {fmtQte(r.stock_total)} + + {r.has_reseau ? ( + + + {fmtQte(r.qte_reseau)} + + / {r.nb_magasins_reseau} mag. + + + ) : ( + + + non synchro + + )} +
+
+
+ ); +} + +function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) { + return ( + + {children} + + ); +} diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx new file mode 100644 index 0000000..2bd18bb --- /dev/null +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -0,0 +1,79 @@ +"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 : 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`. + * + * 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 ( +
+
+
+ + 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 && ( + + )} +
+ +
+ {tooShort && ( +

+ Saisissez au moins 3 caractères. +

+ )} +
+ ); +} diff --git a/src/app/api/produits/opportunites/route.ts b/src/app/api/produits/opportunites/route.ts new file mode 100644 index 0000000..e01ab12 --- /dev/null +++ b/src/app/api/produits/opportunites/route.ts @@ -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 }); + } +} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 152a927..3a075b3 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -26,7 +26,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 +51,33 @@ interface QlikSyncJob { */ const jobs = new Map(); +/** + * 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 +88,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 +103,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 +137,20 @@ async function requireAdmin(): Promise { 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 { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return null; +} + /** * Regex de validation d'un code centrale "raisonnable". * @@ -209,34 +255,95 @@ async function runJob(job: QlikSyncJob): Promise { 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); } } /** - * 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 { + try { + const code = job.codeCentrale!; + job.requested = 1; + + const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); + 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); + } +} + +/** + * 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 +359,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); diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index d9d9dc0..eb6cc96 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -11,6 +11,7 @@ import { useSession, signOut } from "next-auth/react"; const NAV_ITEMS = [ { icon: BarChart2, label: "Dashboard", href: "/dashboard" }, { icon: LayoutGrid, label: "Grille", href: "/grid" }, + { icon: PackageSearch, label: "Produits", href: "/produits" }, { icon: TrendingUp, label: "Analytics", href: "/analytics" }, { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, { icon: Megaphone, label: "Publicités", href: "/publicites" }, diff --git a/src/db/schema.ts b/src/db/schema.ts index 745a320..0bac293 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -125,6 +125,12 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { periode: varchar("periode", { length: 20 }), /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */ qteByMonth: jsonb("qte_by_month"), + /** + * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. + * Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne + * « Tendance / Réseau » de la Grille. + */ + metricsByMonth: jsonb("metrics_by_month"), /** Dernière synchro depuis Qlik */ fetchedAt: timestamp("fetched_at").defaultNow(), }); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 1a8b6ca..38456e6 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -15,8 +15,7 @@ import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; - -const NB_MAGASINS_RESEAU = 270; +import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; interface GetProductRowsInput { codeFournisseur: string; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 4f967a0..4843eff 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -13,7 +13,7 @@ import { RowSelectionState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -26,64 +26,19 @@ import { GammeSelect } from "@/features/grid/components/gamme-select"; import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; import type { ProductRow, GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; - -function getLast12Months(): string[] { - const months: string[] = []; - const now = new Date(); - // On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier) - // pour exclure le mois en cours (i=0) - for (let i = 12; i >= 1; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); - } - return months; -} - -// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module) - -function formatMonthLabel(key: string): string { - const m = parseInt(key.slice(4, 6), 10); - const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"]; - return `${names[m - 1]} ${key.slice(2, 4)}`; -} - -function formatDate(iso?: string): string { - if (!iso) return "—"; - return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); -} - -/** - * Returns premium styling and color for stores based on the store name's hash. - * This guarantees a consistent color for a given store, regardless of its position in the list. - */ -const SITE_LABELS: Record = { - "292": { label: "F", nom: "Frouard (Nancy)" }, - "579": { label: "H", nom: "Houdemont" }, -}; - -function getStoreConfig(name: string) { - const known = SITE_LABELS[name]; - const words = (known?.nom ?? name).trim().split(/\s+/); - let label = known?.label ?? words[0].charAt(0).toUpperCase(); - if (!known && words.length > 1) { - label += words[1].charAt(0).toUpperCase(); - } - - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - const colorInt = Math.abs(hash) % 5; - - switch (colorInt) { - case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo - case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber - case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald - case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink - case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky - default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label }; - } -} +import { + getLast12Months, + formatMonthLabel, + formatDate, + SITE_LABELS, + getStoreConfig, +} from "@/features/grid/lib/months"; +import { + computeNetworkTrend, + trendLabel, + TREND_COLOR, +} from "@/features/grid/lib/network-trend"; +import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts"; interface HeatmapGridProps { onSelectionChange?: (codeins: string[]) => void; @@ -238,135 +193,6 @@ interface CellDetailData { receptions: number | null; } -// ─── Tendance réseau (à partir de qteReseauByMonth) ─────────────────────────── -type NetworkTrend = { - values: number[]; // quantités mensuelles, ordre chronologique - labels: string[]; // "YYYY-MM" - direction: "up" | "down" | "flat"; - pct: number | null; // variation modélisée (régression linéaire) sur 12 mois - hasData: boolean; -}; - -/** - * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois. - * Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la - * variation modélisée sur la période (pente × durée) rapportée à la moyenne. - * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. - */ -function computeNetworkTrend(qteByMonth?: Record | null): NetworkTrend { - const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; - if (!qteByMonth) return empty; - let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement - if (labels.length === 0) return empty; - labels = labels.slice(-12); // 12 derniers mois - const values = labels.map((l) => Number(qteByMonth[l]) || 0); - const n = values.length; - let pct: number | null = null; - let direction: NetworkTrend["direction"] = "flat"; - if (n >= 3) { - const mx = (n - 1) / 2; - const my = values.reduce((s, v) => s + v, 0) / n; - let num = 0, den = 0; - for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); } - const slope = den ? num / den : 0; - if (my > 0) { - pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne - direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; - } else if (slope > 0) { - direction = "up"; - } - } else if (n === 2 && values[0] > 0) { - pct = (values[1] - values[0]) / values[0]; - direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; - } - return { values, labels, direction, pct, hasData: true }; -} - -/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */ -const TREND_STRONG = 0.25; - -const TREND_COLOR: Record = { - up: "#22c55e", - down: "#ef4444", - flat: "#94a3b8", -}; - -/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */ -function TrendSparkline({ trend }: { trend: NetworkTrend }) { - if (!trend.hasData) return
-
; - const { values, direction, pct } = trend; - const color = TREND_COLOR[direction]; - const W = 46, H = 18, n = values.length; - const max = Math.max(...values, 1), min = Math.min(...values, 0); - const range = max - min || 1; - const pts = values.map((v, i) => { - const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2; - const y = H - 1 - ((v - min) / range) * (H - 2); - return `${x.toFixed(1)},${y.toFixed(1)}`; - }).join(" "); - const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; - return ( -
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}> -
- - {n > 1 && } - {n === 1 && } - - -
- {pct != null && ( - - {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% - - )} -
- ); -} - -/** "2026-01" → "01/26" pour un axe compact. */ -function fmtMonthShort(lab: string): string { - const [y, m] = lab.split("-"); - return `${m ?? lab}/${(y ?? "").slice(2)}`; -} - -/** Courbe unique des ventes réseau mensuelles (SVG). */ -function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) { - const W = 460, H = 200; - const padL = 10, padR = 10, padT = 22, padB = 30; - const plotW = W - padL - padR, plotH = H - padT - padB; - const n = values.length; - const maxV = Math.max(...values, 1); - const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); - const y = (v: number) => padT + plotH - (v / maxV) * plotH; - const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); - const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`; - return ( -
- - {/* ligne de base */} - - {/* aire douce sous la courbe */} - {n > 1 && } - {/* courbe */} - {n > 1 && } - {values.map((v, i) => ( - - - {/* quantité au-dessus du point */} - - {Math.round(v).toLocaleString("fr-FR")} - - {/* mois sous l'axe */} - - {fmtMonthShort(labels[i])} - - - ))} - -
- ); -} - /** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) { const trend = computeNetworkTrend(row.qteReseauByMonth); @@ -383,8 +209,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} {pct != null && ( <> · - {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "} - {pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"} + {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)} )}

diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx new file mode 100644 index 0000000..050061a --- /dev/null +++ b/src/features/grid/components/network-charts.tsx @@ -0,0 +1,136 @@ +"use client"; + +/** + * CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts). + * + * Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche + * produit (`/produits`). Aucun changement de comportement. + */ + +import { TrendingUp, TrendingDown, Minus } from "lucide-react"; +import { fmtMonthShort } from "@/features/grid/lib/months"; +import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend"; + +/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */ +export function TrendSparkline({ trend }: { trend: NetworkTrend }) { + if (!trend.hasData) return
-
; + const { values, direction, pct } = trend; + const color = TREND_COLOR[direction]; + const W = 46, H = 18, n = values.length; + const max = Math.max(...values, 1), min = Math.min(...values, 0); + const range = max - min || 1; + const pts = values.map((v, i) => { + const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2; + const y = H - 1 - ((v - min) / range) * (H - 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; + return ( +
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}> +
+ + {n > 1 && } + {n === 1 && } + + +
+ {pct != null && ( + + {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% + + )} +
+ ); +} + +/** Courbe unique des ventes réseau mensuelles (SVG). */ +export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) { + const W = 460, H = 200; + const padL = 10, padR = 10, padT = 22, padB = 30; + const plotW = W - padL - padR, plotH = H - padT - padB; + const n = values.length; + const maxV = Math.max(...values, 1); + const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); + const y = (v: number) => padT + plotH - (v / maxV) * plotH; + const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`; + return ( +
+ + {/* ligne de base */} + + {/* aire douce sous la courbe */} + {n > 1 && } + {/* courbe */} + {n > 1 && } + {values.map((v, i) => ( + + + {/* quantité au-dessus du point */} + + {Math.round(v).toLocaleString("fr-FR")} + + {/* mois sous l'axe */} + + {fmtMonthShort(labels[i])} + + + ))} + +
+ ); +} + +/** + * Deux courbes superposées sur le même axe (comparatif local vs réseau). + * Les valeurs sont normalisées ensemble pour rester comparables visuellement. + */ +export function DualLineChart({ + labels, + series, +}: { + labels: string[]; + series: Array<{ name: string; values: number[]; color: string }>; +}) { + const W = 620, H = 220; + const padL = 10, padR = 10, padT = 26, padB = 30; + const plotW = W - padL - padR, plotH = H - padT - padB; + const n = labels.length; + const maxV = Math.max(1, ...series.flatMap((s) => s.values)); + const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); + const y = (v: number) => padT + plotH - (v / maxV) * plotH; + + return ( +
+
+ {series.map((s) => ( + + + {s.name} + + ))} +
+ + + {series.map((s) => { + const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + return ( + + {n > 1 && ( + + )} + {s.values.map((v, i) => ( + + ))} + + ); + })} + {labels.map((lab, i) => ( + + {fmtMonthShort(lab)} + + ))} + +
+ ); +} diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index 3b1172a..59aef41 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -1,8 +1,9 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react"; +import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; interface SyncQlikButtonProps { /** Code fournisseur affiché (pour sync ciblée) */ @@ -11,24 +12,8 @@ interface SyncQlikButtonProps { lastUpdate?: string | null; } -/** État serveur renvoyé par GET/POST /api/qlik/sync */ -interface SyncJobState { - jobId: string | null; - fournisseur: string; - status: "idle" | "running" | "success" | "error"; - requested: number; - fetched: number; - upserted: number; - periode?: string; - dateDebut?: string; - dateFin?: string; - startedAt?: string; - finishedAt?: string; - error?: string; - message?: string; -} - -function formatDate(iso?: string | null): string { +/** Date + heure de dernière synchro, ou "jamais". */ +function formatLastUpdate(iso?: string | null): string { if (!iso) return "jamais"; const d = new Date(iso); if (isNaN(d.getTime())) return "jamais"; @@ -39,55 +24,15 @@ function formatDate(iso?: string | null): string { * Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché * (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj. * - * UX : le POST démarre un job serveur en arrière-plan et revient immédiatement. - * Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle - * GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de - * la page) et continuer à afficher le spinner correctement. + * Toute la mécanique de job (POST puis polling GET, reprise après reload) vit + * dans `useQlikSyncJob`, partagée avec la fiche produit. */ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) { - const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle"); - const [message, setMessage] = useState(""); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const pollTimerRef = useRef | null>(null); - /** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */ - const trackingRef = useRef(false); - - const clearPollTimer = useCallback(() => { - if (pollTimerRef.current) { - clearTimeout(pollTimerRef.current); - pollTimerRef.current = null; - } - }, []); - - /** - * Met à jour le label visible à droite du bouton selon l'état courant. - */ - const applyJobToUi = useCallback((j: SyncJobState | null) => { - if (!j) return; - if (j.status === "running") { - setStatus("running"); - // Texte informatif pendant l'extraction - const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : ""; - setMessage(`Extraction Qlik en cours${requestedTxt}…`); - } else if (j.status === "success") { - setStatus("success"); - if (j.message) setMessage(j.message); - else setMessage(`${j.upserted} produits réseau synchronisés`); - } else if (j.status === "error") { - setStatus("error"); - setMessage(j.error || "Erreur sync Qlik"); - } else { - setStatus("idle"); - } - }, []); - - /** - * Force le refresh de la grille (avec cache-buster) et de la barre de résumé. - * C'est l'équivalent de ce que faisait l'ancien code après un POST réussi. - */ + /** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */ const refreshGrid = useCallback(() => { const params = new URLSearchParams(searchParams.toString()); params.set("_refresh", String(Date.now())); @@ -95,163 +40,10 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr router.refresh(); }, [pathname, router, searchParams]); - /** - * Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job - * passe en success/error, ou après un timeout de sécurité. - */ - const pollOnce = useCallback(async (): Promise => { - if (!codeFournisseur) return false; - if (!trackingRef.current) return false; - try { - const res = await fetch( - `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, - { method: "GET", cache: "no-store" }, - ); - if (!res.ok) { - // En cas d'erreur HTTP transitoire on retente au tour suivant. - return true; - } - const data: SyncJobState = await res.json(); - applyJobToUi(data); - - if (data.status === "running") { - // Continue à poller - return true; - } - if (data.status === "success" || data.status === "error") { - // Terminé : on rafraîchit la grille, on coupe le polling. - trackingRef.current = false; - if (data.status === "success") { - refreshGrid(); - } - // Petit délai pour laisser l'utilisateur lire le message vert/rouge - setTimeout(() => { - setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur)); - }, 4000); - return false; - } - // idle : rien à faire - trackingRef.current = false; - return false; - } catch { - // Erreur réseau transitoire, on laisse le polling réessayer. - return true; - } - }, [applyJobToUi, codeFournisseur, refreshGrid]); - - /** - * Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut - * continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas - * marteler le serveur pendant une extraction qui dure plusieurs minutes. - */ - const startPolling = useCallback(() => { - clearPollTimer(); - const tick = async () => { - const keepGoing = await pollOnce(); - if (keepGoing && trackingRef.current) { - pollTimerRef.current = setTimeout(tick, 3000); - } else { - pollTimerRef.current = null; - } - }; - // Premier appel quasi immédiat - pollTimerRef.current = setTimeout(tick, 200); - }, [clearPollTimer, pollOnce]); - - /** - * Au mount : si on a un fournisseur, on interroge l'API pour savoir si - * un job tourne déjà (ex. reload de la page pendant l'extraction). Si - * oui, on reprend l'état et on relance le polling. - */ - useEffect(() => { - if (!codeFournisseur) return; - let cancelled = false; - trackingRef.current = false; - clearPollTimer(); - (async () => { - try { - const res = await fetch( - `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, - { method: "GET", cache: "no-store" }, - ); - if (cancelled) return; - if (!res.ok) return; - const data: SyncJobState = await res.json(); - if (cancelled) return; - if (data.status === "running" && data.jobId) { - trackingRef.current = true; - applyJobToUi(data); - startPolling(); - } else if (data.status === "success" || data.status === "error") { - // Job terminé en amont (avant le mount) : on l'affiche brièvement - applyJobToUi(data); - setTimeout(() => { - setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur)); - }, 4000); - } - } catch { - /* ignore : on n'empêche pas l'UI d'afficher le bouton */ - } - })(); - return () => { - cancelled = true; - trackingRef.current = false; - clearPollTimer(); - }; - // On veut interroger une seule fois au mount (et quand le fournisseur change). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [codeFournisseur, clearPollTimer]); - - /** - * Cleanup : annule un timer de polling éventuel au démontage. - */ - useEffect(() => { - return () => { - clearPollTimer(); - }; - }, [clearPollTimer]); - - const handleSync = async () => { - if (!codeFournisseur) { - setStatus("error"); - setMessage("Aucun fournisseur sélectionné"); - setTimeout(() => setStatus("idle"), 6000); - return; - } - // Empêche un double-clic pendant qu'un job tourne déjà. - if (status === "running") return; - - setStatus("running"); - setMessage("Démarrage de l'extraction Qlik…"); - try { - const res = await fetch( - `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, - { method: "POST" }, - ); - const data: SyncJobState & { success?: boolean } = await res.json(); - if (!res.ok || (data as { success?: boolean }).success === false) { - throw new Error(data.error || `HTTP ${res.status}`); - } - // Si le serveur nous dit que c'est déjà en cours (jobId existant), - // on reprend juste le suivi. - if (data.status === "running" && data.jobId) { - trackingRef.current = true; - applyJobToUi(data); - startPolling(); - return; - } - // Status terminal direct (cas rare : 0 codes → success immédiat) - applyJobToUi(data); - if (data.status === "success") { - refreshGrid(); - } - setTimeout(() => setStatus("idle"), 4000); - } catch (e) { - setStatus("error"); - setMessage(e instanceof Error ? e.message : String(e)); - setTimeout(() => setStatus("idle"), 6000); - } - }; + const { status, message, start } = useQlikSyncJob({ + target: { mode: "fournisseur", fournisseur: codeFournisseur }, + onSuccess: refreshGrid, + }); return (
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr ? message : status === "running" ? (message || `Extraction Qlik…`) - : `MAJ Qlik · ${formatDate(lastUpdate)}`} + : `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}