+ 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.
+
+ )}
+
+ )}
+
+ {/* 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.
+
+ É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.
+
+ );
+}
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"}>
-