diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..d1d4270 --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,399 @@ +import { pgGetDashboardData, type DashboardTopItem, type DashboardSiteStats, type DashboardMonthStats } from "@/lib/pg-ff-client"; +import { TrendingUp, TrendingDown, Minus, Store, ShoppingCart, Euro, Percent } from "lucide-react"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SITE_NAMES: Record = { "292": "Frouard", "579": "Houdemont" }; + +function fmtEur(n: number): string { + const abs = Math.abs(n); + if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} M€`; + if (abs >= 1_000) return `${(n / 1_000).toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} k€`; + return `${n.toLocaleString("fr-FR", { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €`; +} + +function fmtQte(n: number): string { + return n.toLocaleString("fr-FR", { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +} + +function fmtPct(n: number, signed = true): string { + const s = signed && n > 0 ? "+" : ""; + return `${s}${n.toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`; +} + +function delta(current: number, previous: number): number | null { + if (!previous || previous === 0) return null; + return ((current - previous) / Math.abs(previous)) * 100; +} + +function monthLabel(mois: string): string { + const [y, m] = mois.split("-"); + const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"]; + return `${names[parseInt(m) - 1]} ${y.slice(2)}`; +} + +function dateLabel(iso: string): string { + const [y, m, d] = iso.split("-"); + return `${d}/${m}/${y}`; +} + +// --------------------------------------------------------------------------- +// Sub-components (server-side, no "use client" needed) +// --------------------------------------------------------------------------- + +function DeltaBadge({ pct }: { pct: number | null }) { + if (pct === null) return —; + const isPos = pct >= 0; + const Icon = pct > 0.5 ? TrendingUp : pct < -0.5 ? TrendingDown : Minus; + return ( + + + {fmtPct(pct)} + + ); +} + +function KpiCard({ label, value, sub, delta: d, icon: Icon }: { + label: string; value: string; sub?: string; delta?: number | null; + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; +}) { + return ( +
+
+ {label} +
+ +
+
+ {value} +
+ {d !== undefined && } + {sub && {sub}} +
+
+ ); +} + +function SiteCard({ site }: { site: DashboardSiteStats }) { + const name = SITE_NAMES[site.site] ?? site.site; + const txMarge = site.ca_hier > 0 ? (site.marge_hier / site.ca_hier) * 100 : 0; + const dCa = delta(site.ca_hier, site.ca_d7); + const dQte = delta(site.qte_hier, site.qte_d7); + + return ( +
+
+ + {name} + site {site.site} +
+
+
+
CA
+
{fmtEur(site.ca_hier)}
+ +
+
+
Quantité
+
{fmtQte(site.qte_hier)}
+ +
+
+
Marge
+
{fmtEur(site.marge_hier)}
+
+
+
Tx Marge
+
= 25 ? "#10b981" : txMarge >= 15 ? "#f59e0b" : "#ef4444" }}> + {fmtPct(txMarge, false)} +
+
+
+
+ {fmtQte(site.lignes_hier)} lignes vendues +
+
+ ); +} + +function Top10Table({ title, items, sortKey }: { + title: string; items: DashboardTopItem[]; sortKey: "ca" | "qte" | "marge"; +}) { + const maxVal = items[0]?.[sortKey] ?? 1; + return ( +
+
+ {title} +
+
+ {items.length === 0 && ( +
+ Aucune donnée pour hier +
+ )} + {items.map((item, i) => { + const val = item[sortKey]; + const barPct = maxVal > 0 ? (val / maxVal) * 100 : 0; + const txMarge = item.ca > 0 ? (item.marge / item.ca) * 100 : 0; + return ( +
+
+ + {i + 1} + + + {item.libelle1 ?? item.codein} + + + {sortKey === "qte" ? fmtQte(val) : fmtEur(val)} + +
+
+
+
+
+ + {sortKey !== "ca" && `CA ${fmtEur(item.ca)} • `} + {fmtPct(txMarge, false)} marge + +
+
+ ); + })} +
+
+ ); +} + +function EvolutionTable({ evolution }: { evolution: DashboardMonthStats[] }) { + // Regrouper par mois YYYY-MM → data + const byMois = new Map(evolution.map(e => [e.mois, e])); + + // Calculer les 12 derniers mois complets (pas le mois en cours) + const now = new Date(); + const months12: string[] = []; + for (let i = 13; i >= 2; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + months12.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); + } + + const rows = months12.map(mois => { + const [y, m] = mois.split("-"); + const prevMois = `${parseInt(y) - 1}-${m}`; + const n = byMois.get(mois); + const n1 = byMois.get(prevMois); + const dCa = n && n1 ? delta(n.ca, n1.ca) : null; + const dQte = n && n1 ? delta(n.qte, n1.qte) : null; + return { mois, n, n1, dCa, dQte }; + }); + + // Trouver les maxCA pour les barres + const maxCa = Math.max(...rows.map(r => Math.max(r.n?.ca ?? 0, r.n1?.ca ?? 0)), 1); + + return ( +
+
+ Évolution mensuelle — CA réseau + N vs N-1 · 12 mois complets +
+
+ + + + + + + + + + + + + + + + {rows.map(({ mois, n, n1, dCa, dQte }) => { + const txN = n && n.ca > 0 ? (n.marge / n.ca) * 100 : null; + const barN = n ? (n.ca / maxCa) * 100 : 0; + const barN1 = n1 ? (n1.ca / maxCa) * 100 : 0; + const isPositive = dCa !== null && dCa >= 0; + return ( + + + + + + + + + + + + ); + })} + +
MoisCA NCA N-1Δ CAQté NQté N-1Δ QtéTx Marge NTendance
+ {monthLabel(mois)} + + {n ? fmtEur(n.ca) : "—"} + + {n1 ? fmtEur(n1.ca) : "—"} + + {dCa !== null ? ( + + {fmtPct(dCa)} + + ) : "—"} + + {n ? fmtQte(n.qte) : "—"} + + {n1 ? fmtQte(n1.qte) : "—"} + + {dQte !== null ? ( + = 0 ? "#10b981" : "#ef4444" }}> + {fmtPct(dQte)} + + ) : "—"} + + {txN !== null ? ( + = 25 ? "#10b981" : txN >= 15 ? "#f59e0b" : "#ef4444" }}> + {fmtPct(txN, false)} + + ) : "—"} + +
+
+ N +
+
+
+
+
+ N-1 +
+
+
+
+
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page principale +// --------------------------------------------------------------------------- + +export default async function DashboardPage() { + let data; + try { + data = await pgGetDashboardData(); + } catch (e) { + return ( +
+

+ Impossible de charger le tableau de bord : {(e as Error).message?.slice(0, 200)} +

+
+ ); + } + + const { dateHier, sites, top10Ca, top10Qte, top10Marge, evolution } = data; + + // Totaux réseau + const totalCa = sites.reduce((s, r) => s + r.ca_hier, 0); + const totalQte = sites.reduce((s, r) => s + r.qte_hier, 0); + const totalMarge = sites.reduce((s, r) => s + r.marge_hier, 0); + const txMarge = totalCa > 0 ? (totalMarge / totalCa) * 100 : 0; + const totalLignes = sites.reduce((s, r) => s + r.lignes_hier, 0); + const totalCaD7 = sites.reduce((s, r) => s + r.ca_d7, 0); + const totalQteD7 = sites.reduce((s, r) => s + r.qte_d7, 0); + const totalMargeD7 = sites.reduce((s, r) => s + r.marge_d7, 0); + const txMargeD7 = totalCaD7 > 0 ? (totalMargeD7 / totalCaD7) * 100 : 0; + + return ( +
+ {/* Header */} +
+

+ Tableau de Bord +

+

+ Données du {dateLabel(dateHier)} + {" "}· Comparaison vs semaine précédente (J-7) +

+
+ + {/* KPI réseau */} +
+ + + + +
+ + {/* Par magasin */} +
+

+ Par magasin +

+ {sites.length === 0 ? ( +

Aucune donnée pour hier.

+ ) : ( +
+ {sites.map(s => )} +
+ )} +
+ + {/* Hit Parade */} +
+

+ Hit Parade — réseau · {dateLabel(dateHier)} +

+
+ + + +
+
+ + {/* Évolution N/N-1 */} +
+

+ Évolution annuelle +

+ +
+
+ ); +} diff --git a/src/app/(dashboard)/docs/ai-analysis/page.tsx b/src/app/(dashboard)/docs/ai-analysis/page.tsx deleted file mode 100644 index b9bef47..0000000 --- a/src/app/(dashboard)/docs/ai-analysis/page.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { Bot, TrendingUp, Calendar, Zap, AlertTriangle, ShieldCheck, Scale, Info, Brain } from "lucide-react"; -import { cn } from "@/lib/utils"; - -export default function AiAnalysisDocPage() { - return ( -
- {/* Header */} -
-
-
- -
-
-

- Intelligence Artificielle Copilot -

-

- Comprendre les algorithmes et règles métiers de notre assistant d'achat. -

-
-
-
- - {/* Grid of Rules */} -
- {/* 1. Loi de Pareto */} - -

- L'IA identifie les produits Indiscutables. Si un produit pèse - {"> 10%"} du CA total de son fournisseur, il est jugé stratégique. - Mary refusera de suggérer sa sortie [Z] pour protéger la relation fournisseur et - garantir la stabilité de l'assortiment. -

-
- - {/* 2. Équité & Normalisation */} - -

- Pour une comparaison juste, les statistiques des produits présents sur un seul magasin sont - doublées (x2). L'IA compare ensuite ces données pondérées aux - Benchmarks du Rayon (Moyenne du Niveau 2) pour détecter les sur-performances. -

-
- - {/* 3. Cycle de Vie */} - -

- Mary distingue les produits récents des produits établis : -

-
    -
  • • Run Rate : Projection sur 12 mois pour les produits de moins d'un an.
  • -
  • • Inactivité : Une alerte est levée après 2 mois sans vente, - orientant le verdict vers le [C] (Saisonnier) ou [Z] (Sortie).
  • -
-
- - {/* 4. PMV & Trafic */} - -

- Un produit peut être maintenu même avec un faible CA s'il génère du Trafic (Volumes {">"} Moyenne Rayon). - À l'inverse, un produit à faible volume peut être un Contributeur de Marge précieux s'il possède un Prix Moyen de Vente (PMV) élevé. -

-
- - {/* 5. Règles IA Fournisseur */} - -

- Vous pouvez définir des Règles IA spécifiques pour chaque fournisseur (via le bouton avec le cerveau 🧠). - Ces directives sont mémorisées d'une session à l'autre et l'IA a pour consigne stricte de les respecter en priorité absolue - sur les calculs algorithmiques standards. -

-
-
- - {/* Footer / Tip */} -
- -
-

Conseil d'expert

-

- L'IA est un outil d'aide à la décision. Elle combine ces règles pour vous proposer une recommandation - brute, mais votre expertise terrain reste essentielle pour valider les cas particuliers (ruptures fournisseurs prolongées, promotions, etc.). -

-
-
-
- ); -} - -function DocCard({ - icon: Icon, - title, - description, - children, - accent -}: { - icon: any, - title: string, - description: string, - children: React.ReactNode, - accent: "teal" | "indigo" | "amber" | "rose" -}) { - const colors = { - teal: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 shadow-emerald-500/5", - indigo: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20 shadow-indigo-500/5", - amber: "bg-amber-500/10 text-amber-600 border-amber-500/20 shadow-amber-500/5", - rose: "bg-rose-500/10 text-rose-600 border-rose-500/20 shadow-rose-500/5", - }; - - return ( -
-
-
- -
-
-

{title}

-

{description}

-
-
-
- {children} -
-
- ); -} diff --git a/src/app/(dashboard)/score/page.tsx b/src/app/(dashboard)/score/page.tsx deleted file mode 100644 index 77e8f94..0000000 --- a/src/app/(dashboard)/score/page.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"use client"; - -import { BarChart3, ArrowRight, TrendingUp, Award, Layers } from "lucide-react"; -import Link from "next/link"; - -function InfoCard({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) { - return ( -
-
- -

{title}

-
-
- {children} -
-
- ); -} - -function FormulaStep({ step, title, formula }: { step: number; title: string; formula: string }) { - return ( -
-
- {step} -
-
-

{title}

- - {formula} - -
-
- ); -} - -function ScoreBadge({ value, label }: { value: string; label: string }) { - const num = parseFloat(value); - const color = num >= 80 ? "text-emerald-500" : num >= 50 ? "text-amber-500" : "text-rose-500"; - const bg = num >= 80 ? "rgba(16, 185, 129, 0.1)" : num >= 50 ? "rgba(245, 158, 11, 0.1)" : "rgba(244, 63, 94, 0.1)"; - const borderColor = num >= 80 ? "rgba(16, 185, 129, 0.2)" : num >= 50 ? "rgba(245, 158, 11, 0.2)" : "rgba(244, 63, 94, 0.2)"; - - return ( -
-
- {value} -
-

{label}

-
- ); -} - -export default function ScorePage() { - return ( -
-
-

- Score Produit -

-

- Comprenez comment chaque produit est évalué pour le référencement. -

-
- - {/* Principe fondamental */} - -
-

- Un produit excellent sur une seule dimension suffit à en faire un bon produit. -

-

- Un produit qui fait 30% du volume du fournisseur est un bon produit même si sa marge est faible. -

-
-
- - {/* Équité & Normalisation */} - -
-

- Afin de ne pas pénaliser les produits présents dans un seul magasin, une pondération d'équité est appliquée : -

-
-
⚖️
-
-

Produit 1 Magasin : Statistiques multipliées par 2

-

- Cela permet de comparer sa performance sur une base réseau normalisée de 2 magasins. -

-
-
-
-
- - {/* Les 3 axes */} - -
-

- Chaque produit est évalué sur son poids relatif par rapport au meilleur produit : -

-
- {[ - { label: "Quantité", desc: "Volume vendu", icon: "📦" }, - { label: "Chiffre d'Affaires", desc: "Montant total", icon: "💰" }, - { label: "Marge", desc: "Profit généré", icon: "📈" }, - ].map(({ label, desc, icon }) => ( -
-
{icon}
-

{label}

-

{desc}

-
- ))} -
-

- Poids = (métrique du produit ÷ métrique du Produit N°1) × 100 -

-
-
- - {/* Formule */} - -
- - - - -
-

- Axe fort = un axe dont le poids dépasse le seuil (configurable dans Paramètres). - Un produit peut obtenir un bonus s'il excelle sur 2 ou 3 axes à la fois. -

-
-
-
- - {/* Signification des couleurs */} - -
- - - -
-
-
-
-

≥ 80 — Produit stratégique, très forte contribution

-
-
-
-

50 – 79 — Contribution significative, à conserver

-
-
-
-

< 50 — Contribution faible, à réévaluer

-
-
- - - {/* Lien vers les paramètres */} - - Ajuster les paramètres du score - - -
- ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9ea537d..9ef1235 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function HomePage() { - redirect("/grid"); + redirect("/dashboard"); } diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 262630c..b6959dd 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, BarChart3, LogOut, User as UserIcon, Loader2, Bot, ChevronRight, ChevronLeft } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -9,11 +9,10 @@ import { useEffect, useState } from "react"; import { useSession, signOut } from "next-auth/react"; const NAV_ITEMS = [ + { icon: BarChart2, label: "Dashboard", href: "/dashboard" }, { icon: LayoutGrid, label: "Grille", href: "/grid" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports" }, - { icon: BarChart3, label: "Score", href: "/score" }, - { icon: Bot, label: "Aide IA", href: "/docs/ai-analysis" }, { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, ]; diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index fe7dbef..a11abfc 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -461,3 +461,151 @@ export async function pgGetCommandesByFournisseur(codefou: string): Promise { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateHier = yesterday.toISOString().split("T")[0]; + + const [sitesResult, top10CaResult, top10QteResult, top10MargeResult, evolutionResult] = + await Promise.all([ + // Stats hier + même jour S-1 par magasin (1 seule requête avec FILTER) + pgNoParallel(sql` + SELECT + m.site, + SUM(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN -m.mntmvtttc ELSE 0 END)::float AS ca_hier, + SUM(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN -m.qtemvt ELSE 0 END)::float AS qte_hier, + SUM(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN m.margemvt ELSE 0 END)::float AS marge_hier, + COUNT(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN 1 END)::int AS lignes_hier, + SUM(CASE WHEN m.datmvt = CURRENT_DATE - 7 THEN -m.mntmvtttc ELSE 0 END)::float AS ca_d7, + SUM(CASE WHEN m.datmvt = CURRENT_DATE - 7 THEN -m.qtemvt ELSE 0 END)::float AS qte_d7, + SUM(CASE WHEN m.datmvt = CURRENT_DATE - 7 THEN m.margemvt ELSE 0 END)::float AS marge_d7 + FROM mvtart m + WHERE m.datmvt IN (CURRENT_DATE - 1, CURRENT_DATE - 7) + AND m.genremvt = 3 + AND m.site IN ('292', '579') + GROUP BY m.site + ORDER BY m.site + `), + // Top 10 CA réseau hier + pgNoParallel(sql` + SELECT + a.codein, + a.libelle1, + SUM(-m.mntmvtttc)::float AS ca, + SUM(-m.qtemvt)::float AS qte, + SUM(m.margemvt)::float AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt = CURRENT_DATE - 1 + AND m.genremvt = 3 + AND m.site IN ('292', '579') + GROUP BY a.codein, a.libelle1 + ORDER BY ca DESC + LIMIT 10 + `), + // Top 10 QTE réseau hier + pgNoParallel(sql` + SELECT + a.codein, + a.libelle1, + SUM(-m.qtemvt)::float AS qte, + SUM(-m.mntmvtttc)::float AS ca, + SUM(m.margemvt)::float AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt = CURRENT_DATE - 1 + AND m.genremvt = 3 + AND m.site IN ('292', '579') + GROUP BY a.codein, a.libelle1 + ORDER BY qte DESC + LIMIT 10 + `), + // Top 10 Marge réseau hier + pgNoParallel(sql` + SELECT + a.codein, + a.libelle1, + SUM(m.margemvt)::float AS marge, + SUM(-m.mntmvtttc)::float AS ca, + SUM(-m.qtemvt)::float AS qte + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt = CURRENT_DATE - 1 + AND m.genremvt = 3 + AND m.site IN ('292', '579') + GROUP BY a.codein, a.libelle1 + ORDER BY marge DESC + LIMIT 10 + `), + // Évolution mensuelle sur 25 mois (N et N-1) + pgNoParallel(sql` + SELECT + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(-m.mntmvtttc)::float AS ca, + SUM(-m.qtemvt)::float AS qte, + SUM(m.margemvt)::float AS marge + FROM mvtart m + WHERE m.datmvt >= (CURRENT_DATE - INTERVAL '25 months') + AND m.datmvt < CURRENT_DATE + AND m.genremvt = 3 + AND m.site IN ('292', '579') + GROUP BY TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY mois + `), + ]); + + console.log(`[pg-ff] Dashboard: ${sitesResult.rows.length} sites, ${evolutionResult.rows.length} mois d'évolution`); + + return { + dateHier, + sites: sitesResult.rows as unknown as DashboardSiteStats[], + top10Ca: top10CaResult.rows as unknown as DashboardTopItem[], + top10Qte: top10QteResult.rows as unknown as DashboardTopItem[], + top10Marge: top10MargeResult.rows as unknown as DashboardTopItem[], + evolution: evolutionResult.rows as unknown as DashboardMonthStats[], + }; +}