From 740aed318164fc2825599287e5b1d358f1815ff7 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 31 Mar 2026 09:40:31 +0200 Subject: [PATCH] feat: add analytics page with CA TTC comparison by supplier/nomenclature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pg-ff-client: add pgGetCaByFournisseur() and pgGetCaByNomenclature() returning CA TTC per site and month for monthly N vs N-1 comparison - analytics/page.tsx: server component with pivot logic to reshape raw query data into store-separated columns (292/579/total) with evolution % - analytics/client.tsx: client controls (mode dropdown, month selector) and responsive table with color-coded evolution badges - sidebar: add "Analytics" link to navigation Features: - Switch between "Fournisseur" and "Nomenclature" views - Select any month for comparison vs same month prior year - Columns split by store: Frouard/Nancy (292), Houdemont (579), Total - Each store shows: CA this month, CA N-1, evolution % - Footer totals row matching sum of detail rows - FR locale currency formatting (€) Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/analytics/client.tsx | 252 +++++++++++++++++++++++ src/app/(dashboard)/analytics/page.tsx | 153 ++++++++++++++ src/components/shared/sidebar.tsx | 1 + src/lib/pg-ff-client.ts | 77 +++++++ 4 files changed, 483 insertions(+) create mode 100644 src/app/(dashboard)/analytics/client.tsx create mode 100644 src/app/(dashboard)/analytics/page.tsx diff --git a/src/app/(dashboard)/analytics/client.tsx b/src/app/(dashboard)/analytics/client.tsx new file mode 100644 index 0000000..a498bdd --- /dev/null +++ b/src/app/(dashboard)/analytics/client.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { BarChart2 } from "lucide-react"; + +interface AnalyticsClientProps { + mode: string; + mois: string; + moisN1: string; + currentMois: string; + pivotted: Array<{ + key: string; + label: string; + ca292: number; + caN1_292: number; + ca579: number; + caN1_579: number; + caTotal: number; + caN1Total: number; + evolutionTotal: number | null; + }>; + totals: { + ca292: number; + caN1_292: number; + ca579: number; + caN1_579: number; + caTotal: number; + caN1Total: number; + }; + evolutionTotal292: number | null; + evolutionTotal579: number | null; + evolutionTotalReseau: number | null; + formatCA: (value: number) => string; +} + +function EvolutionBadge({ value }: { value: number | null }) { + if (value === null) return N/A; + + const isPositive = value >= 0; + const bgColor = isPositive ? "bg-green-100" : "bg-red-100"; + const textColor = isPositive ? "text-green-700" : "text-red-700"; + + return ( + + {isPositive ? "+" : ""} + {value.toFixed(1)}% + + ); +} + +export function AnalyticsClient({ + mode, + mois, + moisN1, + currentMois, + pivotted, + totals, + evolutionTotal292, + evolutionTotal579, + evolutionTotalReseau, + formatCA, +}: AnalyticsClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const handleModeChange = (newMode: string) => { + const params = new URLSearchParams(searchParams); + params.set("mode", newMode); + params.set("mois", mois); + router.push(`/analytics?${params.toString()}`); + }; + + const handleMonthChange = (newMois: string) => { + const params = new URLSearchParams(searchParams); + params.set("mode", mode); + params.set("mois", newMois); + router.push(`/analytics?${params.toString()}`); + }; + + // Générer liste des 24 derniers mois + 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) }); + } + + return ( +
+ {/* Controls */} +
+
+ + +
+ +
+ + +
+ +
+ Comparaison N-1 : {moisN1} +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + + + + + + + + + + + {pivotted.map((row, idx) => ( + + + + + + + + + + + + + ))} + + + + + + + + + + + + + + + +
+ {mode === "fournisseur" ? "Fournisseur" : "Nomenclature"} + + Frouard/Nancy (292) + + Houdemont (579) + + Total Réseau +
+ {mois} + + {moisN1} + + Évol. + + {mois} + + {moisN1} + + Évol. + + {mois} + + {moisN1} + + Évol. +
{row.label}{formatCA(row.ca292)}{formatCA(row.caN1_292)} + 0 + ? ((row.ca292 - row.caN1_292) / row.caN1_292) * 100 + : null + } + /> + {formatCA(row.ca579)}{formatCA(row.caN1_579)} + 0 + ? ((row.ca579 - row.caN1_579) / row.caN1_579) * 100 + : null + } + /> + + {formatCA(row.caTotal)} + + {formatCA(row.caN1Total)} + + +
TOTAL + {formatCA(totals.ca292)} + + {formatCA(totals.caN1_292)} + + + + {formatCA(totals.ca579)} + + {formatCA(totals.caN1_579)} + + + + {formatCA(totals.caTotal)} + + {formatCA(totals.caN1Total)} + + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx new file mode 100644 index 0000000..ec3699a --- /dev/null +++ b/src/app/(dashboard)/analytics/page.tsx @@ -0,0 +1,153 @@ +import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client"; +import { AnalyticsClient } from "./client"; + +// Calcul du mois N-1 (même mois année précédente) +function getMoisN1(mois: string): string { + const [year, month] = mois.split("-").map(Number); + const d = new Date(year, month - 13); // -13 mois + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +// Formatage courant français +function formatCA(value: number): string { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }).format(value); +} + +// Helper pivot : transformer les lignes de requête en tableau pivotté par site +function pivotData( + rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }>, + mois: string, + moisN1: string +): Array<{ + key: string; + label: string; + ca292: number; + caN1_292: number; + ca579: number; + caN1_579: number; + caTotal: number; + caN1Total: number; + evolutionTotal: number | null; +}> { + const map = new Map }>(); + + for (const row of rows) { + const key = `${row.code}|${row.label}`; + if (!map.has(key)) { + map.set(key, { code: row.code, label: row.label, data: {} }); + } + const entry = map.get(key)!; + entry.data[`${row.site}_${row.mois}`] = row.ca_ttc; + } + + const result = Array.from(map.values()).map(({ code, label, data }) => { + const ca292 = data[`292_${mois}`] ?? 0; + const caN1_292 = data[`292_${moisN1}`] ?? 0; + const ca579 = data[`579_${mois}`] ?? 0; + const caN1_579 = data[`579_${moisN1}`] ?? 0; + const caTotal = ca292 + ca579; + const caN1Total = caN1_292 + caN1_579; + const evolutionTotal = + caN1Total > 0 ? ((caTotal - caN1Total) / caN1Total) * 100 : null; + + return { + key: code, + label, + ca292, + caN1_292, + ca579, + caN1_579, + caTotal, + caN1Total, + evolutionTotal, + }; + }); + + // Trier par CA Total décroissant + result.sort((a, b) => b.caTotal - a.caTotal); + return result; +} + +export default async function AnalyticsPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const mode = (searchParams.mode as string) || "fournisseur"; + const moisParam = (searchParams.mois as string) || ""; + + // Mois par défaut = mois courant (YYYY-MM) + const today = new Date(); + const currentMois = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; + const mois = moisParam || currentMois; + const moisN1 = getMoisN1(mois); + + let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> = + []; + + if (mode === "fournisseur") { + const result = await pgGetCaByFournisseur(mois, moisN1); + rows = result.map((r) => ({ + code: r.code, + label: r.nom, + site: r.site, + mois: r.mois, + ca_ttc: r.ca_ttc, + })); + } else { + const result = await pgGetCaByNomenclature(mois, moisN1); + rows = result.map((r) => ({ + code: r.code, + label: r.libelle, + site: r.site, + mois: r.mois, + ca_ttc: r.ca_ttc, + })); + } + + const pivotted = pivotData(rows, mois, moisN1); + + // Calcul des totaux + const totals = pivotted.reduce( + (acc, row) => ({ + ca292: acc.ca292 + row.ca292, + caN1_292: acc.caN1_292 + row.caN1_292, + ca579: acc.ca579 + row.ca579, + caN1_579: acc.caN1_579 + row.caN1_579, + caTotal: acc.caTotal + row.caTotal, + caN1Total: acc.caN1Total + row.caN1Total, + }), + { ca292: 0, caN1_292: 0, ca579: 0, caN1_579: 0, caTotal: 0, caN1Total: 0 } + ); + + const evolutionTotal292 = + totals.caN1_292 > 0 ? ((totals.ca292 - totals.caN1_292) / totals.caN1_292) * 100 : null; + const evolutionTotal579 = + totals.caN1_579 > 0 ? ((totals.ca579 - totals.caN1_579) / totals.caN1_579) * 100 : null; + const evolutionTotalReseau = + totals.caN1Total > 0 ? ((totals.caTotal - totals.caN1Total) / totals.caN1Total) * 100 : null; + + return ( +
+
+

Analytics

+ + +
+
+ ); +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index b6959dd..01c7cee 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -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: BarChart2, label: "Analytics", href: "/analytics" }, { 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 800509f..8115f4d 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -616,3 +616,80 @@ export async function pgGetDashboardData(): Promise { top10BySite, }; } + +// --------------------------------------------------------------------------- +// Analytics — CA TTC par fournisseur et nomenclature +// --------------------------------------------------------------------------- + +export interface CaByFournisseurRow { + code: string; + nom: string; + site: string; + mois: string; + ca_ttc: number; +} + +export interface CaByNomenclatureRow { + code: string; + libelle: string; + site: string; + mois: string; + ca_ttc: number; +} + +/** + * Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1). + */ +export async function pgGetCaByFournisseur( + mois: string, + moisN1: string +): Promise { + const result = await pgNoParallel(sql` + SELECT + af.code AS code, + MAX(f.raisonsociale)::text AS nom, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(-m.mntmvtttc)::float AS ca_ttc + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id + JOIN fouadr1 f ON f.code = af.code AND f.sit_code = '000' + WHERE TO_CHAR(m.datmvt, 'YYYY-MM') = ANY(ARRAY[${mois}, ${moisN1}]) + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY af.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY af.code, m.site, mois + `); + + console.log(`[pg-ff] CaByFournisseur: ${result.rows.length} lignes pour ${mois}/${moisN1}`); + return result.rows as unknown as CaByFournisseurRow[]; +} + +/** + * Retourne le CA TTC par nomenclature (code3) et site pour deux mois donnés. + */ +export async function pgGetCaByNomenclature( + mois: string, + moisN1: string +): Promise { + const result = await pgNoParallel(sql` + SELECT + n.code AS code, + n.libelle::text AS libelle, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(-m.mntmvtttc)::float AS ca_ttc + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN nomenclature n ON n.no_id = a.nom_no_id + WHERE TO_CHAR(m.datmvt, 'YYYY-MM') = ANY(ARRAY[${mois}, ${moisN1}]) + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY n.code, m.site, mois + `); + + console.log(`[pg-ff] CaByNomenclature: ${result.rows.length} lignes pour ${mois}/${moisN1}`); + return result.rows as unknown as CaByNomenclatureRow[]; +}