mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
1 parent
7a30783285
commit
740aed3181
4 files changed
+483
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user