mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
feat: add PublicitesClient component with filtering, sorting, and data visualization for advertising performance
This commit is contained in:
1 parent
5e6b1d354b
commit
480cf5c460
1 file changed
+104
-14
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import type { Publicite } from "./page";
|
||||
|
||||
const FIRST_YEAR = 2020;
|
||||
@@ -18,6 +20,16 @@ function fmtPct(v: number | string) {
|
||||
return Number(v).toFixed(2).replace(".", ",") + " %";
|
||||
}
|
||||
|
||||
type SortKey = "intitule" | "site" | "ca_pub_periode_pub" | "pourc_capub_catotal" | "qte_vendue_pub" | "taux_sortie";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
year: number;
|
||||
publicites: Publicite[];
|
||||
@@ -28,14 +40,58 @@ export function PublicitesClient({ year, publicites }: Props) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = Array.from({ length: currentYear - FIRST_YEAR + 1 }, (_, i) => currentYear - i);
|
||||
|
||||
const [filterSite, setFilterSite] = useState<string>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("intitule");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
const sites = useMemo(() => {
|
||||
const s = new Set(publicites.map(p => p.site).filter(Boolean));
|
||||
return Array.from(s).sort();
|
||||
}, [publicites]);
|
||||
|
||||
function handleYearChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
router.push(`/publicites?year=${e.target.value}`);
|
||||
}
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = filterSite === "all" ? publicites : publicites.filter(p => p.site === filterSite);
|
||||
rows = [...rows].sort((a, b) => {
|
||||
let va: string | number;
|
||||
let vb: string | number;
|
||||
if (sortKey === "intitule" || sortKey === "site") {
|
||||
va = String(a[sortKey] ?? "").toLowerCase();
|
||||
vb = String(b[sortKey] ?? "").toLowerCase();
|
||||
} else {
|
||||
va = Number(a[sortKey]);
|
||||
vb = Number(b[sortKey]);
|
||||
}
|
||||
if (va < vb) return sortDir === "asc" ? -1 : 1;
|
||||
if (va > vb) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return rows;
|
||||
}, [publicites, filterSite, sortKey, sortDir]);
|
||||
|
||||
function thProps(key: SortKey, className: string) {
|
||||
return {
|
||||
className: `${className} cursor-pointer select-none hover:bg-gray-100 transition-colors`,
|
||||
onClick: () => handleSort(key),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de contrôles */}
|
||||
<div className="flex items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
||||
<div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Année</label>
|
||||
<select
|
||||
@@ -49,16 +105,32 @@ export function PublicitesClient({ year, publicites }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{sites.length > 1 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Magasin</label>
|
||||
<select
|
||||
value={filterSite}
|
||||
onChange={e => setFilterSite(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-400 min-w-[160px]"
|
||||
>
|
||||
<option value="all">Tous les magasins</option>
|
||||
{sites.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
||||
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
||||
{publicites.length} publicité{publicites.length !== 1 ? "s" : ""}
|
||||
{filtered.length} publicité{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
{publicites.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 text-sm">
|
||||
Aucune publicité trouvée pour {year}.
|
||||
</div>
|
||||
@@ -67,25 +139,43 @@ export function PublicitesClient({ year, publicites }: Props) {
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 w-20">N°</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500">Libellé</th>
|
||||
<th {...thProps("intitule", "px-3 py-3 text-left text-xs font-semibold text-gray-500")}>
|
||||
Opération <SortIcon col="intitule" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("site", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Magasin <SortIcon col="site" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date début</th>
|
||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date fin</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50">CA pub période</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50">% CA total</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Qté vendue</th>
|
||||
<th {...thProps("ca_pub_periode_pub", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
||||
CA pub période <SortIcon col="ca_pub_periode_pub" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("pourc_capub_catotal", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
||||
% CA total <SortIcon col="pourc_capub_catotal" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th {...thProps("qte_vendue_pub", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Qté vendue <SortIcon col="qte_vendue_pub" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Clients</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Taux sortie</th>
|
||||
<th {...thProps("taux_sortie", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Taux sortie <SortIcon col="taux_sortie" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Nb articles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{publicites.map((pub, idx) => (
|
||||
{filtered.map((pub, idx) => (
|
||||
<tr
|
||||
key={`${pub.tcr_code}-${idx}`}
|
||||
key={`${pub.tcr_code}-${pub.site}-${idx}`}
|
||||
className="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{pub.tcr_code}</td>
|
||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{pub.intitule}</td>
|
||||
<td className="px-3 py-2.5 text-xs text-gray-600 whitespace-nowrap">
|
||||
<span className="inline-block rounded-md bg-blue-50 px-2 py-0.5 text-blue-700 font-medium">
|
||||
{pub.site || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
||||
{fmtDate(pub.date_debut)}
|
||||
</td>
|
||||
@@ -123,17 +213,17 @@ export function PublicitesClient({ year, publicites }: Props) {
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<td colSpan={4} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {publicites.length} publicités
|
||||
<td colSpan={5} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {filtered.length} publicités
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
||||
{fmtCA(publicites.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))}
|
||||
{fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))}
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right tabular-nums text-xs font-semibold text-orange-700">
|
||||
—
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
||||
{publicites.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")}
|
||||
{filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
|
||||
Reference in new issue
Block a user