mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: add Hit Parade page — monthly product sales by store with sort by CA/qty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b3ed4c435
commit
7923861947
4 files changed
+291
-1
No files matched your search
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import type { HitParadePivotRow } from "./page";
|
||||
|
||||
type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "margeTotal";
|
||||
type SortDir = "desc" | "asc";
|
||||
|
||||
function formatCA(v: number) {
|
||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
||||
}
|
||||
|
||||
function formatQte(v: number) {
|
||||
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
}
|
||||
|
||||
function formatPct(ca: number, marge: number) {
|
||||
if (ca === 0) return null;
|
||||
return ((marge / ca) * 100).toFixed(1) + "%";
|
||||
}
|
||||
|
||||
function SortBtn({ active, dir, onClick }: { active: boolean; dir: SortDir; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} className="ml-1 text-xs text-gray-400 hover:text-gray-700">
|
||||
{active ? (dir === "desc" ? "▼" : "▲") : "⇅"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mois: string;
|
||||
currentMois: string;
|
||||
pivotted: HitParadePivotRow[];
|
||||
}
|
||||
|
||||
export function HitParadeClient({ mois, currentMois, pivotted }: Props) {
|
||||
const router = useRouter();
|
||||
const [sortKey, setSortKey] = useState<SortKey>("caTotal");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
|
||||
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) });
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...pivotted].sort((a, b) => {
|
||||
const diff = a[sortKey] - b[sortKey];
|
||||
return sortDir === "desc" ? -diff : diff;
|
||||
});
|
||||
}, [pivotted, sortKey, sortDir]);
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === "desc" ? "asc" : "desc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
}
|
||||
|
||||
const totals = useMemo(() => pivotted.reduce(
|
||||
(acc, r) => ({
|
||||
qte292: acc.qte292 + r.qte292,
|
||||
ca292: acc.ca292 + r.ca292,
|
||||
marge292: acc.marge292 + r.marge292,
|
||||
qte579: acc.qte579 + r.qte579,
|
||||
ca579: acc.ca579 + r.ca579,
|
||||
marge579: acc.marge579 + r.marge579,
|
||||
qteTotal: acc.qteTotal + r.qteTotal,
|
||||
caTotal: acc.caTotal + r.caTotal,
|
||||
margeTotal: acc.margeTotal + r.margeTotal,
|
||||
}),
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0 }
|
||||
), [pivotted]);
|
||||
|
||||
function th(label: string, key: SortKey) {
|
||||
return (
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-gray-600 whitespace-nowrap">
|
||||
{label}
|
||||
<SortBtn active={sortKey === key} dir={sortDir} onClick={() => handleSort(key)} />
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4 rounded-lg bg-white p-4 shadow">
|
||||
<label className="font-semibold text-gray-700">Mois :</label>
|
||||
<select
|
||||
value={mois}
|
||||
onChange={e => router.push(`/hit-parade?mois=${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>
|
||||
<span className="text-sm text-gray-500">{sorted.length} produits</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg bg-white shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700" rowSpan={2}>Code</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700" rowSpan={2}>Désignation</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700" rowSpan={2}>Fournisseur</th>
|
||||
<th colSpan={3} className="px-3 py-2 text-center text-xs font-semibold text-gray-700 border-l border-gray-200">Frouard/Nancy (292)</th>
|
||||
<th colSpan={3} className="px-3 py-2 text-center text-xs font-semibold text-gray-700 border-l border-gray-200">Houdemont (579)</th>
|
||||
<th colSpan={3} className="px-3 py-2 text-center text-xs font-semibold text-gray-700 border-l border-gray-200">Total Réseau</th>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
{th("Qté", "qte292")}
|
||||
{th("CA TTC", "ca292")}
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-gray-600">Marge</th>
|
||||
{th("Qté", "qte579")}
|
||||
{th("CA TTC", "ca579")}
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-gray-600">Marge</th>
|
||||
{th("Qté", "qteTotal")}
|
||||
{th("CA TTC", "caTotal")}
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-gray-600">Marge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row, idx) => (
|
||||
<tr key={row.codein} className={idx % 2 === 0 ? "border-b border-gray-100 bg-white" : "border-b border-gray-100 bg-gray-50"}>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900 max-w-xs truncate" title={row.libelle}>{row.libelle.trim()}</td>
|
||||
<td className="px-3 py-2 text-gray-600 whitespace-nowrap">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-800 border-l border-gray-100">{row.qte292 > 0 ? formatQte(row.qte292) : ""}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-800">{row.ca292 > 0 ? formatCA(row.ca292) : ""}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500 text-xs">{row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-800 border-l border-gray-100">{row.qte579 > 0 ? formatQte(row.qte579) : ""}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-800">{row.ca579 > 0 ? formatCA(row.ca579) : ""}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500 text-xs">{row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-gray-900 border-l border-gray-100">{row.qteTotal > 0 ? formatQte(row.qteTotal) : ""}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-gray-900">{row.caTotal > 0 ? formatCA(row.caTotal) : ""}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500 text-xs">{row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-100">
|
||||
<td colSpan={3} className="px-3 py-2 text-sm font-bold text-gray-900">TOTAL</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-gray-900 border-l border-gray-200">{formatQte(totals.qte292)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-gray-900">{formatCA(totals.ca292)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-gray-600">{formatPct(totals.ca292, totals.marge292)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-gray-900 border-l border-gray-200">{formatQte(totals.qte579)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-gray-900">{formatCA(totals.ca579)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-gray-600">{formatPct(totals.ca579, totals.marge579)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-gray-900 border-l border-gray-200">{formatQte(totals.qteTotal)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-gray-900">{formatCA(totals.caTotal)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-gray-600">{formatPct(totals.caTotal, totals.margeTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { pgGetHitParade } from "@/lib/pg-ff-client";
|
||||
import { HitParadeClient } from "./client";
|
||||
|
||||
export interface HitParadePivotRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
fournisseur: string;
|
||||
qte292: number;
|
||||
ca292: number;
|
||||
marge292: number;
|
||||
qte579: number;
|
||||
ca579: number;
|
||||
marge579: number;
|
||||
qteTotal: number;
|
||||
caTotal: number;
|
||||
margeTotal: number;
|
||||
}
|
||||
|
||||
function pivotHitParade(
|
||||
rows: Array<{ codein: string; libelle: string; fournisseur: string; site: string; qte_vendue: number; ca_ttc: number; marge: number }>
|
||||
): HitParadePivotRow[] {
|
||||
const map = new Map<string, HitParadePivotRow>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.codein)) {
|
||||
map.set(row.codein, {
|
||||
codein: row.codein,
|
||||
libelle: row.libelle,
|
||||
fournisseur: row.fournisseur,
|
||||
qte292: 0, ca292: 0, marge292: 0,
|
||||
qte579: 0, ca579: 0, marge579: 0,
|
||||
qteTotal: 0, caTotal: 0, margeTotal: 0,
|
||||
});
|
||||
}
|
||||
const entry = map.get(row.codein)!;
|
||||
if (row.site === "292") {
|
||||
entry.qte292 = row.qte_vendue;
|
||||
entry.ca292 = row.ca_ttc;
|
||||
entry.marge292 = row.marge;
|
||||
} else if (row.site === "579") {
|
||||
entry.qte579 = row.qte_vendue;
|
||||
entry.ca579 = row.ca_ttc;
|
||||
entry.marge579 = row.marge;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of map.values()) {
|
||||
entry.qteTotal = entry.qte292 + entry.qte579;
|
||||
entry.caTotal = entry.ca292 + entry.ca579;
|
||||
entry.margeTotal = entry.marge292 + entry.marge579;
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export default async function HitParadePage(props: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const today = new Date();
|
||||
const currentMois = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
|
||||
const mois = (searchParams.mois as string) || currentMois;
|
||||
|
||||
const rows = await pgGetHitParade(mois);
|
||||
const pivotted = pivotHitParade(rows);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Hit Parade</h1>
|
||||
<HitParadeClient
|
||||
mois={mois}
|
||||
currentMois={currentMois}
|
||||
pivotted={pivotted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -12,6 +12,7 @@ const NAV_ITEMS = [
|
||||
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
|
||||
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
||||
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
||||
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
||||
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
||||
{ icon: FileDown, label: "Exports", href: "/exports" },
|
||||
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||
|
||||
@@ -693,3 +693,45 @@ export async function pgGetCaByNomenclature(
|
||||
console.log(`[pg-ff] CaByNomenclature: ${result.rows.length} lignes pour ${mois}/${moisN1}`);
|
||||
return result.rows as unknown as CaByNomenclatureRow[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hit Parade — produits les plus vendus sur un mois donné
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HitParadeRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
fournisseur: string;
|
||||
site: string;
|
||||
qte_vendue: number;
|
||||
ca_ttc: number;
|
||||
marge: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les ventes produit pour un mois donné, avec fournisseur, qté, CA TTC et marge par site.
|
||||
*/
|
||||
export async function pgGetHitParade(mois: string): Promise<HitParadeRow[]> {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.codein::text AS codein,
|
||||
a.libelle1::text AS libelle,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
m.site,
|
||||
SUM(ABS(m.qtemvt))::float AS qte_vendue,
|
||||
ABS(SUM(m.mntmvtttc))::float AS ca_ttc,
|
||||
SUM(m.margemvt)::float AS marge
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois}
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.genremvt = 3
|
||||
GROUP BY a.codein, a.libelle1, fi.nom, af.code, m.site
|
||||
ORDER BY ca_ttc DESC
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${mois}`);
|
||||
return result.rows as unknown as HitParadeRow[];
|
||||
}
|
||||
Reference in new issue
Block a user