feat: add analytics page with CA TTC comparison by supplier/nomenclature

- 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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-31 09:40:31 +02:00
1 parent 7a30783285
commit 740aed3181
4 files changed
+483

No files matched your search

+252
View File
@@ -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 <span className="text-gray-400">N/A</span>;
const isPositive = value >= 0;
const bgColor = isPositive ? "bg-green-100" : "bg-red-100";
const textColor = isPositive ? "text-green-700" : "text-red-700";
return (
<span className={`${bgColor} ${textColor} inline-block rounded px-2 py-1 text-sm font-semibold`}>
{isPositive ? "+" : ""}
{value.toFixed(1)}%
</span>
);
}
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 (
<div className="space-y-6">
{/* Controls */}
<div className="flex items-center gap-6 rounded-lg bg-white p-4 shadow">
<div className="flex items-center gap-2">
<label className="font-semibold text-gray-700">Vue :</label>
<select
value={mode}
onChange={(e) => handleModeChange(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
<option value="fournisseur">Fournisseur</option>
<option value="nomenclature">Nomenclature</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="font-semibold text-gray-700">Mois :</label>
<select
value={mois}
onChange={(e) => handleMonthChange(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
{months.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
<div className="text-sm text-gray-600">
Comparaison N-1 : {moisN1}
</div>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg bg-white shadow">
<table className="w-full">
<thead>
<tr className="border-b-2 border-gray-200 bg-gray-50">
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
{mode === "fournisseur" ? "Fournisseur" : "Nomenclature"}
</th>
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
Frouard/Nancy (292)
</th>
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
Houdemont (579)
</th>
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
Total Réseau
</th>
</tr>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600"></th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{mois}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{moisN1}
</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
Évol.
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{mois}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{moisN1}
</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
Évol.
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{mois}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{moisN1}
</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
Évol.
</th>
</tr>
</thead>
<tbody>
{pivotted.map((row, idx) => (
<tr
key={row.key}
className={idx % 2 === 0 ? "border-b border-gray-100 bg-white" : "border-b border-gray-100 bg-gray-50"}
>
<td className="px-4 py-3 text-sm font-medium text-gray-900">{row.label}</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca292)}</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_292)}</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge
value={
row.caN1_292 > 0
? ((row.ca292 - row.caN1_292) / row.caN1_292) * 100
: null
}
/>
</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca579)}</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_579)}</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge
value={
row.caN1_579 > 0
? ((row.ca579 - row.caN1_579) / row.caN1_579) * 100
: null
}
/>
</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
{formatCA(row.caTotal)}
</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
{formatCA(row.caN1Total)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={row.evolutionTotal} />
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50">
<td className="px-4 py-3 text-sm font-bold text-gray-900">TOTAL</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.ca292)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caN1_292)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={evolutionTotal292} />
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.ca579)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caN1_579)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={evolutionTotal579} />
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caTotal)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caN1Total)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={evolutionTotalReseau} />
</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}
+153
View File
@@ -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<string, { code: string; label: string; data: Record<string, number> }>();
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<Record<string, string | string[]>>;
}) {
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 (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-7xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Analytics</h1>
<AnalyticsClient
mode={mode}
mois={mois}
moisN1={moisN1}
currentMois={currentMois}
pivotted={pivotted}
totals={totals}
evolutionTotal292={evolutionTotal292}
evolutionTotal579={evolutionTotal579}
evolutionTotalReseau={evolutionTotalReseau}
formatCA={formatCA}
/>
</div>
</div>
);
}
+1
View File
@@ -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 },
+77
View File
@@ -616,3 +616,80 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
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<CaByFournisseurRow[]> {
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<CaByNomenclatureRow[]> {
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[];
}