mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement client-side dashboard for publicites with filtering, sorting, and pagination views
This commit is contained in:
1 parent
26ba285c0e
commit
e1d79e66ed
3 files changed
+368
-199
No files matched your search
@@ -19,7 +19,12 @@
|
||||
"Bash(node -e \"let d=''; process.stdin.on\\('data',c=>d+=c\\); process.stdin.on\\('end',\\(\\)=>{ const j=JSON.parse\\(d\\); const rows=Array.isArray\\(j\\)?j:\\(j.propositions??j.data??j.items??[]\\); console.log\\('top-level keys:', Object.keys\\(j\\)\\); if\\(rows.length\\){ const s=rows[0]; console.log\\('row keys:', Object.keys\\(s\\).join\\(', '\\)\\); console.log\\('franco_ht:', s.franco_ht, '| franco:', s.franco, '| franco_atteint:', s.franco_atteint\\); } else console.log\\('empty'\\); }\\)\")",
|
||||
"Bash(node -e \"let d=''; process.stdin.on\\('data',c=>d+=c\\); process.stdin.on\\('end',\\(\\)=>{ const j=JSON.parse\\(d\\); console.log\\('top-level keys:', Object.keys\\(j\\)\\); const resume=j.resume; console.log\\('resume type:', Array.isArray\\(resume\\)?'array':'object'\\); if\\(Array.isArray\\(resume\\)&&resume.length\\){ console.log\\('resume[0]:', JSON.stringify\\(resume[0],null,2\\)\\); } else { console.log\\('resume:', JSON.stringify\\(resume,null,2\\)\\); } }\\)\")",
|
||||
"Bash(node -e \"let d=''; process.stdin.on\\('data',c=>d+=c\\); process.stdin.on\\('end',\\(\\)=>{ const j=JSON.parse\\(d\\); console.log\\('top-level keys:', Object.keys\\(j\\)\\); const resume=j.resume; console.log\\('resume type:', Array.isArray\\(resume\\)?'array':'object'\\); if\\(Array.isArray\\(resume\\)&&resume.length\\){ console.log\\('resume[0]:', JSON.stringify\\(resume[0]\\)\\); } else { console.log\\('resume:', JSON.stringify\\(resume\\)\\); } }\\)\")",
|
||||
"Bash(node -e ' *)"
|
||||
"Bash(node -e ' *)",
|
||||
"Bash(xargs grep *)",
|
||||
"Bash(node -e \"let d=''; process.stdin.on\\('data',c=>d+=c\\); process.stdin.on\\('end',\\(\\)=>{ const j=JSON.parse\\(d\\); console.log\\('top-level keys:', Object.keys\\(j\\)\\); console.log\\('total:', j.total, '| pages:', j.pages, '| page:', j.page, '| limit:', j.limit\\); const rows=j.publicites??[]; if\\(rows[0]\\) console.log\\('row keys:', Object.keys\\(rows[0]\\).join\\(', '\\)\\); }\\)\")",
|
||||
"Bash(node -e \"let d=''; process.stdin.on\\('data',c=>d+=c\\); process.stdin.on\\('end',\\(\\)=>{ const j=JSON.parse\\(d\\); console.log\\('top-level keys:', Object.keys\\(j\\)\\); console.log\\('total:', j.total, '| pages:', j.pages\\); const rows=j.publicites??[]; if\\(rows[0]\\) { console.log\\('row keys:', Object.keys\\(rows[0]\\).join\\(', '\\)\\); console.log\\('statut field:', rows[0].statut\\); } }\\)\")",
|
||||
"Bash(node -e \"let d=''; process.stdin.on\\('data',c=>d+=c\\); process.stdin.on\\('end',\\(\\)=>{ const j=JSON.parse\\(d\\); console.log\\('top-level keys:', Object.keys\\(j\\)\\); const rows=j.publicites??j.historique??j.data??[]; if\\(rows[0]\\) console.log\\('row keys:', Object.keys\\(rows[0]\\).join\\(', '\\)\\); console.log\\('total/count:', j.total??j.count\\); }\\)\")",
|
||||
"Bash(node -e \"let d=''; process.stdin.on\\('data',c=>d+=c\\); process.stdin.on\\('end',\\(\\)=>{ const j=JSON.parse\\(d\\); console.log\\(JSON.stringify\\(j.publicites[0], null, 2\\)\\); }\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,47 +2,66 @@
|
||||
|
||||
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;
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { Publicite, PubliciteHistorique } from "./page";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
function fmtCA(v: number | string) {
|
||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(Number(v));
|
||||
}
|
||||
|
||||
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 SortKey = "intitule" | "site" | "ca_pub_periode_pub" | "pourc_capub_catotal" | "qte_vendue_pub" | "taux_sortie" | "statut";
|
||||
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" />;
|
||||
return sortDir === "asc" ? <ChevronUp className="inline w-3 h-3 ml-1" /> : <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
|
||||
const STATUT_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" },
|
||||
passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" },
|
||||
};
|
||||
|
||||
function StatutBadge({ statut }: { statut?: string }) {
|
||||
const s = statut ?? "";
|
||||
const cfg = STATUT_LABELS[s] ?? { label: s || "—", cls: "bg-gray-100 text-gray-500" };
|
||||
return (
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold whitespace-nowrap ${cfg.cls}`}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
year: number;
|
||||
vue: string;
|
||||
statut: string;
|
||||
page: number;
|
||||
publicites: Publicite[];
|
||||
total: number;
|
||||
pages: number;
|
||||
historique: PubliciteHistorique[];
|
||||
}
|
||||
|
||||
export function PublicitesClient({ year, publicites }: Props) {
|
||||
export function PublicitesClient({ vue, statut, page, publicites, total, pages, historique }: Props) {
|
||||
const router = useRouter();
|
||||
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 [sortKey, setSortKey] = useState<SortKey>("intitule");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function nav(params: Record<string, string | number>) {
|
||||
const sp = new URLSearchParams({
|
||||
vue, statut, page: String(page), ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
});
|
||||
router.push(`/publicites?${sp}`);
|
||||
}
|
||||
|
||||
const validPublicites = useMemo(() => publicites.filter(p => p.site !== "000"), [publicites]);
|
||||
|
||||
@@ -53,200 +72,290 @@ export function PublicitesClient({ year, publicites }: Props) {
|
||||
|
||||
const SITE_COLORS: Record<string, string> = useMemo(() => {
|
||||
const palette = [
|
||||
"bg-blue-50 text-blue-700",
|
||||
"bg-violet-50 text-violet-700",
|
||||
"bg-emerald-50 text-emerald-700",
|
||||
"bg-rose-50 text-rose-700",
|
||||
"bg-amber-50 text-amber-700",
|
||||
"bg-cyan-50 text-cyan-700",
|
||||
"bg-pink-50 text-pink-700",
|
||||
"bg-indigo-50 text-indigo-700",
|
||||
"bg-blue-50 text-blue-700", "bg-violet-50 text-violet-700",
|
||||
"bg-emerald-50 text-emerald-700", "bg-rose-50 text-rose-700",
|
||||
"bg-amber-50 text-amber-700", "bg-cyan-50 text-cyan-700",
|
||||
];
|
||||
return Object.fromEntries(sites.map((s, i) => [s, palette[i % palette.length]]));
|
||||
}, [sites]);
|
||||
|
||||
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");
|
||||
}
|
||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = filterSite === "all" ? validPublicites : validPublicites.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]);
|
||||
}
|
||||
return [...rows].sort((a, b) => {
|
||||
const va = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut")
|
||||
? String(a[sortKey] ?? "").toLowerCase()
|
||||
: Number(a[sortKey as keyof Publicite]);
|
||||
const vb = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut")
|
||||
? String(b[sortKey] ?? "").toLowerCase()
|
||||
: Number(b[sortKey as keyof Publicite]);
|
||||
if (va < vb) return sortDir === "asc" ? -1 : 1;
|
||||
if (va > vb) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return rows;
|
||||
}, [validPublicites, 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),
|
||||
};
|
||||
function thProps(key: SortKey, cls = "") {
|
||||
return { className: `${cls} cursor-pointer select-none hover:bg-gray-100 transition-colors`, onClick: () => handleSort(key) };
|
||||
}
|
||||
|
||||
const isHistorique = vue === "historique";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de contrôles */}
|
||||
<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
|
||||
value={year}
|
||||
onChange={handleYearChange}
|
||||
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-[120px]"
|
||||
{/* ── Onglets ── */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{[
|
||||
{ key: "publicites", label: `Publicités${total ? ` (${total})` : ""}` },
|
||||
{ key: "historique", label: `Historique${historique.length ? ` (${historique.length})` : ""}` },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => nav({ vue: t.key, page: 1 })}
|
||||
className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px transition-colors ${
|
||||
vue === t.key
|
||||
? "border-orange-500 text-orange-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{years.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</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">
|
||||
{filtered.length} publicité{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 text-sm">
|
||||
Aucune publicité trouvée pour {year}.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<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 {...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 {...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 {...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>
|
||||
{filtered.map((pub, idx) => (
|
||||
<tr
|
||||
key={`${pub.tcr_code}-${pub.site}-${idx}`}
|
||||
className="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
{/* ── Barre de contrôles ── */}
|
||||
<div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
||||
{!isHistorique && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Statut</label>
|
||||
<select
|
||||
value={statut}
|
||||
onChange={e => nav({ vue, statut: e.target.value, page: 1 })}
|
||||
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="toutes">Toutes</option>
|
||||
<option value="en_cours">En cours</option>
|
||||
<option value="passees">Passées</option>
|
||||
<option value="a_venir">À venir</option>
|
||||
</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]"
|
||||
>
|
||||
<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 px-2 py-0.5 font-medium ${SITE_COLORS[pub.site] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
{pub.site || "—"}
|
||||
</span>
|
||||
<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">
|
||||
{total} publicité{total !== 1 ? "s" : ""} au total
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isHistorique && (
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
||||
{historique.length} opération{historique.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Vue Publicités ── */}
|
||||
{!isHistorique && (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 text-sm">Aucune publicité trouvée.</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<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 {...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("statut", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
||||
Statut <SortIcon col="statut" 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 {...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 {...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>
|
||||
{filtered.map((pub, idx) => (
|
||||
<tr key={`${pub.tcr_code}-${pub.site}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<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"><StatutBadge statut={pub.statut} /></td>
|
||||
<td className="px-3 py-2.5 text-xs text-gray-600 whitespace-nowrap">
|
||||
<span className={`inline-block rounded-md px-2 py-0.5 font-medium ${SITE_COLORS[pub.site] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
{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>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(pub.date_fin)}</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">{fmtCA(pub.ca_pub_periode_pub)}</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right tabular-nums">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
Number(pub.pourc_capub_catotal) >= 20 ? "bg-orange-100 text-orange-700"
|
||||
: Number(pub.pourc_capub_catotal) >= 10 ? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{fmtPct(pub.pourc_capub_catotal)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.client_pub_periode).toLocaleString("fr-FR")}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-500 text-xs">{pub.nb_articles}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<td colSpan={6} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {filtered.length} publicité{filtered.length !== 1 ? "s" : ""} (page {page}/{pages})
|
||||
</td>
|
||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
||||
{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 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">
|
||||
{filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-white px-5 py-3 shadow-sm border border-gray-100">
|
||||
<span className="text-sm text-gray-500">
|
||||
Page <strong>{page}</strong> sur <strong>{pages}</strong> — {total} publicités
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => nav({ vue, statut, page: page - 1 })}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Préc.
|
||||
</button>
|
||||
{/* Pages around current */}
|
||||
{Array.from({ length: Math.min(pages, 7) }, (_, i) => {
|
||||
const p = pages <= 7 ? i + 1 : Math.max(1, page - 3) + i;
|
||||
if (p > pages) return null;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => nav({ vue, statut, page: p })}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium ${
|
||||
p === page
|
||||
? "bg-orange-500 text-white"
|
||||
: "border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
disabled={page >= pages}
|
||||
onClick={() => nav({ vue, statut, page: page + 1 })}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Suiv. <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Vue Historique ── */}
|
||||
{isHistorique && (
|
||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
||||
{historique.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 text-sm">Aucun historique disponible.</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<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">Opération</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500">Statut</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">Sites</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é totale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historique.map((h, idx) => (
|
||||
<tr key={`${h.tcr_code}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{h.tcr_code}</td>
|
||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{h.intitule}</td>
|
||||
<td className="px-3 py-2.5"><StatutBadge statut={h.statut} /></td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(h.date_debut)}</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(h.date_fin)}</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-500">{h.nb_sites}</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">{fmtCA(h.ca_total)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(h.qte_totale).toLocaleString("fr-FR")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<td colSpan={6} className="px-4 py-3 text-sm font-bold text-gray-800">
|
||||
TOTAL — {historique.length} opérations
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
||||
{fmtDate(pub.date_debut)}
|
||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
||||
{fmtCA(historique.reduce((s, h) => s + Number(h.ca_total), 0))}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
||||
{fmtDate(pub.date_fin)}
|
||||
</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">
|
||||
{fmtCA(pub.ca_pub_periode_pub)}
|
||||
</td>
|
||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right tabular-nums">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
Number(pub.pourc_capub_catotal) >= 20
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: Number(pub.pourc_capub_catotal) >= 10
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{fmtPct(pub.pourc_capub_catotal)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">
|
||||
{Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">
|
||||
{Number(pub.client_pub_periode).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">
|
||||
{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-500 text-xs">
|
||||
{pub.nb_articles}
|
||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
||||
{historique.reduce((s, h) => s + Number(h.qte_totale), 0).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
||||
<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(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">
|
||||
{filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,31 +5,72 @@ export interface Publicite {
|
||||
intitule: string;
|
||||
date_debut: string;
|
||||
date_fin: string;
|
||||
datecalcul?: string;
|
||||
site: string;
|
||||
statut?: string;
|
||||
ca_pub_periode_pub: number;
|
||||
qte_vendue_pub: number;
|
||||
ca_total_periode_pub: number;
|
||||
pourc_capub_catotal: number;
|
||||
client_pub_periode: number;
|
||||
client_total_periode: number;
|
||||
stock_datedebut?: number;
|
||||
stock_datefin?: number;
|
||||
ca_pub_30_jours?: number;
|
||||
ca_pub_60_jours?: number;
|
||||
ca_pub_90_jours?: number;
|
||||
ca_pub_180_jours?: number;
|
||||
ca_depuis_finpub?: number;
|
||||
taux_sortie: number;
|
||||
marge: number;
|
||||
taux_marge: number;
|
||||
nb_articles: number;
|
||||
}
|
||||
|
||||
async function fetchPublicites(year: number): Promise<{ data: Publicite[]; error?: string }> {
|
||||
const dateDebut = `${year}-01-01`;
|
||||
const dateFin = `${year}-12-31`;
|
||||
const url = `https://api.ffnancy.fr/api/publicites?dateDebut=${dateDebut}&dateFin=${dateFin}&limit=500`;
|
||||
export interface PubliciteHistorique {
|
||||
tcr_code: string;
|
||||
intitule: string;
|
||||
date_debut: string;
|
||||
date_fin: string;
|
||||
statut: string;
|
||||
nb_sites: number;
|
||||
ca_total: number;
|
||||
qte_totale: number;
|
||||
}
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
async function fetchPublicites(statut: string, page: number): Promise<{
|
||||
data: Publicite[];
|
||||
total: number;
|
||||
pages: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) });
|
||||
if (statut && statut !== "toutes") params.set("statut", statut);
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) return { data: [], error: `Erreur API: ${res.status} ${res.statusText}` };
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { cache: "no-store" });
|
||||
if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` };
|
||||
const json = await res.json();
|
||||
return { data: (json.publicites ?? []) as Publicite[] };
|
||||
return {
|
||||
data: (json.publicites ?? []) as Publicite[],
|
||||
total: Number(json.total ?? 0),
|
||||
pages: Number(json.pages ?? 0),
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { data: [], error: `Impossible de joindre l'API: ${msg}` };
|
||||
return { data: [], total: 0, pages: 0, error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistorique(): Promise<{ data: PubliciteHistorique[]; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, { cache: "no-store" });
|
||||
if (!res.ok) return { data: [], error: `Erreur historique: ${res.status}` };
|
||||
const json = await res.json();
|
||||
return { data: (json.publicites ?? []) as PubliciteHistorique[] };
|
||||
} catch (e: unknown) {
|
||||
return { data: [], error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +78,16 @@ export default async function PublicitesPage(props: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const year = Number(searchParams.year) || currentYear;
|
||||
const vue = String(searchParams.vue ?? "publicites");
|
||||
const statut = String(searchParams.statut ?? "toutes");
|
||||
const page = Math.max(1, Number(searchParams.page) || 1);
|
||||
|
||||
const { data: publicites, error } = await fetchPublicites(year);
|
||||
const [pubResult, histoResult] = await Promise.all([
|
||||
fetchPublicites(statut, page),
|
||||
fetchHistorique(),
|
||||
]);
|
||||
|
||||
const error = pubResult.error ?? histoResult.error;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
@@ -51,7 +98,15 @@ export default async function PublicitesPage(props: {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<PublicitesClient year={year} publicites={publicites} />
|
||||
<PublicitesClient
|
||||
vue={vue}
|
||||
statut={statut}
|
||||
page={page}
|
||||
publicites={pubResult.data}
|
||||
total={pubResult.total}
|
||||
pages={pubResult.pages}
|
||||
historique={histoResult.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user