feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair

Chaque page reprend le socle du lot 2 : en-tête avec une phrase
d'explication, couleurs du thème (plus de pages figées en blanc), textes
de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par
toast / confirmation, états vides et d'erreur expliqués.

- Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10
  avec lien vers la fiche produit.
- Révision d'assortiment : un seul endroit pour enregistrer et exporter
  (menu Export unique, mêmes droits pour tous), annulation confirmée, sens
  des gammes affiché partout, magasin choisi en un clic, colonnes nommées
  en clair, bandeau de progression au lieu de la fenêtre floue, droits lus
  côté serveur, ligne sélectionnée opaque sur les colonnes figées.
- Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures
  ventes, Ventes par mois (dernier mois complet par défaut), Publicités,
  Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation
  et Connexion migrés sur DataTable / Tabs / Card.
- Le nom de l'utilisateur est transmis à la session (affiché dans
  l'en-tête).
- Composants devenus inutiles supprimés (anciennes fenêtres modales,
  export-dropdown, financial-cell, supplier-selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 21:05:46 +00:00
1 parent 0c890c84c5
commit 220ca2263a
61 files changed
+6101 -6864

No files matched your search

+5 -10
View File
@@ -1,17 +1,12 @@
import type { Metadata } from "next";
import { LoginForm } from "@/features/auth/components/login-form";
export const metadata: Metadata = { title: "Connexion" };
export default function LoginPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-6 relative overflow-hidden">
{/* Éléments de design en arrière-plan pour l'effet "Apple Premium" */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-500/10 dark:bg-indigo-500/5 blur-[120px] rounded-full" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-emerald-500/10 dark:bg-emerald-500/5 blur-[120px] rounded-full" />
</div>
<div className="relative z-10 w-full flex justify-center">
<LoginForm />
</div>
<main className="flex min-h-screen items-center justify-center bg-[var(--bg-base)] p-6">
<LoginForm />
</main>
);
}
@@ -3,9 +3,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
CheckCircle, Database, Network, Search,
CheckCircle2, Database, Network, Clock,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge, type Ton } from "@/components/ui/badge";
import { Input, Label, SearchInput } from "@/components/ui/form-controls";
import { Segmented, type TabItem } from "@/components/ui/tabs";
import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { StatCard } from "@/components/ui/stat-card";
import { ErrorState, Skeleton } from "@/components/ui/states";
import { Tooltip } from "@/components/ui/tooltip";
import { confirmer, toast } from "@/components/ui/feedback";
import { fmtEntier } from "@/lib/format";
interface SyncSettings {
actif: boolean;
@@ -51,18 +62,46 @@ interface Resume {
desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number;
}
const fmtDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "jamais";
const fmtDuree = (ms: number | null) =>
ms == null ? "" : ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`;
type Filtre = "tous" | "actifs" | "inactifs" | "echecs";
function Pastille({ statut }: { statut: string | null }) {
if (!statut) return <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>—</span>;
const couleurs: Record<string, string> = {
succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500",
};
const libelles: Record<string, string> = { succes: "ok", echec: "échec", vide: "vide" };
return <span className={cn("text-[11px] font-semibold", couleurs[statut] ?? "")}>{libelles[statut] ?? statut}</span>;
/** Libellés des deux sources, identiques partout dans la page. */
const VENTES = "Ventes (base FF)";
const RESEAU = "Réseau (Qlik)";
const fmtDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "Jamais";
function fmtDuree(ms: number | null): string {
if (ms == null) return "";
if (ms < 1000) return `${ms} ms`;
const s = Math.round(ms / 1000);
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`;
}
const STATUTS: Record<string, { ton: Ton; libelle: string; aide: string }> = {
succes: { ton: "succes", libelle: "OK", aide: "Dernière mise à jour réussie" },
echec: { ton: "erreur", libelle: "Échec", aide: "La dernière mise à jour a échoué" },
vide: { ton: "alerte", libelle: "Vide", aide: "Aucune donnée reçue lors de la dernière mise à jour" },
};
function StatutBadge({ statut }: { statut: string | null }) {
if (!statut) return null;
const s = STATUTS[statut];
return s ? <Badge ton={s.ton} title={s.aide}>{s.libelle}</Badge> : <Badge>{statut}</Badge>;
}
const CASE = "h-4 w-4 cursor-pointer accent-[var(--accent)]";
function correspond(r: FournisseurRow, f: Filtre): boolean {
if (f === "actifs") return r.actifSql || r.actifQlik;
if (f === "inactifs") return !r.actifSql && !r.actifQlik;
if (f === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec";
return true;
}
/** « Lancement automatique » (planifié la nuit) ou « manuel » (bouton). */
function libelleOrigine(origine: SyncState["origine"]): string {
return origine === "auto" ? " automatique" : origine === "manuel" ? " manuel" : "";
}
export function SynchronisationClient() {
@@ -74,7 +113,8 @@ export function SynchronisationClient() {
const [erreur, setErreur] = useState<string | null>(null);
const [chargement, setChargement] = useState(true);
const [recherche, setRecherche] = useState("");
const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous");
const [filtre, setFiltre] = useState<Filtre>("tous");
const [actionEnCours, setActionEnCours] = useState<"start" | "stop" | "seed" | null>(null);
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
const lireEtat = useCallback(async () => {
@@ -107,7 +147,12 @@ export function SynchronisationClient() {
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
// Suivi rapproché pendant un round : l'état change à chaque fournisseur.
const reessayer = () => {
setChargement(true);
rafraichirTout();
};
// Suivi rapproché pendant un lancement : l'état change à chaque fournisseur.
useEffect(() => {
if (!state?.enCours) return;
const id = setInterval(rafraichirEtat, 3000);
@@ -119,7 +164,6 @@ export function SynchronisationClient() {
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
const majSettings = async (patch: Partial<SyncSettings>) => {
setErreur(null);
try {
const res = await fetch("/api/admin/sync", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
@@ -128,7 +172,9 @@ export function SynchronisationClient() {
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
// Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse.
setSettings({ ...d.settings, ...patchEnAttente.current });
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
} catch (e) {
toast.erreur(`Le réglage n'a pas pu être enregistré : ${e instanceof Error ? e.message : String(e)}`);
}
};
// Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur
@@ -147,19 +193,41 @@ export function SynchronisationClient() {
};
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
setErreur(null);
setActionEnCours(act);
try {
const res = await fetch("/api/admin/sync", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
if (act === "seed") {
toast.succes(`Liste des fournisseurs actualisée (${fmtEntier(Number(d.fournisseurs) || 0)} dans le référentiel FF).`);
} else if (act === "stop") {
toast.info("Arrêt demandé : la mise à jour s'arrête après le fournisseur en cours.");
}
rafraichirTout();
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
} catch (e) {
const libelles = { start: "Le lancement a échoué", stop: "L'arrêt a échoué", seed: "La liste n'a pas pu être actualisée" };
toast.erreur(`${libelles[act]} : ${e instanceof Error ? e.message : String(e)}`);
} finally {
setActionEnCours(null);
}
};
const lancer = async () => {
if (!dansLaFenetre) {
const ok = await confirmer({
titre: "Lancer la mise à jour maintenant ?",
message: "Nous sommes en dehors de la plage horaire nocturne : la mise à jour va solliciter la base FF et Qlik en pleine journée, ce qui peut ralentir l'application pour les magasins.",
libelleConfirmer: "Lancer maintenant",
});
if (!ok) return;
}
void action("start", !dansLaFenetre);
};
const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => {
setErreur(null);
if (codes.length === 0) return;
// Mise à jour optimiste : la liste peut compter des centaines de lignes,
// un rechargement complet à chaque case cochée serait pénible.
setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur)
@@ -172,7 +240,7 @@ export function SynchronisationClient() {
});
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
} catch (e) {
setErreur(e instanceof Error ? e.message : String(e));
toast.erreur(`La modification n'a pas pu être enregistrée : ${e instanceof Error ? e.message : String(e)}`);
rafraichirTout(); // l'optimisme était infondé : on resynchronise
}
};
@@ -181,254 +249,385 @@ export function SynchronisationClient() {
const q = recherche.trim().toLowerCase();
return rows.filter((r) => {
if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false;
if (filtre === "actifs") return r.actifSql || r.actifQlik;
if (filtre === "inactifs") return !r.actifSql && !r.actifQlik;
if (filtre === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec";
return true;
return correspond(r, filtre);
});
}, [rows, recherche, filtre]);
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
return (
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
<div className="mx-auto max-w-screen-xl">
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<CalendarClock className="w-6 h-6" style={{ color: "var(--accent)" }} />
Synchronisation nocturne
</h1>
<p className="text-[13px] mt-1 mb-5" style={{ color: "var(--text-muted)" }}>
Les fournisseurs sont traités du plus anciennement synchronisé au plus récent. Si la fenêtre
se referme avant la fin, la nuit suivante reprend là où on s&apos;est arrêté ; quand tout le
monde est passé, un nouveau tour recommence.
</p>
const basculerEnMasse = async (champ: "actifSql" | "actifQlik", valeur: boolean) => {
const n = codesVisibles.length;
if (n === 0) return;
if (!valeur) {
const quoi = champ === "actifSql" ? "des ventes (base FF)" : "des données du réseau (Qlik)";
const ok = await confirmer({
titre: `Désactiver la mise à jour ${quoi} ?`,
message: `${fmtEntier(n)} fournisseur${n > 1 ? "s" : ""} de la liste affichée ne ser${n > 1 ? "ont" : "a"} plus mis à jour la nuit pour cette source. Vous pourrez les réactiver à tout moment.`,
libelleConfirmer: "Désactiver",
danger: true,
});
if (!ok) return;
}
void basculer(codesVisibles, champ, valeur);
};
{erreur && (
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
<span>{erreur}</span>
</div>
)}
const filtres: readonly TabItem<Filtre>[] = useMemo(() => [
{ value: "tous", label: "Tous", count: rows.length },
{ value: "actifs", label: "Actifs", count: rows.filter((r) => correspond(r, "actifs")).length },
{ value: "inactifs", label: "Inactifs", count: rows.filter((r) => correspond(r, "inactifs")).length },
{ value: "echecs", label: "En échec", count: rows.filter((r) => correspond(r, "echecs")).length },
], [rows]);
{/* ── Réglages ── */}
{settings && (
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
<div className="flex flex-wrap items-end gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={settings.actif}
onChange={(e) => majSettings({ actif: e.target.checked })} className="w-4 h-4" />
<span className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
Synchronisation nocturne active
</span>
</label>
<div className="flex items-end gap-2">
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
<input type="number" min={0} max={23} value={settings.heureDebut}
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h à</span>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
<input type="number" min={0} max={23} value={settings.heureFin}
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h</span>
</div>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
<input type="number" min={0} max={200} value={settings.qlikParNuit}
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
<input type="number" min={0} max={365} value={settings.qlikMinJours}
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<div className="flex-1" />
{state?.enCours ? (
<button onClick={() => action("stop")} className="rounded-xl px-4 py-2 text-[13px] font-semibold flex items-center gap-2"
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)", border: "1px solid var(--accent-error)" }}>
<Square className="w-3.5 h-3.5" /> Arrêter
</button>
) : (
<button onClick={() => action("start", !dansLaFenetre)} className="rounded-xl px-4 py-2 text-[13px] font-semibold text-white flex items-center gap-2"
style={{ background: "var(--accent)" }}>
<Play className="w-3.5 h-3.5" /> Lancer maintenant
</button>
)}
</div>
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
{dansLaFenetre
? "Nous sommes actuellement dans la fenêtre."
: "Hors fenêtre — « Lancer maintenant » forcera un round immédiat."}
{" "}Une extraction Qlik peut durer 8 minutes : le plafond par nuit évite qu&apos;elle
monopolise la fenêtre. Un fournisseur déjà démarré n&apos;est jamais interrompu à l&apos;heure de fin.
</p>
</div>
)}
{/* ── Round en cours ── */}
{state && (state.enCours || state.finAt) && (
<div className="rounded-2xl p-4 mb-4 text-[13px]"
style={{ background: state.enCours ? "var(--accent-bg)" : "var(--bg-surface)", border: `1px solid ${state.enCours ? "var(--accent-border)" : "var(--border)"}` }}>
<div className="flex items-center gap-2 flex-wrap">
{state.enCours
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: "var(--accent)" }} />
: <CheckCircle className="w-4 h-4 text-emerald-500" />}
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>
{state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"}
</span>
{state.enCours && state.fournisseurCourant && (
<span style={{ color: "var(--text-secondary)" }}>
— {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<span><Database className="w-3 h-3 inline mr-1" />SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`}</span>
<span><Network className="w-3 h-3 inline mr-1" />Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`}</span>
{state.debutAt && <span>Début {fmtDate(state.debutAt)}</span>}
{state.tourTermine && <span className="text-emerald-500 font-semibold">Tour bouclé — tout le monde est à jour</span>}
</div>
{state.derniereErreur && (
<p className="mt-2 text-[11px] text-red-400">Dernière erreur : {state.derniereErreur}</p>
)}
</div>
)}
{/* ── Résumé ── */}
{resume && (
<div className="flex flex-wrap gap-2 mb-3 text-[12px]">
{[
{ l: "Fournisseurs", v: resume.total },
{ l: "Actifs SQL", v: resume.actifsSql },
{ l: "Actifs Qlik", v: resume.actifsQlik },
{ l: "Jamais synchronisés", v: resume.jamaisSql },
{ l: "Désactivés auto", v: resume.desactivesAuto },
{ l: "En échec", v: resume.enEchecSql + resume.enEchecQlik },
].map((s) => (
<span key={s.l} className="rounded-lg px-2.5 py-1"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
{s.l} <strong style={{ color: "var(--text-primary)" }}>{s.v}</strong>
</span>
))}
</div>
)}
{/* ── Filtres et actions de masse ── */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
<input value={recherche} onChange={(e) => setRecherche(e.target.value)} placeholder="Code ou nom"
className="rounded-lg pl-8 pr-3 py-1.5 text-[12px] w-56"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => (
<button key={f} onClick={() => setFiltre(f)}
className="rounded-full px-3 py-1 text-[12px] font-semibold"
style={{
background: filtre === f ? "var(--accent)" : "var(--bg-elevated)",
color: filtre === f ? "#fff" : "var(--text-secondary)",
border: "1px solid var(--border)",
}}>
{f === "echecs" ? "en échec" : f}
</button>
))}
<div className="flex-1" />
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{visibles.length} affichés :</span>
<button onClick={() => basculer(codesVisibles, "actifSql", true)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Activer SQL
</button>
<button onClick={() => basculer(codesVisibles, "actifSql", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Désactiver SQL
</button>
<button onClick={() => basculer(codesVisibles, "actifQlik", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Désactiver Qlik
</button>
<button onClick={() => action("seed")} title="Récupérer les nouveaux fournisseurs du référentiel"
className="rounded-lg px-2.5 py-1 text-[11px] flex items-center gap-1"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
<RefreshCw className="w-3 h-3" /> Actualiser la liste
</button>
const colonnes: DataColumn<FournisseurRow>[] = [
{
id: "fournisseur",
header: "Fournisseur",
sortValue: (r) => r.nomFournisseur ?? r.codeFournisseur,
cell: (r) => (
<div className="min-w-[180px]">
<div className="font-medium text-[var(--text-primary)]">{r.nomFournisseur ?? r.codeFournisseur}</div>
<div className="text-xs text-[var(--text-muted)]">{r.codeFournisseur}</div>
</div>
{/* ── Tableau ── */}
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
{chargement ? (
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-4 h-4 animate-spin inline mr-2" /> Chargement…
</div>
) : visibles.length === 0 ? (
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => (
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{visibles.map((r) => (
<tr key={r.codeFournisseur} style={{ borderBottom: "1px solid var(--border)" }}>
<td className="px-3 py-2">
<div className="font-medium" style={{ color: "var(--text-primary)" }}>
{r.nomFournisseur ?? r.codeFournisseur}
</div>
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{r.codeFournisseur}</div>
</td>
<td className="px-3 py-2">
<input type="checkbox" checked={r.actifSql} className="w-4 h-4"
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)} />
</td>
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
<Pastille statut={r.dernierSqlStatut} />{" "}
{fmtDate(r.dernierSqlAt)}
{r.dernierSqlLignes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}</span>}
</td>
<td className="px-3 py-2">
<input type="checkbox" checked={r.actifQlik} className="w-4 h-4"
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)} />
</td>
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
<Pastille statut={r.dernierQlikStatut} />{" "}
{fmtDate(r.dernierQlikAt)}
{r.dernierQlikCodes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes</span>}
</td>
<td className="px-3 py-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
{r.desactiveMotif}
</td>
</tr>
))}
</tbody>
</table>
),
},
{
id: "actifSql",
header: VENTES,
hint: "Cochez pour mettre à jour chaque nuit les ventes de ce fournisseur dans nos magasins.",
align: "center",
sortValue: (r) => (r.actifSql ? 1 : 0),
cell: (r) => (
<input
type="checkbox"
checked={r.actifSql}
className={CASE}
aria-label={`Mettre à jour les ventes de ${r.nomFournisseur ?? r.codeFournisseur}`}
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)}
/>
),
},
{
id: "dernierSql",
header: "Dernière mise à jour des ventes",
sortValue: (r) => r.dernierSqlAt,
cell: (r) => (
<div className="whitespace-nowrap">
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
<StatutBadge statut={r.dernierSqlStatut} />
{fmtDate(r.dernierSqlAt)}
</div>
{r.dernierSqlLignes != null && (
<div className="text-xs text-[var(--text-muted)]">
{fmtEntier(r.dernierSqlLignes)} lignes{r.dernierSqlMs != null && <> en {fmtDuree(r.dernierSqlMs)}</>}
</div>
)}
</div>
),
},
{
id: "actifQlik",
header: RESEAU,
hint: "Cochez pour mettre à jour les ventes de ce fournisseur dans les magasins du réseau. Ces extractions sont longues : leur nombre par nuit est plafonné.",
align: "center",
sortValue: (r) => (r.actifQlik ? 1 : 0),
cell: (r) => (
<input
type="checkbox"
checked={r.actifQlik}
className={CASE}
aria-label={`Mettre à jour les données réseau de ${r.nomFournisseur ?? r.codeFournisseur}`}
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)}
/>
),
},
{
id: "dernierQlik",
header: "Dernière mise à jour réseau",
sortValue: (r) => r.dernierQlikAt,
cell: (r) => (
<div className="whitespace-nowrap">
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
<StatutBadge statut={r.dernierQlikStatut} />
{fmtDate(r.dernierQlikAt)}
</div>
{r.dernierQlikCodes != null && (
<div className="text-xs text-[var(--text-muted)]">{fmtEntier(r.dernierQlikCodes)} produits</div>
)}
</div>
),
},
{
id: "remarque",
header: "Remarque",
cell: (r) => (r.desactiveMotif
? <span className="text-[13px] text-[var(--text-secondary)]">{r.desactiveMotif}</span>
: null),
},
];
if (chargement) {
return (
<div className="mx-auto w-full max-w-screen-xl">
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
<div className="space-y-4" aria-busy="true" aria-label="Chargement">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-96 w-full rounded-xl" />
</div>
</div>
);
}
if (!settings && erreur) {
return (
<div className="mx-auto w-full max-w-screen-xl">
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
<ErrorState
title="Les réglages de la synchronisation n'ont pas pu être chargés"
detail={erreur}
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
/>
</div>
);
}
return (
<div className="mx-auto w-full max-w-screen-xl space-y-6">
<PageHeader
icon={CalendarClock}
title="Synchronisation"
description={DESCRIPTION}
actions={state?.enCours ? (
<Button variant="outline" onClick={() => action("stop")} disabled={actionEnCours === "stop"}
className="border-[var(--accent-error)] text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]">
{actionEnCours === "stop" ? <Loader2 className="animate-spin" /> : <Square />}
Arrêter
</Button>
) : (
<Button onClick={lancer} disabled={actionEnCours === "start" || !settings}>
{actionEnCours === "start" ? <Loader2 className="animate-spin" /> : <Play />}
Lancer maintenant
</Button>
)}
/>
{erreur && (
<div role="alert" className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--accent-error)]/40 bg-[var(--accent-error-bg)] px-4 py-3 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 text-[var(--accent-error)]" aria-hidden />
<span className="flex-1 text-[var(--text-primary)]">
Les informations n&apos;ont pas pu être actualisées : <span className="text-[var(--text-secondary)]">{erreur}</span>
</span>
<Button variant="outline" size="sm" onClick={rafraichirTout}>Réessayer</Button>
</div>
)}
{/* ── Réglages ── */}
{settings && (
<Card>
<CardHeader
title="Réglages"
description="Chaque nuit, dans la plage horaire choisie, les fournisseurs sont mis à jour un par un, en commençant par ceux dont les données sont les plus anciennes. Si la nuit ne suffit pas, la suivante reprend là où la précédente s'est arrêtée."
/>
<CardContent className="space-y-5">
<label htmlFor="sync-actif" className="inline-flex cursor-pointer items-center gap-2.5">
<input id="sync-actif" type="checkbox" checked={settings.actif}
onChange={(e) => majSettings({ actif: e.target.checked })} className={CASE} />
<span className="text-sm font-semibold text-[var(--text-primary)]">
Synchronisation nocturne activée
</span>
<Badge ton={settings.actif ? "succes" : "neutre"}>{settings.actif ? "Activée" : "Désactivée"}</Badge>
</label>
<div className="grid gap-5 md:grid-cols-3">
<div>
<Label htmlFor="sync-debut">Plage horaire</Label>
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<span>de</span>
<Input id="sync-debut" type="number" min={0} max={23} value={settings.heureDebut}
aria-label="Heure de début"
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
className="w-16 text-center tabular-nums" />
<span>h à</span>
<Input id="sync-fin" type="number" min={0} max={23} value={settings.heureFin}
aria-label="Heure de fin"
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
className="w-16 text-center tabular-nums" />
<span>h</span>
</div>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Aucun fournisseur n&apos;est commencé après l&apos;heure de fin ; celui en cours va jusqu&apos;au bout.
</p>
</div>
<div>
<Label htmlFor="sync-qlik-nuit">Extractions réseau par nuit</Label>
<Input id="sync-qlik-nuit" type="number" min={0} max={200} value={settings.qlikParNuit}
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
className="w-20 text-center tabular-nums" />
<p className="mt-1 text-xs text-[var(--text-muted)]">
Une extraction des données du réseau (Qlik) peut durer 8 minutes : ce plafond évite qu&apos;elles occupent toute la nuit.
</p>
</div>
<div>
<Label htmlFor="sync-qlik-jours">Fraîcheur des données réseau</Label>
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--text-secondary)]">
<span>Actualiser les données réseau de plus de</span>
<Input id="sync-qlik-jours" type="number" min={0} max={365} value={settings.qlikMinJours}
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
className="w-20 text-center tabular-nums" />
<span>jours</span>
</div>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien.
</p>
</div>
</div>
<p className="text-xs text-[var(--text-muted)]">Les modifications sont enregistrées automatiquement.</p>
</CardContent>
</Card>
)}
{/* ── Lancement en cours ou dernier lancement ── */}
{state && (
<Card className={state.enCours ? "border-[var(--accent-border)] bg-[var(--accent-bg)]" : undefined}>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{state.enCours
? <Loader2 className="h-5 w-5 animate-spin text-[var(--accent)]" aria-hidden />
: state.finAt
? <CheckCircle2 className="h-5 w-5 text-[var(--accent-success)]" aria-hidden />
: <Clock className="h-5 w-5 text-[var(--text-muted)]" aria-hidden />}
<span className="text-base font-semibold text-[var(--text-primary)]">
{state.enCours
? `Lancement${libelleOrigine(state.origine)} en cours`
: state.finAt
? `Dernier lancement${libelleOrigine(state.origine)} terminé`
: "Aucun lancement depuis le démarrage du serveur"}
</span>
{state.enCours && state.fournisseurCourant && (
<span className="text-sm text-[var(--text-secondary)]">
: {state.etape === "qlik" ? RESEAU : VENTES}, {state.fournisseurCourant}
</span>
)}
{state.tourTermine && (
<Badge ton="succes">Tous les fournisseurs ont été traités</Badge>
)}
</div>
{(state.enCours || state.finAt) && (
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[13px] text-[var(--text-secondary)]">
<span className="inline-flex items-center gap-1.5">
<Database className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
{VENTES} : {fmtEntier(state.sqlFaits)} à jour
{state.sqlEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.sqlEchecs)} en échec</span>}
</span>
<span className="inline-flex items-center gap-1.5">
<Network className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
{RESEAU} : {fmtEntier(state.qlikFaits)} à jour
{state.qlikEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.qlikEchecs)} en échec</span>}
</span>
{state.debutAt && <span>Commencé le {fmtDate(state.debutAt)}</span>}
{!state.enCours && state.finAt && <span>Terminé le {fmtDate(state.finAt)}</span>}
</div>
)}
{state.derniereErreur && (
<p className="text-[13px] text-[var(--accent-error)]">Dernière erreur : {state.derniereErreur}</p>
)}
<p className="text-xs text-[var(--text-muted)]">
{dansLaFenetre
? "Nous sommes actuellement dans la plage horaire nocturne."
: "Hors de la plage horaire : « Lancer maintenant » démarre quand même une mise à jour immédiate."}
</p>
</CardContent>
</Card>
)}
{/* ── Résumé ── */}
{resume && (
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-6">
<StatCard label="Fournisseurs" value={fmtEntier(resume.total)} />
<StatCard label={`${VENTES} activées`} value={fmtEntier(resume.actifsSql)} />
<StatCard label={`${RESEAU} activé`} value={fmtEntier(resume.actifsQlik)} />
<StatCard label="Ventes jamais mises à jour" value={fmtEntier(resume.jamaisSql)} />
<StatCard
label={
<Tooltip content="Fournisseurs sans aucun article dans la base FF : ils ont été retirés de la mise à jour nocturne. Cochez-les à nouveau pour les réintégrer.">
<span tabIndex={0} className="cursor-help underline decoration-dotted underline-offset-2">Retirés automatiquement</span>
</Tooltip>
}
value={fmtEntier(resume.desactivesAuto)}
hint="aucun article dans la base FF"
/>
<StatCard
label="En échec"
value={fmtEntier(resume.enEchecSql + resume.enEchecQlik)}
hint={`${fmtEntier(resume.enEchecSql)} ventes, ${fmtEntier(resume.enEchecQlik)} réseau`}
/>
</div>
)}
{/* ── Fournisseurs ── */}
<Card>
<CardHeader
title="Fournisseurs"
description="Cochez, pour chaque fournisseur, les données à mettre à jour chaque nuit."
actions={
<Button variant="outline" size="sm" onClick={() => action("seed")} disabled={actionEnCours === "seed"}
title="Ajoute les nouveaux fournisseurs du référentiel FF et met à jour leurs noms">
{actionEnCours === "seed" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Actualiser la liste des fournisseurs
</Button>
}
/>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2">
<span className="text-[13px] text-[var(--text-secondary)]">
Pour les <strong className="text-[var(--text-primary)]">{fmtEntier(visibles.length)}</strong> fournisseur{visibles.length > 1 ? "s" : ""} de la liste ci-dessous :
</span>
<Button variant="outline" size="sm" disabled={visibles.length === 0}
onClick={() => basculerEnMasse("actifSql", true)}>
Activer les ventes
</Button>
<Button variant="outline" size="sm" disabled={visibles.length === 0}
onClick={() => basculerEnMasse("actifSql", false)}>
Désactiver les ventes
</Button>
<Button variant="outline" size="sm" disabled={visibles.length === 0}
onClick={() => basculerEnMasse("actifQlik", false)}>
Désactiver le réseau
</Button>
</div>
<DataTable
rows={visibles}
columns={colonnes}
rowKey={(r) => r.codeFournisseur}
pageSize={0}
unite="fournisseurs"
toolbar={
<>
<Segmented items={filtres} value={filtre} onChange={setFiltre} />
<SearchInput
value={recherche}
onChange={setRecherche}
placeholder="Code ou nom du fournisseur"
aria-label="Rechercher un fournisseur"
/>
</>
}
emptyTitle={rows.length === 0 ? "Aucun fournisseur" : "Aucun fournisseur ne correspond"}
emptyDescription={rows.length === 0
? "Utilisez « Actualiser la liste des fournisseurs » pour importer le référentiel FF."
: "Modifiez la recherche ou choisissez « Tous »."}
/>
</CardContent>
</Card>
</div>
);
}
const DESCRIPTION =
"Mise à jour nocturne des données des fournisseurs : leurs ventes dans nos magasins (base FF) et dans le réseau (Qlik).";
@@ -1,8 +1,7 @@
import type { Metadata } from "next";
import { SynchronisationClient } from "./client";
export const metadata = {
title: "Synchronisation nocturne — CollectFlow",
};
export const metadata: Metadata = { title: "Synchronisation" };
/**
* Paramétrage de la synchronisation nocturne des fournisseurs.
+216 -210
View File
@@ -1,55 +1,95 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useTransition, type ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { Segmented, type TabItem } from "@/components/ui/tabs";
import { Select } from "@/components/ui/form-controls";
import { DeltaBadge } from "@/components/ui/badge";
import { Terme } from "@/components/ui/tooltip";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtEur0 } from "@/lib/format";
import { telechargerExcel } from "@/lib/export-excel";
import { cn } from "@/lib/utils";
export type ModeAnalytics = "fournisseur" | "nomenclature";
export interface OptionMois {
value: string;
label: string;
}
export interface AnalyticsRow {
key: string;
label: string;
ca292: number;
caN1_292: number;
ca579: number;
caN1_579: number;
caTotal: number;
caN1Total: number;
evolutionTotal: number | null;
}
interface AnalyticsClientProps {
mode: string;
mode: ModeAnalytics;
/** Mois affiché, « AAAA-MM ». */
mois: string;
moisN1: string;
currentMois: string;
pivotted: Array<{
key: string;
label: string;
ca292: number;
caN1_292: number;
ca579: number;
caN1_579: number;
caTotal: number;
caN1Total: number;
evolutionTotal: number | null;
}>;
totals: {
ca292: number;
caN1_292: number;
ca579: number;
caN1_579: number;
caTotal: number;
caN1Total: number;
};
evolutionTotal292: number | null;
evolutionTotal579: number | null;
evolutionTotalReseau: number | null;
/** Mois en clair : « octobre 2026 ». */
libelleMois: string;
libelleMoisN1: string;
moisDisponibles: OptionMois[];
pivotted: AnalyticsRow[];
}
function formatCA(value: number): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(value);
/** Champs numériques de la ligne pivotée. */
type Champ = { [K in keyof AnalyticsRow]: AnalyticsRow[K] extends number ? K : never }[keyof AnalyticsRow];
interface Groupe {
id: string;
titre: string;
ca: Champ;
caN1: Champ;
}
function EvolutionBadge({ value }: { value: number | null }) {
if (value === null) return <span className="text-gray-400">N/A</span>;
/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */
const CHAMPS_MAGASIN: Record<string, Pick<Groupe, "ca" | "caN1">> = {
"292": { ca: "ca292", caN1: "caN1_292" },
"579": { ca: "ca579", caN1: "caN1_579" },
};
const isPositive = value >= 0;
const bgColor = isPositive ? "bg-green-100" : "bg-red-100";
const textColor = isPositive ? "text-green-700" : "text-red-700";
const GROUPES: Groupe[] = [
...MAGASINS.filter((m) => CHAMPS_MAGASIN[m.code]).map((m) => ({
id: m.code,
titre: nomMagasin(m.code),
...CHAMPS_MAGASIN[m.code],
})),
{ id: "total", titre: "Total nos magasins", ca: "caTotal", caN1: "caN1Total" },
];
const MODES: TabItem<ModeAnalytics>[] = [
{ value: "fournisseur", label: "Fournisseur" },
{ value: "nomenclature", label: "Famille" },
];
const somme = (rows: readonly AnalyticsRow[], champ: Champ) => rows.reduce((acc, r) => acc + r[champ], 0);
/** Évolution en % par rapport à N-1 ; `null` sans chiffre d'affaires N-1. */
const evolution = (ca: number, caN1: number) => (caN1 > 0 ? ((ca - caN1) / caN1) * 100 : null);
const recherche = (r: AnalyticsRow) => [r.label, r.key];
function Montant({ v }: { v: number }) {
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur0(v)}</span>;
}
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
return (
<span className={`${bgColor} ${textColor} inline-block rounded px-2 py-1 text-sm font-semibold`}>
{isPositive ? "+" : ""}
{value.toFixed(1)}%
<span className="flex flex-col items-end leading-tight">
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
<span className="inline-flex items-center gap-1">{children}</span>
</span>
);
}
@@ -57,193 +97,159 @@ function EvolutionBadge({ value }: { value: number | null }) {
export function AnalyticsClient({
mode,
mois,
moisN1,
currentMois,
libelleMois,
libelleMoisN1,
moisDisponibles,
pivotted,
totals,
evolutionTotal292,
evolutionTotal579,
evolutionTotalReseau,
}: AnalyticsClientProps) {
const router = useRouter();
const [navigation, demarrerNavigation] = useTransition();
const handleModeChange = (newMode: string) => {
router.push(`/analytics?mode=${newMode}&mois=${mois}`);
demarrerNavigation(() => {
router.push(`/analytics?mode=${newMode}&mois=${mois}`);
});
};
const handleMonthChange = (newMois: string) => {
router.push(`/analytics?mode=${mode}&mois=${newMois}`);
demarrerNavigation(() => {
router.push(`/analytics?mode=${mode}&mois=${newMois}`);
});
};
// Générer liste des 24 derniers mois
const months: { value: string; label: string }[] = [];
const now = new Date();
for (let i = 0; i < 24; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i);
const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const label = new Intl.DateTimeFormat("fr-FR", { year: "numeric", month: "long" }).format(d);
months.push({ value, label: label.charAt(0).toUpperCase() + label.slice(1) });
const libelleColonne = mode === "fournisseur" ? "Fournisseur" : "Famille";
const colonnes = useMemo<DataColumn<AnalyticsRow>[]>(() => [
{
id: "label",
header: libelleColonne,
sortValue: (r) => r.label,
cell: (r) => <span className="font-medium">{r.label}</span>,
footer: () => "Total",
className: "min-w-[220px]",
},
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
const total = g.id === "total";
const bord = "border-l border-[var(--border)]";
return [
{
id: `${g.id}-ca`,
header: <EnTete groupe={g.titre}>{libelleMois}</EnTete>,
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`,
align: "right",
sortValue: (r) => r[g.ca],
cell: (r) => <Montant v={r[g.ca]} />,
footer: (rows) => <Montant v={somme(rows, g.ca)} />,
className: cn(bord, total && "font-semibold"),
headerClassName: bord,
},
{
id: `${g.id}-n1`,
header: (
<EnTete groupe={g.titre}>
{libelleMoisN1} · <Terme id="n1" />
</EnTete>
),
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMoisN1}, même mois l'année précédente`,
align: "right",
sortValue: (r) => r[g.caN1],
cell: (r) => <Montant v={r[g.caN1]} />,
footer: (rows) => <Montant v={somme(rows, g.caN1)} />,
className: cn(total && "font-semibold"),
},
{
id: `${g.id}-evolution`,
header: <EnTete groupe={g.titre}>Évolution</EnTete>,
hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`,
align: "right",
sortValue: (r) => evolution(r[g.ca], r[g.caN1]),
cell: (r) => <DeltaBadge pct={evolution(r[g.ca], r[g.caN1])} />,
footer: (rows) => <DeltaBadge pct={evolution(somme(rows, g.ca), somme(rows, g.caN1))} />,
},
];
}),
], [libelleColonne, libelleMois, libelleMoisN1]);
async function exporter(rows: readonly AnalyticsRow[]) {
const arrondi = (v: number) => Math.round(v * 100) / 100;
const evol = (ca: number, caN1: number) => {
const e = evolution(ca, caN1);
return e === null ? null : Math.round(e * 10) / 10;
};
await telechargerExcel({
feuille: "Ventes par mois",
fichier: `ventes-par-mois_${mode === "fournisseur" ? "fournisseurs" : "familles"}_${mois}`,
entetes: [
libelleColonne,
...GROUPES.flatMap((g) => [
`${g.titre} — CA ${libelleMois}`,
`${g.titre} — CA ${libelleMoisN1} (N-1)`,
`${g.titre} — Évolution (%)`,
]),
],
largeurs: [40, ...GROUPES.flatMap(() => [22, 22, 16])],
lignes: rows.map((r) => [
r.label,
...GROUPES.flatMap((g) => [arrondi(r[g.ca]), arrondi(r[g.caN1]), evol(r[g.ca], r[g.caN1])]),
]),
totaux: [
"Total",
...GROUPES.flatMap((g) => {
const ca = somme(rows, g.ca);
const caN1 = somme(rows, g.caN1);
return [arrondi(ca), arrondi(caN1), evol(ca, caN1)];
}),
],
});
}
return (
<div className="space-y-6">
{/* Controls */}
<div className="flex items-center gap-6 rounded-lg bg-white p-4 shadow">
<div className="flex items-center gap-2">
<label className="font-semibold text-gray-700">Vue :</label>
<select
value={mode}
onChange={(e) => handleModeChange(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
<option value="fournisseur">Fournisseur</option>
<option value="nomenclature">Nomenclature</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="font-semibold text-gray-700">Mois :</label>
<select
value={mois}
onChange={(e) => handleMonthChange(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
{months.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
<div className="text-sm text-gray-600">
Comparaison N-1 : {moisN1}
<div className="space-y-4" aria-busy={navigation}>
{/* Contrôles (relancent le calcul côté serveur) */}
<div className="flex flex-wrap items-end gap-4">
<div role="group" aria-labelledby="analytics-vue">
<span id="analytics-vue" className="mb-1 block text-[13px] font-medium text-[var(--text-secondary)]">
Afficher par
</span>
<Segmented items={MODES} value={mode} onChange={handleModeChange} disabled={navigation} />
</div>
<Select
id="analytics-mois"
label="Mois"
value={mois}
onChange={handleMonthChange}
options={moisDisponibles}
disabled={navigation}
/>
{navigation && (
<span role="status" className="inline-flex h-9 items-center gap-2 text-[13px] text-[var(--text-secondary)]">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Chargement…
</span>
)}
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg bg-white shadow">
<table className="w-full">
<thead>
<tr className="border-b-2 border-gray-200 bg-gray-50">
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
{mode === "fournisseur" ? "Fournisseur" : "Nomenclature"}
</th>
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
Frouard/Nancy (292)
</th>
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
Houdemont (579)
</th>
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
Total Réseau
</th>
</tr>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600"></th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{mois}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{moisN1}
</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
Évol.
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{mois}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{moisN1}
</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
Évol.
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{mois}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
{moisN1}
</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
Évol.
</th>
</tr>
</thead>
<tbody>
{pivotted.map((row, idx) => (
<tr
key={row.key}
className={idx % 2 === 0 ? "border-b border-gray-100 bg-white" : "border-b border-gray-100 bg-gray-50"}
>
<td className="px-4 py-3 text-sm font-medium text-gray-900">{row.label}</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca292)}</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_292)}</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge
value={
row.caN1_292 > 0
? ((row.ca292 - row.caN1_292) / row.caN1_292) * 100
: null
}
/>
</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca579)}</td>
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_579)}</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge
value={
row.caN1_579 > 0
? ((row.ca579 - row.caN1_579) / row.caN1_579) * 100
: null
}
/>
</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
{formatCA(row.caTotal)}
</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
{formatCA(row.caN1Total)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={row.evolutionTotal} />
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50">
<td className="px-4 py-3 text-sm font-bold text-gray-900">TOTAL</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.ca292)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caN1_292)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={evolutionTotal292} />
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.ca579)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caN1_579)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={evolutionTotal579} />
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caTotal)}
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
{formatCA(totals.caN1Total)}
</td>
<td className="px-4 py-3 text-center text-sm">
<EvolutionBadge value={evolutionTotalReseau} />
</td>
</tr>
</tfoot>
</table>
<p className="text-sm text-[var(--text-secondary)]">
Chiffre d&apos;affaires TTC de <strong className="font-semibold text-[var(--text-primary)]">{libelleMois}</strong>,
comparé à <strong className="font-semibold text-[var(--text-primary)]">{libelleMoisN1}</strong> (même mois
l&apos;année précédente). Les retours clients sont déduits.
</p>
<div className={cn("transition-opacity", navigation && "pointer-events-none opacity-50")}>
<DataTable<AnalyticsRow>
key={mode}
rows={pivotted}
columns={colonnes}
rowKey={(r) => `${r.key}|${r.label}`}
searchIn={recherche}
searchPlaceholder={mode === "fournisseur" ? "Rechercher un fournisseur…" : "Rechercher une famille…"}
pageSize={100}
unite={mode === "fournisseur" ? "fournisseurs" : "familles"}
showFooter
emptyTitle={`Aucune vente en ${libelleMois}`}
emptyDescription="Aucun chiffre d'affaires n'est enregistré pour ce mois ni pour le même mois de l'année précédente. Choisissez un autre mois."
onExport={exporter}
/>
</div>
</div>
);
+91 -67
View File
@@ -1,6 +1,30 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CalendarRange, RotateCcw } from "lucide-react";
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
import { AnalyticsClient } from "./client";
import { cachedFF } from "@/lib/ff-cache";
import { PageHeader } from "@/components/ui/page-header";
import { ErrorState } from "@/components/ui/states";
import { Button } from "@/components/ui/button";
import { AnalyticsClient, type AnalyticsRow, type ModeAnalytics, type OptionMois } from "./client";
export const metadata: Metadata = { title: "Ventes par mois" };
const NOMS_MOIS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
/** « 2026-10 » → « octobre 2026 » (sans dépendre du fuseau horaire). */
function libelleMois(mois: string): string {
const [year, month] = mois.split("-").map(Number);
const nom = NOMS_MOIS[month - 1];
return nom && Number.isFinite(year) ? `${nom} ${year}` : mois;
}
function formatMois(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
// Calcul du mois N-1 (même mois année précédente)
function getMoisN1(mois: string): string {
@@ -15,17 +39,7 @@ function pivotData(
mois: string,
moisN1: string,
mode: string
): Array<{
key: string;
label: string;
ca292: number;
caN1_292: number;
ca579: number;
caN1_579: number;
caTotal: number;
caN1Total: number;
evolutionTotal: number | null;
}> {
): AnalyticsRow[] {
const map = new Map<string, { code: string; label: string; data: Record<string, number> }>();
for (const row of rows) {
@@ -72,77 +86,87 @@ export default async function AnalyticsPage(props: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const searchParams = await props.searchParams;
const mode = (searchParams.mode as string) || "fournisseur";
// Toute autre valeur que « fournisseur » affichait déjà la vue par famille.
const mode: ModeAnalytics = ((searchParams.mode as string) || "fournisseur") === "fournisseur" ? "fournisseur" : "nomenclature";
const moisParam = (searchParams.mois as string) || "";
// Mois par défaut = mois courant (YYYY-MM)
// Mois par défaut = dernier mois complet (le mois précédent) : le mois en
// cours, incomplet, se comparerait mal à un mois N-1 entier.
const today = new Date();
const currentMois = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
const mois = moisParam || currentMois;
const currentMois = formatMois(today);
const dernierMoisComplet = formatMois(new Date(today.getFullYear(), today.getMonth() - 1));
const mois = moisParam || dernierMoisComplet;
const moisN1 = getMoisN1(mois);
let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> =
[];
if (mode === "fournisseur") {
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
rows = result.map((r) => ({
code: r.code,
label: r.nom,
site: r.site,
mois: r.mois,
ca_ttc: r.ca_ttc,
}));
} else {
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
rows = result.map((r) => ({
code: r.code,
label: `${r.code} — ${r.libelle}`,
site: r.site,
mois: r.mois,
ca_ttc: r.ca_ttc,
}));
// Liste des 24 derniers mois (calculée ici : même résultat côté serveur et navigateur).
const moisDisponibles: OptionMois[] = [];
for (let i = 0; i < 24; i++) {
const value = formatMois(new Date(today.getFullYear(), today.getMonth() - i));
moisDisponibles.push({ value, label: libelleMois(value) + (value === currentMois ? " (en cours)" : "") });
}
if (!moisDisponibles.some((m) => m.value === mois)) {
moisDisponibles.push({ value: mois, label: libelleMois(mois) });
}
const pivotted = pivotData(rows, mois, moisN1, mode);
let pivotted: AnalyticsRow[] | null = null;
let erreur: string | null = null;
try {
let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> =
[];
// Calcul des totaux
const totals = pivotted.reduce(
(acc, row) => ({
ca292: acc.ca292 + row.ca292,
caN1_292: acc.caN1_292 + row.caN1_292,
ca579: acc.ca579 + row.ca579,
caN1_579: acc.caN1_579 + row.caN1_579,
caTotal: acc.caTotal + row.caTotal,
caN1Total: acc.caN1Total + row.caN1Total,
}),
{ ca292: 0, caN1_292: 0, ca579: 0, caN1_579: 0, caTotal: 0, caN1Total: 0 }
);
if (mode === "fournisseur") {
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
rows = result.map((r) => ({
code: r.code,
label: r.nom,
site: r.site,
mois: r.mois,
ca_ttc: r.ca_ttc,
}));
} else {
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
rows = result.map((r) => ({
code: r.code,
label: `${r.code} — ${r.libelle}`,
site: r.site,
mois: r.mois,
ca_ttc: r.ca_ttc,
}));
}
const evolutionTotal292 =
totals.caN1_292 > 0 ? ((totals.ca292 - totals.caN1_292) / totals.caN1_292) * 100 : null;
const evolutionTotal579 =
totals.caN1_579 > 0 ? ((totals.ca579 - totals.caN1_579) / totals.caN1_579) * 100 : null;
const evolutionTotalReseau =
totals.caN1Total > 0 ? ((totals.caTotal - totals.caN1Total) / totals.caN1Total) * 100 : null;
pivotted = pivotData(rows, mois, moisN1, mode);
} catch (e) {
console.error("[analytics] chargement impossible :", e);
erreur = e instanceof Error ? e.message : String(e);
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-7xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Analytics</h1>
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={CalendarRange}
title="Ventes par mois"
description="Le chiffre d'affaires TTC d'un mois comparé au même mois de l'année précédente, par fournisseur ou par famille de produits, pour chacun de nos magasins."
/>
{pivotted ? (
<AnalyticsClient
mode={mode}
mois={mois}
moisN1={moisN1}
currentMois={currentMois}
libelleMois={libelleMois(mois)}
libelleMoisN1={libelleMois(moisN1)}
moisDisponibles={moisDisponibles}
pivotted={pivotted}
totals={totals}
evolutionTotal292={evolutionTotal292}
evolutionTotal579={evolutionTotal579}
evolutionTotalReseau={evolutionTotalReseau}
/>
</div>
) : (
<ErrorState
title="Les ventes du mois n'ont pas pu être chargées"
detail={erreur ?? undefined}
action={
<Button asChild variant="outline">
<Link href={`/analytics?mode=${mode}&mois=${mois}`}><RotateCcw /> Réessayer</Link>
</Button>
}
/>
)}
</div>
);
}
@@ -1,15 +1,7 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
AlertTriangle,
CalendarClock,
CheckCircle2,
HelpCircle,
Plus,
Power,
Trash2,
} from "lucide-react";
import { useMemo, useState, useTransition, type FormEvent } from "react";
import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react";
import {
upsertCadence,
toggleCadence,
@@ -17,41 +9,76 @@ import {
type CadenceView,
type CadenceStatut,
} from "@/features/commandes-auto/actions";
import { Badge, type Ton } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { confirmer, toast } from "@/components/ui/feedback";
import { Input, Label } from "@/components/ui/form-controls";
import { EmptyState } from "@/components/ui/states";
import { Terme, Tooltip, InfoBulle } from "@/components/ui/tooltip";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import { cn } from "@/lib/utils";
const SITES: { code: string; label: string }[] = [
{ code: "292", label: "292 — Frouard / Nancy" },
{ code: "579", label: "579 — Houdemont" },
];
type Fournisseur = { code: string; nom: string };
type Resultat = { ok: boolean; error?: string };
const SEMAINES_MIN = 1;
const SEMAINES_MAX = 104;
function fmtDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
const pluriel = (n: number, mot: string) => `${n} ${mot}${Math.abs(n) > 1 ? "s" : ""}`;
function tempsRestant(jours: number | null): string {
if (jours === null) return "Réception inconnue";
if (jours === 0) return "Aujourd'hui";
if (jours < 0) return `En retard de ${Math.abs(jours)} j`;
if (jours < 0) return `En retard de ${pluriel(Math.abs(jours), "jour")}`;
if (jours === 1) return "Demain";
return `Dans ${jours} j`;
return `Dans ${pluriel(jours, "jour")}`;
}
const STATUT_META: Record<CadenceStatut, { label: string; cls: string; Icon: typeof CheckCircle2 }> = {
a_commander: { label: "À commander", cls: "bg-red-100 text-red-700", Icon: AlertTriangle },
bientot: { label: "Bientôt", cls: "bg-amber-100 text-amber-700", Icon: CalendarClock },
ok: { label: "OK", cls: "bg-emerald-100 text-emerald-700", Icon: CheckCircle2 },
inconnu: { label: "Inconnu", cls: "bg-gray-100 text-gray-500", Icon: HelpCircle },
const STATUT_META: Record<CadenceStatut, { libelle: string; ton: Ton; explication: string }> = {
a_commander: {
libelle: "À commander",
ton: "erreur",
explication: "L'échéance est atteinte ou dépassée : passez commande.",
},
bientot: {
libelle: "Bientôt",
ton: "alerte",
explication: "L'échéance tombe dans les 7 prochains jours.",
},
ok: {
libelle: "À jour",
ton: "succes",
explication: "L'échéance est dans plus de 7 jours.",
},
inconnu: {
libelle: "Inconnu",
ton: "neutre",
explication: "Aucune réception connue pour ce fournisseur dans ce magasin : l'échéance ne peut pas être calculée.",
},
};
function StatutBadge({ statut }: { statut: CadenceStatut }) {
const { label, cls, Icon } = STATUT_META[statut];
function StatutBadge({ cadence }: { cadence: CadenceView }) {
if (!cadence.actif) {
return <Badge title="Rappel en pause : aucune alerte pour ce fournisseur.">Désactivé</Badge>;
}
const { libelle, ton, explication } = STATUT_META[cadence.statut];
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${cls}`}>
<Icon className="h-3 w-3" /> {label}
</span>
<Badge ton={ton} title={explication}>
{cadence.statut === "a_commander" && <AlertTriangle className="h-3.5 w-3.5" aria-hidden />}
{libelle}
</Badge>
);
}
/** Libellé suivi d'un « i » : explication libre au survol (hors glossaire). */
/** Tri : à commander d'abord (plus en retard en tête), puis bientôt, ok, inconnu, et inactifs en dernier. */
function trier(rows: CadenceView[]): CadenceView[] {
const rank: Record<CadenceStatut, number> = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 };
@@ -64,248 +91,365 @@ function trier(rows: CadenceView[]): CadenceView[] {
});
}
function AddCadenceForm({
function FormulaireAjout({
site,
fournisseurs,
existingCodes,
onDone,
codesExistants,
onAjouter,
pending,
}: {
site: string;
fournisseurs: { code: string; nom: string }[];
existingCodes: Set<string>;
onDone: (input: { codefou: string; nomfou: string; semaines: number }) => void;
fournisseurs: Fournisseur[];
codesExistants: Set<string>;
onAjouter: (input: { codefou: string; nomfou: string; semaines: number }) => Promise<boolean>;
pending: boolean;
}) {
const [value, setValue] = useState("");
const [semaines, setSemaines] = useState(4);
const [err, setErr] = useState<string | null>(null);
const listId = `fou-list-${site}`;
const [saisie, setSaisie] = useState("");
const [semaines, setSemaines] = useState("4");
const [erreur, setErreur] = useState<string | null>(null);
const idFournisseur = `cadence-fournisseur-${site}`;
const idSemaines = `cadence-semaines-${site}`;
const idListe = `cadence-liste-${site}`;
const idErreur = `cadence-erreur-${site}`;
// label affiché dans le datalist → fournisseur
const byLabel = useMemo(() => {
const m = new Map<string, { code: string; nom: string }>();
// Libellé affiché dans la liste de suggestions → fournisseur
const parLibelle = useMemo(() => {
const m = new Map<string, Fournisseur>();
for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f);
return m;
}, [fournisseurs]);
function submit() {
setErr(null);
const f = byLabel.get(value.trim());
async function ajouter(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setErreur(null);
const f = parLibelle.get(saisie.trim());
if (!f) {
setErr("Sélectionnez un fournisseur dans la liste.");
setErreur("Choisissez un fournisseur dans la liste proposée pendant la saisie.");
return;
}
if (existingCodes.has(f.code)) {
setErr("Ce fournisseur est déjà cadencé sur ce magasin.");
if (codesExistants.has(f.code)) {
setErreur("Ce fournisseur a déjà un rappel dans ce magasin : modifiez son rythme dans le tableau ci-dessous.");
return;
}
if (!Number.isFinite(semaines) || semaines < 1 || semaines > 104) {
setErr("Intervalle : 1 à 104 semaines.");
const n = Number(semaines);
if (!Number.isInteger(n) || n < SEMAINES_MIN || n > SEMAINES_MAX) {
setErreur(`Indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`);
return;
}
onDone({ codefou: f.code, nomfou: f.nom, semaines });
setValue("");
setSemaines(4);
// Le formulaire n'est vidé qu'une fois le rappel réellement enregistré.
if (await onAjouter({ codefou: f.code, nomfou: f.nom, semaines: n })) {
setSaisie("");
setSemaines("4");
}
}
return (
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-3">
<div className="flex flex-col">
<label className="mb-1 text-xs font-medium text-gray-500">Fournisseur</label>
<input
list={listId}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Rechercher…"
className="min-w-[260px] 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"
/>
<datalist id={listId}>
{fournisseurs.map((f) => (
<option key={f.code} value={`${f.nom} — ${f.code}`} />
))}
</datalist>
<form
onSubmit={ajouter}
className="rounded-xl border border-dashed border-[var(--border-strong)] bg-[var(--bg-surface)] p-4"
>
<p className="mb-3 text-sm font-medium text-[var(--text-primary)]">Ajouter un rappel pour ce magasin</p>
<div className="flex flex-wrap items-end gap-3">
<div>
<Label htmlFor={idFournisseur}>Fournisseur</Label>
<Input
id={idFournisseur}
list={idListe}
value={saisie}
onChange={(e) => setSaisie(e.target.value)}
placeholder="Tapez le nom ou le code…"
autoComplete="off"
aria-invalid={erreur !== null || undefined}
aria-describedby={erreur ? idErreur : undefined}
className="w-[300px] max-w-full"
/>
<datalist id={idListe}>
{fournisseurs.map((f) => (
<option key={f.code} value={`${f.nom} — ${f.code}`} />
))}
</datalist>
</div>
<div>
<Label htmlFor={idSemaines}>Toutes les … semaines</Label>
<Input
id={idSemaines}
type="number"
inputMode="numeric"
min={SEMAINES_MIN}
max={SEMAINES_MAX}
step={1}
value={semaines}
onChange={(e) => setSemaines(e.target.value)}
className="w-32"
/>
</div>
<Button type="submit" disabled={pending}>
<Plus /> Ajouter le rappel
</Button>
</div>
<div className="flex flex-col">
<label className="mb-1 text-xs font-medium text-gray-500">Toutes les (semaines)</label>
<input
type="number"
min={1}
max={104}
value={semaines}
onChange={(e) => setSemaines(parseInt(e.target.value, 10))}
className="w-28 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"
/>
</div>
<button
type="button"
onClick={submit}
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50"
>
<Plus className="h-4 w-4" /> Activer l&apos;alerte
</button>
{err && <span className="text-xs font-medium text-red-600">{err}</span>}
</div>
{erreur && (
<p id={idErreur} role="alert" className="mt-2 text-[13px] font-medium text-[var(--accent-error)]">
{erreur}
</p>
)}
</form>
);
}
function SiteSection({
const TH = "whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-left text-[13px] font-semibold text-[var(--text-secondary)]";
function SectionMagasin({
site,
label,
rows,
fournisseurs,
}: {
site: string;
label: string;
rows: CadenceView[];
fournisseurs: { code: string; nom: string }[];
fournisseurs: Fournisseur[];
}) {
const [pending, startTransition] = useTransition();
const nom = nomMagasin(site);
const sorted = useMemo(() => trier(rows), [rows]);
const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
const triees = useMemo(() => trier(rows), [rows]);
const codesExistants = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length;
// Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
// la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
function run(fn: () => Promise<unknown>) {
startTransition(async () => {
await fn();
/**
* Exécute une action serveur et en rend compte : message de réussite, ou
* message d'erreur (refus de l'action ou exception) — jamais d'échec muet.
* Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
* la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
*/
function executer(action: () => Promise<Resultat>, messages: { succes: string; echec: string }): Promise<boolean> {
return new Promise((resolve) => {
startTransition(async () => {
try {
const resultat = await action();
if (resultat.ok) toast.succes(messages.succes);
else toast.erreur(resultat.error ?? messages.echec);
resolve(resultat.ok);
} catch (e) {
console.error("[cadencier]", e);
toast.erreur(`${messages.echec} Vérifiez votre connexion puis réessayez.`);
resolve(false);
}
});
});
}
const add = (input: { codefou: string; nomfou: string; semaines: number }) =>
run(() =>
upsertCadence({
codefou: input.codefou,
nomfou: input.nomfou,
site,
intervalleSemaines: input.semaines,
actif: true,
}),
const ajouter = (input: { codefou: string; nomfou: string; semaines: number }) =>
executer(
() =>
upsertCadence({
codefou: input.codefou,
nomfou: input.nomfou,
site,
intervalleSemaines: input.semaines,
actif: true,
}),
{
succes: `Rappel ajouté : ${input.nomfou}, toutes les ${pluriel(input.semaines, "semaine")}.`,
echec: "Le rappel n'a pas pu être ajouté.",
},
);
const changeSemaines = (r: CadenceView, semaines: number) =>
run(() =>
upsertCadence({
codefou: r.codefou,
nomfou: r.nomfou,
site,
intervalleSemaines: semaines,
actif: r.actif,
}),
const changerSemaines = (r: CadenceView, semaines: number) =>
executer(
() =>
upsertCadence({
codefou: r.codefou,
nomfou: r.nomfou,
site,
intervalleSemaines: semaines,
actif: r.actif,
}),
{
succes: `Rythme enregistré : ${r.nomfou}, toutes les ${pluriel(semaines, "semaine")}.`,
echec: "Le rythme n'a pas pu être enregistré.",
},
);
const basculer = (r: CadenceView) =>
executer(() => toggleCadence(r.codefou, r.site, !r.actif), {
succes: r.actif ? `Rappel désactivé : ${r.nomfou}.` : `Rappel réactivé : ${r.nomfou}.`,
echec: r.actif ? "Le rappel n'a pas pu être désactivé." : "Le rappel n'a pas pu être réactivé.",
});
async function supprimer(r: CadenceView) {
const ok = await confirmer({
titre: "Supprimer ce rappel ?",
message: `Le rythme de commande de ${r.nomfou} pour ${nom} sera supprimé. Pour simplement suspendre les alertes, utilisez plutôt « Désactiver ».`,
libelleConfirmer: "Supprimer",
danger: true,
});
if (!ok) return;
await executer(() => removeCadence(r.codefou, r.site), {
succes: `Rappel supprimé : ${r.nomfou}.`,
echec: "Le rappel n'a pas pu être supprimé.",
});
}
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
{rows.length} cadencé{rows.length !== 1 ? "s" : ""}
</span>
<section className="space-y-3" aria-label={`Rythme de commande — ${nom}`}>
<div className="flex flex-wrap items-center gap-2">
<h2 className="mr-1 text-lg font-semibold text-[var(--text-primary)]">{nom}</h2>
<Badge>
{pluriel(rows.length, "fournisseur")} suivi{rows.length > 1 ? "s" : ""}
</Badge>
{nbACommander > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-semibold text-red-700">
<AlertTriangle className="h-3 w-3" /> {nbACommander} à commander
</span>
<Badge ton="erreur">
<AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {nbACommander} à commander
</Badge>
)}
</div>
<AddCadenceForm
<FormulaireAjout
site={site}
fournisseurs={fournisseurs}
existingCodes={existingCodes}
onDone={add}
codesExistants={codesExistants}
onAjouter={ajouter}
pending={pending}
/>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="border-b border-gray-200 bg-gray-50">
<tr>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Code</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Fournisseur</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-600">Toutes les</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Dernière réception</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Prochaine commande</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Temps restant</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Statut</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
{triees.length === 0 ? (
<EmptyState
icon={CalendarClock}
className="py-8"
title="Aucun rappel pour ce magasin"
description="Ajoutez un fournisseur avec le formulaire ci-dessus pour être prévenu quand il faut lui passer commande."
/>
) : (
<div
aria-busy={pending}
className="overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
>
<table className="min-w-full text-sm">
<thead className="bg-[var(--bg-elevated)]">
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-gray-400">
Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus.
</td>
<th scope="col" className={TH}>Fournisseur</th>
<th scope="col" className={TH}>Rythme</th>
<th scope="col" className={TH}>Dernière réception</th>
<th scope="col" className={TH}>
<InfoBulle explication="Date de la dernière réception du fournisseur dans ce magasin, plus le rythme choisi.">
Prochaine commande
</InfoBulle>
</th>
<th scope="col" className={TH}>Temps restant</th>
<th scope="col" className={TH}>Statut</th>
<th scope="col" className={cn(TH, "text-right")}>Actions</th>
</tr>
)}
{sorted.map((r) => {
const urgent = r.actif && r.statut === "a_commander";
return (
<tr
key={`${r.site}-${r.codefou}`}
className={[
"transition-colors",
!r.actif ? "opacity-50" : "",
urgent ? "bg-red-50/60 hover:bg-red-50" : "hover:bg-gray-50",
].join(" ")}
>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{r.codefou}</td>
<td className="px-3 py-2.5 font-medium text-gray-900">{r.nomfou}</td>
<td className="px-3 py-2.5 text-center">
<span className="inline-flex items-center gap-1">
<input
type="number"
min={1}
max={104}
defaultValue={r.intervalleSemaines}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (v && v !== r.intervalleSemaines && v >= 1 && v <= 104) changeSemaines(r, v);
}}
className="w-16 rounded-md border border-gray-200 px-2 py-1 text-center text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-xs text-gray-400">sem.</span>
</span>
</td>
<td className="px-3 py-2.5 text-gray-600">{fmtDate(r.derniereReception)}</td>
<td className="px-3 py-2.5 font-medium text-gray-800">{fmtDate(r.echeance)}</td>
<td className={["px-3 py-2.5 font-semibold", urgent ? "text-red-700" : "text-gray-700"].join(" ")}>
{tempsRestant(r.joursRestants)}
</td>
<td className="px-3 py-2.5">
<StatutBadge statut={r.actif ? r.statut : "inconnu"} />
</td>
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1">
<button
type="button"
title={r.actif ? "Désactiver" : "Réactiver"}
onClick={() => run(() => toggleCadence(r.codefou, r.site, !r.actif))}
disabled={pending}
className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
>
<Power className="h-4 w-4" />
</button>
<button
type="button"
title="Supprimer"
onClick={() => run(() => removeCadence(r.codefou, r.site))}
disabled={pending}
className="rounded-md p-1.5 text-gray-500 hover:bg-red-100 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</thead>
<tbody>
{triees.map((r) => {
const urgent = r.actif && r.statut === "a_commander";
const texte = r.actif ? "text-[var(--text-primary)]" : "text-[var(--text-muted)]";
return (
<tr
key={`${r.site}-${r.codefou}`}
className={cn(
"border-b border-[var(--border)] last:border-b-0",
urgent ? "bg-[var(--accent-error-bg)]" : "hover:bg-[var(--bg-elevated)]",
)}
>
<td className={cn("px-3 py-2", texte)}>
<div className="font-medium">{r.nomfou}</div>
<div className="text-xs text-[var(--text-muted)]">Code {r.codefou}</div>
</td>
<td className={cn("whitespace-nowrap px-3 py-2", texte)}>
<span className="inline-flex items-center gap-2">
Toutes les
<Input
// Remonté quand la valeur enregistrée change (après revalidation).
key={r.intervalleSemaines}
type="number"
inputMode="numeric"
min={SEMAINES_MIN}
max={SEMAINES_MAX}
step={1}
defaultValue={r.intervalleSemaines}
aria-label={`Rythme de commande de ${r.nomfou}, en semaines`}
title="Modifiez le nombre puis quittez le champ (ou appuyez sur Entrée) pour l'enregistrer."
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
onBlur={(e) => {
const champ = e.currentTarget;
const brut = champ.value.trim();
const v = Number(brut);
if (brut !== "" && v === r.intervalleSemaines) return;
if (brut === "" || !Number.isInteger(v) || v < SEMAINES_MIN || v > SEMAINES_MAX) {
champ.value = String(r.intervalleSemaines);
toast.erreur(
`Rythme non enregistré : indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`,
);
return;
}
void changerSemaines(r, v).then((ok) => {
if (!ok) champ.value = String(r.intervalleSemaines);
});
}}
className="h-8 w-20 px-2 text-center"
/>
semaines
</span>
</td>
<td className={cn("whitespace-nowrap px-3 py-2 tabular-nums", r.actif ? "text-[var(--text-secondary)]" : texte)}>
{fmtDate(r.derniereReception)}
</td>
<td className={cn("whitespace-nowrap px-3 py-2 font-medium tabular-nums", texte)}>
{fmtDate(r.echeance)}
</td>
<td
className={cn(
"whitespace-nowrap px-3 py-2 font-semibold",
urgent ? "text-[var(--accent-error)]" : texte,
)}
>
{tempsRestant(r.joursRestants)}
</td>
<td className="px-3 py-2">
<StatutBadge cadence={r} />
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1.5">
<Tooltip
content={
r.actif
? "Mettre les alertes en pause, sans perdre le rythme choisi."
: "Reprendre les alertes pour ce fournisseur."
}
>
<Button
variant="outline"
size="sm"
onClick={() => void basculer(r)}
disabled={pending}
aria-label={`${r.actif ? "Désactiver" : "Réactiver"} le rappel de ${r.nomfou}`}
>
<Power /> {r.actif ? "Désactiver" : "Réactiver"}
</Button>
</Tooltip>
<Tooltip content="Supprimer définitivement ce rappel.">
<Button
variant="ghost"
size="sm"
onClick={() => void supprimer(r)}
disabled={pending}
aria-label={`Supprimer le rappel de ${r.nomfou}`}
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
>
<Trash2 /> Supprimer
</Button>
</Tooltip>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
@@ -315,9 +459,9 @@ export function CadencierClient({
fournisseurs,
}: {
cadences: CadenceView[];
fournisseurs: { code: string; nom: string }[];
fournisseurs: Fournisseur[];
}) {
const bySite = useMemo(() => {
const parMagasin = useMemo(() => {
const m = new Map<string, CadenceView[]>();
for (const c of cadences) {
if (!m.has(c.site)) m.set(c.site, []);
@@ -328,19 +472,17 @@ export function CadencierClient({
return (
<div className="space-y-10">
<p className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
Activez un fournisseur en alerte de commande sur un magasin et définissez sa fréquence
(toutes les X semaines). L&apos;échéance est calculée à partir de la{" "}
<strong>dernière réception</strong> du fournisseur sur le magasin.
</p>
{SITES.map(({ code, label }) => (
<SiteSection
key={code}
site={code}
label={label}
rows={bySite.get(code) ?? []}
fournisseurs={fournisseurs}
/>
<div className="flex items-start gap-3 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)] px-4 py-3 text-sm text-[var(--text-primary)]">
<CalendarClock className="mt-0.5 h-5 w-5 shrink-0 text-[var(--accent)]" aria-hidden />
<p>
Le <Terme id="cadencier">cadencier</Terme> vous rappelle quand commander : pour chaque fournisseur suivi,
choisissez un rythme (toutes les X semaines). La prochaine commande est calculée à partir de la{" "}
<strong>dernière réception</strong> du fournisseur dans le magasin. Le statut passe à « Bientôt » dans les
7 jours qui précèdent, puis à « À commander » le jour venu.
</p>
</div>
{MAGASINS.map((m) => (
<SectionMagasin key={m.code} site={m.code} rows={parMagasin.get(m.code) ?? []} fournisseurs={fournisseurs} />
))}
</div>
);
+178 -191
View File
@@ -1,199 +1,187 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronUp, ChevronDown, ChevronsUpDown, CheckCircle2, AlertCircle, Minus } from "lucide-react";
import { useMemo } from "react";
import { ShoppingCart } from "lucide-react";
import type { PgCommandeAutoRow } from "./page";
import { Badge, type Ton } from "@/components/ui/badge";
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
import { EmptyState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
import { fmtEntier, fmtEur2 } from "@/lib/format";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
type SortDir = "asc" | "desc";
type EtatFranco = "oui" | "non" | "inconnu";
const SITES: { code: string; label: string }[] = [
{ code: "292", label: "292 — Frouard / Nancy" },
{ code: "579", label: "579 — Houdemont" },
/**
* Franco atteint ? Sans montant de franco connu (0), on ne peut pas répondre,
* quel que soit l'indicateur renvoyé par l'API.
*/
function etatFranco(r: PgCommandeAutoRow): EtatFranco {
if (!(Number(r.franco) > 0)) return "inconnu";
if (r.franco_atteint === true) return "oui";
if (r.franco_atteint === false) return "non";
return "inconnu";
}
const ETATS_FRANCO: Record<EtatFranco, { libelle: string; ton: Ton }> = {
oui: { libelle: "Oui", ton: "succes" },
non: { libelle: "Non", ton: "alerte" },
inconnu: { libelle: "Inconnu", ton: "neutre" },
};
function explicationFranco(r: PgCommandeAutoRow): string {
switch (etatFranco(r)) {
case "oui":
return "Le montant proposé atteint le franco : livraison sans frais de port.";
case "non":
return "Le montant proposé n'atteint pas le franco : des frais de port peuvent s'appliquer.";
default:
return Number(r.franco) > 0
? "Le franco est connu, mais FF n'indique pas s'il est atteint."
: "Aucun montant de franco connu pour ce fournisseur.";
}
}
/** Écart entre le montant proposé et le franco, toujours positif. */
function ecartFranco(r: PgCommandeAutoRow): number {
return Math.abs(Number(r.ecart_franco)) || Math.abs(Number(r.franco) - Number(r.montant_cde)) || 0;
}
function EcartFranco({ r }: { r: PgCommandeAutoRow }) {
const etat = etatFranco(r);
const ecart = ecartFranco(r);
if (etat === "non") {
return <span className="font-semibold text-[var(--accent-warning)]">Il manque {fmtEur2(ecart)}</span>;
}
if (etat === "oui") {
return <span className="text-[var(--accent-success)]">{ecart > 0 ? `Dépassé de ${fmtEur2(ecart)}` : "Atteint"}</span>;
}
return <span className="text-[var(--text-muted)]">—</span>;
}
const somme = (rows: readonly PgCommandeAutoRow[], valeur: (r: PgCommandeAutoRow) => number) =>
rows.reduce((s, r) => s + (Number(valeur(r)) || 0), 0);
const COLONNES: DataColumn<PgCommandeAutoRow>[] = [
{
id: "fournisseur",
header: "Fournisseur",
sortValue: (r) => r.nomfou,
cell: (r) => (
<div className="min-w-[200px]">
<div className="font-medium text-[var(--text-primary)]">{r.nomfou || "Fournisseur sans nom"}</div>
{r.codefou && <div className="text-xs text-[var(--text-muted)]">Code {r.codefou}</div>}
</div>
),
footer: (rows) => `Total · ${fmtEntier(rows.length)} fournisseur${rows.length > 1 ? "s" : ""}`,
},
{
id: "articles",
header: "Nb articles",
align: "right",
sortValue: (r) => Number(r.nb_articles),
cell: (r) => fmtEntier(Number(r.nb_articles)),
footer: (rows) => fmtEntier(somme(rows, (r) => r.nb_articles)),
},
{
id: "montant",
header: "Montant proposé",
hint: "Montant hors taxes de la commande automatique proposée par FF.",
align: "right",
sortValue: (r) => Number(r.montant_cde),
cell: (r) => <span className="font-semibold">{fmtEur2(Number(r.montant_cde))}</span>,
footer: (rows) => fmtEur2(somme(rows, (r) => r.montant_cde)),
},
{
id: "franco",
header: <Terme id="franco" />,
align: "right",
sortValue: (r) => (Number(r.franco) > 0 ? Number(r.franco) : null),
cell: (r) =>
Number(r.franco) > 0 ? (
fmtEur2(Number(r.franco))
) : (
<span className="text-[var(--text-muted)]" title="Aucun montant de franco connu pour ce fournisseur.">—</span>
),
},
{
id: "atteint",
header: "Franco atteint",
align: "center",
// Oui avant Non, inconnus toujours en fin de liste.
sortValue: (r) => {
const etat = etatFranco(r);
return etat === "oui" ? 1 : etat === "non" ? 0 : null;
},
cell: (r) => {
const { libelle, ton } = ETATS_FRANCO[etatFranco(r)];
return <Badge ton={ton} title={explicationFranco(r)}>{libelle}</Badge>;
},
},
{
id: "ecart",
header: "Écart au franco",
hint: "Ce qu'il manque pour atteindre le franco, ou de combien il est dépassé.",
align: "right",
sortValue: (r) => {
const etat = etatFranco(r);
if (etat === "inconnu") return null;
return etat === "oui" ? ecartFranco(r) : -ecartFranco(r);
},
cell: (r) => <EcartFranco r={r} />,
},
];
function fmt(n: number) {
return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
}
const FILTRES: DataFilter<PgCommandeAutoRow>[] = [
{
id: "atteint",
label: "Franco atteint",
valueOf: (r) => ETATS_FRANCO[etatFranco(r)].libelle,
allLabel: "Tous",
},
];
function SortIcon({ col, sortKey, sortDir }: { col: string; sortKey: string; 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" />;
}
type SK = keyof PgCommandeAutoRow;
const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde"];
function FrancoCell({ atteint, ecart, franco }: { atteint: boolean | null; ecart: number; franco: number }) {
// franco === 0 : aucune donnée franco disponible
// atteint === null ET franco === 0 : pas de franco (N/A ou données manquantes)
// atteint === null ET franco > 0 : franco connu mais statut non déterminé → on affiche quand même le montant
if (franco === 0) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
<Minus className="w-3 h-3" /> Pas de franco
</span>
);
}
if (atteint === null) {
// Franco connu mais statut non calculé par l'API
return (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
<Minus className="w-3 h-3" /> N/A ({fmt(franco)})
</span>
);
}
if (atteint) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
<CheckCircle2 className="w-3 h-3" /> Atteint
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-semibold text-amber-700">
<AlertCircle className="w-3 h-3" /> −{fmt(Math.abs(ecart))} manquant
</span>
);
}
function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) {
const [sortKey, setSortKey] = useState<SK>("nomfou");
const [sortDir, setSortDir] = useState<SortDir>("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) => (
<th
className={[
"cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900",
right ? "text-right" : "text-left",
].join(" ")}
onClick={() => handleSort(key)}
>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
function SectionMagasin({ nom, rows }: { nom: string; rows: PgCommandeAutoRow[] }) {
const nbAtteint = rows.filter((r) => etatFranco(r) === "oui").length;
const totalMontant = somme(rows, (r) => r.montant_cde);
return (
<div className="space-y-3">
{/* En-tête magasin */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
{rows.length} fournisseur{rows.length !== 1 ? "s" : ""}
</span>
{nbFrancoAtteint > 0 && (
<span className="rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
{nbFrancoAtteint} franco atteint
</span>
)}
<span className="text-sm text-gray-500">
Total : <span className="font-semibold text-gray-800">{fmt(totalMontant)}</span>
</span>
</div>
<input
type="text"
placeholder="Rechercher un fournisseur…"
value={search}
onChange={e => 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]"
/>
<section className="space-y-3" aria-label={`Propositions de commande — ${nom}`}>
<div className="flex flex-wrap items-center gap-2">
<h2 className="mr-1 text-lg font-semibold text-[var(--text-primary)]">{nom}</h2>
<Badge>
{fmtEntier(rows.length)} fournisseur{rows.length > 1 ? "s" : ""}
</Badge>
{nbAtteint > 0 && (
<Badge ton="succes">
{fmtEntier(nbAtteint)} franco{nbAtteint > 1 ? "s" : ""} atteint{nbAtteint > 1 ? "s" : ""}
</Badge>
)}
<span className="text-[13px] text-[var(--text-secondary)]">
Montant total proposé :{" "}
<span className="font-semibold tabular-nums text-[var(--text-primary)]">{fmtEur2(totalMontant)}</span>
</span>
</div>
{/* Tableau */}
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codefou", "Code")}
{th("nomfou", "Fournisseur")}
{th("nb_articles", "Articles", true)}
{th("montant_cde", "Montant CDE auto", true)}
{th("franco", "Franco", true)}
<th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">
Statut franco
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-gray-400">
Aucun fournisseur en commande automatique
</td>
</tr>
)}
{sorted.map(row => {
const francoAtteint = row.franco_atteint === true;
const hasFranco = row.franco > 0 && row.franco_atteint !== null;
return (
<tr
key={`${row.site}-${row.codefou}`}
className={[
"transition-colors",
francoAtteint
? "bg-emerald-50/50 hover:bg-emerald-50"
: "hover:bg-gray-50",
].join(" ")}
>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{row.codefou}</td>
<td className="px-3 py-2.5 font-medium text-gray-900">{row.nomfou}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700">{row.nb_articles}</td>
<td className={[
"px-3 py-2.5 text-right tabular-nums font-semibold",
francoAtteint ? "text-emerald-700" : "text-blue-700",
].join(" ")}>
{fmt(Number(row.montant_cde))}
</td>
<td className="px-3 py-2.5 text-right tabular-nums text-gray-600">
{row.franco > 0 ? fmt(Number(row.franco)) : <span className="text-gray-400">—</span>}
</td>
<td className="px-3 py-2.5">
<FrancoCell
atteint={row.franco_atteint}
ecart={Number(row.ecart_franco)}
franco={Number(row.franco)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<DataTable
rows={rows}
columns={COLONNES}
rowKey={(r) => `${r.site}-${r.codefou}`}
searchIn={(r) => [r.nomfou, r.codefou]}
searchPlaceholder="Rechercher un fournisseur…"
filters={FILTRES}
pageSize={0}
unite="fournisseurs"
showFooter
initialSort={{ id: "fournisseur", dir: "asc" }}
emptyTitle="Aucune proposition pour ce magasin"
emptyDescription="FF ne propose aucune commande automatique pour ce magasin en ce moment."
/>
</section>
);
}
export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) {
const bySite = useMemo(() => {
const parMagasin = useMemo(() => {
const map = new Map<string, PgCommandeAutoRow[]>();
for (const r of rows) {
if (!map.has(r.site)) map.set(r.site, []);
@@ -204,20 +192,19 @@ export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) {
if (rows.length === 0) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-12 text-center text-gray-400 shadow-sm">
Aucune commande automatique trouvée.
</div>
<EmptyState
icon={ShoppingCart}
title="Aucune proposition de commande"
description="FF ne propose aucune commande automatique pour le moment. Si vous en attendiez, le serveur FF est peut-être momentanément indisponible : rechargez la page dans quelques minutes."
/>
);
}
return (
<div className="space-y-8">
{SITES.map(({ code, label }) => {
const siteRows = bySite.get(code) ?? [];
return (
<StoreTable key={code} site={code} label={label} rows={siteRows} />
);
})}
<div className="space-y-10">
{MAGASINS.map((m) => (
<SectionMagasin key={m.code} nom={nomMagasin(m.code)} rows={parMagasin.get(m.code) ?? []} />
))}
</div>
);
}
+17 -7
View File
@@ -1,14 +1,22 @@
import type { Metadata } from "next";
import { ShoppingCart } from "lucide-react";
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
import { CommandesAutoTabs } from "./tabs";
import { PageHeader } from "@/components/ui/page-header";
import { CommandesAutoTabs, type Onglet } from "./tabs";
import { cachedFF } from "@/lib/ff-cache";
export type { PgCommandeAutoRow };
export const metadata: Metadata = { title: "Commandes fournisseurs" };
// Données live (DB + API FF Nancy) : rendu à la requête, jamais prérendu au build.
export const dynamic = "force-dynamic";
export default async function CommandesAutoPage() {
export default async function CommandesAutoPage(props: { searchParams: Promise<{ onglet?: string | string[] }> }) {
const { onglet } = await props.searchParams;
const ongletInitial: Onglet = onglet === "cadencier" ? "cadencier" : "propositions";
const [rows, cadences, fournisseurs] = await Promise.all([
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
// gardées 2 minutes, pour que chaque modification du cadencier — qui
@@ -22,11 +30,13 @@ export default async function CommandesAutoPage() {
]);
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-screen-2xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Commandes automatiques</h1>
<CommandesAutoTabs rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
</div>
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={ShoppingCart}
title="Commandes fournisseurs"
description="Propositions de commande automatique par magasin, et rappels selon le rythme de commande choisi pour chaque fournisseur."
/>
<CommandesAutoTabs initial={ongletInitial} rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
</div>
);
}
+23 -28
View File
@@ -1,56 +1,51 @@
"use client";
import { useState } from "react";
import { ShoppingCart, CalendarClock } from "lucide-react";
import { Tabs, useUrlTab } from "@/components/ui/tabs";
import { CommandesAutoClient } from "./client";
import { CadencierClient } from "./cadencier-client";
import type { PgCommandeAutoRow } from "./page";
import type { CadenceView } from "@/features/commandes-auto/actions";
type TabKey = "propositions" | "cadencier";
export type Onglet = "propositions" | "cadencier";
export function CommandesAutoTabs({
initial,
rows,
cadences,
fournisseurs,
}: {
initial: Onglet;
rows: PgCommandeAutoRow[];
cadences: CadenceView[];
fournisseurs: { code: string; nom: string }[];
}) {
const [tab, setTab] = useState<TabKey>("propositions");
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
const nbACommander = cadences.filter((c) => c.actif && c.statut === "a_commander").length;
const tabBtn = (key: TabKey, label: string, Icon: typeof ShoppingCart, badge?: number) => (
<button
type="button"
onClick={() => setTab(key)}
className={[
"inline-flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors",
tab === key
? "border-blue-600 text-blue-700"
: "border-transparent text-gray-500 hover:text-gray-800",
].join(" ")}
>
<Icon className="h-4 w-4" />
{label}
{badge != null && badge > 0 && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-bold text-red-700">
{badge}
</span>
)}
</button>
);
const onglets = [
{ value: "propositions", label: "Propositions de commande", icon: ShoppingCart },
{
value: "cadencier",
label: (
<>
Rythme de commande
{nbACommander > 0 && <span className="sr-only"> (commandes à passer)</span>}
</>
),
icon: CalendarClock,
// Pastille rouge : nombre de commandes à passer (masquée s'il n'y en a aucune).
count: nbACommander > 0 ? nbACommander : undefined,
alert: true,
},
] as const;
return (
<div className="space-y-6">
<div className="flex gap-2 border-b border-gray-200">
{tabBtn("propositions", "Propositions", ShoppingCart)}
{tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)}
</div>
<Tabs items={onglets} value={onglet} onChange={setOnglet} />
{tab === "propositions" ? (
{onglet === "propositions" ? (
<CommandesAutoClient rows={rows} />
) : (
<CadencierClient cadences={cadences} fournisseurs={fournisseurs} />
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -1,7 +1,11 @@
import type { Metadata } from "next";
import { auth } from "@/lib/auth";
import { getFournisseurs, getMagasins } from "@/features/grid/actions";
import { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
export const metadata: Metadata = { title: "Révision d'assortiment" };
interface GridPageProps {
searchParams: Promise<{
fournisseur?: string;
@@ -23,11 +27,13 @@ export default async function GridPage({ searchParams }: GridPageProps) {
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
};
// 1. Fetch available suppliers & stores
const [fournisseurs, magasins] = await Promise.all([
// 1. Fetch available suppliers & stores (et les droits, lus une fois côté serveur)
const [fournisseurs, magasins, session] = await Promise.all([
getFournisseurs(),
getMagasins(),
auth(),
]);
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
// 2. If no supplier selected, show compact selection UI
if (!codeFournisseur) {
@@ -44,6 +50,7 @@ export default async function GridPage({ searchParams }: GridPageProps) {
magasins={magasins}
magasin={magasin}
filters={filters}
isAdmin={isAdmin}
/>
);
}
+239 -339
View File
@@ -1,381 +1,281 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
import { useState, useTransition, type ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
import { Input, Label } from "@/components/ui/form-controls";
import { Button } from "@/components/ui/button";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { telechargerExcel } from "@/lib/export-excel";
import { cn } from "@/lib/utils";
import type { HitParadePivotRow } from "./page";
type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "stockTotal" | "stock292" | "stock579";
type SortDir = "desc" | "asc";
type Ligne = HitParadePivotRow;
const DEFAULT_SORT: SortKey = "caTotal";
const DEFAULT_DIR: SortDir = "desc";
/** Champs numériques de la ligne pivotée. */
type Champ = { [K in keyof Ligne]: Ligne[K] extends number ? K : never }[keyof Ligne];
function formatCA(v: number) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
interface Groupe {
id: string;
/** Nom affiché au-dessus des colonnes du groupe. */
titre: string;
/** Nom complet, pour les infobulles et l'export. */
nom: string;
qte: Champ;
ca: Champ;
marge: Champ;
stock: Champ;
}
function formatQte(v: number) {
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */
const CHAMPS_MAGASIN: Record<string, Pick<Groupe, "qte" | "ca" | "marge" | "stock">> = {
"292": { qte: "qte292", ca: "ca292", marge: "marge292", stock: "stock292" },
"579": { qte: "qte579", ca: "ca579", marge: "marge579", stock: "stock579" },
};
const GROUPES: Groupe[] = [
...MAGASINS.filter(m => CHAMPS_MAGASIN[m.code]).map(m => ({
id: m.code,
titre: nomMagasin(m.code),
nom: nomMagasin(m.code),
...CHAMPS_MAGASIN[m.code],
})),
{ id: "total", titre: "Total nos magasins", nom: "nos magasins", qte: "qteTotal", ca: "caTotal", marge: "margeTotal", stock: "stockTotal" },
];
const somme = (rows: readonly Ligne[], champ: Champ) => rows.reduce((acc, r) => acc + r[champ], 0);
/** Taux de marge en %, `null` sans chiffre d'affaires. */
const tauxMarge = (ca: number, marge: number) => (ca > 0 ? (marge / ca) * 100 : null);
const famille = (r: Ligne) => (r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "Sans famille");
const vide = <span className="text-[var(--text-muted)]">—</span>;
function CelluleMarge({ taux }: { taux: number | null }) {
if (taux === null) return vide;
return <span className="font-medium" style={{ color: couleurMarge(taux) }}>{fmtDecimal1(taux)} %</span>;
}
function formatPct(ca: number, marge: number) {
if (ca === 0) return "—";
return ((marge / ca) * 100).toFixed(1) + " %";
function CelluleStock({ v }: { v: number }) {
return (
<span className={cn(v < 0 ? "font-semibold text-[var(--accent-error)]" : v === 0 ? "text-[var(--text-muted)]" : undefined)}>
{fmtEntier(v)}
</span>
);
}
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
return (
<span className="flex flex-col items-end leading-tight">
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
<span>{children}</span>
</span>
);
}
function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
const total = g.id === "total";
const bord = "border-l border-[var(--border)]";
return [
{
id: `${g.id}-qte`,
header: <EnTete groupe={g.titre}>Qté</EnTete>,
hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`,
align: "right",
sortValue: r => r[g.qte],
cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide),
footer: rows => fmtEntier(somme(rows, g.qte)),
className: cn(bord, total && "font-semibold"),
headerClassName: bord,
},
{
id: `${g.id}-ca`,
header: <EnTete groupe={g.titre}>CA TTC</EnTete>,
hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`,
align: "right",
sortValue: r => r[g.ca],
cell: r => (r[g.ca] !== 0 ? fmtEur0(r[g.ca]) : vide),
footer: rows => fmtEur0(somme(rows, g.ca)),
className: cn("whitespace-nowrap", total && "font-semibold"),
},
{
id: `${g.id}-marge`,
header: <EnTete groupe={g.titre}>% marge</EnTete>,
hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`,
align: "right",
sortValue: r => tauxMarge(r[g.ca], r[g.marge]),
cell: r => <CelluleMarge taux={tauxMarge(r[g.ca], r[g.marge])} />,
footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
className: "whitespace-nowrap",
},
{
id: `${g.id}-stock`,
header: <EnTete groupe={g.titre}>Stock</EnTete>,
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
align: "right",
sortValue: r => r[g.stock],
cell: r => <CelluleStock v={r[g.stock]} />,
footer: rows => fmtEntier(somme(rows, g.stock)),
},
];
}
const COLONNES: DataColumn<Ligne>[] = [
{
id: "codein",
header: "Code",
sortValue: r => r.codein,
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
footer: () => "Total",
className: "whitespace-nowrap",
},
{
id: "libelle",
header: "Désignation",
sortValue: r => r.libelle.trim(),
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
className: "min-w-[220px]",
},
{
id: "fournisseur",
header: "Fournisseur",
sortValue: r => r.fournisseur,
cell: r => <span className="text-[13px] text-[var(--text-secondary)]">{r.fournisseur}</span>,
className: "whitespace-nowrap",
},
{
id: "famille",
header: "Famille",
hint: "Famille de produits (nomenclature FF)",
sortValue: famille,
cell: r =>
r.nomenclature_code ? (
<span className="text-[13px] text-[var(--text-secondary)]" title={famille(r)}>
<span className="font-mono text-[var(--text-muted)]">{r.nomenclature_code}</span> {r.nomenclature}
</span>
) : vide,
className: "min-w-[160px]",
},
...GROUPES.flatMap(colonnesGroupe),
];
const FILTRES: DataFilter<Ligne>[] = [
{ id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" },
{ id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" },
];
const recherche = (r: Ligne) => [r.codein, r.libelle];
interface Props {
dateDebut: string;
dateFin: string;
pivotted: HitParadePivotRow[];
}
function ColHeader({
label,
sortable,
group,
sortKey,
sortDir,
onSort,
}: {
label: string;
sortable?: SortKey;
group?: "292" | "579" | "total";
sortKey: SortKey;
sortDir: SortDir;
onSort: (key: SortKey) => void;
}) {
const isActive = sortable && sortKey === sortable;
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
return (
<th
onClick={sortable ? () => onSort(sortable) : undefined}
className={[
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
sortable ? "cursor-pointer" : "",
isActive ? activeBg : `${groupBg} text-gray-600`,
].join(" ")}
>
{label}
{sortable && (
<span className="ml-1 inline-block w-3 text-center">
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
</span>
)}
</th>
);
}
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const router = useRouter();
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
const [sortDir, setSortDir] = useState<SortDir>(DEFAULT_DIR);
const [filterFournisseur, setFilterFournisseur] = useState<string>("Tous");
const [filterNomenclature, setFilterNomenclature] = useState<string>("Tous");
const [navigation, demarrerNavigation] = useTransition();
// Dates locales pour les inputs (évite rechargement à chaque frappe)
const [localDebut, setLocalDebut] = useState(dateDebut);
const [localFin, setLocalFin] = useState(dateFin);
const fournisseurs = useMemo(() => {
const set = new Set(pivotted.map(r => r.fournisseur));
return ["Tous", ...Array.from(set).sort((a, b) => a.localeCompare(b, "fr"))];
}, [pivotted]);
const nomenclatures = useMemo(() => {
const map = new Map<string, string>(); // code → libelle
for (const r of pivotted) map.set(r.nomenclature_code, r.nomenclature);
const entries = Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0], "fr"));
return [{ code: "Tous", libelle: "Tous" }, ...entries.map(([code, libelle]) => ({ code, libelle }))];
}, [pivotted]);
function handleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(d => d === "desc" ? "asc" : "desc");
} else {
setSortKey(key);
setSortDir("desc");
}
}
function resetSort() {
setSortKey(DEFAULT_SORT);
setSortDir(DEFAULT_DIR);
}
const periodeInvalide = Boolean(localDebut && localFin && localDebut > localFin);
function applyDates() {
if (localDebut && localFin) {
router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`);
if (localDebut && localFin && !periodeInvalide) {
demarrerNavigation(() => {
router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`);
});
}
}
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
const filtered = useMemo(() =>
pivotted.filter(r =>
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
),
[pivotted, filterFournisseur, filterNomenclature]
);
const sorted = useMemo(() =>
[...filtered].sort((a, b) => {
const diff = a[sortKey] - b[sortKey];
return sortDir === "desc" ? -diff : diff;
}),
[filtered, sortKey, sortDir]
);
const totals = useMemo(() => sorted.reduce(
(acc, r) => ({
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
}),
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
), [sorted]);
async function exportToExcel() {
// Chargée au clic seulement : la bibliothèque pèse plusieurs centaines de Ko.
const XLSX = await import("xlsx");
const headers = [
"Code", "Désignation", "Fournisseur", "Nomenclature",
"Qté Nancy", "CA TTC Nancy", "% Marge Nancy", "Stock Nancy",
"Qté Houdemont", "CA TTC Houdemont", "% Marge Houdemont", "Stock Houdemont",
"Qté Total", "CA TTC Total", "% Marge Total", "Stock Total",
];
async function exportToExcel(rows: readonly Ligne[]) {
const pct = (ca: number, marge: number) => ca === 0 ? 0 : Math.round((marge / ca) * 1000) / 10;
const nomExport = (g: Groupe) => (g.id === "total" ? "Total" : g.nom);
const dataRows = sorted.map(r => [
r.codein,
r.libelle.trim(),
r.fournisseur,
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "",
r.qte292, r.ca292, pct(r.ca292, r.marge292), r.stock292,
r.qte579, r.ca579, pct(r.ca579, r.marge579), r.stock579,
r.qteTotal, r.caTotal, pct(r.caTotal, r.margeTotal), r.stockTotal,
]);
const totalRow = [
`TOTAL — ${sorted.length} articles`, "", "", "",
totals.qte292, totals.ca292, pct(totals.ca292, totals.marge292), totals.stock292,
totals.qte579, totals.ca579, pct(totals.ca579, totals.marge579), totals.stock579,
totals.qteTotal, totals.caTotal, pct(totals.caTotal, totals.margeTotal), totals.stockTotal,
];
const ws = XLSX.utils.aoa_to_sheet([headers, ...dataRows, totalRow]);
// Largeurs de colonnes
ws["!cols"] = [
{ wch: 12 }, { wch: 40 }, { wch: 25 }, { wch: 30 },
{ wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 },
{ wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 },
{ wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Hit Parade");
XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`);
await telechargerExcel({
feuille: "Meilleures ventes",
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
entetes: [
"Code", "Désignation", "Fournisseur", "Nomenclature",
...GROUPES.flatMap(g => [
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
]),
],
largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
lignes: rows.map(r => [
r.codein,
r.libelle.trim(),
r.fournisseur,
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "",
...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]),
]),
totaux: [
`TOTAL — ${rows.length} articles`, "", "", "",
...GROUPES.flatMap(g => {
const ca = somme(rows, g.ca);
return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)];
}),
],
});
}
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">
{/* Période */}
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-500">Du</label>
<input
type="date"
value={localDebut}
onChange={e => setLocalDebut(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-blue-400"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-500">Au</label>
<input
type="date"
value={localFin}
onChange={e => setLocalFin(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-blue-400"
/>
</div>
<button
onClick={applyDates}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
Appliquer
</button>
<div className="space-y-4" aria-busy={navigation}>
{/* Période (relance le calcul côté serveur) */}
<div className="flex flex-wrap items-end gap-3">
<div>
<Label htmlFor="hit-parade-debut">Du</Label>
<Input
id="hit-parade-debut"
type="date"
value={localDebut}
onChange={e => setLocalDebut(e.target.value)}
/>
</div>
<div className="h-8 w-px bg-gray-200 self-center" />
{/* Filtre fournisseur */}
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-500">Fournisseur</label>
<select
value={filterFournisseur}
onChange={e => setFilterFournisseur(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-blue-400 min-w-[200px]"
>
{fournisseurs.map(f => (
<option key={f} value={f}>{f}</option>
))}
</select>
<div>
<Label htmlFor="hit-parade-fin">Au</Label>
<Input
id="hit-parade-fin"
type="date"
value={localFin}
onChange={e => setLocalFin(e.target.value)}
/>
</div>
<div className="h-8 w-px bg-gray-200 self-center" />
{/* Filtre nomenclature */}
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-500">Nomenclature</label>
<select
value={filterNomenclature}
onChange={e => setFilterNomenclature(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-blue-400 min-w-[200px]"
>
{nomenclatures.map(n => (
<option key={n.code} value={n.code}>
{n.code === "Tous" ? "Tous" : `${n.code} — ${n.libelle}`}
</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">
{sorted.length} produits
</span>
{isSorted && (
<button
onClick={resetSort}
className="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-sm font-medium text-orange-700 hover:bg-orange-100 transition-colors self-center"
>
<span>✕</span> Annuler le tri
</button>
<Button onClick={applyDates} disabled={navigation || !localDebut || !localFin || periodeInvalide}>
{navigation && <Loader2 className="animate-spin" aria-hidden />}
{navigation ? "Chargement…" : "Appliquer"}
</Button>
{periodeInvalide && (
<p role="alert" className="flex h-9 items-center text-[13px] text-[var(--accent-error)]">
La date de début doit précéder la date de fin.
</p>
)}
<button
onClick={exportToExcel}
className="ml-auto flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 transition-colors self-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Exporter Excel
</button>
</div>
{/* Tableau */}
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th rowSpan={2} className="w-20 border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Code</th>
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Désignation</th>
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Fournisseur</th>
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Nom.</th>
<th colSpan={4} className="border-b border-l-2 border-blue-200 bg-blue-50 px-4 py-2 text-center text-xs font-bold text-blue-700 tracking-wide">
Frouard / Nancy — 292
</th>
<th colSpan={4} className="border-b border-l-2 border-violet-200 bg-violet-50 px-4 py-2 text-center text-xs font-bold text-violet-700 tracking-wide">
Houdemont — 579
</th>
<th colSpan={4} className="border-b border-l-2 border-emerald-200 bg-emerald-50 px-4 py-2 text-center text-xs font-bold text-emerald-700 tracking-wide">
Total Réseau
</th>
</tr>
<tr className="border-b-2 border-gray-200">
<ColHeader label="Qté" sortable="qte292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="ca292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
<ColHeader label="Stock" sortable="stock292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="Qté" sortable="qte579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="ca579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
<ColHeader label="Stock" sortable="stock579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="Qté" sortable="qteTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="caTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
<ColHeader label="Stock" sortable="stockTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
<td className="bg-white px-2 py-2.5 text-xs text-gray-500 whitespace-nowrap">{row.fournisseur}</td>
<td className="bg-white px-2 py-2.5 text-xs text-center whitespace-nowrap" title={row.nomenclature_code ? `${row.nomenclature_code} — ${row.nomenclature}` : ""}>
{row.nomenclature_code
? <span className="font-mono font-semibold text-gray-700">{row.nomenclature_code}</span>
: <span className="text-gray-300">—</span>
}
</td>
<td className={`border-l-2 border-blue-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
{row.qte292 > 0 ? formatQte(row.qte292) : <span className="text-gray-300">—</span>}
</td>
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
{row.ca292 > 0 ? formatCA(row.ca292) : <span className="text-gray-300">—</span>}
</td>
<td className="bg-blue-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-blue-500">
{row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""}
</td>
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock292" ? "bg-blue-100 font-semibold text-blue-700" : "bg-blue-50/60 text-amber-600"}`}>
{row.stock292 > 0 ? formatQte(row.stock292) : <span className="text-gray-300">—</span>}
</td>
<td className={`border-l-2 border-violet-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
{row.qte579 > 0 ? formatQte(row.qte579) : <span className="text-gray-300">—</span>}
</td>
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
{row.ca579 > 0 ? formatCA(row.ca579) : <span className="text-gray-300">—</span>}
</td>
<td className="bg-violet-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-violet-500">
{row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""}
</td>
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock579" ? "bg-violet-100 font-semibold text-violet-700" : "bg-violet-50/60 text-amber-600"}`}>
{row.stock579 > 0 ? formatQte(row.stock579) : <span className="text-gray-300">—</span>}
</td>
<td className={`border-l-2 border-emerald-200 px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "qteTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
{row.qteTotal > 0 ? formatQte(row.qteTotal) : <span className="text-gray-300">—</span>}
</td>
<td className={`px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "caTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
{row.caTotal > 0 ? formatCA(row.caTotal) : <span className="text-gray-300">—</span>}
</td>
<td className="bg-emerald-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-emerald-600">
{row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}
</td>
<td className={`px-2 py-2.5 text-center tabular-nums text-xs font-semibold ${sortKey === "stockTotal" ? "bg-emerald-100 text-emerald-700" : row.stockTotal <= 0 ? "bg-emerald-50/60 text-red-400" : "bg-emerald-50/60 text-amber-600"}`}>
{row.stockTotal > 0 ? formatQte(row.stockTotal) : <span className="text-red-400 font-semibold">0</span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300">
<td colSpan={4} className="bg-gray-100 px-4 py-3 text-sm font-bold text-gray-800">
TOTAL — {sorted.length} articles
</td>
<td className="border-l-2 border-blue-300 bg-blue-100 px-4 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte292)}</td>
<td className="bg-blue-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca292)}</td>
<td className="bg-blue-100 px-2 py-3 text-center text-xs tabular-nums text-blue-700 font-semibold">{formatPct(totals.ca292, totals.marge292)}</td>
<td className="bg-blue-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock292)}</td>
<td className="border-l-2 border-violet-300 bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte579)}</td>
<td className="bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca579)}</td>
<td className="bg-violet-100 px-2 py-3 text-center text-xs tabular-nums text-violet-700 font-semibold">{formatPct(totals.ca579, totals.marge579)}</td>
<td className="bg-violet-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock579)}</td>
<td className="border-l-2 border-emerald-300 bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qteTotal)}</td>
<td className="bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.caTotal)}</td>
<td className="bg-emerald-100 px-2 py-3 text-center text-xs tabular-nums text-emerald-800 font-semibold">{formatPct(totals.caTotal, totals.margeTotal)}</td>
<td className="bg-emerald-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stockTotal)}</td>
</tr>
</tfoot>
</table>
<div className={cn("transition-opacity", navigation && "pointer-events-none opacity-50")}>
<DataTable<Ligne>
rows={pivotted}
columns={COLONNES}
rowKey={r => r.codein}
searchIn={recherche}
searchPlaceholder="Code ou désignation…"
filters={FILTRES}
pageSize={100}
unite="produits"
initialSort={{ id: "total-ca", dir: "desc" }}
showFooter
emptyTitle="Aucune vente sur cette période"
emptyDescription="Choisissez une autre période, puis cliquez sur « Appliquer »."
onExport={exportToExcel}
/>
</div>
</div>
);
+36 -7
View File
@@ -1,6 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";
import { RotateCcw, Trophy } from "lucide-react";
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
import { HitParadeClient } from "./client";
import { cachedFF } from "@/lib/ff-cache";
import { PageHeader } from "@/components/ui/page-header";
import { ErrorState } from "@/components/ui/states";
import { Button } from "@/components/ui/button";
import { HitParadeClient } from "./client";
export const metadata: Metadata = { title: "Meilleures ventes" };
export interface HitParadePivotRow {
codein: string;
@@ -81,19 +89,40 @@ export default async function HitParadePage(props: {
const dateDebut = (searchParams.debut as string) || defaults.debut;
const dateFin = (searchParams.fin as string) || defaults.fin;
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
const pivotted = pivotHitParade(rows);
let pivotted: HitParadePivotRow[] | null = null;
let erreur: string | null = null;
try {
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
pivotted = pivotHitParade(rows);
} catch (e) {
console.error("[hit-parade] chargement impossible :", e);
erreur = e instanceof Error ? e.message : String(e);
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-screen-2xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Hit Parade</h1>
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={Trophy}
title="Meilleures ventes"
description="Le classement des produits les plus vendus sur une période, magasin par magasin : quantités, chiffre d'affaires, taux de marge et stock actuel."
/>
{pivotted ? (
<HitParadeClient
dateDebut={dateDebut}
dateFin={dateFin}
pivotted={pivotted}
/>
</div>
) : (
<ErrorState
title="Les meilleures ventes n'ont pas pu être chargées"
detail={erreur ?? undefined}
action={
<Button asChild variant="outline">
<Link href={`/hit-parade?debut=${dateDebut}&fin=${dateFin}`}><RotateCcw /> Réessayer</Link>
</Button>
}
/>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+118 -73
View File
@@ -2,11 +2,82 @@
import { useState } from "react";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
import { Loader2, RefreshCw, TrendingUp } from "lucide-react";
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
import { Button } from "@/components/ui/button";
import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { EmptyState, ErrorState } from "@/components/ui/states";
import { GLOSSAIRE } from "@/lib/glossaire";
import { fmtDecimal1, fmtEntier } from "@/lib/format";
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
const COLONNES: DataColumn<PgOpportuniteRow>[] = [
{
id: "produit",
header: "Produit",
sortValue: (r) => r.libelle1 || r.codein,
cell: (r) => (
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
className="font-medium text-[var(--accent)] hover:underline"
>
{r.libelle1 || r.codein}
</Link>
),
},
{
id: "fournisseur",
header: "Fournisseur",
sortValue: (r) => r.fournisseur,
cell: (r) => <span className="text-[var(--text-secondary)]">{r.fournisseur}</span>,
},
{
id: "reseau",
header: "Réseau, par magasin",
hint: "Quantité vendue sur 12 mois par un magasin du réseau qui a ce produit.",
align: "right",
sortValue: (r) => r.qte_reseau_par_magasin,
cell: (r) => fmtDecimal1(r.qte_reseau_par_magasin),
},
{
id: "nous",
header: "Nous, par magasin",
hint: "Quantité vendue sur 12 mois par chacun de nos magasins, en moyenne.",
align: "right",
sortValue: (r) => r.qte_locale_par_magasin,
cell: (r) => fmtDecimal1(r.qte_locale_par_magasin),
},
{
id: "ecart",
header: "Écart",
hint: "Réseau par magasin moins nous par magasin. Positif : le réseau vend plus que nous.",
align: "right",
sortValue: (r) => r.ecart_par_magasin,
cell: (r) => {
const opportunite = r.ecart_par_magasin > 0;
return (
<span
className="font-semibold"
style={{ color: opportunite ? "var(--accent-warning)" : "var(--accent-success)" }}
title={opportunite
? "Le réseau vend plus que nous sur ce produit"
: "Nous vendons autant ou plus que le réseau"}
>
{opportunite ? "+" : ""}{fmtDecimal1(r.ecart_par_magasin)}
</span>
);
},
},
{
id: "magasins",
header: "Magasins vendeurs",
hint: GLOSSAIRE.presenceReseau.definition,
align: "right",
sortValue: (r) => r.nb_magasins_reseau,
cell: (r) => <span className="text-[var(--text-secondary)]">{fmtEntier(r.nb_magasins_reseau)}</span>,
},
];
const rechercherDans = (r: PgOpportuniteRow) => [r.libelle1, r.fournisseur, r.codein, r.code_centrale];
/**
* Produits de la même famille que le produit affiché, classés par écart de
@@ -45,17 +116,31 @@ export function OpportunitesFamille({
}
if (rows === null) {
if (error && !loading) {
return (
<ErrorState
className="py-8"
title="L'analyse de la famille n'a pas pu être faite"
description="Réessayez dans un instant. Si le problème persiste, prévenez un administrateur."
detail={error}
action={
<Button variant="outline" onClick={load}>
<RefreshCw /> Réessayer
</Button>
}
/>
);
}
return (
<div className="space-y-2">
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
lesquelles le réseau vend bien et que nous vendons peu ?
<div className="space-y-3">
<p className="text-sm text-[var(--text-secondary)]">
Comparez ce produit aux autres produits{familleLabel ? <> de la famille « {familleLabel} »</> : " de la même famille"} :
lesquels le réseau vend bien alors que nous les vendons peu ?
</p>
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
{loading ? "Analyse…" : "Analyser la famille"}
</button>
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
<Button variant="outline" onClick={load} disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : <TrendingUp />}
{loading ? "Analyse en cours…" : "Analyser la famille"}
</Button>
</div>
);
}
@@ -65,71 +150,31 @@ export function OpportunitesFamille({
if (others.length === 0) {
return (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Aucune autre référence de cette famille n&apos;a de données réseau en cache.
Synchronisez d&apos;autres fournisseurs pour enrichir la comparaison.
</p>
<EmptyState
className="py-8"
title="Aucun autre produit à comparer"
description="Aucun autre produit de cette famille n'a encore de données du réseau. Mettez à jour les données du réseau d'autres fournisseurs (révision d'assortiment) pour enrichir la comparaison."
/>
);
}
return (
<div className="space-y-2">
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
</tr>
</thead>
<tbody>
{others.map((r) => {
const opportunite = r.ecart_par_magasin > 0;
return (
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-3 py-2">
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
className="font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
{r.libelle1 || r.codein}
</Link>
</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_reseau_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_locale_par_magasin)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-semibold"
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
title={opportunite
? "Le réseau vend plus que nous sur ce produit"
: "Nous vendons autant ou plus que le réseau"}
>
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{fmtQte(r.nb_magasins_reseau)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
Un écart positif signale une référence sous-exploitée chez nous.
Périmètre : les articles <strong>de notre catalogue</strong> ayant déjà des données réseau —
Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
<div className="space-y-3">
<DataTable
rows={others}
columns={COLONNES}
rowKey={(r) => r.codein}
searchIn={rechercherDans}
searchPlaceholder="Filtrer les produits…"
pageSize={25}
unite={others.length > 1 ? "produits" : "produit"}
maxHeight="60vh"
/>
<p className="text-[13px] text-[var(--text-secondary)]">
Écart = quantité vendue par magasin dans le réseau − notre quantité vendue par magasin, sur 12 mois.
Un écart positif signale un produit sous-exploité chez nous. Seuls les produits <strong>de notre
catalogue</strong> qui ont déjà des données du réseau apparaissent ici : un produit absent de notre
catalogue ne peut pas y figurer.
</p>
</div>
);
+41 -30
View File
@@ -1,8 +1,15 @@
import type { Metadata } from "next";
import { PackageSearch, SearchX } from "lucide-react";
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
import { PageHeader } from "@/components/ui/page-header";
import { EmptyState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
import { ProduitSearchBar } from "./search-bar";
import { ProduitResults } from "./results";
import { ProduitFicheView } from "./fiche";
export const metadata: Metadata = { title: "Recherche produit" };
export const dynamic = "force-dynamic";
/**
@@ -30,40 +37,44 @@ export default async function ProduitsPage(props: {
const ficheDemandee = Boolean(codein || codeCentrale);
return (
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
Recherche produit
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue —
performance réseau et ventes locales sur 12 mois glissants.
</p>
</header>
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={PackageSearch}
title="Recherche produit"
description={
<>
Cherchez un produit par son nom ou son code, puis comparez ses ventes à celles des
magasins du <Terme id="reseau">réseau.</Terme>
</>
}
/>
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
<ProduitSearchBar key={q} initialQuery={q} />
<div className="space-y-6">
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
<ProduitSearchBar key={q} initialQuery={q} />
{ficheDemandee && !fiche && (
<div
className="rounded-xl p-6 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun produit trouvé pour{" "}
{codein
? <>le code article <span className="font-mono">{codein}</span></>
: <>le code centrale <span className="font-mono">{codeCentrale}</span></>}.
{codeCentrale && !codein && (
<div className="mt-1">
Ce code n&apos;est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche.
</div>
)}
</div>
)}
{ficheDemandee && !fiche && (
<EmptyState
icon={SearchX}
title="Produit introuvable"
description={
codein ? (
<>Aucun produit de notre catalogue ne porte le code article <span className="font-mono">{codein}</span>.</>
) : (
<>
Le code centrale <span className="font-mono">{codeCentrale}</span> n&apos;est ni dans notre
catalogue, ni dans les données du réseau déjà chargées. Relancez une recherche par le nom
ou le code du produit.
</>
)
}
/>
)}
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
{!ficheDemandee && <ProduitResults query={q} />}
{!ficheDemandee && <ProduitResults query={q} />}
</div>
</div>
);
}
+271 -214
View File
@@ -1,19 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import Link from "next/link";
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
import { TrendSparkline } from "@/features/grid/components/network-charts";
import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { computeNetworkTrend, NB_MAGASINS_RESEAU, type NetworkTrend } from "@/features/grid/lib/network-trend";
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number, d = 1) =>
new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
const fmtEur = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
const fmtEur2 = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { EmptyState, ErrorState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
import { GLOSSAIRE } from "@/lib/glossaire";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
/** Intervalle d'interrogation de l'état du job de recherche. */
const POLL_MS = 2000;
@@ -57,6 +57,38 @@ async function lireJson(res: Response): Promise<Record<string, unknown>> {
return data;
}
/**
* Les messages du serveur nomment l'outil source (Qlik) : l'utilisateur lit
* « données du réseau », le message d'origine reste dans le détail technique.
*/
const mentionneOutil = (texte: string | null | undefined) => /qlik/i.test(texte ?? "");
function libelleEtape(etape: string | null): string {
if (!etape || mentionneOutil(etape)) return "Recherche dans les données du réseau…";
return etape;
}
/** Bandeau d'avertissement : explication en clair, détail technique replié. */
function Avertissement({ children, detail }: { children: ReactNode; detail?: string | null }) {
return (
<div
role="status"
className="flex items-start gap-2.5 rounded-xl border border-[var(--accent-warning)]/40 bg-[var(--accent-warning-bg)] px-4 py-3 text-sm text-[var(--text-primary)]"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent-warning)]" aria-hidden />
<div className="min-w-0">
{children}
{detail && (
<details className="mt-1 text-xs text-[var(--text-secondary)]">
<summary className="cursor-pointer">Détail technique</summary>
<p className="mt-1 break-words">{detail}</p>
</details>
)}
</div>
</div>
);
}
/**
* Résultats de recherche produit.
*
@@ -144,36 +176,31 @@ export function ProduitResults({ query }: { query: string }) {
if (!query) {
return (
<div
className="rounded-xl p-10 text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
Recherchez un produit dans le réseau
</p>
<p className="text-[12px] mt-1 max-w-lg mx-auto" style={{ color: "var(--text-muted)" }}>
La recherche interroge <strong>Qlik Sense</strong> (les ~{NB_MAGASINS_RESEAU} magasins du réseau),
puis rapproche chaque produit trouvé de notre catalogue.
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
</p>
</div>
<EmptyState
icon={PackageSearch}
title="Recherchez un produit"
description={
<>
Saisissez le nom d&apos;un produit (ex. « poêle 28 ») ou son code centrale
(ex. « 10000167303 »). Vous verrez ses ventes dans les {NB_MAGASINS_RESEAU} magasins
du réseau, puis chez nous s&apos;il est dans notre catalogue.
</>
}
/>
);
}
if (loading) {
return (
<div
className="rounded-xl p-10 text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
aria-busy="true"
className="flex flex-col items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] px-6 py-12 text-center"
>
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
{etape ?? "Interrogation de Qlik Sense…"}
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants.
Cela peut prendre plus d&apos;une minute — vous pouvez laisser la page ouverte.
<Loader2 className="h-7 w-7 animate-spin text-[var(--accent)]" aria-hidden />
<p className="text-base font-semibold text-[var(--text-primary)]">{libelleEtape(etape)}</p>
<p className="max-w-lg text-sm text-[var(--text-secondary)]">
Recherche des produits puis de leurs ventes sur les 12 derniers mois.
Cela peut prendre plus d&apos;une minute : vous pouvez laisser la page ouverte.
</p>
</div>
);
@@ -181,21 +208,20 @@ export function ProduitResults({ query }: { query: string }) {
if (error) {
return (
<div className="rounded-xl p-6" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="flex items-start gap-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<div className="min-w-0">
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>
La recherche a échoué
</p>
<p className="text-[12px] mt-0.5 break-words" style={{ color: "var(--text-muted)" }}>{error}</p>
<button onClick={relancer} className="btn-action btn-action-secondary mt-3 flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Réessayer
</button>
</div>
</div>
</div>
<ErrorState
title="La recherche a échoué"
description={
mentionneOutil(error)
? "Les données du réseau n'ont pas répondu. Réessayez dans un instant ; si la recherche est très large, ajoutez un mot plus précis."
: error
}
detail={mentionneOutil(error) ? error : undefined}
action={
<Button variant="outline" onClick={relancer}>
<RefreshCw /> Réessayer
</Button>
}
/>
);
}
@@ -204,196 +230,227 @@ export function ProduitResults({ query }: { query: string }) {
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
{source === "qlik" ? (
<Globe className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} />
) : (
<Database className="w-3.5 h-3.5" />
)}
{rows.length} produit{rows.length > 1 ? "s" : ""}
{source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"}
{tronque && " (liste tronquée — affinez la recherche)"}
{" · "}{(dureeMs / 1000).toFixed(1)} s
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
{source === "qlik"
? <Globe className="h-4 w-4 text-[var(--accent)]" aria-hidden />
: <Database className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />}
<span>
<span className="font-semibold text-[var(--text-primary)]">{fmtEntier(rows.length)}</span>{" "}
produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""}
{source === "qlik" ? " dans le réseau" : " dans notre catalogue"}
{tronque && " (liste limitée : précisez la recherche pour tout voir)"}
<span className="text-[var(--text-muted)]"> · recherche en {fmtDecimal1(dureeMs / 1000)} s</span>
</span>
</p>
<button onClick={relancer} className="btn-action btn-action-secondary flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Relancer
</button>
<Button
variant="outline"
size="sm"
onClick={relancer}
title="Interroger à nouveau les données du réseau, sans réutiliser le dernier résultat"
>
<RefreshCw /> Relancer la recherche
</Button>
</div>
{source === "db" && (
<div
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<span>
Qlik n&apos;a pas pu être interrogé, la recherche s&apos;est repliée sur le catalogue local
(les chiffres réseau affichés viennent du dernier cache).
{qlikError && <span className="block mt-0.5" style={{ color: "var(--text-muted)" }}>{qlikError}</span>}
</span>
</div>
<Avertissement detail={qlikError}>
Les données du réseau n&apos;ont pas pu être interrogées : seuls les produits de notre catalogue
sont affichés, avec les chiffres du réseau de la dernière mise à jour.
</Avertissement>
)}
{source === "qlik" && qlikError && (
<div
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<span>
{rows.length === 0
? qlikError
: `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`}
</span>
</div>
<Avertissement detail={qlikError}>
{rows.length === 0
? "La recherche n'a pas pu être appliquée aux données du réseau. Essayez un terme plus précis (deux mots, ou le code centrale)."
: "Produits trouvés, mais leurs ventes dans le réseau n'ont pas pu être chargées."}
</Avertissement>
)}
{rows.length === 0 ? (
<div
className="rounded-xl p-8 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun article du réseau ne correspond à « {query} ».
</div>
<EmptyState
title={<>Aucun produit ne correspond à « {query} »</>}
description="Vérifiez l'orthographe, essayez un mot plus court ou le code centrale du produit."
/>
) : (
<ResultTable rows={rows} query={query} />
<>
<ResultTable rows={rows} query={query} />
<p className="text-[13px] text-[var(--text-secondary)]">
Chiffres des magasins du <Terme id="reseau">réseau</Terme> sur les 12 derniers mois
(mois en cours exclu). Cliquez sur un produit pour ouvrir sa fiche.
</p>
</>
)}
{locauxHorsReseau.length > 0 && (
<details className="rounded-xl" style={{ border: "1px solid var(--border)" }}>
<summary className="cursor-pointer px-3 py-2.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
sans correspondance réseau (pas de code centrale, ou non vendu par le réseau)
<details className="rounded-xl border border-[var(--border)] bg-[var(--bg-surface)]">
<summary className="cursor-pointer px-4 py-3 text-sm text-[var(--text-secondary)]">
{fmtEntier(locauxHorsReseau.length)} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
sans ventes connues dans le réseau (pas de code centrale, ou pas vendu par le réseau)
</summary>
<div className="px-3 pb-3">
<ul className="space-y-1">
{locauxHorsReseau.slice(0, 30).map((l) => (
<li key={l.codein} className="text-[12px]">
<Link
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
{l.libelle1 || l.codein}
</Link>
<span style={{ color: "var(--text-muted)" }}>
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
</span>
</li>
))}
</ul>
</div>
<ul className="space-y-1.5 px-4 pb-4">
{locauxHorsReseau.slice(0, 30).map((l) => (
<li key={l.codein} className="text-sm">
<Link
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
className="font-medium text-[var(--accent)] hover:underline"
>
{l.libelle1 || l.codein}
</Link>
<span className="text-[var(--text-muted)]">
{" · "}{l.fournisseur}{" · "}code article <span className="font-mono">{l.codein}</span>
</span>
</li>
))}
</ul>
</details>
)}
</div>
);
}
// ─── Tableau des résultats ──────────────────────────────────────────────────
type Ligne = ProduitRechercheRow & { trend: NetworkTrend };
const rechercherDans = (r: Ligne) => [r.libelle, r.fournisseur, r.codeCentrale, r.codein, r.nomenclature];
function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) {
return (
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[13px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<Th>Libellé</Th>
<Th>Fournisseur</Th>
<Th>Code centrale</Th>
<Th align="right">Magasins</Th>
<Th align="right">Qté réseau</Th>
<Th align="right">Qté / mag.</Th>
<Th align="right">Prix moyen</Th>
<Th align="right">CA / mag.</Th>
<Th align="right">Marge %</Th>
<Th align="center">Tendance</Th>
<Th align="right">Nous</Th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth);
const href = r.codein
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
return (
<tr
key={r.codeCentrale}
className="transition-colors hover:bg-[var(--bg-elevated)]"
style={{ borderTop: "1px solid var(--border)" }}
>
<td className="px-3 py-2 max-w-[280px]">
<Link href={href} className="font-medium hover:underline" style={{ color: "var(--accent)" }}>
{r.libelle || "—"}
</Link>
{r.nomenclature && (
<div className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>{r.nomenclature}</div>
)}
</td>
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{r.fournisseur || "—"}
</td>
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
{r.codeCentrale}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtQte(r.nbMagasinsReseau)}
<span className="text-[11px] ml-1" style={{ color: "var(--text-muted)" }}>
{fmtDec(r.tauxPresence * 100, 0)} %
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
{fmtQte(r.qteReseau)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qteParMagasinReseau)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"}
</td>
<td className="px-2 py-2 text-center">
{trend.hasData ? <TrendSparkline trend={trend} /> : <span style={{ color: "var(--text-muted)" }}>—</span>}
</td>
<td className="px-3 py-2 text-right text-[12px]">
{r.codein ? (
<span style={{ color: "var(--text-secondary)" }} title={`Code article ${r.codein}`}>
stock {fmtQte(r.stockLocal ?? 0)}
</span>
) : (
<span
className="rounded-md px-1.5 py-0.5 text-[11px]"
style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
title="Le réseau travaille ce produit, notre catalogue ne le référence pas"
>
non référencé
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
// Tendance calculée une fois par ligne (et non à chaque tri ou rendu).
const lignes = useMemo<Ligne[]>(
() => rows.map((r) => ({ ...r, trend: computeNetworkTrend(r.qteByMonth, r.nbMagByMonth) })),
[rows],
);
}
const colonnes = useMemo<DataColumn<Ligne>[]>(() => [
{
id: "produit",
header: "Produit",
sortValue: (r) => r.libelle,
className: "max-w-[320px]",
cell: (r) => {
const href = r.codein
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
return (
<>
<Link href={href} className="font-medium text-[var(--accent)] hover:underline">
{r.libelle || "—"}
</Link>
{r.nomenclature && (
<div className="truncate text-xs text-[var(--text-muted)]" title={r.nomenclature}>{r.nomenclature}</div>
)}
</>
);
},
},
{
id: "fournisseur",
header: "Fournisseur",
sortValue: (r) => r.fournisseur,
cell: (r) => <span className="text-[var(--text-secondary)]">{r.fournisseur || "—"}</span>,
},
{
id: "codeCentrale",
header: "Code centrale",
hint: GLOSSAIRE.codeCentrale.definition,
cell: (r) => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codeCentrale}</span>,
},
{
id: "magasins",
header: "Magasins vendeurs",
hint: GLOSSAIRE.presenceReseau.definition,
align: "right",
sortValue: (r) => r.nbMagasinsReseau,
cell: (r) => (
<>
{fmtEntier(r.nbMagasinsReseau)}
<span className="ml-1 text-xs text-[var(--text-muted)]">({fmtEntier(r.tauxPresence * 100)} %)</span>
</>
),
},
{
id: "qteReseau",
header: "Quantité vendue",
hint: "Quantité vendue par l'ensemble des magasins du réseau sur les 12 derniers mois.",
align: "right",
sortValue: (r) => r.qteReseau,
cell: (r) => <span className="font-semibold">{fmtEntier(r.qteReseau)}</span>,
},
{
id: "qteParMagasin",
header: "Quantité par magasin",
hint: "Quantité moyenne vendue sur 12 mois par un magasin du réseau qui a ce produit.",
align: "right",
sortValue: (r) => r.qteParMagasinReseau,
cell: (r) => fmtDecimal1(r.qteParMagasinReseau),
},
{
id: "prixMoyen",
header: "Prix moyen",
hint: "Prix de vente moyen constaté dans le réseau (chiffre d'affaires divisé par la quantité).",
align: "right",
sortValue: (r) => r.prixMoyenReseau,
cell: (r) => (r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"),
},
{
id: "caParMagasin",
header: "CA par magasin",
hint: GLOSSAIRE.caParMagasin.definition,
align: "right",
sortValue: (r) => (r.caParMagasinReseau > 0 ? r.caParMagasinReseau : null),
cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur0(r.caParMagasinReseau) : "—"),
},
{
id: "marge",
header: "Taux de marge",
hint: GLOSSAIRE.marge.definition,
align: "right",
sortValue: (r) => r.margePctReseau,
cell: (r) =>
r.margePctReseau != null ? (
<span className="font-medium" style={{ color: couleurMarge(r.margePctReseau) }}>
{fmtDecimal1(r.margePctReseau)} %
</span>
) : "—",
},
{
id: "tendance",
header: "Tendance",
hint: "Ventes par magasin des 4 derniers mois comparées aux 4 premiers mois de la période de 12 mois.",
align: "center",
sortValue: (r) => (r.trend.hasData ? r.trend.pct : null),
cell: (r) => (r.trend.hasData ? <TrendSparkline trend={r.trend} /> : <span className="text-[var(--text-muted)]">—</span>),
},
{
id: "nous",
header: "Chez nous",
hint: "Notre stock (tous nos magasins) si le produit est dans notre catalogue.",
align: "right",
sortValue: (r) => (r.codein ? (r.stockLocal ?? 0) : null),
cell: (r) =>
r.codein ? (
<span className="text-[var(--text-secondary)]" title={`Code article ${r.codein}`}>
Stock {fmtEntier(r.stockLocal ?? 0)}
</span>
) : (
<Badge title="Le réseau vend ce produit, mais il n'est pas dans notre catalogue">Non référencé</Badge>
),
},
], [query]);
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) {
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
return (
<th
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
style={{ color: "var(--text-muted)" }}
>
{children}
</th>
<DataTable
rows={lignes}
columns={colonnes}
rowKey={(r) => r.codeCentrale}
searchIn={rechercherDans}
searchPlaceholder="Filtrer les résultats…"
pageSize={50}
unite={lignes.length > 1 ? "produits" : "produit"}
emptyTitle="Aucun produit"
/>
);
}
+21 -20
View File
@@ -3,6 +3,8 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/form-controls";
/**
* Champ de recherche produit.
@@ -35,43 +37,42 @@ export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<label htmlFor="recherche-produit" className="block text-sm font-medium text-[var(--text-primary)]">
Nom du produit ou code centrale
</label>
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full max-w-xl flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: "var(--text-muted)" }}
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]"
aria-hidden
/>
<input
<Input
id="recherche-produit"
type="text"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
placeholder="Libellé produit ou code centrale…"
aria-label="Rechercher un produit par libellé ou code centrale"
className="apple-input h-10 text-[14px]"
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
placeholder="Ex. « poêle 28 » ou « 10000167303 »"
aria-describedby={tooShort ? "recherche-produit-aide" : undefined}
className="h-10 w-full pl-9 pr-9"
/>
{term && (
<button
type="button"
onClick={clear}
aria-label="Effacer la recherche"
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-muted)" }}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
>
<X className="w-3.5 h-3.5" />
<X className="h-4 w-4" />
</button>
)}
</div>
<button
onClick={submit}
disabled={term.trim().length < 3}
className="btn-action btn-action-primary disabled:opacity-50"
>
Rechercher
</button>
<Button onClick={submit} disabled={term.trim().length < 3} className="h-10">
<Search /> Rechercher
</Button>
</div>
{tooShort && (
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
<p id="recherche-produit-aide" className="text-[13px] text-[var(--text-secondary)]">
Saisissez au moins 3 caractères.
</p>
)}
+383 -322
View File
@@ -1,363 +1,424 @@
"use client";
import { useMemo, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from "lucide-react";
import { History, Info, Megaphone, RotateCcw } from "lucide-react";
import type { Publicite, PubliciteHistorique } from "./page";
import { Badge, StoreBadge, type Ton } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
import { Select, type SelectOption } from "@/components/ui/form-controls";
import { Pagination } from "@/components/ui/pagination";
import { ErrorState } from "@/components/ui/states";
import { Tabs, useUrlTab } from "@/components/ui/tabs";
import { Terme, InfoBulle } from "@/components/ui/tooltip";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { nomMagasin } from "@/lib/magasins";
import { cn } from "@/lib/utils";
function fmtDate(iso: string) {
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 Vue = "publicites" | "historique";
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" />;
}
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" },
const STATUTS: Record<string, { libelle: string; ton: Ton }> = {
en_cours: { libelle: "En cours", ton: "succes" },
passee: { libelle: "Passée", ton: "neutre" },
// Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes.
passees: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" },
passees: { libelle: "Passée", ton: "neutre" },
a_venir: { libelle: "À venir", ton: "accent" },
};
const OPTIONS_STATUT: SelectOption[] = [
{ value: "toutes", label: "Toutes" },
{ value: "en_cours", label: "En cours" },
{ value: "passees", label: "Passées" },
{ value: "a_venir", label: "À venir" },
];
/** Pourquoi la liste est vide, selon le statut choisi. */
const VIDE_PAR_STATUT: Record<string, string> = {
toutes: "Aucune opération publicitaire n'est enregistrée pour le moment.",
en_cours: "Aucune opération publicitaire n'est en cours en ce moment.",
passees: "Aucune opération publicitaire passée n'a été trouvée.",
a_venir: "Aucune opération publicitaire à venir n'est encore enregistrée.",
};
const EXPLICATION_CA_PUB =
"Chiffre d'affaires réalisé sur les produits de l'opération pendant sa durée (du premier au dernier jour de la publicité).";
function fmtDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
/** Les montants de l'API arrivent parfois en texte : valeur absente ou illisible = 0. */
function nombre(v: number | string | null | undefined): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function somme<T>(rows: readonly T[], valeur: (r: T) => number | string | null | undefined): number {
return rows.reduce((s, r) => s + nombre(valeur(r)), 0);
}
const libelleStatut = (statut?: string) => STATUTS[statut ?? ""]?.libelle ?? statut ?? "";
/** `passee` et `passees` désignent le même statut. */
const cleStatut = (statut?: string) => (statut === "passees" ? "passee" : statut || null);
/** Libellé suivi d'un « i » : explication libre au survol (hors glossaire). */
function StatutBadge({ statut }: { statut?: string }) {
const s = statut ?? "";
const cfg = STATUT_LABELS[s] ?? { label: s || "—", cls: "bg-gray-100 text-gray-500" };
const s = STATUTS[statut ?? ""];
if (s) return <Badge ton={s.ton}>{s.libelle}</Badge>;
return statut ? <Badge>{statut}</Badge> : <span className="text-[var(--text-muted)]">—</span>;
}
/** Intitulé d'abord, code de l'opération en petit dessous. */
function Operation({ intitule, code }: { intitule: string; code: string }) {
return (
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold whitespace-nowrap ${cfg.cls}`}>
{cfg.label}
<div className="min-w-[220px]">
<div className="font-medium text-[var(--text-primary)]">{intitule || "Opération sans intitulé"}</div>
{code && <div className="text-xs text-[var(--text-muted)]">Code {code}</div>}
</div>
);
}
/** Part du CA total : mise en avant à partir de 10 %, puis de 20 %. */
function PartCa({ pct }: { pct: number }) {
return (
<span
className={cn(
pct >= 20 ? "font-semibold text-[var(--accent)]" : pct >= 10 ? "font-semibold" : "text-[var(--text-secondary)]",
)}
>
{fmtDecimal1(pct)} %
</span>
);
}
/** Pied de la colonne « Part du CA total » : somme du CA pub / somme du CA total. */
function PartCaTotal({ rows }: { rows: readonly Publicite[] }) {
const caPub = somme(rows, (r) => r.ca_pub_periode_pub);
const caTotal = somme(rows, (r) => r.ca_total_periode_pub);
if (caTotal <= 0) {
return <span className="text-[var(--text-muted)]" title="Chiffre d'affaires total inconnu : la part ne peut pas être calculée.">—</span>;
}
return (
<InfoBulle explication="Somme du CA pub période des lignes affichées, divisée par la somme de leur CA total sur les mêmes périodes.">
{fmtDecimal1((caPub / caTotal) * 100)} %
</InfoBulle>
);
}
interface Props {
vue: string;
vueInitiale: Vue;
statut: string;
page: number;
pageSize: number;
publicites: Publicite[];
total: number;
pages: number;
erreurPublicites?: string;
historique: PubliciteHistorique[];
erreurHistorique?: string;
}
export function PublicitesClient({ vue, statut, page, publicites, total, pages, historique }: Props) {
export function PublicitesClient({
vueInitiale,
statut,
page,
pageSize,
publicites,
total,
pages,
erreurPublicites,
historique,
erreurHistorique,
}: Props) {
const router = useRouter();
const [chargement, startTransition] = useTransition();
const [vue, setVue] = useUrlTab<Vue>("vue", vueInitiale);
const [filterSite, setFilterSite] = useState<string>("all");
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}`);
function naviguer(changements: { statut?: string; page?: number }) {
const sp = new URLSearchParams({ vue, statut, page: String(page) });
if (changements.statut !== undefined) sp.set("statut", changements.statut);
if (changements.page !== undefined) sp.set("page", String(changements.page));
startTransition(() => router.push(`/publicites?${sp}`));
}
const validPublicites = useMemo(() => publicites.filter(p => p.site !== "000"), [publicites]);
const reessayer = (
<Button variant="outline" onClick={() => startTransition(() => router.refresh())} disabled={chargement}>
<RotateCcw /> Réessayer
</Button>
);
const sites = useMemo(() => {
const s = new Set(validPublicites.map(p => p.site).filter(Boolean));
return Array.from(s).sort();
}, [validPublicites]);
const validPublicites = useMemo(() => publicites.filter((p) => p.site !== "000"), [publicites]);
const plusieursSites = useMemo(() => new Set(validPublicites.map((p) => p.site).filter(Boolean)).size > 1, [validPublicites]);
const paginee = pages > 1;
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",
];
return Object.fromEntries(sites.map((s, i) => [s, palette[i % palette.length]]));
}, [sites]);
const filtresPublicites = useMemo<DataFilter<Publicite>[]>(
() =>
plusieursSites
? [{ id: "site", label: "Magasin", valueOf: (p) => p.site || null, optionLabel: (code) => nomMagasin(code), allLabel: "Tous les magasins" }]
: [],
[plusieursSites],
);
function handleSort(key: SortKey) {
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortKey(key); setSortDir("asc"); }
}
const colonnesPublicites = useMemo<DataColumn<Publicite>[]>(
() => [
{
id: "operation",
header: "Opération",
sortValue: (p) => p.intitule,
cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />,
footer: (rows) =>
`Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`,
},
{
id: "statut",
header: "Statut",
sortValue: (p) => libelleStatut(p.statut),
cell: (p) => <StatutBadge statut={p.statut} />,
},
{
id: "site",
header: "Magasin",
sortValue: (p) => (p.site ? nomMagasin(p.site) : null),
cell: (p) => (p.site ? <StoreBadge code={p.site} /> : <span className="text-[var(--text-muted)]">—</span>),
},
{
id: "debut",
header: "Début",
sortValue: (p) => p.date_debut,
cell: (p) => fmtDate(p.date_debut),
className: "whitespace-nowrap tabular-nums",
},
{
id: "fin",
header: "Fin",
sortValue: (p) => p.date_fin,
cell: (p) => fmtDate(p.date_fin),
className: "whitespace-nowrap tabular-nums",
},
{
id: "caPub",
header: <InfoBulle explication={EXPLICATION_CA_PUB}>CA pub période</InfoBulle>,
align: "right",
sortValue: (p) => nombre(p.ca_pub_periode_pub),
cell: (p) => <span className="font-semibold">{fmtEur0(nombre(p.ca_pub_periode_pub))}</span>,
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_pub_periode_pub)),
},
{
id: "partCa",
header: <Terme id="partCaTotal" />,
align: "right",
sortValue: (p) => nombre(p.pourc_capub_catotal),
cell: (p) => <PartCa pct={nombre(p.pourc_capub_catotal)} />,
footer: (rows) => <PartCaTotal rows={rows} />,
},
{
id: "quantite",
header: "Quantité vendue",
align: "right",
sortValue: (p) => nombre(p.qte_vendue_pub),
cell: (p) => fmtEntier(nombre(p.qte_vendue_pub)),
footer: (rows) => fmtEntier(somme(rows, (r) => r.qte_vendue_pub)),
},
{
id: "clients",
header: "Clients",
align: "right",
sortValue: (p) => nombre(p.client_pub_periode),
cell: (p) => fmtEntier(nombre(p.client_pub_periode)),
},
{
id: "tauxSortie",
header: <Terme id="tauxSortie" />,
align: "right",
sortValue: (p) => nombre(p.taux_sortie),
cell: (p) => `${fmtDecimal1(nombre(p.taux_sortie))} %`,
},
{
id: "articles",
header: "Nb articles",
align: "right",
sortValue: (p) => nombre(p.nb_articles),
cell: (p) => fmtEntier(nombre(p.nb_articles)),
},
],
[paginee],
);
const filtered = useMemo(() => {
let rows = filterSite === "all" ? validPublicites : validPublicites.filter(p => p.site === filterSite);
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;
});
}, [validPublicites, filterSite, sortKey, sortDir]);
const filtresHistorique = useMemo<DataFilter<PubliciteHistorique>[]>(
() => [{ id: "statut", label: "Statut", valueOf: (h) => cleStatut(h.statut), optionLabel: (s) => libelleStatut(s), allLabel: "Tous" }],
[],
);
function thProps(key: SortKey, cls = "") {
return { className: `${cls} cursor-pointer select-none hover:bg-gray-100 transition-colors`, onClick: () => handleSort(key) };
}
const colonnesHistorique = useMemo<DataColumn<PubliciteHistorique>[]>(
() => [
{
id: "operation",
header: "Opération",
sortValue: (h) => h.intitule,
cell: (h) => <Operation intitule={h.intitule} code={h.tcr_code} />,
footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`,
},
{
id: "statut",
header: "Statut",
sortValue: (h) => libelleStatut(h.statut),
cell: (h) => <StatutBadge statut={h.statut} />,
},
{
id: "debut",
header: "Début",
sortValue: (h) => h.date_debut,
cell: (h) => fmtDate(h.date_debut),
className: "whitespace-nowrap tabular-nums",
},
{
id: "fin",
header: "Fin",
sortValue: (h) => h.date_fin,
cell: (h) => fmtDate(h.date_fin),
className: "whitespace-nowrap tabular-nums",
},
{
id: "magasins",
header: "Magasins",
hint: "Nombre de magasins concernés par l'opération.",
align: "right",
sortValue: (h) => nombre(h.nb_sites),
cell: (h) => fmtEntier(nombre(h.nb_sites)),
},
{
id: "ca",
header: "CA total",
hint: "Chiffre d'affaires de l'opération, tous magasins confondus.",
align: "right",
sortValue: (h) => nombre(h.ca_total),
cell: (h) => <span className="font-semibold">{fmtEur0(nombre(h.ca_total))}</span>,
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_total)),
},
{
id: "quantite",
header: "Quantité vendue",
align: "right",
sortValue: (h) => nombre(h.qte_totale),
cell: (h) => fmtEntier(nombre(h.qte_totale)),
footer: (rows) => fmtEntier(somme(rows, (r) => r.qte_totale)),
},
],
[],
);
const isHistorique = vue === "historique";
const onglets = [
{ value: "publicites", label: "Publicités", icon: Megaphone, count: erreurPublicites ? undefined : total },
{ value: "historique", label: "Historique", icon: History, count: erreurHistorique ? undefined : historique.length },
] as const;
const selectStatut = (
<Select
id="publicites-statut"
label="Statut"
value={statut}
options={OPTIONS_STATUT}
onChange={(v) => naviguer({ statut: v, page: 1 })}
disabled={chargement}
/>
);
const descriptionVide =
page > 1 && total > 0 ? (
<>
Cette page ne contient aucune publicité.{" "}
<button
type="button"
onClick={() => naviguer({ page: 1 })}
className="font-medium text-[var(--accent)] underline underline-offset-2"
>
Revenir à la première page
</button>
</>
) : (
<>
{VIDE_PAR_STATUT[statut] ?? VIDE_PAR_STATUT.toutes}
{statut !== "toutes" && " Choisissez « Toutes » dans le filtre Statut pour voir les autres opérations."}
</>
);
return (
<div className="space-y-4">
{/* ── 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"
}`}
>
{t.label}
</button>
))}
</div>
<div className="space-y-5">
<Tabs items={onglets} value={vue} onChange={setVue} />
{/* ── 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]"
>
<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>
<div aria-busy={chargement} className={cn("transition-opacity", chargement && "pointer-events-none opacity-60")}>
{vue === "publicites" ? (
erreurPublicites ? (
<ErrorState
title="Les publicités n'ont pas pu être chargées"
description="Le serveur des publicités n'a pas répondu correctement. Réessayez dans un instant ; si le problème persiste, prévenez un administrateur."
detail={erreurPublicites}
action={reessayer}
/>
) : (
<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="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-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>
</tfoot>
</table>
)}
</div>
)}
<div className="space-y-3">
{paginee && (
<p className="flex items-start gap-2 text-[13px] text-[var(--text-secondary)]">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--text-muted)]" aria-hidden />
<span>
<span className="font-semibold text-[var(--text-primary)]">
Tri et filtre magasin appliqués à cette page.
</span>{" "}
Les publicités sont chargées par pages de {fmtEntier(pageSize)} (page {fmtEntier(page)} sur{" "}
{fmtEntier(pages)}) : la ligne Total ne porte, elle aussi, que sur la page affichée.
</span>
</p>
)}
<DataTable
rows={validPublicites}
columns={colonnesPublicites}
rowKey={(p, i) => `${p.tcr_code}-${p.site}-${i}`}
filters={filtresPublicites}
toolbar={selectStatut}
pageSize={0}
unite="publicités"
showFooter
initialSort={{ id: "operation", dir: "asc" }}
emptyTitle="Aucune publicité à afficher"
emptyDescription={descriptionVide}
/>
<Pagination
page={page}
totalPages={pages}
total={total}
pageSize={pageSize}
onChange={(p) => naviguer({ page: p })}
unite="publicités"
/>
</div>
)
) : erreurHistorique ? (
<ErrorState
title="L'historique n'a pas pu être chargé"
description="Le serveur des publicités n'a pas répondu correctement. Réessayez dans un instant ; si le problème persiste, prévenez un administrateur."
detail={erreurHistorique}
action={reessayer}
/>
) : (
<DataTable
rows={historique}
columns={colonnesHistorique}
rowKey={(h, i) => `${h.tcr_code}-${i}`}
searchIn={(h) => [h.intitule, h.tcr_code]}
searchPlaceholder="Rechercher une opération ou un code…"
filters={filtresHistorique}
unite="opérations"
showFooter
emptyTitle="Aucune opération dans l'historique"
emptyDescription="L'historique des opérations publicitaires est vide pour le moment."
/>
)}
</div>
</div>
);
}
+23 -20
View File
@@ -1,6 +1,11 @@
import type { Metadata } from "next";
import { Megaphone } from "lucide-react";
import { PageHeader } from "@/components/ui/page-header";
import { PublicitesClient } from "./client";
import { cachedFF } from "@/lib/ff-cache";
export const metadata: Metadata = { title: "Publicités" };
export interface Publicite {
tcr_code: string;
intitule: string;
@@ -114,27 +119,25 @@ export default async function PublicitesPage(props: {
fetchHistorique(),
]);
const error = pubResult.error ?? histoResult.error;
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-screen-xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Publicités</h1>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">
{error}
</div>
)}
<PublicitesClient
vue={vue}
statut={statut}
page={page}
publicites={pubResult.data}
total={pubResult.total}
pages={pubResult.pages}
historique={histoResult.data}
/>
</div>
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={Megaphone}
title="Publicités"
description="Résultats des opérations publicitaires (en cours, passées, à venir) et leur poids dans les ventes de vos magasins."
/>
<PublicitesClient
vueInitiale={vue === "historique" ? "historique" : "publicites"}
statut={statut}
page={page}
pageSize={PAGE_SIZE}
publicites={pubResult.data}
total={pubResult.total}
pages={pubResult.pages}
erreurPublicites={pubResult.error}
historique={histoResult.data}
erreurHistorique={histoResult.error}
/>
</div>
);
}
+619
View File
@@ -0,0 +1,619 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import {
AlertCircle,
CheckCircle2,
Eye,
EyeOff,
FileText,
KeyRound,
Loader2,
PlugZap,
RefreshCw,
RotateCcw,
Save,
Settings,
Users,
} from "lucide-react";
import { PageHeader } from "@/components/ui/page-header";
import { Tabs, useUrlTab, type TabItem } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input, Label } from "@/components/ui/form-controls";
import { Terme } from "@/components/ui/tooltip";
import { toast } from "@/components/ui/feedback";
import { fmtEntier } from "@/lib/format";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
import {
testDatabaseConnection,
saveDatabaseSettings,
getSavedDatabaseConfig,
saveQlikSettings,
testQlikConnection,
saveFfApiSettings,
testFfApiConnection,
} from "@/features/settings/actions";
import { UserManagement } from "@/features/admin/components/user-management";
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
import { GridWarmup } from "@/features/admin/components/grid-warmup";
import { ServerLogs } from "@/features/settings/components/server-logs";
/**
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
* serveur s'exécutent les unes après les autres.
*/
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
if (force || !savedConfigPromise) {
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
savedConfigPromise = null;
throw e;
});
}
return savedConfigPromise;
}
/** Message lisible d'une erreur de server action (onglet périmé compris). */
function messageErreur(e: unknown): string {
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
return e instanceof Error ? e.message : String(e);
}
function fmtDateHeure(valeur: string | null | undefined): string {
if (!valeur) return "—";
const d = new Date(valeur);
return isNaN(d.getTime()) ? valeur : d.toLocaleString("fr-FR");
}
// ── Onglets ──────────────────────────────────────────────────────────────────
type Onglet = "connexions" | "utilisateurs" | "api" | "journal";
const ONGLETS: readonly TabItem<Onglet>[] = [
{ value: "connexions", label: "Connexions", icon: PlugZap },
{ value: "utilisateurs", label: "Utilisateurs", icon: Users },
{ value: "api", label: "Accès API", icon: KeyRound },
{ value: "journal", label: "Journal serveur", icon: FileText },
];
function estOnglet(v: string | undefined): v is Onglet {
return ONGLETS.some((o) => o.value === v);
}
/**
* Contenu d'un onglet : monté à la première visite puis seulement masqué, pour
* qu'une saisie non enregistrée survive au changement d'onglet et que rien ne
* soit rechargé en revenant.
*/
function Panneau({ actif, visite, children }: { actif: boolean; visite: boolean; children: ReactNode }) {
if (!visite) return null;
return (
<div role="tabpanel" hidden={!actif} className="space-y-6">
{children}
</div>
);
}
// ── Éléments de formulaire ───────────────────────────────────────────────────
function Champ({ id, label, aide, children, className }: {
id: string;
label: ReactNode;
aide?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<div className={className}>
<Label htmlFor={id}>{label}</Label>
{children}
{aide && <p className="mt-1 text-xs text-[var(--text-muted)]">{aide}</p>}
</div>
);
}
type EtatTest = { etat: "idle" } | { etat: "en-cours" } | { etat: "ok" } | { etat: "erreur"; message: string };
/** Résultat d'un test de connexion, affiché à côté des boutons. */
function ResultatTest({ test }: { test: EtatTest }) {
if (test.etat === "ok") {
return (
<Badge ton="succes">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Connexion réussie
</Badge>
);
}
if (test.etat === "erreur") {
return (
<p role="alert" className="flex items-start gap-1.5 text-[13px] text-[var(--accent-error)]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<span>
<span className="font-semibold">Connexion impossible.</span>{" "}
<span className="text-[var(--text-secondary)]">{test.message}</span>
</span>
</p>
);
}
return null;
}
// ── PostgreSQL ───────────────────────────────────────────────────────────────
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
const {
host, setHost,
port, setPort,
database, setDatabase,
user, setUser,
password, setPassword,
ssl, setSsl,
getDatabaseUrl,
} = useDbSettingsStore();
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
const [enregistrement, setEnregistrement] = useState(false);
const [rechargement, setRechargement] = useState(false);
const tester = async () => {
setTest({ etat: "en-cours" });
try {
const res = await testDatabaseConnection(getDatabaseUrl());
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." });
} catch (e) {
setTest({ etat: "erreur", message: messageErreur(e) });
}
};
const enregistrer = async () => {
setEnregistrement(true);
try {
const res = await saveDatabaseSettings(getDatabaseUrl());
if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée.");
else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
} finally {
setEnregistrement(false);
}
};
const rechargerConfig = async () => {
setRechargement(true);
try {
await recharger();
toast.info("Les valeurs enregistrées sur le serveur ont été rechargées.");
} catch (e) {
toast.erreur(`La configuration n'a pas pu être relue : ${messageErreur(e)}`);
} finally {
setRechargement(false);
}
};
return (
<Card>
<CardHeader
title="Base de données de l'application (PostgreSQL)"
description="Contient les comptes utilisateurs, l'historique des sessions et les réglages. Les produits et les ventes, eux, viennent de l'API FF Nancy."
/>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Champ
id="pg-hote"
label="Serveur"
aide="Adresse du serveur : localhost, une adresse IP (192.168.1.10) ou le nom du service Docker (postgres, db)."
className="sm:col-span-2"
>
<Input id="pg-hote" className="w-full font-mono" placeholder="localhost" value={host}
onChange={(e) => setHost(e.target.value)} />
</Champ>
<Champ id="pg-port" label="Port" aide="5432 dans la plupart des installations.">
<Input id="pg-port" className="w-full font-mono" placeholder="5432" value={port}
onChange={(e) => setPort(e.target.value)} />
</Champ>
<Champ id="pg-base" label="Nom de la base">
<Input id="pg-base" className="w-full font-mono" placeholder="collectflow" value={database}
onChange={(e) => setDatabase(e.target.value)} />
</Champ>
<Champ id="pg-utilisateur" label="Utilisateur">
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
</Champ>
<Champ id="pg-mdp" label="Mot de passe">
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Champ>
</div>
<div>
<label htmlFor="pg-ssl" className="inline-flex cursor-pointer items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
id="pg-ssl"
checked={ssl}
onChange={(e) => setSsl(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-[var(--accent)]"
/>
Connexion chiffrée (SSL)
</label>
<p className="mt-1 text-xs text-[var(--text-muted)]">
À cocher si le serveur de base de données l&apos;exige, en général quand il est hébergé à distance.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={enregistrer} disabled={enregistrement}>
{enregistrement ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer
</Button>
<Button variant="outline" onClick={tester} disabled={test.etat === "en-cours"}>
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion
</Button>
<Button variant="ghost" onClick={rechargerConfig} disabled={rechargement}>
{rechargement ? <Loader2 className="animate-spin" /> : <RotateCcw />}
Recharger les valeurs enregistrées
</Button>
<ResultatTest test={test} />
</div>
</CardContent>
</Card>
);
}
// ── API FF Nancy ─────────────────────────────────────────────────────────────
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
function SectionFfApi() {
const [status, setStatus] = useState<FfSyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState("");
const [savedUrl, setSavedUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testedUrl, setTestedUrl] = useState<string | null>(null);
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
useEffect(() => {
loadSavedConfig()
.then((cfg) => {
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
})
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
}, []);
const runTest = useCallback((candidate?: string) => {
setLoading(true);
setError(null);
setStatus(null);
testFfApiConnection(candidate)
.then((res) => {
setTestedUrl(res.url);
if (res.success) setStatus(res.status as FfSyncStatus);
else setError(res.error ?? "Échec inconnu.");
})
.catch((e) => setError(messageErreur(e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { runTest(); }, [runTest]);
const save = async () => {
setSaving(true);
try {
const res = await saveFfApiSettings(url);
if (!res.success) {
toast.erreur(res.error ?? "Enregistrement impossible.");
return;
}
setSavedUrl(url.trim() || null);
toast.succes(url.trim() ? "Adresse de l'API FF enregistrée." : "L'adresse par défaut de l'API FF sera utilisée.");
runTest(url);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
} finally {
setSaving(false);
}
};
const test: EtatTest = loading ? { etat: "en-cours" } : status ? { etat: "ok" } : error ? { etat: "erreur", message: error } : { etat: "idle" };
return (
<Card>
<CardHeader
title="API FF Nancy"
description="Source des produits et des ventes de nos magasins. Ses données sont mises à jour chaque nuit depuis la base FF : elles s'arrêtent donc à la veille."
/>
<CardContent className="space-y-4">
<Champ
id="ff-url"
label="Adresse de l'API"
aide="Laissez vide pour utiliser l'adresse par défaut (https://api.ffnancy.fr)."
>
<Input
id="ff-url"
type="text"
placeholder="https://api.ffnancy.fr"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
className="w-full font-mono"
/>
</Champ>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={save} disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer
</Button>
<Button variant="outline" onClick={() => runTest(url)} disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion
</Button>
<ResultatTest test={test} />
</div>
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
alors que le réglage enregistré ou la variable d'environnement prime. */}
{testedUrl && (
<p className="text-xs text-[var(--text-muted)]">
Adresse testée : <span className="font-mono">{testedUrl}</span>
{!savedUrl && <> (adresse par défaut, aucun réglage enregistré)</>}
</p>
)}
{status && (
<div className="space-y-2">
<p className="text-[13px] text-[var(--text-secondary)]">
Dernière mise à jour complète des données :{" "}
<span className="font-medium text-[var(--text-primary)]">{fmtDateHeure(status.lastSync)}</span>
</p>
{status.tables?.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
<tr>
<th scope="col" className="px-3 py-2 text-left">Table de données</th>
<th scope="col" className="px-3 py-2 text-right">Mise à jour</th>
<th scope="col" className="px-3 py-2 text-right">Lignes</th>
<th scope="col" className="px-3 py-2 text-right">État</th>
</tr>
</thead>
<tbody>
{status.tables.map((t) => (
<tr key={t.nom} className="border-t border-[var(--border)]">
<td className="px-3 py-2 text-sm font-mono text-[var(--text-primary)]">{t.nom}</td>
<td className="px-3 py-2 text-sm text-right text-[var(--text-secondary)]">{fmtDateHeure(t.derniereSync)}</td>
<td className="px-3 py-2 text-sm text-right tabular-nums text-[var(--text-secondary)]">
{t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"}
</td>
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
<td className="px-3 py-2 text-sm text-right">
{t.statut === "ok"
? <Badge ton="succes">OK</Badge>
: <Badge ton="erreur" title={t.erreur ?? undefined}>{t.statut ?? "—"}</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
// ── Qlik Sense ───────────────────────────────────────────────────────────────
function SectionQlik() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
useEffect(() => {
loadSavedConfig()
.then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
})
.catch(() => { /* l'échec de lecture est signalé par la page */ });
}, []);
const handleSave = async () => {
setSaving(true);
try {
const res = await saveQlikSettings(host.trim(), user.trim(), password);
if (res.success) toast.succes("Identifiants Qlik enregistrés.");
else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTest({ etat: "en-cours" });
try {
const res = await testQlikConnection(host.trim(), user.trim(), password);
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." });
} catch (e) {
setTest({ etat: "erreur", message: messageErreur(e) });
}
};
const testImpossible = !user || !password;
return (
<Card>
<CardHeader
title="Qlik Sense (données du réseau)"
description={
<>
Identifiants utilisés pour récupérer les ventes des magasins du{" "}
<Terme id="reseau">réseau</Terme> dans l&apos;outil de reporting Qlik Sense de La Foir&apos;Fouille.
</>
}
/>
<CardContent className="space-y-4">
<Champ
id="qlik-hote"
label="Serveur Qlik"
aide="Laissez vide pour utiliser le serveur habituel : reporting-magasins.lafoirfouille.fr."
>
<Input id="qlik-hote" type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
onChange={(e) => setHost(e.target.value)} className="w-full font-mono" />
</Champ>
<div className="grid gap-4 sm:grid-cols-2">
<Champ
id="qlik-utilisateur"
label="Identifiant"
aide="Identifiant Windows sans le domaine, par exemple FFSCH (connexion par authentification Windows, dite NTLM)."
>
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
</Champ>
<Champ id="qlik-mdp" label="Mot de passe">
<div className="relative">
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowPwd((v) => !v)}
aria-label={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
title={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
className="absolute right-0.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
>
{showPwd ? <EyeOff /> : <Eye />}
</Button>
</div>
</Champ>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer
</Button>
<Button
variant="outline"
onClick={handleTest}
disabled={test.etat === "en-cours" || testImpossible}
title={testImpossible ? "Renseignez l'identifiant et le mot de passe pour tester" : undefined}
>
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion
</Button>
<ResultatTest test={test} />
</div>
</CardContent>
</Card>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) {
const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions";
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => new Set([initial]));
const [isMounted, setIsMounted] = useState(false);
const setHost = useDbSettingsStore((s) => s.setHost);
const setPort = useDbSettingsStore((s) => s.setPort);
const setDatabase = useDbSettingsStore((s) => s.setDatabase);
const setUser = useDbSettingsStore((s) => s.setUser);
const setPassword = useDbSettingsStore((s) => s.setPassword);
const setSsl = useDbSettingsStore((s) => s.setSsl);
const reloadFromServer = useCallback(async (force = false) => {
const config = await loadSavedConfig(force);
if (config?.url) {
// Parser l'URL pour remettre dans le store
try {
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
setHost(url.hostname);
setPort(url.port || "5432");
setDatabase(url.pathname.slice(1).split("?")[0]);
setUser(url.username);
setPassword(decodeURIComponent(url.password));
setSsl(config.url.includes("sslmode=require"));
} catch (e) {
console.error("Failed to parse saved URL", e);
}
}
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
useEffect(() => {
// `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL
// lus dans le localStorage) : il doit donc être posé au montage, ce que
// la règle déconseille en général mais qui est ici le but.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMounted(true);
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
// juste après (cf. `isMounted`), réutilisent cette même requête.
reloadFromServer(true).catch((e) => {
toast.erreur(`La configuration enregistrée n'a pas pu être lue : ${messageErreur(e)}`);
});
}, [reloadFromServer]);
const changerOnglet = (suivant: Onglet) => {
setOnglet(suivant);
setVisites((prev) => (prev.has(suivant) ? prev : new Set([...prev, suivant])));
};
const recharger = useCallback(() => reloadFromServer(true), [reloadFromServer]);
return (
<div className="mx-auto w-full max-w-5xl">
<PageHeader
icon={Settings}
title="Paramètres"
description="Connexions aux sources de données, comptes des utilisateurs et clés d'accès à l'API (réservé aux administrateurs)."
/>
<Tabs items={ONGLETS} value={onglet} onChange={changerOnglet} className="mb-6" />
{isMounted && (
<>
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
<SectionPostgres recharger={recharger} />
<SectionFfApi />
<SectionQlik />
</Panneau>
<Panneau actif={onglet === "utilisateurs"} visite={visites.has("utilisateurs")}>
<UserManagement />
</Panneau>
<Panneau actif={onglet === "api"} visite={visites.has("api")}>
<ApiConnectionInfo />
<ApiKeyManagement />
<GridWarmup />
</Panneau>
<Panneau actif={onglet === "journal"} visite={visites.has("journal")}>
<ServerLogs />
</Panneau>
</>
)}
</div>
);
}
+11 -539
View File
@@ -1,544 +1,16 @@
"use client";
import type { Metadata } from "next";
import { ParametresClient } from "./client";
import { useState, useCallback } from "react";
import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, RotateCcw } from "lucide-react";
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions";
import { useEffect } from "react";
import { UserManagement } from "@/features/admin/components/user-management";
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
import { GridWarmup } from "@/features/admin/components/grid-warmup";
import { ServerLogs } from "@/features/settings/components/server-logs";
export const metadata: Metadata = { title: "Paramètres" };
/**
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
* serveur s'exécutent les unes après les autres.
* Paramètres (administrateurs) : connexions aux sources de données, comptes
* utilisateurs, accès à l'API et journal serveur, rangés en onglets.
*
* Composant serveur minimal : il lit l'onglet demandé (`?onglet=`) pour que le
* lien se partage et survive au rechargement, puis rend la page cliente.
*/
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
if (force || !savedConfigPromise) {
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
savedConfigPromise = null;
throw e;
});
}
return savedConfigPromise;
}
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section className="apple-card">
<div className="apple-card-header">
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h2>
{subtitle && <p className="text-[12px] mt-0.5 text-[var(--text-secondary)]">{subtitle}</p>}
</div>
<div className="apple-card-content space-y-4">
{children}
</div>
</section>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-[11px] text-[var(--text-muted)]">{hint}</p>}
</div>
);
}
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
function FfApiStatusSection() {
const [status, setStatus] = useState<FfSyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState("");
const [savedUrl, setSavedUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testedUrl, setTestedUrl] = useState<string | null>(null);
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
useEffect(() => {
loadSavedConfig()
.then((cfg) => {
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
})
.catch(() => { /* réglage optionnel : on laisse le champ vide */ });
}, []);
const runTest = useCallback((candidate?: string) => {
setLoading(true);
setError(null);
setStatus(null);
testFfApiConnection(candidate)
.then((res) => {
setTestedUrl(res.url);
if (res.success) setStatus(res.status as FfSyncStatus);
else setError(res.error ?? "Échec inconnu");
})
.catch((e) => setError(e instanceof Error ? e.message : "Erreur inconnue"))
.finally(() => setLoading(false));
}, []);
useEffect(() => { runTest(); }, [runTest]);
const save = async () => {
setSaving(true);
setError(null);
try {
const res = await saveFfApiSettings(url);
if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; }
setSavedUrl(url.trim() || null);
runTest(url);
} finally {
setSaving(false);
}
};
return (
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
<input
type="text"
placeholder="https://api.ffnancy.fr"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
className="apple-input w-full"
/>
</Field>
<div className="flex items-center gap-3 flex-wrap">
<button onClick={save} disabled={saving} className="apple-btn h-9 px-4">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
<button onClick={() => runTest(url)} disabled={loading} className="apple-btn-secondary h-9 px-4">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Tester
</button>
{status && <span className="text-[12px] text-emerald-400 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5" /> Connexion OK</span>}
{error && <span className="text-[12px] text-red-400 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> {error}</span>}
</div>
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
alors que le réglage enregistré ou la variable d'environnement prime. */}
{testedUrl && (
<p className="text-[11px] text-[var(--text-muted)]">
Adresse testée : <span className="font-mono">{testedUrl}</span>
{!savedUrl && <> — valeur par défaut, aucun réglage enregistré</>}
</p>
)}
{status && (
<div className="space-y-2 mt-2">
<p className="text-[12px] text-[var(--text-secondary)]">
Dernière sync globale : <span className="font-mono font-medium">{(() => { const d = new Date(status.lastSync); return isNaN(d.getTime()) ? (status.lastSync ?? "—") : d.toLocaleString("fr-FR"); })()}</span>
</p>
{status.tables?.length > 0 && (
<div className="rounded-lg overflow-hidden border border-[var(--border-subtle)]">
<table className="w-full text-[11px]">
<thead>
<tr className="bg-[var(--surface-tertiary)]">
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
</tr>
</thead>
<tbody>
{status.tables.map((t) => (
<tr key={t.nom} className="border-t border-[var(--border-subtle)]">
<td className="px-3 py-2 font-mono text-[var(--text-primary)]">{t.nom}</td>
<td className="px-3 py-2 text-right text-[var(--text-secondary)]">
{(() => { const d = new Date(t.derniereSync); return isNaN(d.getTime()) ? (t.derniereSync ?? "—") : d.toLocaleString("fr-FR"); })()}
</td>
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
</td>
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
<td className="px-3 py-2 text-right">
{t.statut === "ok"
? <span className="text-emerald-400">ok</span>
: <span className="text-red-400" title={t.erreur ?? undefined}>{t.statut ?? "—"}</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Section>
);
}
function QlikSettingsSection() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
useEffect(() => {
loadSavedConfig().then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
});
}, []);
const handleSave = async () => {
setSaving(true);
setStatus(null);
const res = await saveQlikSettings(host.trim(), user.trim(), password);
setSaving(false);
setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" });
};
const handleTest = async () => {
setTesting(true);
setStatus(null);
const res = await testQlikConnection(host.trim(), user.trim(), password);
setTesting(false);
setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" });
};
return (
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
<input type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<div className="relative">
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
<button type="button" onClick={() => setShowPwd((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field>
{status && (
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{status.msg}
</div>
)}
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving}
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
<button onClick={handleTest} disabled={testing || !user || !password}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Tester
</button>
</div>
</Section>
);
}
export default function SettingsPage() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [dbError, setDbError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [isMounted, setIsMounted] = useState(false);
const {
host, setHost,
port, setPort,
database, setDatabase,
user, setUser,
password, setPassword,
ssl, setSsl,
getDatabaseUrl
} = useDbSettingsStore();
const reloadFromServer = useCallback(async (force = false) => {
const config = await loadSavedConfig(force);
if (config) {
if (config.url) {
// Parser l'URL pour remettre dans le store
try {
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
setHost(url.hostname);
setPort(url.port || "5432");
setDatabase(url.pathname.slice(1).split("?")[0]);
setUser(url.username);
setPassword(decodeURIComponent(url.password));
setSsl(config.url.includes("sslmode=require"));
} catch (e) {
console.error("Failed to parse saved URL", e);
}
}
}
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
useEffect(() => {
// `isMounted` sert à éviter un écart d'hydratation : il doit donc être posé
// au montage, ce que la règle déconseille en général mais qui est ici le but.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMounted(true);
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
// juste après (cf. `isMounted`), réutilisent cette même requête.
reloadFromServer(true);
}, [reloadFromServer]);
const testDb = async () => {
setDbStatus("testing");
setDbError(null);
const url = getDatabaseUrl();
const res = await testDatabaseConnection(url);
if (res.success) {
setDbStatus("ok");
} else {
setDbStatus("error");
setDbError(res.error || "Erreur de connexion inconnue");
}
};
const handleSave = async () => {
setSaveStatus("saving");
const url = getDatabaseUrl();
const res = await saveDatabaseSettings(url);
if (res.success) {
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2500);
} else {
setSaveStatus("idle");
alert("Erreur lors de la sauvegarde : " + res.error);
}
};
if (!isMounted) {
return null;
}
return (
<div className="space-y-6 max-w-xl">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px] text-[var(--text-primary)]">Paramètres</h1>
<p className="text-[13px] mt-1 text-[var(--text-secondary)]">Configuration de la connexion et des services.</p>
</div>
{/* PostgreSQL */}
<Section title="PostgreSQL — Auth & Historique" subtitle="Connexion pour les comptes utilisateurs, snapshots et contexte IA. Les données produits proviennent de l'API FF Nancy.">
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
<input
type="text"
placeholder="localhost"
value={host}
onChange={(e) => setHost(e.target.value)}
className="apple-input font-mono"
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Port">
<input
type="text"
placeholder="5432"
value={port}
onChange={(e) => setPort(e.target.value)}
className="apple-input font-mono"
/>
</Field>
<Field label="Base de données">
<input
type="text"
placeholder="collectflow"
value={database}
onChange={(e) => setDatabase(e.target.value)}
className="apple-input font-mono"
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Utilisateur">
<input
type="text"
placeholder="postgres"
value={user}
onChange={(e) => setUser(e.target.value)}
className="apple-input font-mono"
/>
</Field>
<Field label="Mot de passe">
<input
type="password"
placeholder="••••••••"
value={password || ""}
onChange={(e) => setPassword(e.target.value)}
className="apple-input font-mono"
/>
</Field>
</div>
<div className="flex items-center gap-2.5">
<input
type="checkbox"
id="ssl"
checked={ssl}
onChange={(e) => setSsl(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer"
/>
<label htmlFor="ssl" className="text-[12px] cursor-pointer text-[var(--text-secondary)]">Connexion SSL</label>
</div>
<div className="flex items-center gap-3">
<button onClick={testDb} disabled={dbStatus === "testing"} className="apple-btn-secondary h-9 px-4">
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
Tester la connexion
</button>
{dbStatus === "error" && (
<div className="flex flex-col">
<span className="text-[12px] text-red-400 font-medium">Connexion refusée</span>
{dbError && <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{dbError}</span>}
</div>
)}
{dbStatus === "ok" && (
<span className="text-[12px] text-teal-500 font-medium">Connexion réussie !</span>
)}
</div>
<div className="pt-2">
<button
onClick={() => reloadFromServer(true)}
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
>
<RotateCcw className="w-3 h-3" />
Recharger la configuration enregistrée sur le serveur
</button>
</div>
</Section>
{/* API FF Nancy */}
<FfApiStatusSection />
<QlikSettingsSection />
{/* Apparence */}
<Section title="Apparence">
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">Thème</p>
<p className="text-[12px] text-[var(--text-secondary)]">Interface claire ou sombre</p>
</div>
{/* Segment control */}
<div className="segment-control">
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
<Sun className="w-3.5 h-3.5" /> Clair
</button>
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
<Moon className="w-3.5 h-3.5" /> Sombre
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">Densité de la grille</p>
<p className="text-[12px] text-[var(--text-secondary)]">Hauteur de ligne par défaut</p>
</div>
<select
className="apple-input w-auto"
value={displayDensity}
onChange={(e) => setDisplayDensity(e.target.value as "compact" | "normal" | "comfortable")}
>
<option value="compact">Compact (32px)</option>
<option value="normal">Normal (40px)</option>
<option value="comfortable">Spacieux (48px)</option>
</select>
</div>
</Section>
{/* Journaux serveur — diagnostic des extractions Qlik */}
<Section
title="Journaux serveur"
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
>
<ServerLogs />
</Section>
{/* Gestion des Utilisateurs */}
<Section
title="Gestion des Utilisateurs"
subtitle="Administration des accès et des rôles"
>
<UserManagement />
</Section>
{/* API publique /api/v1 — connexion puis gestion des clés */}
<Section
title="API CollectFlow — Connexion"
subtitle="Tout ce qu'il faut pour appeler l'API depuis un script ou un outil externe"
>
<ApiConnectionInfo />
</Section>
<Section
title="Clés d'API"
subtitle="Créer et révoquer les clés d'accès à /api/v1"
>
<ApiKeyManagement />
</Section>
<Section
title="Données exposées à l'API"
subtitle="Calculer l'instantané de tous les fournisseurs pour qu'une IA externe y ait accès"
>
<GridWarmup />
</Section>
{/* Save Button — Floating/Sticky style at bottom */}
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
<button
onClick={handleSave}
disabled={saveStatus === "saving"}
className={`apple-btn-primary min-w-[240px] shadow-lg ${saveStatus === "saving" ? "animate-pulse" : saveStatus === "saved" ? "animate-apple-save" : ""}`}
style={{ background: saveStatus === "saved" ? "var(--accent-success)" : "var(--brand-solid)" }}
>
{saveStatus === "saving" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "saved" ? (
<CheckCircle className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
<span className="ml-1">
{saveStatus === "saving"
? "Enregistrement en cours..."
: saveStatus === "saved"
? "Configuration enregistrée !"
: "Sauvegarder la configuration"}
</span>
</button>
</div>
</div>
);
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
const { onglet } = await props.searchParams;
return <ParametresClient onglet={onglet} />;
}
+296 -492
View File
@@ -2,28 +2,20 @@
import { useRouter } from "next/navigation";
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Download,
Search,
} from "lucide-react";
import LoadingModal from "@/components/shared/loading-modal";
import { Loader2 } from "lucide-react";
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
import { Tabs, type TabItem } from "@/components/ui/tabs";
import { Select } from "@/components/ui/form-controls";
import { StoreBadge } from "@/components/ui/badge";
import { Terme } from "@/components/ui/tooltip";
import { MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page";
type SortDir = "asc" | "desc";
/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */
const PAGE_SIZE = 50;
/** Un seul collateur réutilisé : `localeCompare` en crée un par appel (très coûteux sur 17 000 lignes). */
const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
function fmtDate(raw: string | null) {
if (!raw) return "—";
const d = new Date(raw);
@@ -40,7 +32,7 @@ function getYear(raw: string | null): string {
/**
* Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
* Évite le clignotement du modal sur les transitions instantanées.
* Évite le clignotement de l'indicateur sur les transitions instantanées.
*/
function useDelayedFlag(active: boolean, delay = 120) {
const [elapsed, setElapsed] = useState(false);
@@ -63,17 +55,15 @@ function useDelayedFlag(active: boolean, delay = 120) {
}
// ---------------------------------------------------------------------------
// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export)
// Export Excel — format d'import de régularisation dans FF.
//
// En-têtes (CODEIN / GENCODE / QTE / CODMV), codes mouvement (503 / 412),
// colonnes, valeurs et nom de fichier : à ne pas modifier, le fichier est
// importé tel quel dans FF. (`telechargerExcel` n'est pas utilisé : il fige la
// ligne d'en-tête et renseigne les propriétés du classeur, le fichier ne
// serait donc plus strictement identique.)
// ---------------------------------------------------------------------------
interface Column<T> {
key: Extract<keyof T, string>;
label: string;
/** Tri numérique au lieu du tri alphabétique. */
numeric?: boolean;
render?: (row: T) => ReactNode;
}
interface ExcelSpec<T> {
sheet: string;
filePrefix: string;
@@ -82,432 +72,243 @@ interface ExcelSpec<T> {
row: (r: T) => (string | number)[];
}
interface StockTableProps<T> {
rows: T[];
magasin: string;
columns: Column<T>[];
fournisseurOf: (r: T) => string;
/** Source du filtre « Année » — omis, le filtre n'est pas affiché. */
anneeOf?: (r: T) => string;
anneeLabel?: string;
searchIn: (r: T) => (string | null | undefined)[];
emptyLabel: string;
excel: ExcelSpec<T>;
async function exporterPourFF<T>(excel: ExcelSpec<T>, rows: readonly T[], magasin: string) {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(excel.sheet);
ws.addRow(excel.header);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
for (const r of rows) ws.addRow(excel.row(r));
ws.columns = excel.widths.map(width => ({ width }));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
}
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
if (!active) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
return dir === "asc"
? <ChevronUp className="inline w-3 h-3 ml-1" />
: <ChevronDown className="inline w-3 h-3 ml-1" />;
}
const EXCEL_NEGATIF: ExcelSpec<PgStockNegatifRow> = {
sheet: "Stock Négatif",
filePrefix: "Stock_Negatif",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
widths: [16, 16, 10, 10, 16, 22],
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
};
function FilterSelect({ label, value, onChange, options }: {
label: string;
value: string;
onChange: (v: string) => void;
options: string[];
}) {
return (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-600 whitespace-nowrap">{label}</label>
<select
value={value}
onChange={e => onChange(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-[150px]"
>
<option value="">Tous</option>
{options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
);
}
const EXCEL_SANS_VENTE: ExcelSpec<PgStockSansVenteRow> = {
sheet: "Entrées sans vente",
filePrefix: "Entrees_Sans_Vente",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
widths: [16, 16, 10, 10, 22],
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
};
function Pagination({ page, totalPages, total, from, to, onChange }: {
page: number;
totalPages: number;
total: number;
from: number;
to: number;
onChange: (p: number) => void;
}) {
if (total === 0) return null;
const btn = "rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40";
return (
<div className="flex flex-wrap items-center justify-between gap-3 px-1">
<span className="text-xs text-gray-500">
Lignes <span className="font-medium text-gray-700">{from.toLocaleString("fr-FR")}</span>
{" à "}
<span className="font-medium text-gray-700">{to.toLocaleString("fr-FR")}</span>
{" sur "}
<span className="font-medium text-gray-700">{total.toLocaleString("fr-FR")}</span>
</span>
<div className="flex items-center gap-1.5">
<button className={btn} onClick={() => onChange(1)} disabled={page <= 1} aria-label="Première page">
<ChevronsLeft className="w-4 h-4" />
</button>
<button className={btn} onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Page précédente">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-2 text-xs text-gray-600 tabular-nums">
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
</span>
<button className={btn} onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
<ChevronRight className="w-4 h-4" />
</button>
<button className={btn} onClick={() => onChange(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
);
}
function StockTable<T extends object>({
rows,
magasin,
columns,
fournisseurOf,
anneeOf,
anneeLabel = "Année entrée",
searchIn,
emptyLabel,
excel,
}: StockTableProps<T>) {
const [filterFournisseur, setFilterFournisseur] = useState("");
const [filterAnnee, setFilterAnnee] = useState("");
const [search, setSearch] = useState("");
// `null` = ordre renvoyé par la base (le plus pertinent : stock le plus négatif,
// entrée la plus récente…). Aucun tri client tant que l'utilisateur n'en demande pas.
const [sortKey, setSortKey] = useState<Extract<keyof T, string> | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [page, setPage] = useState(1);
const [exporting, setExporting] = useState(false);
const fournisseurs = useMemo(
() => [...new Set(rows.map(fournisseurOf).filter(Boolean))].sort((a, b) => collator.compare(a, b)),
[rows, fournisseurOf]
);
const annees = useMemo(
() => anneeOf ? [...new Set(rows.map(anneeOf).filter(Boolean))].sort((a, b) => b.localeCompare(a)) : [],
[rows, anneeOf]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!filterFournisseur && !filterAnnee && !q) return rows;
return rows.filter(r =>
(!filterFournisseur || fournisseurOf(r) === filterFournisseur) &&
(!filterAnnee || (anneeOf ? anneeOf(r) === filterAnnee : true)) &&
(!q || searchIn(r).some(v => v?.toLowerCase().includes(q)))
);
}, [rows, filterFournisseur, filterAnnee, search, fournisseurOf, anneeOf, searchIn]);
const numericKeys = useMemo(
() => new Set(columns.filter(c => c.numeric).map(c => c.key as string)),
[columns]
);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
const numeric = numericKeys.has(sortKey);
const dir = sortDir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey];
const bv = (b as Record<string, unknown>)[sortKey];
if (numeric) {
const an = Number(av);
const bn = Number(bv);
if (Number.isNaN(an) && Number.isNaN(bn)) return 0;
if (Number.isNaN(an)) return 1;
if (Number.isNaN(bn)) return -1;
return (an - bn) * dir;
}
return collator.compare(String(av ?? ""), String(bv ?? "")) * dir;
});
}, [filtered, sortKey, sortDir, numericKeys]);
// Toute modification des filtres / du tri ramène à la première page.
// Ajustement pendant le rendu : pas d'effet, donc pas de rendu en cascade.
const [pagedSet, setPagedSet] = useState(sorted);
if (pagedSet !== sorted) {
setPagedSet(sorted);
setPage(1);
}
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageRows = useMemo(
() => sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[sorted, currentPage]
);
function handleSort(key: Extract<keyof T, string>) {
if (sortKey === key) setSortDir(d => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
}
const hasFilters = Boolean(filterFournisseur || filterAnnee || search);
async function handleExport() {
if (exporting) return;
setExporting(true);
try {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(excel.sheet);
ws.addRow(excel.header);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
for (const r of sorted) ws.addRow(excel.row(r));
ws.columns = excel.widths.map(width => ({ width }));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
}
return (
<div className="space-y-4">
{exporting && (
<LoadingModal
message="Génération du fichier Excel"
subMessage={`${total.toLocaleString("fr-FR")} ligne${total !== 1 ? "s" : ""} en cours d'écriture…`}
/>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Code article ou libellé…"
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
/>
</div>
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
{anneeOf && (
<FilterSelect label={anneeLabel} value={filterAnnee} onChange={setFilterAnnee} options={annees} />
)}
{hasFilters && (
<button
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
>
✕ Réinitialiser
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">
{total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""}
{hasFilters && total !== rows.length && (
<span className="text-gray-400"> / {rows.length.toLocaleString("fr-FR")}</span>
)}
</span>
<button
onClick={handleExport}
disabled={exporting || total === 0}
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{columns.map(c => (
<th
key={c.key}
onClick={() => handleSort(c.key)}
className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
>
{c.label}
<SortIcon active={sortKey === c.key} dir={sortDir} />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{pageRows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-400">{emptyLabel}</td>
</tr>
)}
{pageRows.map((row, i) => (
<tr key={`${String((row as Record<string, unknown>).codein)}-${String((row as Record<string, unknown>).site)}-${i}`}
className="hover:bg-gray-50 transition-colors">
{columns.map(c => (
<td key={c.key} className="px-3 py-2">
{c.render
? c.render(row)
: String((row as Record<string, unknown>)[c.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={currentPage}
totalPages={totalPages}
total={total}
from={total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
to={Math.min(currentPage * PAGE_SIZE, total)}
onChange={p => setPage(Math.min(Math.max(1, p), totalPages))}
/>
</div>
);
}
const EXCEL_SANS_VENTE_6MOIS: ExcelSpec<PgSansVente6MoisRow> = {
sheet: "Sans vente 6 mois",
filePrefix: "Sans_Vente_6Mois",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
widths: [16, 16, 10, 10, 22],
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
};
// ---------------------------------------------------------------------------
// Cellules partagées
// ---------------------------------------------------------------------------
const cellCode = (v: string) => <span className="font-mono text-xs text-gray-700">{v}</span>;
const cellSite = (v: string) => (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{v}</span>
);
const cellDate = (v: string | null) => <span className="text-gray-600 text-xs">{fmtDate(v)}</span>;
// ---------------------------------------------------------------------------
// Configuration des 3 onglets
// Colonnes et filtres des 3 onglets
//
// Définies au niveau module : leur identité doit rester stable d'un rendu à
// l'autre, sinon les `useMemo` de StockTable se recalculent en boucle.
// Définis au niveau module : leur identité doit rester stable d'un rendu à
// l'autre, sinon les `useMemo` du tableau se recalculent en boucle.
// ---------------------------------------------------------------------------
type TabConfig<T> = Omit<StockTableProps<T>, "rows" | "magasin">;
interface LigneStock {
codein: string;
libelle1: string;
fournisseur: string;
site: string;
}
const NEGATIF_CONFIG: TabConfig<PgStockNegatifRow> = {
fournisseurOf: r => r.fournisseur,
anneeOf: r => getYear(r.derniereentree),
searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article en stock négatif",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stockdispo", label: "Stock", numeric: true, render: r => <span className="font-semibold text-red-600">{r.stockdispo}</span> },
{ key: "dernierevente", label: "Dernière vente", render: r => cellDate(r.dernierevente) },
{ key: "derniereentree", label: "Dernière entrée", render: r => cellDate(r.derniereentree) },
],
excel: {
sheet: "Stock Négatif",
filePrefix: "Stock_Negatif",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
widths: [16, 16, 10, 10, 16, 22],
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
},
};
const SANS_VENTE_CONFIG: TabConfig<PgStockSansVenteRow> = {
fournisseurOf: r => r.fournisseur,
anneeOf: r => getYear(r.derniere_entree),
searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article trouvé",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
],
excel: {
sheet: "Entrées sans vente",
filePrefix: "Entrees_Sans_Vente",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
widths: [16, 16, 10, 10, 22],
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
},
};
const SANS_VENTE_6MOIS_CONFIG: TabConfig<PgSansVente6MoisRow> = {
fournisseurOf: r => r.fournisseur,
searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article trouvé",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
{ key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) },
/** Colonnes communes aux 3 onglets : code, libellé, fournisseur, magasin. */
function colonnesIdentite<T extends LigneStock>(): DataColumn<T>[] {
return [
{
key: "jours_sans_vente",
label: "Jours sans vente",
numeric: true,
render: r => (
<span
className="tabular-nums font-medium"
style={{ color: r.jours_sans_vente && r.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}
>
{r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"}
</span>
),
id: "codein",
header: "Code article",
sortValue: r => r.codein,
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
className: "whitespace-nowrap",
},
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
],
excel: {
sheet: "Sans vente 6 mois",
filePrefix: "Sans_Vente_6Mois",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
widths: [16, 16, 10, 10, 22],
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
{
id: "libelle1",
header: "Libellé",
sortValue: r => r.libelle1,
cell: r => r.libelle1,
className: "min-w-[220px]",
},
{
id: "fournisseur",
header: "Fournisseur",
sortValue: r => r.fournisseur,
cell: r => <span className="text-[var(--text-secondary)]">{r.fournisseur}</span>,
},
{
id: "site",
header: "Magasin",
sortValue: r => nomMagasin(r.site),
cell: r => <StoreBadge code={r.site} />,
},
];
}
/** Quantité en stock : rouge si négative, en clair sinon. */
function celluleStock(v: number) {
return (
<span
className={cn("font-semibold", v < 0 ? "text-[var(--accent-error)]" : "text-[var(--text-primary)]")}
>
{fmtDecimal1(v)}
</span>
);
}
const celluleDate = (v: string | null) => (
<span className="whitespace-nowrap text-[13px] text-[var(--text-secondary)]">{fmtDate(v)}</span>
);
const rechercheStock = (r: LigneStock) => [r.codein, r.libelle1];
function filtreFournisseur<T extends LigneStock>(): DataFilter<T> {
return { id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" };
}
function filtreAnneeEntree<T>(anneeOf: (r: T) => string): DataFilter<T> {
return { id: "annee", label: "Année d'entrée", valueOf: anneeOf, allLabel: "Toutes les années", order: "desc" };
}
const COLONNES_NEGATIF: DataColumn<PgStockNegatifRow>[] = [
...colonnesIdentite<PgStockNegatifRow>(),
{
id: "stockdispo",
header: "Stock",
hint: "Stock calculé dans FF : il est négatif quand des ventes ont été enregistrées sans l'entrée de marchandise correspondante.",
align: "right",
sortValue: r => r.stockdispo,
cell: r => celluleStock(r.stockdispo),
},
};
{ id: "dernierevente", header: "Dernière vente", sortValue: r => r.dernierevente, cell: r => celluleDate(r.dernierevente) },
{ id: "derniereentree", header: "Dernière entrée", sortValue: r => r.derniereentree, cell: r => celluleDate(r.derniereentree) },
];
const FILTRES_NEGATIF: DataFilter<PgStockNegatifRow>[] = [
filtreFournisseur<PgStockNegatifRow>(),
filtreAnneeEntree<PgStockNegatifRow>(r => getYear(r.derniereentree)),
];
const COLONNES_SANS_VENTE: DataColumn<PgStockSansVenteRow>[] = [
...colonnesIdentite<PgStockSansVenteRow>(),
{
id: "stock_actuel",
header: "Stock actuel",
align: "right",
sortValue: r => r.stock_actuel,
cell: r => celluleStock(r.stock_actuel),
},
{ id: "derniere_entree", header: "Dernière entrée", sortValue: r => r.derniere_entree, cell: r => celluleDate(r.derniere_entree) },
];
const FILTRES_SANS_VENTE: DataFilter<PgStockSansVenteRow>[] = [
filtreFournisseur<PgStockSansVenteRow>(),
filtreAnneeEntree<PgStockSansVenteRow>(r => getYear(r.derniere_entree)),
];
const COLONNES_SANS_VENTE_6MOIS: DataColumn<PgSansVente6MoisRow>[] = [
...colonnesIdentite<PgSansVente6MoisRow>(),
{
id: "stock_actuel",
header: "Stock actuel",
align: "right",
sortValue: r => r.stock_actuel,
cell: r => celluleStock(r.stock_actuel),
},
{ id: "derniere_vente", header: "Dernière vente", sortValue: r => r.derniere_vente, cell: r => celluleDate(r.derniere_vente) },
{
id: "jours_sans_vente",
header: "Jours sans vente",
hint: "Nombre de jours depuis la dernière vente. En rouge : plus d'un an.",
align: "right",
sortValue: r => r.jours_sans_vente,
cell: r =>
r.jours_sans_vente != null ? (
<span
className={cn(
"font-medium",
r.jours_sans_vente > 365 ? "text-[var(--accent-error)]" : "text-[var(--text-secondary)]",
)}
>
{fmtEntier(r.jours_sans_vente)}
</span>
) : (
<span className="text-[13px] text-[var(--text-muted)]">Jamais vendu</span>
),
},
{ id: "derniere_entree", header: "Dernière entrée", sortValue: r => r.derniere_entree, cell: r => celluleDate(r.derniere_entree) },
];
const FILTRES_SANS_VENTE_6MOIS: DataFilter<PgSansVente6MoisRow>[] = [filtreFournisseur<PgSansVente6MoisRow>()];
const cleLigne = (r: LigneStock, i: number) => `${r.codein}-${r.site}-${i}`;
// ---------------------------------------------------------------------------
// Composant principal avec onglets
// ---------------------------------------------------------------------------
const TABS = [
{ key: "negatif", label: "Stock négatif" },
{ key: "sans-vente", label: "Entrées sans vente" },
{ key: "sans-vente-6mois", label: "Sans vente 6 mois" },
] as const;
const TAB_KEYS = ["negatif", "sans-vente", "sans-vente-6mois"] as const;
type TabKey = typeof TABS[number]["key"];
type TabKey = typeof TAB_KEYS[number];
function isTabKey(v: string): v is TabKey {
return TABS.some(t => t.key === v);
return (TAB_KEYS as readonly string[]).includes(v);
}
/** Ce que liste chaque onglet, et quoi en faire. */
const EXPLICATIONS: Record<TabKey, ReactNode> = {
"negatif": (
<>
Produits dont le <Terme id="stockNegatif">stock est négatif</Terme> : des ventes ont été enregistrées sans
l&apos;entrée de marchandise correspondante. Vérifiez s&apos;il manque une réception, puis exportez la
liste : le fichier Excel est prêt à être importé dans FF pour régulariser le stock.
</>
),
"sans-vente": (
<>
Produits reçus avant ce mois-ci, encore en stock, mais qui n&apos;ont jamais été vendus dans ce magasin.
Vérifiez qu&apos;ils sont bien en rayon et étiquetés ; si le stock est erroné, exportez la liste : le
fichier Excel est prêt à être importé dans FF pour le régulariser.
</>
),
"sans-vente-6mois": (
<>
Produits en stock qui ne se sont pas vendus depuis plus de 6 mois (ou jamais) et qui n&apos;ont pas été
réapprovisionnés depuis. Mettez-les en avant ou en promotion ; si le stock est erroné, le fichier Excel
exporté est prêt à être importé dans FF pour le régulariser.
</>
),
};
const OPTIONS_MAGASINS = MAGASINS.map(m => ({ value: m.code, label: nomMagasin(m.code) }));
interface Props {
rowsNegatif: PgStockNegatifRow[];
rowsSansVente: PgStockSansVenteRow[];
rowsSansVente6Mois: PgSansVente6MoisRow[];
magasin: string;
tab: string;
sites: { code: string; label: string }[];
}
export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) {
export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab }: Props) {
const router = useRouter();
const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
@@ -531,8 +332,7 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
return `/stock-negatif?${params.toString()}`;
}
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
const val = e.target.value;
function handleMagasinChange(val: string) {
startNavigation(() => {
router.push(buildUrl(activeTab, val));
});
@@ -545,83 +345,87 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
startSwitch(() => setActiveTab(key));
}
const counts: Record<TabKey, number> = useMemo(() => ({
"negatif": rowsNegatif.length,
"sans-vente": rowsSansVente.length,
"sans-vente-6mois": rowsSansVente6Mois.length,
}), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
const tabs: TabItem<TabKey>[] = useMemo(() => [
{ value: "negatif", label: "Stock négatif", count: rowsNegatif.length, alert: true },
{ value: "sans-vente", label: "Entrées sans vente", count: rowsSansVente.length },
{ value: "sans-vente-6mois", label: "Sans vente depuis 6 mois", count: rowsSansVente6Mois.length },
], [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
const busy = switching || navigating;
const showLoader = useDelayedFlag(busy);
const loaderMessage = navigating ? "Chargement des données" : "Préparation de l'onglet";
const loaderSubMessage = navigating
? "Interrogation du stock pour le magasin sélectionné…"
: "Application des filtres et du tri…";
return (
<div className="space-y-4" aria-busy={busy}>
{showLoader && <LoadingModal message={loaderMessage} subMessage={loaderSubMessage} />}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Magasin</label>
<select
value={magasin}
onChange={handleMagasinChange}
disabled={navigating}
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 disabled:cursor-wait disabled:opacity-60"
>
<option value="">Tous les magasins</option>
{sites.map(s => (
<option key={s.code} value={s.code}>{s.label}</option>
))}
</select>
</div>
{/* Choix du magasin */}
<div className="flex flex-wrap items-end gap-3">
<Select
id="stock-magasin"
label="Magasin"
value={magasin}
onChange={handleMagasinChange}
disabled={navigating}
placeholder={LIBELLE_TOUS_MAGASINS}
options={OPTIONS_MAGASINS}
/>
{showLoader && (
<span role="status" className="inline-flex h-9 items-center gap-2 text-[13px] text-[var(--text-secondary)]">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Chargement…
</span>
)}
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex gap-1">
{TABS.map(t => {
const isActive = activeTab === t.key;
return (
<button
key={t.key}
onClick={() => handleTabChange(t.key)}
disabled={busy}
aria-current={isActive ? "page" : undefined}
className={[
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
isActive
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
busy ? "cursor-wait" : "",
].join(" ")}
>
{t.label}
<span className={[
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
isActive ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
].join(" ")}>
{counts[t.key].toLocaleString("fr-FR")}
</span>
</button>
);
})}
</nav>
</div>
<Tabs items={tabs} value={activeTab} onChange={handleTabChange} disabled={busy} />
{/* Tab content */}
{activeTab === "negatif" && (
<StockTable<PgStockNegatifRow> rows={rowsNegatif} magasin={magasin} {...NEGATIF_CONFIG} />
)}
{activeTab === "sans-vente" && (
<StockTable<PgStockSansVenteRow> rows={rowsSansVente} magasin={magasin} {...SANS_VENTE_CONFIG} />
)}
{activeTab === "sans-vente-6mois" && (
<StockTable<PgSansVente6MoisRow> rows={rowsSansVente6Mois} magasin={magasin} {...SANS_VENTE_6MOIS_CONFIG} />
)}
<p className="max-w-4xl text-sm text-[var(--text-secondary)]">{EXPLICATIONS[activeTab]}</p>
<div className={cn("transition-opacity", showLoader && "pointer-events-none opacity-50")}>
{activeTab === "negatif" && (
<DataTable<PgStockNegatifRow>
rows={rowsNegatif}
columns={COLONNES_NEGATIF}
rowKey={cleLigne}
searchIn={rechercheStock}
searchPlaceholder="Code article ou libellé…"
filters={FILTRES_NEGATIF}
pageSize={PAGE_SIZE}
unite="articles"
emptyTitle="Aucun article en stock négatif"
emptyDescription="Aucun stock n'est inférieur à zéro pour ce choix de magasin : rien à régulariser."
onExport={rows => exporterPourFF(EXCEL_NEGATIF, rows, magasin)}
/>
)}
{activeTab === "sans-vente" && (
<DataTable<PgStockSansVenteRow>
rows={rowsSansVente}
columns={COLONNES_SANS_VENTE}
rowKey={cleLigne}
searchIn={rechercheStock}
searchPlaceholder="Code article ou libellé…"
filters={FILTRES_SANS_VENTE}
pageSize={PAGE_SIZE}
unite="articles"
emptyTitle="Aucun article reçu sans vente"
emptyDescription="Tous les produits reçus et encore en stock ont été vendus au moins une fois."
onExport={rows => exporterPourFF(EXCEL_SANS_VENTE, rows, magasin)}
/>
)}
{activeTab === "sans-vente-6mois" && (
<DataTable<PgSansVente6MoisRow>
rows={rowsSansVente6Mois}
columns={COLONNES_SANS_VENTE_6MOIS}
rowKey={cleLigne}
searchIn={rechercheStock}
searchPlaceholder="Code article ou libellé…"
filters={FILTRES_SANS_VENTE_6MOIS}
pageSize={PAGE_SIZE}
unite="articles"
emptyTitle="Aucun article sans vente depuis 6 mois"
emptyDescription="Tous les produits en stock se sont vendus ou ont été réapprovisionnés ces 6 derniers mois."
onExport={rows => exporterPourFF(EXCEL_SANS_VENTE_6MOIS, rows, magasin)}
/>
)}
</div>
</div>
);
}
+52 -18
View File
@@ -1,13 +1,22 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PackageMinus, RotateCcw } from "lucide-react";
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client";
import { GestionStockClient } from "./client";
import { cachedFF } from "@/lib/ff-cache";
import { PageHeader } from "@/components/ui/page-header";
import { ErrorState } from "@/components/ui/states";
import { Button } from "@/components/ui/button";
import { GestionStockClient } from "./client";
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
const SITES = [
{ code: "292", label: "292 — Frouard / Nancy" },
{ code: "579", label: "579 — Houdemont" },
];
export const metadata: Metadata = { title: "Stocks à surveiller" };
interface DonneesStock {
rowsNegatif: PgStockNegatifRow[];
rowsSansVente: PgStockSansVenteRow[];
rowsSansVente6Mois: PgSansVente6MoisRow[];
}
export default async function GestionStockPage(props: {
searchParams: Promise<Record<string, string | string[]>>;
@@ -18,25 +27,50 @@ export default async function GestionStockPage(props: {
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
const site = magasin || undefined;
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
]);
let donnees: DonneesStock | null = null;
let erreur: string | null = null;
try {
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
]);
donnees = { rowsNegatif, rowsSansVente, rowsSansVente6Mois };
} catch (e) {
console.error("[stock-negatif] chargement impossible :", e);
erreur = e instanceof Error ? e.message : String(e);
}
const params = new URLSearchParams();
if (magasin) params.set("magasin", magasin);
params.set("tab", tab);
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-screen-2xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Gestion de Stock</h1>
<div className="mx-auto w-full max-w-screen-2xl">
<PageHeader
icon={PackageMinus}
title="Stocks à surveiller"
description="Les produits dont le stock pose question : stock négatif, reçus mais jamais vendus, ou sans vente depuis 6 mois. Vérifiez-les en magasin, puis régularisez le stock dans FF grâce à l'export Excel."
/>
{donnees ? (
<GestionStockClient
rowsNegatif={rowsNegatif}
rowsSansVente={rowsSansVente}
rowsSansVente6Mois={rowsSansVente6Mois}
rowsNegatif={donnees.rowsNegatif}
rowsSansVente={donnees.rowsSansVente}
rowsSansVente6Mois={donnees.rowsSansVente6Mois}
magasin={magasin}
tab={tab}
sites={SITES}
/>
</div>
) : (
<ErrorState
title="Les stocks n'ont pas pu être chargés"
detail={erreur ?? undefined}
action={
<Button asChild variant="outline">
<Link href={`/stock-negatif?${params.toString()}`}><RotateCcw /> Réessayer</Link>
</Button>
}
/>
)}
</div>
);
}