From 70c425befb4fb389d249e9facd3a5ad40fb6857d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 8 Apr 2026 15:53:59 +0200 Subject: [PATCH] feat: implement sidebar navigation and automated order management module with PostgreSQL client integration --- src/app/(dashboard)/commandes-auto/client.tsx | 223 ++++++++++++++++++ .../(dashboard)/commandes-auto/loading.tsx | 5 + src/app/(dashboard)/commandes-auto/page.tsx | 16 ++ src/app/api/commandes-auto/route.ts | 33 +++ src/components/shared/sidebar.tsx | 3 +- src/lib/pg-ff-client.ts | 72 ++++++ 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/commandes-auto/client.tsx create mode 100644 src/app/(dashboard)/commandes-auto/loading.tsx create mode 100644 src/app/(dashboard)/commandes-auto/page.tsx create mode 100644 src/app/api/commandes-auto/route.ts diff --git a/src/app/(dashboard)/commandes-auto/client.tsx b/src/app/(dashboard)/commandes-auto/client.tsx new file mode 100644 index 0000000..1621b14 --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/client.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, CheckCircle2, AlertCircle, Minus } from "lucide-react"; +import type { PgCommandeAutoRow } from "./page"; + +type SortDir = "asc" | "desc"; + +const SITES: { code: string; label: string }[] = [ + { code: "292", label: "292 — Frouard / Nancy" }, + { code: "579", label: "579 — Houdemont" }, +]; + +function fmt(n: number) { + return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; +} + +function SortIcon({ col, sortKey, sortDir }: { col: string; sortKey: string; sortDir: SortDir }) { + if (col !== sortKey) return ; + return sortDir === "asc" + ? + : ; +} + +type SK = keyof PgCommandeAutoRow; +const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde", "ecart_franco"]; + +function FrancoCell({ atteint, ecart, franco }: { atteint: boolean | null; ecart: number; franco: number }) { + if (franco === 0 || atteint === null) { + return ( + + Pas de franco + + ); + } + if (atteint) { + return ( + + Atteint + + ); + } + return ( + + −{fmt(Math.abs(ecart))} manquant + + ); +} + +function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) { + const [sortKey, setSortKey] = useState("nomfou"); + const [sortDir, setSortDir] = useState("asc"); + const [search, setSearch] = useState(""); + + function handleSort(key: SK) { + if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); + else { setSortKey(key); setSortDir("asc"); } + } + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return rows.filter(r => !q || r.nomfou.toLowerCase().includes(q) || r.codefou.toLowerCase().includes(q)); + }, [rows, search]); + + const sorted = useMemo(() => [...filtered].sort((a, b) => { + const av = a[sortKey]; + const bv = b[sortKey]; + if (NUMERIC.includes(sortKey)) { + return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); + } + return sortDir === "asc" + ? String(av ?? "").localeCompare(String(bv ?? ""), "fr") + : String(bv ?? "").localeCompare(String(av ?? ""), "fr"); + }), [filtered, sortKey, sortDir]); + + const nbFrancoAtteint = rows.filter(r => r.franco_atteint === true).length; + const totalMontant = rows.reduce((s, r) => s + Number(r.montant_cde), 0); + + const th = (key: SK, label: string, right = false) => ( + handleSort(key)} + > + {label} + + ); + + return ( +
+ {/* En-tête magasin */} +
+
+

{label}

+ + {rows.length} fournisseur{rows.length !== 1 ? "s" : ""} + + {nbFrancoAtteint > 0 && ( + + {nbFrancoAtteint} franco atteint + + )} + + Total : {fmt(totalMontant)} + +
+ setSearch(e.target.value)} + className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]" + /> +
+ + {/* Tableau */} +
+ + + + {th("codefou", "Code")} + {th("nomfou", "Fournisseur")} + {th("nb_articles", "Articles", true)} + {th("montant_cde", "Montant CDE auto", true)} + {th("franco", "Franco", true)} + + {th("ecart_franco", "Écart", true)} + + + + {sorted.length === 0 && ( + + + + )} + {sorted.map(row => { + const francoAtteint = row.franco_atteint === true; + const hasFranco = row.franco > 0 && row.franco_atteint !== null; + return ( + + + + + + + + + + ); + })} + +
+ Statut franco +
+ Aucun fournisseur en commande automatique +
{row.codefou}{row.nomfou}{row.nb_articles} + {fmt(Number(row.montant_cde))} + + {row.franco > 0 ? fmt(Number(row.franco)) : —} + + + + {hasFranco + ? (Number(row.ecart_franco) > 0 + ? `−${fmt(Number(row.ecart_franco))}` + : `+${fmt(Math.abs(Number(row.ecart_franco)))}`) + : "—"} +
+
+
+ ); +} + +export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) { + const bySite = useMemo(() => { + const map = new Map(); + for (const r of rows) { + if (!map.has(r.site)) map.set(r.site, []); + map.get(r.site)!.push(r); + } + return map; + }, [rows]); + + if (rows.length === 0) { + return ( +
+ Aucune commande automatique trouvée. +
+ ); + } + + return ( +
+ {SITES.map(({ code, label }) => { + const siteRows = bySite.get(code) ?? []; + return ( + + ); + })} +
+ ); +} diff --git a/src/app/(dashboard)/commandes-auto/loading.tsx b/src/app/(dashboard)/commandes-auto/loading.tsx new file mode 100644 index 0000000..6d6405d --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/loading.tsx @@ -0,0 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + +export default function CommandesAutoLoading() { + return ; +} diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx new file mode 100644 index 0000000..44ec19d --- /dev/null +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -0,0 +1,16 @@ +import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client"; +import { CommandesAutoClient } from "./client"; + +export type { PgCommandeAutoRow }; + +export default async function CommandesAutoPage() { + const rows = await pgGetCommandesAuto(); + return ( +
+
+

Commandes automatiques

+ +
+
+ ); +} diff --git a/src/app/api/commandes-auto/route.ts b/src/app/api/commandes-auto/route.ts new file mode 100644 index 0000000..d3c1a6e --- /dev/null +++ b/src/app/api/commandes-auto/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { pgGetCommandesAuto } from "@/lib/pg-ff-client"; + +/** + * GET /api/commandes-auto + * + * Retourne la liste des fournisseurs en commande automatique, + * groupés par site (292, 579), avec franco et écart franco. + * + * Réponse : + * [ + * { + * site: "292", + * codefou: "FOU123", + * nomfou: "Nom Fournisseur", + * franco: 500, + * nb_articles: 12, + * montant_cde: 620.50, + * franco_atteint: true, + * ecart_franco: -120.50 + * }, + * ... + * ] + */ +export async function GET() { + try { + const rows = await pgGetCommandesAuto(); + return NextResponse.json(rows); + } catch (e) { + console.error("[api/commandes-auto]", e); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 90d0796..f9b4971 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -15,6 +15,7 @@ const NAV_ITEMS = [ { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, { icon: Megaphone, label: "Publicités", href: "/publicites" }, { icon: PackageMinus, label: "Gestion de Stock", href: "/stock-negatif" }, + { icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 6d66ed7..b54cfaa 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -919,3 +919,75 @@ export async function pgGetStockNegatif(site?: string): Promise { + try { + const result = await pgNoParallel(sql` + SELECT + cs.site, + fi.code AS codefou, + COALESCE(fi.nom, af.code) AS nomfou, + COALESCE(fi.franco::numeric, 0) AS franco, + COUNT(DISTINCT a.codein) AS nb_articles, + ROUND(SUM( + GREATEST(1, COALESCE(af.pcb, 1))::numeric + * COALESCE(pa.pa, 0)::numeric + ), 2) AS montant_cde, + CASE + WHEN COALESCE(fi.franco::numeric, 0) = 0 THEN NULL + WHEN SUM( + GREATEST(1, COALESCE(af.pcb, 1))::numeric + * COALESCE(pa.pa, 0)::numeric + ) >= COALESCE(fi.franco::numeric, 0) THEN true + ELSE false + END AS franco_atteint, + ROUND( + COALESCE(fi.franco::numeric, 0) - SUM( + GREATEST(1, COALESCE(af.pcb, 1))::numeric + * COALESCE(pa.pa, 0)::numeric + ), 2 + ) AS ecart_franco + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + JOIN fouident fi ON fi.code = af.code + JOIN cube_stock cs ON cs.artnoid = a.no_id + LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id + WHERE af.cdeauto::text IN ('1', 'true', 't', 'yes', 'y', 'on') + AND cs.site IN ('292', '579') + AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + GROUP BY cs.site, fi.code, fi.nom, af.code, fi.franco + ORDER BY cs.site, COALESCE(fi.nom, af.code) + `); + console.log(`[pg-ff] pgGetCommandesAuto: ${result.rows.length} lignes`); + return result.rows as unknown as PgCommandeAutoRow[]; + } catch (e) { + console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300)); + return []; + } +}