mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 14:08:08 +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";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||||
import type { Publicite } from "./page";
|
import type { Publicite } from "./page";
|
||||||
|
|
||||||
const FIRST_YEAR = 2020;
|
const FIRST_YEAR = 2020;
|
||||||
@@ -18,6 +20,16 @@ function fmtPct(v: number | string) {
|
|||||||
return Number(v).toFixed(2).replace(".", ",") + " %";
|
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 {
|
interface Props {
|
||||||
year: number;
|
year: number;
|
||||||
publicites: Publicite[];
|
publicites: Publicite[];
|
||||||
@@ -28,14 +40,58 @@ export function PublicitesClient({ year, publicites }: Props) {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const years = Array.from({ length: currentYear - FIRST_YEAR + 1 }, (_, i) => currentYear - i);
|
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>) {
|
function handleYearChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
router.push(`/publicites?year=${e.target.value}`);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Barre de contrôles */}
|
{/* 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">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-semibold text-gray-500">Année</label>
|
<label className="text-xs font-semibold text-gray-500">Année</label>
|
||||||
<select
|
<select
|
||||||
@@ -49,16 +105,32 @@ export function PublicitesClient({ year, publicites }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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" />
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tableau */}
|
{/* Tableau */}
|
||||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
<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">
|
<div className="py-16 text-center text-gray-400 text-sm">
|
||||||
Aucune publicité trouvée pour {year}.
|
Aucune publicité trouvée pour {year}.
|
||||||
</div>
|
</div>
|
||||||
@@ -67,25 +139,43 @@ export function PublicitesClient({ year, publicites }: Props) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
<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 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 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-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 {...thProps("ca_pub_periode_pub", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
||||||
<th className="px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50">% CA total</th>
|
CA pub période <SortIcon col="ca_pub_periode_pub" sortKey={sortKey} sortDir={sortDir} />
|
||||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Qté vendue</th>
|
</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">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>
|
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Nb articles</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{publicites.map((pub, idx) => (
|
{filtered.map((pub, idx) => (
|
||||||
<tr
|
<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"
|
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 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-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">
|
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
||||||
{fmtDate(pub.date_debut)}
|
{fmtDate(pub.date_debut)}
|
||||||
</td>
|
</td>
|
||||||
@@ -123,17 +213,17 @@ export function PublicitesClient({ year, publicites }: Props) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
<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">
|
<td colSpan={5} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||||
TOTAL — {publicites.length} publicités
|
TOTAL — {filtered.length} publicités
|
||||||
</td>
|
</td>
|
||||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
<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>
|
||||||
<td className="bg-orange-100 px-3 py-3 text-right tabular-nums text-xs font-semibold text-orange-700">
|
<td className="bg-orange-100 px-3 py-3 text-right tabular-nums text-xs font-semibold text-orange-700">
|
||||||
—
|
—
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
<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>
|
||||||
<td colSpan={3} />
|
<td colSpan={3} />
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in new issue
Block a user