mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 14:38:10 +02:00
feat: Implement dashboard page, its data fetching client, and shared sidebar component.
This commit is contained in:
1 parent
e391550a30
commit
d5a422e738
6 files changed
+550
-364
No files matched your search
@@ -0,0 +1,399 @@
|
|||||||
|
import { pgGetDashboardData, type DashboardTopItem, type DashboardSiteStats, type DashboardMonthStats } from "@/lib/pg-ff-client";
|
||||||
|
import { TrendingUp, TrendingDown, Minus, Store, ShoppingCart, Euro, Percent } from "lucide-react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SITE_NAMES: Record<string, string> = { "292": "Frouard", "579": "Houdemont" };
|
||||||
|
|
||||||
|
function fmtEur(n: number): string {
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} M€`;
|
||||||
|
if (abs >= 1_000) return `${(n / 1_000).toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} k€`;
|
||||||
|
return `${n.toLocaleString("fr-FR", { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtQte(n: number): string {
|
||||||
|
return n.toLocaleString("fr-FR", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(n: number, signed = true): string {
|
||||||
|
const s = signed && n > 0 ? "+" : "";
|
||||||
|
return `${s}${n.toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delta(current: number, previous: number): number | null {
|
||||||
|
if (!previous || previous === 0) return null;
|
||||||
|
return ((current - previous) / Math.abs(previous)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(mois: string): string {
|
||||||
|
const [y, m] = mois.split("-");
|
||||||
|
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||||
|
return `${names[parseInt(m) - 1]} ${y.slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateLabel(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split("-");
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components (server-side, no "use client" needed)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeltaBadge({ pct }: { pct: number | null }) {
|
||||||
|
if (pct === null) return <span className="text-xs" style={{ color: "var(--text-muted)" }}>—</span>;
|
||||||
|
const isPos = pct >= 0;
|
||||||
|
const Icon = pct > 0.5 ? TrendingUp : pct < -0.5 ? TrendingDown : Minus;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-xs font-semibold"
|
||||||
|
style={{ color: isPos ? "#10b981" : "#ef4444" }}>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{fmtPct(pct)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, sub, delta: d, icon: Icon }: {
|
||||||
|
label: string; value: string; sub?: string; delta?: number | null;
|
||||||
|
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl p-4 flex flex-col gap-2" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>{label}</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
|
||||||
|
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>{value}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{d !== undefined && <DeltaBadge pct={d} />}
|
||||||
|
{sub && <span className="text-xs" style={{ color: "var(--text-muted)" }}>{sub}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SiteCard({ site }: { site: DashboardSiteStats }) {
|
||||||
|
const name = SITE_NAMES[site.site] ?? site.site;
|
||||||
|
const txMarge = site.ca_hier > 0 ? (site.marge_hier / site.ca_hier) * 100 : 0;
|
||||||
|
const dCa = delta(site.ca_hier, site.ca_d7);
|
||||||
|
const dQte = delta(site.qte_hier, site.qte_d7);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl p-5 flex flex-col gap-4" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Store className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||||
|
<span className="text-base font-bold" style={{ color: "var(--text-primary)" }}>{name}</span>
|
||||||
|
<span className="text-xs ml-auto" style={{ color: "var(--text-muted)" }}>site {site.site}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>CA</div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{fmtEur(site.ca_hier)}</div>
|
||||||
|
<DeltaBadge pct={dCa} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Quantité</div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{fmtQte(site.qte_hier)}</div>
|
||||||
|
<DeltaBadge pct={dQte} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Marge</div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: "var(--text-primary)" }}>{fmtEur(site.marge_hier)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Tx Marge</div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: txMarge >= 25 ? "#10b981" : txMarge >= 15 ? "#f59e0b" : "#ef4444" }}>
|
||||||
|
{fmtPct(txMarge, false)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs pt-2" style={{ color: "var(--text-muted)", borderTop: "1px solid var(--border)" }}>
|
||||||
|
{fmtQte(site.lignes_hier)} lignes vendues
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Top10Table({ title, items, sortKey }: {
|
||||||
|
title: string; items: DashboardTopItem[]; sortKey: "ca" | "qte" | "marge";
|
||||||
|
}) {
|
||||||
|
const maxVal = items[0]?.[sortKey] ?? 1;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl overflow-hidden flex flex-col" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: "var(--border)" }}>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Aucune donnée pour hier
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const val = item[sortKey];
|
||||||
|
const barPct = maxVal > 0 ? (val / maxVal) * 100 : 0;
|
||||||
|
const txMarge = item.ca > 0 ? (item.marge / item.ca) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={item.codein} className="px-4 py-2.5 flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold w-5 shrink-0 text-center rounded"
|
||||||
|
style={{ color: i === 0 ? "#f59e0b" : i === 1 ? "#94a3b8" : i === 2 ? "#92400e" : "var(--text-muted)" }}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs flex-1 truncate" style={{ color: "var(--text-primary)" }} title={item.libelle1}>
|
||||||
|
{item.libelle1 ?? item.codein}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold shrink-0" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{sortKey === "qte" ? fmtQte(val) : fmtEur(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pl-7">
|
||||||
|
<div className="flex-1 h-1 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: "var(--accent)" }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs shrink-0" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{sortKey !== "ca" && `CA ${fmtEur(item.ca)} • `}
|
||||||
|
{fmtPct(txMarge, false)} marge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvolutionTable({ evolution }: { evolution: DashboardMonthStats[] }) {
|
||||||
|
// Regrouper par mois YYYY-MM → data
|
||||||
|
const byMois = new Map(evolution.map(e => [e.mois, e]));
|
||||||
|
|
||||||
|
// Calculer les 12 derniers mois complets (pas le mois en cours)
|
||||||
|
const now = new Date();
|
||||||
|
const months12: string[] = [];
|
||||||
|
for (let i = 13; i >= 2; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
months12.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = months12.map(mois => {
|
||||||
|
const [y, m] = mois.split("-");
|
||||||
|
const prevMois = `${parseInt(y) - 1}-${m}`;
|
||||||
|
const n = byMois.get(mois);
|
||||||
|
const n1 = byMois.get(prevMois);
|
||||||
|
const dCa = n && n1 ? delta(n.ca, n1.ca) : null;
|
||||||
|
const dQte = n && n1 ? delta(n.qte, n1.qte) : null;
|
||||||
|
return { mois, n, n1, dCa, dQte };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trouver les maxCA pour les barres
|
||||||
|
const maxCa = Math.max(...rows.map(r => Math.max(r.n?.ca ?? 0, r.n1?.ca ?? 0)), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl overflow-hidden" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>Évolution mensuelle — CA réseau</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>N vs N-1 · 12 mois complets</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<th className="px-4 py-2 text-left font-medium" style={{ color: "var(--text-muted)" }}>Mois</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-muted)" }}>CA N</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-muted)" }}>CA N-1</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-muted)" }}>Δ CA</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-muted)" }}>Qté N</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-muted)" }}>Qté N-1</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-muted)" }}>Δ Qté</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-muted)" }}>Tx Marge N</th>
|
||||||
|
<th className="px-4 py-2 font-medium min-w-[120px]" style={{ color: "var(--text-muted)" }}>Tendance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(({ mois, n, n1, dCa, dQte }) => {
|
||||||
|
const txN = n && n.ca > 0 ? (n.marge / n.ca) * 100 : null;
|
||||||
|
const barN = n ? (n.ca / maxCa) * 100 : 0;
|
||||||
|
const barN1 = n1 ? (n1.ca / maxCa) * 100 : 0;
|
||||||
|
const isPositive = dCa !== null && dCa >= 0;
|
||||||
|
return (
|
||||||
|
<tr key={mois} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<td className="px-4 py-2 font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{monthLabel(mois)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{n ? fmtEur(n.ca) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{n1 ? fmtEur(n1.ca) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{dCa !== null ? (
|
||||||
|
<span className="font-semibold" style={{ color: isPositive ? "#10b981" : "#ef4444" }}>
|
||||||
|
{fmtPct(dCa)}
|
||||||
|
</span>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right font-medium" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{n ? fmtQte(n.qte) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{n1 ? fmtQte(n1.qte) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{dQte !== null ? (
|
||||||
|
<span className="font-semibold" style={{ color: dQte >= 0 ? "#10b981" : "#ef4444" }}>
|
||||||
|
{fmtPct(dQte)}
|
||||||
|
</span>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{txN !== null ? (
|
||||||
|
<span style={{ color: txN >= 25 ? "#10b981" : txN >= 15 ? "#f59e0b" : "#ef4444" }}>
|
||||||
|
{fmtPct(txN, false)}
|
||||||
|
</span>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] w-5 shrink-0" style={{ color: "var(--accent)" }}>N</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${barN}%`, background: "var(--accent)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] w-5 shrink-0" style={{ color: "var(--text-muted)" }}>N-1</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${barN1}%`, background: "var(--text-muted)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page principale
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await pgGetDashboardData();
|
||||||
|
} catch (e) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Impossible de charger le tableau de bord : {(e as Error).message?.slice(0, 200)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dateHier, sites, top10Ca, top10Qte, top10Marge, evolution } = data;
|
||||||
|
|
||||||
|
// Totaux réseau
|
||||||
|
const totalCa = sites.reduce((s, r) => s + r.ca_hier, 0);
|
||||||
|
const totalQte = sites.reduce((s, r) => s + r.qte_hier, 0);
|
||||||
|
const totalMarge = sites.reduce((s, r) => s + r.marge_hier, 0);
|
||||||
|
const txMarge = totalCa > 0 ? (totalMarge / totalCa) * 100 : 0;
|
||||||
|
const totalLignes = sites.reduce((s, r) => s + r.lignes_hier, 0);
|
||||||
|
const totalCaD7 = sites.reduce((s, r) => s + r.ca_d7, 0);
|
||||||
|
const totalQteD7 = sites.reduce((s, r) => s + r.qte_d7, 0);
|
||||||
|
const totalMargeD7 = sites.reduce((s, r) => s + r.marge_d7, 0);
|
||||||
|
const txMargeD7 = totalCaD7 > 0 ? (totalMargeD7 / totalCaD7) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 pb-12 max-w-screen-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
|
||||||
|
Tableau de Bord
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Données du <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{dateLabel(dateHier)}</span>
|
||||||
|
{" "}· Comparaison vs semaine précédente (J-7)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI réseau */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<KpiCard
|
||||||
|
label="CA Réseau"
|
||||||
|
value={fmtEur(totalCa)}
|
||||||
|
delta={delta(totalCa, totalCaD7)}
|
||||||
|
sub={`J-7 : ${fmtEur(totalCaD7)}`}
|
||||||
|
icon={Euro}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Quantité vendue"
|
||||||
|
value={fmtQte(totalQte)}
|
||||||
|
delta={delta(totalQte, totalQteD7)}
|
||||||
|
sub={`J-7 : ${fmtQte(totalQteD7)}`}
|
||||||
|
icon={ShoppingCart}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Marge Brute"
|
||||||
|
value={fmtEur(totalMarge)}
|
||||||
|
delta={delta(totalMarge, totalMargeD7)}
|
||||||
|
sub={`J-7 : ${fmtEur(totalMargeD7)}`}
|
||||||
|
icon={TrendingUp}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Taux de Marge"
|
||||||
|
value={fmtPct(txMarge, false)}
|
||||||
|
delta={txMarge - txMargeD7}
|
||||||
|
sub={`J-7 : ${fmtPct(txMargeD7, false)} · ${fmtQte(totalLignes)} lignes`}
|
||||||
|
icon={Percent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Par magasin */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold mb-3 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Par magasin
|
||||||
|
</h2>
|
||||||
|
{sites.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Aucune donnée pour hier.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{sites.map(s => <SiteCard key={s.site} site={s} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hit Parade */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold mb-3 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Hit Parade — réseau · {dateLabel(dateHier)}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Top10Table title="Top 10 Chiffre d'affaires" items={top10Ca} sortKey="ca" />
|
||||||
|
<Top10Table title="Top 10 Quantité vendue" items={top10Qte} sortKey="qte" />
|
||||||
|
<Top10Table title="Top 10 Marge brute" items={top10Marge} sortKey="marge" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Évolution N/N-1 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold mb-3 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Évolution annuelle
|
||||||
|
</h2>
|
||||||
|
<EvolutionTable evolution={evolution} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Bot, TrendingUp, Calendar, Zap, AlertTriangle, ShieldCheck, Scale, Info, Brain } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function AiAnalysisDocPage() {
|
|
||||||
return (
|
|
||||||
<div className="max-w-5xl mx-auto py-10 px-6 space-y-12">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-12 h-12 rounded-2xl bg-[var(--accent)] flex items-center justify-center shadow-lg shadow-brand-500/20">
|
|
||||||
<Bot className="w-7 h-7 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-black tracking-tight text-[var(--text-primary)]">
|
|
||||||
Intelligence Artificielle Copilot
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--text-secondary)] font-medium">
|
|
||||||
Comprendre les algorithmes et règles métiers de notre assistant d'achat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Grid of Rules */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* 1. Loi de Pareto */}
|
|
||||||
<DocCard
|
|
||||||
icon={ShieldCheck}
|
|
||||||
title="Loi de Pareto (Poids %)"
|
|
||||||
description="Priorité n°1 : Le poids du produit chez le fournisseur."
|
|
||||||
accent="teal"
|
|
||||||
>
|
|
||||||
<p className="text-[13px] leading-relaxed">
|
|
||||||
L'IA identifie les produits <strong>Indiscutables</strong>. Si un produit pèse
|
|
||||||
<strong> {"> 10%"}</strong> du CA total de son fournisseur, il est jugé stratégique.
|
|
||||||
Mary refusera de suggérer sa sortie [Z] pour protéger la relation fournisseur et
|
|
||||||
garantir la stabilité de l'assortiment.
|
|
||||||
</p>
|
|
||||||
</DocCard>
|
|
||||||
|
|
||||||
{/* 2. Équité & Normalisation */}
|
|
||||||
<DocCard
|
|
||||||
icon={Scale}
|
|
||||||
title="Normalisation & Équité"
|
|
||||||
description="Égalité de traitement entre mono et multi-magasins."
|
|
||||||
accent="rose"
|
|
||||||
>
|
|
||||||
<p className="text-[13px] leading-relaxed">
|
|
||||||
Pour une comparaison juste, les statistiques des produits présents sur un seul magasin sont
|
|
||||||
<strong> doublées (x2)</strong>. L'IA compare ensuite ces données pondérées aux
|
|
||||||
<strong> Benchmarks du Rayon</strong> (Moyenne du Niveau 2) pour détecter les sur-performances.
|
|
||||||
</p>
|
|
||||||
</DocCard>
|
|
||||||
|
|
||||||
{/* 3. Cycle de Vie */}
|
|
||||||
<DocCard
|
|
||||||
icon={TrendingUp}
|
|
||||||
title="Potentiel & Cycle de Vie"
|
|
||||||
description="Détection des lancements et des fins de vie."
|
|
||||||
accent="indigo"
|
|
||||||
>
|
|
||||||
<p className="text-[13px] leading-relaxed">
|
|
||||||
Mary distingue les produits récents des produits établis :
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 space-y-1 text-[13px]">
|
|
||||||
<li>• <strong>Run Rate</strong> : Projection sur 12 mois pour les produits de moins d'un an.</li>
|
|
||||||
<li>• <strong>Inactivité</strong> : Une alerte est levée après <strong>2 mois sans vente</strong>,
|
|
||||||
orientant le verdict vers le [C] (Saisonnier) ou [Z] (Sortie).</li>
|
|
||||||
</ul>
|
|
||||||
</DocCard>
|
|
||||||
|
|
||||||
{/* 4. PMV & Trafic */}
|
|
||||||
<DocCard
|
|
||||||
icon={Zap}
|
|
||||||
title="Volumes vs Valeur (PMV)"
|
|
||||||
description="Identifier les générateurs de trafic."
|
|
||||||
accent="amber"
|
|
||||||
>
|
|
||||||
<p className="text-[13px] leading-relaxed">
|
|
||||||
Un produit peut être maintenu même avec un faible CA s'il génère du <strong>Trafic</strong> (Volumes {">"} Moyenne Rayon).
|
|
||||||
À l'inverse, un produit à faible volume peut être un <strong>Contributeur de Marge</strong> précieux s'il possède un Prix Moyen de Vente (PMV) élevé.
|
|
||||||
</p>
|
|
||||||
</DocCard>
|
|
||||||
|
|
||||||
{/* 5. Règles IA Fournisseur */}
|
|
||||||
<DocCard
|
|
||||||
icon={Brain}
|
|
||||||
title="Règles Métier Fournisseur"
|
|
||||||
description="Priorité absolue aux directives personnalisées."
|
|
||||||
accent="indigo"
|
|
||||||
>
|
|
||||||
<p className="text-[13px] leading-relaxed">
|
|
||||||
Vous pouvez définir des <strong>Règles IA</strong> spécifiques pour chaque fournisseur (via le bouton avec le cerveau 🧠).
|
|
||||||
Ces directives sont mémorisées d'une session à l'autre et l'IA a pour consigne stricte de les <strong>respecter en priorité absolue</strong>
|
|
||||||
sur les calculs algorithmiques standards.
|
|
||||||
</p>
|
|
||||||
</DocCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer / Tip */}
|
|
||||||
<div className="p-6 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex gap-4">
|
|
||||||
<Info className="w-6 h-6 text-[var(--accent)] shrink-0" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-bold text-[var(--accent)]">Conseil d'expert</p>
|
|
||||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
|
||||||
L'IA est un outil d'aide à la décision. Elle combine ces règles pour vous proposer une recommandation
|
|
||||||
brute, mais votre expertise terrain reste essentielle pour valider les cas particuliers (ruptures fournisseurs prolongées, promotions, etc.).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocCard({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
accent
|
|
||||||
}: {
|
|
||||||
icon: any,
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
children: React.ReactNode,
|
|
||||||
accent: "teal" | "indigo" | "amber" | "rose"
|
|
||||||
}) {
|
|
||||||
const colors = {
|
|
||||||
teal: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 shadow-emerald-500/5",
|
|
||||||
indigo: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20 shadow-indigo-500/5",
|
|
||||||
amber: "bg-amber-500/10 text-amber-600 border-amber-500/20 shadow-amber-500/5",
|
|
||||||
rose: "bg-rose-500/10 text-rose-600 border-rose-500/20 shadow-rose-500/5",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group p-6 rounded-2xl bg-white dark:bg-slate-900 border border-[var(--border)] shadow-sm hover:shadow-md transition-all">
|
|
||||||
<div className="flex items-start gap-4 mb-4">
|
|
||||||
<div className={cn("w-10 h-10 rounded-xl flex items-center justify-center shadow-sm border", colors[accent])}>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-[15px] font-bold text-[var(--text-primary)]">{title}</h3>
|
|
||||||
<p className="text-[12px] text-[var(--text-muted)] font-medium leading-tight">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[var(--text-secondary)] leading-relaxed">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { BarChart3, ArrowRight, TrendingUp, Award, Layers } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
function InfoCard({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-[14px] overflow-hidden"
|
|
||||||
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
|
|
||||||
>
|
|
||||||
<div className="px-5 py-4 flex items-center gap-2.5" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
|
|
||||||
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={2} />
|
|
||||||
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-5" style={{ background: "var(--bg-surface)" }}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormulaStep({ step, title, formula }: { step: number; title: string; formula: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold flex-shrink-0 mt-0.5"
|
|
||||||
style={{ background: "var(--accent-bg)", color: "var(--accent)", border: "1px solid var(--accent-border)" }}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>{title}</p>
|
|
||||||
<code className="text-[12px] mt-1 block px-3 py-2 rounded-[8px] font-mono" style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)", border: "1px solid var(--border)" }}>
|
|
||||||
{formula}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScoreBadge({ value, label }: { value: string; label: string }) {
|
|
||||||
const num = parseFloat(value);
|
|
||||||
const color = num >= 80 ? "text-emerald-500" : num >= 50 ? "text-amber-500" : "text-rose-500";
|
|
||||||
const bg = num >= 80 ? "rgba(16, 185, 129, 0.1)" : num >= 50 ? "rgba(245, 158, 11, 0.1)" : "rgba(244, 63, 94, 0.1)";
|
|
||||||
const borderColor = num >= 80 ? "rgba(16, 185, 129, 0.2)" : num >= 50 ? "rgba(245, 158, 11, 0.2)" : "rgba(244, 63, 94, 0.2)";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div
|
|
||||||
className={`text-[18px] font-black ${color} inline-flex items-center justify-center w-12 h-12 rounded-[10px]`}
|
|
||||||
style={{ background: bg, border: `1px solid ${borderColor}` }}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] mt-1.5 font-medium" style={{ color: "var(--text-muted)" }}>{label}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScorePage() {
|
|
||||||
return (
|
|
||||||
<div className="p-6 space-y-6 max-w-2xl overflow-y-auto h-full">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Score Produit
|
|
||||||
</h1>
|
|
||||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Comprenez comment chaque produit est évalué pour le référencement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Principe fondamental */}
|
|
||||||
<InfoCard icon={Award} title="Principe fondamental">
|
|
||||||
<div
|
|
||||||
className="rounded-[10px] px-4 py-3"
|
|
||||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}
|
|
||||||
>
|
|
||||||
<p className="text-[13px] font-medium" style={{ color: "var(--accent)" }}>
|
|
||||||
Un produit excellent sur <strong>une seule dimension</strong> suffit à en faire un bon produit.
|
|
||||||
</p>
|
|
||||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Un produit qui fait 30% du volume du fournisseur est un bon produit même si sa marge est faible.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</InfoCard>
|
|
||||||
|
|
||||||
{/* Équité & Normalisation */}
|
|
||||||
<InfoCard icon={Layers} title="Équité & Normalisation">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Afin de ne pas pénaliser les produits présents dans un seul magasin, une <strong>pondération d'équité</strong> est appliquée :
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="rounded-[10px] px-4 py-3 flex items-center gap-4"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<div className="text-2xl">⚖️</div>
|
|
||||||
<div className="text-[12px]" style={{ color: "var(--text-primary)" }}>
|
|
||||||
<p><strong>Produit 1 Magasin</strong> : Statistiques multipliées par <strong>2</strong></p>
|
|
||||||
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Cela permet de comparer sa performance sur une base réseau normalisée de 2 magasins.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</InfoCard>
|
|
||||||
|
|
||||||
{/* Les 3 axes */}
|
|
||||||
<InfoCard icon={Layers} title="Les 3 axes d'évaluation">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Chaque produit est évalué sur son <strong>poids relatif par rapport au meilleur produit</strong> :
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: "Quantité", desc: "Volume vendu", icon: "📦" },
|
|
||||||
{ label: "Chiffre d'Affaires", desc: "Montant total", icon: "💰" },
|
|
||||||
{ label: "Marge", desc: "Profit généré", icon: "📈" },
|
|
||||||
].map(({ label, desc, icon }) => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className="px-3 py-3 rounded-[10px] text-center"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<div className="text-lg">{icon}</div>
|
|
||||||
<p className="text-[12px] font-semibold mt-1" style={{ color: "var(--text-primary)" }}>{label}</p>
|
|
||||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Poids = (métrique du produit ÷ métrique du Produit N°1) × 100
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</InfoCard>
|
|
||||||
|
|
||||||
{/* Formule */}
|
|
||||||
<InfoCard icon={TrendingUp} title="Formule de calcul">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormulaStep
|
|
||||||
step={1}
|
|
||||||
title="Calculer les 3 scores de base (indexés sur le TOP 100)"
|
|
||||||
formula="score_axe = produit.métrique / top1_fournisseur × 100"
|
|
||||||
/>
|
|
||||||
<FormulaStep
|
|
||||||
step={2}
|
|
||||||
title="Prendre le meilleur axe (MAX)"
|
|
||||||
formula="base = MAX(poids_qty, poids_ca, poids_marge)"
|
|
||||||
/>
|
|
||||||
<FormulaStep
|
|
||||||
step={3}
|
|
||||||
title="Ajouter le bonus polyvalence"
|
|
||||||
formula="bonus = nb_axes_forts × bonus_par_axe"
|
|
||||||
/>
|
|
||||||
<FormulaStep
|
|
||||||
step={4}
|
|
||||||
title="Score final"
|
|
||||||
formula="score = MIN(base + bonus, 100)"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="rounded-[10px] px-4 py-3 mt-2"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<strong>Axe fort</strong> = un axe dont le poids dépasse le seuil (configurable dans Paramètres).
|
|
||||||
Un produit peut obtenir un bonus s'il excelle sur 2 ou 3 axes à la fois.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</InfoCard>
|
|
||||||
|
|
||||||
{/* Signification des couleurs */}
|
|
||||||
<InfoCard icon={BarChart3} title="Lecture du score">
|
|
||||||
<div className="flex items-center justify-center gap-6 py-2">
|
|
||||||
<ScoreBadge value="85" label="Excellent" />
|
|
||||||
<ScoreBadge value="55" label="Correct" />
|
|
||||||
<ScoreBadge value="12" label="Faible" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mt-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>≥ 80</strong> — Produit stratégique, très forte contribution</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>50 – 79</strong> — Contribution significative, à conserver</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-rose-500" />
|
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>< 50</strong> — Contribution faible, à réévaluer</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</InfoCard>
|
|
||||||
|
|
||||||
{/* Lien vers les paramètres */}
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
className="flex items-center gap-2 px-4 py-3 rounded-[10px] transition-all hover:gap-3"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium">Ajuster les paramètres du score</span>
|
|
||||||
<ArrowRight className="w-4 h-4 ml-auto" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
redirect("/grid");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3, LogOut, User as UserIcon, Loader2, Bot, ChevronRight, ChevronLeft } from "lucide-react";
|
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -9,11 +9,10 @@ import { useEffect, useState } from "react";
|
|||||||
import { useSession, signOut } from "next-auth/react";
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
|
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
|
||||||
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
||||||
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
||||||
{ icon: FileDown, label: "Exports", href: "/exports" },
|
{ icon: FileDown, label: "Exports", href: "/exports" },
|
||||||
{ icon: BarChart3, label: "Score", href: "/score" },
|
|
||||||
{ icon: Bot, label: "Aide IA", href: "/docs/ai-analysis" },
|
|
||||||
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -461,3 +461,151 @@ export async function pgGetCommandesByFournisseur(codefou: string): Promise<Map<
|
|||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 7. Dashboard quotidien (hit parade + évolution N/N-1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DashboardSiteStats {
|
||||||
|
site: string;
|
||||||
|
ca_hier: number;
|
||||||
|
qte_hier: number;
|
||||||
|
marge_hier: number;
|
||||||
|
lignes_hier: number;
|
||||||
|
ca_d7: number;
|
||||||
|
qte_d7: number;
|
||||||
|
marge_d7: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTopItem {
|
||||||
|
codein: string;
|
||||||
|
libelle1: string;
|
||||||
|
ca: number;
|
||||||
|
qte: number;
|
||||||
|
marge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardMonthStats {
|
||||||
|
mois: string; // "YYYY-MM"
|
||||||
|
ca: number;
|
||||||
|
qte: number;
|
||||||
|
marge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
dateHier: string; // "YYYY-MM-DD"
|
||||||
|
sites: DashboardSiteStats[];
|
||||||
|
top10Ca: DashboardTopItem[];
|
||||||
|
top10Qte: DashboardTopItem[];
|
||||||
|
top10Marge: DashboardTopItem[];
|
||||||
|
evolution: DashboardMonthStats[]; // 25 mois pour N vs N-1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne toutes les données nécessaires pour le dashboard quotidien.
|
||||||
|
* 5 requêtes SQL en parallèle — données réseau (sites 292 + 579).
|
||||||
|
*/
|
||||||
|
export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dateHier = yesterday.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const [sitesResult, top10CaResult, top10QteResult, top10MargeResult, evolutionResult] =
|
||||||
|
await Promise.all([
|
||||||
|
// Stats hier + même jour S-1 par magasin (1 seule requête avec FILTER)
|
||||||
|
pgNoParallel(sql`
|
||||||
|
SELECT
|
||||||
|
m.site,
|
||||||
|
SUM(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN -m.mntmvtttc ELSE 0 END)::float AS ca_hier,
|
||||||
|
SUM(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN -m.qtemvt ELSE 0 END)::float AS qte_hier,
|
||||||
|
SUM(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN m.margemvt ELSE 0 END)::float AS marge_hier,
|
||||||
|
COUNT(CASE WHEN m.datmvt = CURRENT_DATE - 1 THEN 1 END)::int AS lignes_hier,
|
||||||
|
SUM(CASE WHEN m.datmvt = CURRENT_DATE - 7 THEN -m.mntmvtttc ELSE 0 END)::float AS ca_d7,
|
||||||
|
SUM(CASE WHEN m.datmvt = CURRENT_DATE - 7 THEN -m.qtemvt ELSE 0 END)::float AS qte_d7,
|
||||||
|
SUM(CASE WHEN m.datmvt = CURRENT_DATE - 7 THEN m.margemvt ELSE 0 END)::float AS marge_d7
|
||||||
|
FROM mvtart m
|
||||||
|
WHERE m.datmvt IN (CURRENT_DATE - 1, CURRENT_DATE - 7)
|
||||||
|
AND m.genremvt = 3
|
||||||
|
AND m.site IN ('292', '579')
|
||||||
|
GROUP BY m.site
|
||||||
|
ORDER BY m.site
|
||||||
|
`),
|
||||||
|
// Top 10 CA réseau hier
|
||||||
|
pgNoParallel(sql`
|
||||||
|
SELECT
|
||||||
|
a.codein,
|
||||||
|
a.libelle1,
|
||||||
|
SUM(-m.mntmvtttc)::float AS ca,
|
||||||
|
SUM(-m.qtemvt)::float AS qte,
|
||||||
|
SUM(m.margemvt)::float AS marge
|
||||||
|
FROM mvtart m
|
||||||
|
JOIN articles a ON a.no_id = m.artnoid
|
||||||
|
WHERE m.datmvt = CURRENT_DATE - 1
|
||||||
|
AND m.genremvt = 3
|
||||||
|
AND m.site IN ('292', '579')
|
||||||
|
GROUP BY a.codein, a.libelle1
|
||||||
|
ORDER BY ca DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
// Top 10 QTE réseau hier
|
||||||
|
pgNoParallel(sql`
|
||||||
|
SELECT
|
||||||
|
a.codein,
|
||||||
|
a.libelle1,
|
||||||
|
SUM(-m.qtemvt)::float AS qte,
|
||||||
|
SUM(-m.mntmvtttc)::float AS ca,
|
||||||
|
SUM(m.margemvt)::float AS marge
|
||||||
|
FROM mvtart m
|
||||||
|
JOIN articles a ON a.no_id = m.artnoid
|
||||||
|
WHERE m.datmvt = CURRENT_DATE - 1
|
||||||
|
AND m.genremvt = 3
|
||||||
|
AND m.site IN ('292', '579')
|
||||||
|
GROUP BY a.codein, a.libelle1
|
||||||
|
ORDER BY qte DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
// Top 10 Marge réseau hier
|
||||||
|
pgNoParallel(sql`
|
||||||
|
SELECT
|
||||||
|
a.codein,
|
||||||
|
a.libelle1,
|
||||||
|
SUM(m.margemvt)::float AS marge,
|
||||||
|
SUM(-m.mntmvtttc)::float AS ca,
|
||||||
|
SUM(-m.qtemvt)::float AS qte
|
||||||
|
FROM mvtart m
|
||||||
|
JOIN articles a ON a.no_id = m.artnoid
|
||||||
|
WHERE m.datmvt = CURRENT_DATE - 1
|
||||||
|
AND m.genremvt = 3
|
||||||
|
AND m.site IN ('292', '579')
|
||||||
|
GROUP BY a.codein, a.libelle1
|
||||||
|
ORDER BY marge DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
// Évolution mensuelle sur 25 mois (N et N-1)
|
||||||
|
pgNoParallel(sql`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||||||
|
SUM(-m.mntmvtttc)::float AS ca,
|
||||||
|
SUM(-m.qtemvt)::float AS qte,
|
||||||
|
SUM(m.margemvt)::float AS marge
|
||||||
|
FROM mvtart m
|
||||||
|
WHERE m.datmvt >= (CURRENT_DATE - INTERVAL '25 months')
|
||||||
|
AND m.datmvt < CURRENT_DATE
|
||||||
|
AND m.genremvt = 3
|
||||||
|
AND m.site IN ('292', '579')
|
||||||
|
GROUP BY TO_CHAR(m.datmvt, 'YYYY-MM')
|
||||||
|
ORDER BY mois
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`[pg-ff] Dashboard: ${sitesResult.rows.length} sites, ${evolutionResult.rows.length} mois d'évolution`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateHier,
|
||||||
|
sites: sitesResult.rows as unknown as DashboardSiteStats[],
|
||||||
|
top10Ca: top10CaResult.rows as unknown as DashboardTopItem[],
|
||||||
|
top10Qte: top10QteResult.rows as unknown as DashboardTopItem[],
|
||||||
|
top10Marge: top10MargeResult.rows as unknown as DashboardTopItem[],
|
||||||
|
evolution: evolutionResult.rows as unknown as DashboardMonthStats[],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user