diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1a99832..88efc74 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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\\)\\); }\\)\")" ] } } diff --git a/src/app/(dashboard)/publicites/client.tsx b/src/app/(dashboard)/publicites/client.tsx index f38af40..e9e0ba7 100644 --- a/src/app/(dashboard)/publicites/client.tsx +++ b/src/app/(dashboard)/publicites/client.tsx @@ -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 ; - return sortDir === "asc" - ? - : ; + return sortDir === "asc" ? : ; +} + +const STATUT_LABELS: Record = { + 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 ( + + {cfg.label} + + ); } 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("all"); - const [sortKey, setSortKey] = useState("intitule"); - const [sortDir, setSortDir] = useState("asc"); + const [sortKey, setSortKey] = useState("intitule"); + const [sortDir, setSortDir] = useState("asc"); + + function nav(params: Record) { + 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 = 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) { - 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 (
- {/* Barre de contrôles */} -
-
- - -
- - {sites.length > 1 && ( -
- - -
- )} - -
- - - {filtered.length} publicité{filtered.length !== 1 ? "s" : ""} - + {t.label} + + ))}
- {/* Tableau */} -
- {filtered.length === 0 ? ( -
- Aucune publicité trouvée pour {year}. -
- ) : ( - - - - - - - - - - - - - - - - - - {filtered.map((pub, idx) => ( - + {!isHistorique && ( + <> +
+ + +
+ + {sites.length > 1 && ( +
+ +
- -
N° - Opération - - Magasin - Date débutDate fin - CA pub période - - % CA total - - Qté vendue - Clients - Taux sortie - Nb articles
{pub.tcr_code}{pub.intitule} - - {pub.site || "—"} - + + {sites.map(s => )} + + + )} + +
+ + + {total} publicité{total !== 1 ? "s" : ""} au total + + + )} + + {isHistorique && ( + + {historique.length} opération{historique.length !== 1 ? "s" : ""} + + )} +
+ + {/* ── Vue Publicités ── */} + {!isHistorique && ( + <> +
+ {filtered.length === 0 ? ( +
Aucune publicité trouvée.
+ ) : ( + + + + + + + + + + + + + + + + + + + {filtered.map((pub, idx) => ( + + + + + + + + + + + + + + + ))} + + + + + + + + + +
N° + Opération + + Statut + + Magasin + Date débutDate fin + CA pub période + + % CA total + + Qté vendue + Clients + Taux sortie + Nb articles
{pub.tcr_code}{pub.intitule} + + {pub.site || "—"} + + {fmtDate(pub.date_debut)}{fmtDate(pub.date_fin)}{fmtCA(pub.ca_pub_periode_pub)} + = 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)} + + {Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}{Number(pub.client_pub_periode).toLocaleString("fr-FR")}{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %{pub.nb_articles}
+ TOTAL — {filtered.length} publicité{filtered.length !== 1 ? "s" : ""} (page {page}/{pages}) + + {fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))} + — + {filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")} + +
+ )} +
+ + {/* ── Pagination ── */} + {pages > 1 && ( +
+ + Page {page} sur {pages} — {total} publicités + +
+ + {/* 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 ( + + ); + })} + +
+
+ )} + + )} + + {/* ── Vue Historique ── */} + {isHistorique && ( +
+ {historique.length === 0 ? ( +
Aucun historique disponible.
+ ) : ( + + + + + + + + + + + + + + + {historique.map((h, idx) => ( + + + + + + + + + + + ))} + + + + - - - - - - - - - ))} - - - - - - - - - -
N°OpérationStatutDate débutDate finSitesCA totalQté totale
{h.tcr_code}{h.intitule}{fmtDate(h.date_debut)}{fmtDate(h.date_fin)}{h.nb_sites}{fmtCA(h.ca_total)}{Number(h.qte_totale).toLocaleString("fr-FR")}
+ TOTAL — {historique.length} opérations - {fmtDate(pub.date_debut)} + + {fmtCA(historique.reduce((s, h) => s + Number(h.ca_total), 0))} - {fmtDate(pub.date_fin)} - - {fmtCA(pub.ca_pub_periode_pub)} - - = 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)} - - - {Number(pub.qte_vendue_pub).toLocaleString("fr-FR")} - - {Number(pub.client_pub_periode).toLocaleString("fr-FR")} - - {Number(pub.taux_sortie).toFixed(1).replace(".", ",")} % - - {pub.nb_articles} + + {historique.reduce((s, h) => s + Number(h.qte_totale), 0).toLocaleString("fr-FR")}
- TOTAL — {filtered.length} publicités - - {fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))} - - — - - {filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")} - -
- )} -
+ +
+ )} +
+ )}
); } diff --git a/src/app/(dashboard)/publicites/page.tsx b/src/app/(dashboard)/publicites/page.tsx index 72815e7..7924c52 100644 --- a/src/app/(dashboard)/publicites/page.tsx +++ b/src/app/(dashboard)/publicites/page.tsx @@ -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>; }) { 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 (
@@ -51,7 +98,15 @@ export default async function PublicitesPage(props: { {error}
)} - +
);