mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +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\\); 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],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 \"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 { useRouter } from "next/navigation";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import type { Publicite } from "./page";
|
import type { Publicite, PubliciteHistorique } from "./page";
|
||||||
|
|
||||||
const FIRST_YEAR = 2020;
|
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
function fmtDate(iso: string) {
|
||||||
const d = new Date(iso);
|
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtCA(v: number | string) {
|
function fmtCA(v: number | string) {
|
||||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(Number(v));
|
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(Number(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtPct(v: number | string) {
|
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 SortKey = "intitule" | "site" | "ca_pub_periode_pub" | "pourc_capub_catotal" | "qte_vendue_pub" | "taux_sortie" | "statut";
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
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" />;
|
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||||
return sortDir === "asc"
|
return sortDir === "asc" ? <ChevronUp className="inline w-3 h-3 ml-1" /> : <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||||
? <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 {
|
interface Props {
|
||||||
year: number;
|
vue: string;
|
||||||
|
statut: string;
|
||||||
|
page: number;
|
||||||
publicites: Publicite[];
|
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 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 [filterSite, setFilterSite] = useState<string>("all");
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("intitule");
|
const [sortKey, setSortKey] = useState<SortKey>("intitule");
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
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]);
|
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 SITE_COLORS: Record<string, string> = useMemo(() => {
|
||||||
const palette = [
|
const palette = [
|
||||||
"bg-blue-50 text-blue-700",
|
"bg-blue-50 text-blue-700", "bg-violet-50 text-violet-700",
|
||||||
"bg-violet-50 text-violet-700",
|
"bg-emerald-50 text-emerald-700", "bg-rose-50 text-rose-700",
|
||||||
"bg-emerald-50 text-emerald-700",
|
"bg-amber-50 text-amber-700", "bg-cyan-50 text-cyan-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",
|
|
||||||
];
|
];
|
||||||
return Object.fromEntries(sites.map((s, i) => [s, palette[i % palette.length]]));
|
return Object.fromEntries(sites.map((s, i) => [s, palette[i % palette.length]]));
|
||||||
}, [sites]);
|
}, [sites]);
|
||||||
|
|
||||||
function handleYearChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
|
||||||
router.push(`/publicites?year=${e.target.value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSort(key: SortKey) {
|
function handleSort(key: SortKey) {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||||
setSortDir(d => d === "asc" ? "desc" : "asc");
|
else { setSortKey(key); setSortDir("asc"); }
|
||||||
} else {
|
|
||||||
setSortKey(key);
|
|
||||||
setSortDir("asc");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let rows = filterSite === "all" ? validPublicites : validPublicites.filter(p => p.site === filterSite);
|
let rows = filterSite === "all" ? validPublicites : validPublicites.filter(p => p.site === filterSite);
|
||||||
rows = [...rows].sort((a, b) => {
|
return [...rows].sort((a, b) => {
|
||||||
let va: string | number;
|
const va = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut")
|
||||||
let vb: string | number;
|
? String(a[sortKey] ?? "").toLowerCase()
|
||||||
if (sortKey === "intitule" || sortKey === "site") {
|
: Number(a[sortKey as keyof Publicite]);
|
||||||
va = String(a[sortKey] ?? "").toLowerCase();
|
const vb = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut")
|
||||||
vb = String(b[sortKey] ?? "").toLowerCase();
|
? String(b[sortKey] ?? "").toLowerCase()
|
||||||
} else {
|
: Number(b[sortKey as keyof Publicite]);
|
||||||
va = Number(a[sortKey]);
|
|
||||||
vb = Number(b[sortKey]);
|
|
||||||
}
|
|
||||||
if (va < vb) return sortDir === "asc" ? -1 : 1;
|
if (va < vb) return sortDir === "asc" ? -1 : 1;
|
||||||
if (va > vb) return sortDir === "asc" ? 1 : -1;
|
if (va > vb) return sortDir === "asc" ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
return rows;
|
|
||||||
}, [validPublicites, filterSite, sortKey, sortDir]);
|
}, [validPublicites, filterSite, sortKey, sortDir]);
|
||||||
|
|
||||||
function thProps(key: SortKey, className: string) {
|
function thProps(key: SortKey, cls = "") {
|
||||||
return {
|
return { className: `${cls} cursor-pointer select-none hover:bg-gray-100 transition-colors`, onClick: () => handleSort(key) };
|
||||||
className: `${className} cursor-pointer select-none hover:bg-gray-100 transition-colors`,
|
|
||||||
onClick: () => handleSort(key),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHistorique = vue === "historique";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Barre de contrôles */}
|
{/* ── Onglets ── */}
|
||||||
<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 gap-2 border-b border-gray-200">
|
||||||
<div className="flex flex-col gap-1">
|
{[
|
||||||
<label className="text-xs font-semibold text-gray-500">Année</label>
|
{ key: "publicites", label: `Publicités${total ? ` (${total})` : ""}` },
|
||||||
<select
|
{ key: "historique", label: `Historique${historique.length ? ` (${historique.length})` : ""}` },
|
||||||
value={year}
|
].map(t => (
|
||||||
onChange={handleYearChange}
|
<button
|
||||||
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]"
|
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 => (
|
{t.label}
|
||||||
<option key={y} value={y}>{y}</option>
|
</button>
|
||||||
))}
|
))}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tableau */}
|
{/* ── Barre de contrôles ── */}
|
||||||
<div className="overflow-x-auto rounded-xl bg-white 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">
|
||||||
{filtered.length === 0 ? (
|
{!isHistorique && (
|
||||||
<div className="py-16 text-center text-gray-400 text-sm">
|
<>
|
||||||
Aucune publicité trouvée pour {year}.
|
<div className="flex flex-col gap-1">
|
||||||
</div>
|
<label className="text-xs font-semibold text-gray-500">Statut</label>
|
||||||
) : (
|
<select
|
||||||
<table className="w-full border-collapse text-sm">
|
value={statut}
|
||||||
<thead>
|
onChange={e => nav({ vue, statut: e.target.value, page: 1 })}
|
||||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
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]"
|
||||||
<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")}>
|
<option value="toutes">Toutes</option>
|
||||||
Opération <SortIcon col="intitule" sortKey={sortKey} sortDir={sortDir} />
|
<option value="en_cours">En cours</option>
|
||||||
</th>
|
<option value="passees">Passées</option>
|
||||||
<th {...thProps("site", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
<option value="a_venir">À venir</option>
|
||||||
Magasin <SortIcon col="site" sortKey={sortKey} sortDir={sortDir} />
|
</select>
|
||||||
</th>
|
</div>
|
||||||
<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>
|
{sites.length > 1 && (
|
||||||
<th {...thProps("ca_pub_periode_pub", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
<div className="flex flex-col gap-1">
|
||||||
CA pub période <SortIcon col="ca_pub_periode_pub" sortKey={sortKey} sortDir={sortDir} />
|
<label className="text-xs font-semibold text-gray-500">Magasin</label>
|
||||||
</th>
|
<select
|
||||||
<th {...thProps("pourc_capub_catotal", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
value={filterSite}
|
||||||
% CA total <SortIcon col="pourc_capub_catotal" sortKey={sortKey} sortDir={sortDir} />
|
onChange={e => setFilterSite(e.target.value)}
|
||||||
</th>
|
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]"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{pub.tcr_code}</td>
|
<option value="all">Tous les magasins</option>
|
||||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{pub.intitule}</td>
|
{sites.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-600 whitespace-nowrap">
|
</select>
|
||||||
<span className={`inline-block rounded-md px-2 py-0.5 font-medium ${SITE_COLORS[pub.site] ?? "bg-gray-100 text-gray-600"}`}>
|
</div>
|
||||||
{pub.site || "—"}
|
)}
|
||||||
</span>
|
|
||||||
|
<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>
|
||||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
||||||
{fmtDate(pub.date_debut)}
|
{fmtCA(historique.reduce((s, h) => s + Number(h.ca_total), 0))}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">
|
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
||||||
{fmtDate(pub.date_fin)}
|
{historique.reduce((s, h) => s + Number(h.qte_totale), 0).toLocaleString("fr-FR")}
|
||||||
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</tfoot>
|
||||||
</tbody>
|
</table>
|
||||||
<tfoot>
|
)}
|
||||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,31 +5,72 @@ export interface Publicite {
|
|||||||
intitule: string;
|
intitule: string;
|
||||||
date_debut: string;
|
date_debut: string;
|
||||||
date_fin: string;
|
date_fin: string;
|
||||||
|
datecalcul?: string;
|
||||||
site: string;
|
site: string;
|
||||||
|
statut?: string;
|
||||||
ca_pub_periode_pub: number;
|
ca_pub_periode_pub: number;
|
||||||
qte_vendue_pub: number;
|
qte_vendue_pub: number;
|
||||||
ca_total_periode_pub: number;
|
ca_total_periode_pub: number;
|
||||||
pourc_capub_catotal: number;
|
pourc_capub_catotal: number;
|
||||||
client_pub_periode: number;
|
client_pub_periode: number;
|
||||||
client_total_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;
|
taux_sortie: number;
|
||||||
marge: number;
|
marge: number;
|
||||||
taux_marge: number;
|
taux_marge: number;
|
||||||
nb_articles: number;
|
nb_articles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPublicites(year: number): Promise<{ data: Publicite[]; error?: string }> {
|
export interface PubliciteHistorique {
|
||||||
const dateDebut = `${year}-01-01`;
|
tcr_code: string;
|
||||||
const dateFin = `${year}-12-31`;
|
intitule: string;
|
||||||
const url = `https://api.ffnancy.fr/api/publicites?dateDebut=${dateDebut}&dateFin=${dateFin}&limit=500`;
|
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 {
|
try {
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { cache: "no-store" });
|
||||||
if (!res.ok) return { data: [], error: `Erreur API: ${res.status} ${res.statusText}` };
|
if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` };
|
||||||
const json = await res.json();
|
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) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
return { data: [], total: 0, pages: 0, error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
return { data: [], error: `Impossible de joindre l'API: ${msg}` };
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]>>;
|
searchParams: Promise<Record<string, string | string[]>>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const currentYear = new Date().getFullYear();
|
const vue = String(searchParams.vue ?? "publicites");
|
||||||
const year = Number(searchParams.year) || currentYear;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="min-h-screen bg-gray-50 p-6">
|
||||||
@@ -51,7 +98,15 @@ export default async function PublicitesPage(props: {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user