feat: Introduce direct PostgreSQL client for FF Nancy data to replace HTTP API calls and add a new dashboard page.

This commit is contained in:
Michael committed 2026-03-21 14:37:02 +01:00
1 parent d1bbe290da
commit 6ee0e3294d
2 files changed
+32 -22

No files matched your search

+2 -1
View File
@@ -15,7 +15,7 @@ const SITE_NAMES: Record<string, string> = {
function fmtEur(n: number): string { function fmtEur(n: number): string {
const abs = Math.abs(n); const abs = Math.abs(n);
if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} M€`; if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} M€`;
return `${Math.round(n).toLocaleString("fr-FR")} €`; return `${n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
} }
function fmtQte(n: number): string { function fmtQte(n: number): string {
@@ -147,6 +147,7 @@ function Top10Table({ title, items, sortKey }: {
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: "var(--accent)" }} /> <div className="h-full rounded-full" style={{ width: `${barPct}%`, background: "var(--accent)" }} />
</div> </div>
<span className="text-xs shrink-0" style={{ color: "var(--text-muted)" }}> <span className="text-xs shrink-0" style={{ color: "var(--text-muted)" }}>
{sortKey === "ca" && `${fmtQte(item.qte)} pcs · `}
{sortKey !== "ca" && `CA ${fmtEur(item.ca)} · `} {sortKey !== "ca" && `CA ${fmtEur(item.ca)} · `}
{fmtPct(txMarge, false)} marge {fmtPct(txMarge, false)} marge
</span> </span>
+30 -21
View File
@@ -539,31 +539,41 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
const dateHier = yesterday.toISOString().split("T")[0]; const dateHier = yesterday.toISOString().split("T")[0];
// Même jour de semaine N-1 : 364 jours = 52 semaines exactes (conserve le jour) // N-1 : même jour de semaine, même semaine N-1 → 364 jours = 52 semaines exactes
const n1Date = new Date(yesterday); const n1Date = new Date(yesterday);
n1Date.setDate(n1Date.getDate() - 364); n1Date.setDate(n1Date.getDate() - 364);
const dateN1Param = n1Date.toISOString().split("T")[0]; const dateN1Param = n1Date.toISOString().split("T")[0];
const apiBase = "https://api.ffnancy.fr/api/performance"; const apiBase = "https://api.ffnancy.fr/api/performance";
const apiResult = await fetch( // Deux appels parallèles : données hier + données même jour N-1
`${apiBase}/dashboard?date=${dateHier}&date_n1=${dateN1Param}`, const [apiResult, apiN1Result] = await Promise.all([
{ cache: "no-store" } fetch(`${apiBase}/dashboard?date=${dateHier}`, { cache: "no-store" })
).then(r => { .then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise<ApiPerfDashboard>; }),
if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); fetch(`${apiBase}/dashboard?date=${dateN1Param}`, { cache: "no-store" })
return r.json() as Promise<ApiPerfDashboard>; .then(r => r.ok ? r.json() as Promise<ApiPerfDashboard> : null)
}); .catch(() => null),
]);
const sites: DashboardSiteStats[] = (apiResult.sites ?? []).map(s => ({ // Construire map N-1 par site depuis l'appel séparé
site: s.site, const n1BySite = new Map<string, { ca: number; trafic: number }>();
ca_hier: Number(s.ca_ttc) || 0, for (const s of apiN1Result?.sites ?? []) {
ca_n1: Number(s.ca_ttc_n1) || 0, n1BySite.set(s.site, { ca: Number(s.ca_ttc) || 0, trafic: Number(s.trafic) || 0 });
tickets_hier: Number(s.trafic) || 0, }
tickets_n1: Number(s.trafic_n1) || 0,
qte_hier: 0, const sites: DashboardSiteStats[] = (apiResult.sites ?? []).map(s => {
marge_hier: 0, const n1 = n1BySite.get(s.site);
lignes_hier: 0, return {
})); site: s.site,
ca_hier: Number(s.ca_ttc) || 0,
ca_n1: n1?.ca ?? Number(s.ca_ttc_n1) || 0,
tickets_hier: Number(s.trafic) || 0,
tickets_n1: n1?.trafic ?? Number(s.trafic_n1) || 0,
qte_hier: 0,
marge_hier: 0,
lignes_hier: 0,
};
});
// Top 10 par magasin : grouper les items par site, trier, garder top 10 // Top 10 par magasin : grouper les items par site, trier, garder top 10
const buildTop10 = (items: ApiPerfTopItem[], sortKey: "ca" | "qte" | "marge"): DashboardTopItem[] => const buildTop10 = (items: ApiPerfTopItem[], sortKey: "ca" | "qte" | "marge"): DashboardTopItem[] =>
@@ -595,12 +605,11 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
}; };
}); });
const dateN1 = apiResult.date_n1 ?? dateN1Param; console.log(`[pg-ff] Dashboard: hier=${dateHier} n1=${dateN1Param} sites=${sites.length} n1_found=${n1BySite.size}`);
console.log(`[pg-ff] Dashboard API: ${sites.length} sites, date=${apiResult.date}, date_n1=${dateN1}`);
return { return {
dateHier: apiResult.date ?? dateHier, dateHier: apiResult.date ?? dateHier,
dateN1, dateN1: apiN1Result?.date ?? dateN1Param,
sites, sites,
top10BySite, top10BySite,
}; };