diff --git a/src/app/(dashboard)/hit-parade/client.tsx b/src/app/(dashboard)/hit-parade/client.tsx new file mode 100644 index 0000000..7c75c66 --- /dev/null +++ b/src/app/(dashboard)/hit-parade/client.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useMemo } from "react"; +import type { HitParadePivotRow } from "./page"; + +type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "margeTotal"; +type SortDir = "desc" | "asc"; + +function formatCA(v: number) { + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v); +} + +function formatQte(v: number) { + return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v); +} + +function formatPct(ca: number, marge: number) { + if (ca === 0) return null; + return ((marge / ca) * 100).toFixed(1) + "%"; +} + +function SortBtn({ active, dir, onClick }: { active: boolean; dir: SortDir; onClick: () => void }) { + return ( + + ); +} + +interface Props { + mois: string; + currentMois: string; + pivotted: HitParadePivotRow[]; +} + +export function HitParadeClient({ mois, currentMois, pivotted }: Props) { + const router = useRouter(); + const [sortKey, setSortKey] = useState("caTotal"); + const [sortDir, setSortDir] = useState("desc"); + + const months: { value: string; label: string }[] = []; + const now = new Date(); + for (let i = 0; i < 24; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i); + const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const label = new Intl.DateTimeFormat("fr-FR", { year: "numeric", month: "long" }).format(d); + months.push({ value, label: label.charAt(0).toUpperCase() + label.slice(1) }); + } + + const sorted = useMemo(() => { + return [...pivotted].sort((a, b) => { + const diff = a[sortKey] - b[sortKey]; + return sortDir === "desc" ? -diff : diff; + }); + }, [pivotted, sortKey, sortDir]); + + function handleSort(key: SortKey) { + if (sortKey === key) { + setSortDir(d => d === "desc" ? "asc" : "desc"); + } else { + setSortKey(key); + setSortDir("desc"); + } + } + + const totals = useMemo(() => pivotted.reduce( + (acc, r) => ({ + qte292: acc.qte292 + r.qte292, + ca292: acc.ca292 + r.ca292, + marge292: acc.marge292 + r.marge292, + qte579: acc.qte579 + r.qte579, + ca579: acc.ca579 + r.ca579, + marge579: acc.marge579 + r.marge579, + qteTotal: acc.qteTotal + r.qteTotal, + caTotal: acc.caTotal + r.caTotal, + margeTotal: acc.margeTotal + r.margeTotal, + }), + { qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0 } + ), [pivotted]); + + function th(label: string, key: SortKey) { + return ( + + {label} + handleSort(key)} /> + + ); + } + + return ( +
+ {/* Controls */} +
+ + + {sorted.length} produits +
+ + {/* Table */} +
+ + + + + + + + + + + + {th("Qté", "qte292")} + {th("CA TTC", "ca292")} + + {th("Qté", "qte579")} + {th("CA TTC", "ca579")} + + {th("Qté", "qteTotal")} + {th("CA TTC", "caTotal")} + + + + + {sorted.map((row, idx) => ( + + + + + + + + + + + + + + + ))} + + + + + + + + + + + + + + + +
CodeDésignationFournisseurFrouard/Nancy (292)Houdemont (579)Total Réseau
MargeMargeMarge
{row.codein}{row.libelle.trim()}{row.fournisseur}{row.qte292 > 0 ? formatQte(row.qte292) : ""}{row.ca292 > 0 ? formatCA(row.ca292) : ""}{row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""}{row.qte579 > 0 ? formatQte(row.qte579) : ""}{row.ca579 > 0 ? formatCA(row.ca579) : ""}{row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""}{row.qteTotal > 0 ? formatQte(row.qteTotal) : ""}{row.caTotal > 0 ? formatCA(row.caTotal) : ""}{row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}
TOTAL{formatQte(totals.qte292)}{formatCA(totals.ca292)}{formatPct(totals.ca292, totals.marge292)}{formatQte(totals.qte579)}{formatCA(totals.ca579)}{formatPct(totals.ca579, totals.marge579)}{formatQte(totals.qteTotal)}{formatCA(totals.caTotal)}{formatPct(totals.caTotal, totals.margeTotal)}
+
+
+ ); +} diff --git a/src/app/(dashboard)/hit-parade/page.tsx b/src/app/(dashboard)/hit-parade/page.tsx new file mode 100644 index 0000000..b5673c5 --- /dev/null +++ b/src/app/(dashboard)/hit-parade/page.tsx @@ -0,0 +1,79 @@ +import { pgGetHitParade } from "@/lib/pg-ff-client"; +import { HitParadeClient } from "./client"; + +export interface HitParadePivotRow { + codein: string; + libelle: string; + fournisseur: string; + qte292: number; + ca292: number; + marge292: number; + qte579: number; + ca579: number; + marge579: number; + qteTotal: number; + caTotal: number; + margeTotal: number; +} + +function pivotHitParade( + rows: Array<{ codein: string; libelle: string; fournisseur: string; site: string; qte_vendue: number; ca_ttc: number; marge: number }> +): HitParadePivotRow[] { + const map = new Map(); + + for (const row of rows) { + if (!map.has(row.codein)) { + map.set(row.codein, { + codein: row.codein, + libelle: row.libelle, + fournisseur: row.fournisseur, + qte292: 0, ca292: 0, marge292: 0, + qte579: 0, ca579: 0, marge579: 0, + qteTotal: 0, caTotal: 0, margeTotal: 0, + }); + } + const entry = map.get(row.codein)!; + if (row.site === "292") { + entry.qte292 = row.qte_vendue; + entry.ca292 = row.ca_ttc; + entry.marge292 = row.marge; + } else if (row.site === "579") { + entry.qte579 = row.qte_vendue; + entry.ca579 = row.ca_ttc; + entry.marge579 = row.marge; + } + } + + for (const entry of map.values()) { + entry.qteTotal = entry.qte292 + entry.qte579; + entry.caTotal = entry.ca292 + entry.ca579; + entry.margeTotal = entry.marge292 + entry.marge579; + } + + return Array.from(map.values()); +} + +export default async function HitParadePage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const today = new Date(); + const currentMois = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; + const mois = (searchParams.mois as string) || currentMois; + + const rows = await pgGetHitParade(mois); + const pivotted = pivotHitParade(rows); + + return ( +
+
+

Hit Parade

+ +
+
+ ); +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 3c682fe..60cf9db 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, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, 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"; @@ -12,6 +12,7 @@ const NAV_ITEMS = [ { icon: BarChart2, label: "Dashboard", href: "/dashboard" }, { icon: LayoutGrid, label: "Grille", href: "/grid" }, { icon: TrendingUp, label: "Analytics", href: "/analytics" }, + { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports" }, { 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 4c4bf60..a56883f 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -693,3 +693,45 @@ export async function pgGetCaByNomenclature( console.log(`[pg-ff] CaByNomenclature: ${result.rows.length} lignes pour ${mois}/${moisN1}`); return result.rows as unknown as CaByNomenclatureRow[]; } + +// --------------------------------------------------------------------------- +// Hit Parade — produits les plus vendus sur un mois donné +// --------------------------------------------------------------------------- + +export interface HitParadeRow { + codein: string; + libelle: string; + fournisseur: string; + site: string; + qte_vendue: number; + ca_ttc: number; + marge: number; +} + +/** + * Retourne les ventes produit pour un mois donné, avec fournisseur, qté, CA TTC et marge par site. + */ +export async function pgGetHitParade(mois: string): Promise { + const result = await pgNoParallel(sql` + SELECT + a.codein::text AS codein, + a.libelle1::text AS libelle, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + m.site, + SUM(ABS(m.qtemvt))::float AS qte_vendue, + ABS(SUM(m.mntmvtttc))::float AS ca_ttc, + SUM(m.margemvt)::float AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + LEFT JOIN fouident fi ON fi.code = af.code + WHERE TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY a.codein, a.libelle1, fi.nom, af.code, m.site + ORDER BY ca_ttc DESC + `); + + console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${mois}`); + return result.rows as unknown as HitParadeRow[]; +}