diff --git a/docs/plan-optimisation-webui.md b/docs/plan-optimisation-webui.md index f68e243..e2a86af 100644 --- a/docs/plan-optimisation-webui.md +++ b/docs/plan-optimisation-webui.md @@ -304,3 +304,32 @@ Règle commune à chaque page : - pages d'erreur (`error.tsx`) et « page introuvable » en français ; - après connexion, arrivée sur l'Accueil, comme pour `/`. - **Écart** : les en-têtes Excel de la page Stocks (CODEIN, GENCODE, QTE, CODMV, avec les codes 503 / 412) **ne seront pas renommés**. C'est vraisemblablement un format d'import de régularisation dans FF. + +## Suivi — Lot 3 réalisé + +Toutes les pages utilisent désormais : +- `PageHeader` avec une phrase d'explication ; +- les couleurs du thème (plus de pages figées en blanc) ; +- des textes d'au moins 12 px ; +- les noms de magasins, `toast` et `confirmer`. + +Vérifié dans un navigateur : sur les pages visitées, en clair et en sombre, aucun texte n'est sous 12 px. + +| Page | Changements principaux | +|---|---| +| Accueil | Cartes « Nos 2 magasins » : CA, tickets et panier moyen, avec l'évolution N-1. Une carte par magasin. Top 10 lisible, avec un lien vers la fiche produit. | +| Révision d'assortiment | **Barre du bas** : seul endroit pour enregistrer et exporter, avec les mêmes droits pour tous. Le menu Export regroupe les deux anciens. Bouton « Annuler » avec confirmation. **Filtres** : sens des gammes affiché (A Cœur…), magasin choisi en un clic, menu « Colonnes » en clair. **Chargement** : bandeau de progression au lieu de la fenêtre floue. **Droits** : lus côté serveur. | +| Recherche produit | Fiche lisible (Secteur / Rayon / Famille), infobulles PCB, PRMP, code-barres, réseau. Plus de mention technique de Qlik. | +| Stocks à surveiller | `DataTable` avec trois onglets et une explication pour chacun. **Export Excel inchangé** (format d'import FF). | +| Meilleures ventes | `DataTable` avec un tri sur toutes les colonnes et une ligne de totaux. Export via exceljs (`xlsx` n'est plus utilisé). | +| Ventes par mois | Dernier mois complet par défaut, mois en clair, bascule Fournisseur / Famille, recherche, tri, export. | +| Publicités | Onglets, statuts lisibles, part du CA calculée en pied de tableau. Le tri ne porte que sur la page affichée, et la page le signale. | +| Commandes fournisseurs | Onglets dans l'URL, confirmation avant suppression, retours de toutes les actions. | +| Historique | « Reprendre dans la Grille », chiffres visibles, états vides et d'erreur. | +| Paramètres | Onglets Connexions / Utilisateurs / Accès API / Journal serveur, un bouton Enregistrer par section. | +| Synchronisation | Vocabulaire clair, unités affichées, résumé en indicateurs. | +| Connexion | Libellés lisibles, fond sobre. | + +Autres changements : +- **Composants retirés** : `SuccessModal`, `ConfirmModal`, `ChangePasswordModal`, `LoadingModal`, `export-dropdown`, `financial-cell` et `supplier-selection`, qui n'étaient plus utilisés. +- **Correction** : le nom de l'utilisateur est maintenant transmis à la session, et l'en-tête l'affiche. diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 5eb7011..f31bd72 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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 ( -
- {/* Éléments de design en arrière-plan pour l'effet "Apple Premium" */} -
-
-
-
- -
- -
+
+
); } diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx index 929e1e1..0f6c649 100644 --- a/src/app/(dashboard)/admin/synchronisation/client.tsx +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -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 —; - const couleurs: Record = { - succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500", - }; - const libelles: Record = { succes: "ok", echec: "échec", vide: "vide" }; - return {libelles[statut] ?? statut}; +/** 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 = { + 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 ? {s.libelle} : {statut}; +} + +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(null); const [chargement, setChargement] = useState(true); const [recherche, setRecherche] = useState(""); - const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous"); + const [filtre, setFiltre] = useState("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 | null>(null); const majSettings = async (patch: Partial) => { - 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 ( -
-
-

- - Synchronisation nocturne -

-

- 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'est arrêté ; quand tout le - monde est passé, un nouveau tour recommence. -

+ 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 && ( -
- - {erreur} -
- )} + const filtres: readonly TabItem[] = 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 && ( -
-
- - -
-
- - 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)" }} /> -
- h à -
- - 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)" }} /> -
- h -
- -
- - 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)" }} /> -
-
- - 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)" }} /> -
- -
- - {state?.enCours ? ( - - ) : ( - - )} -
- -

- {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'elle - monopolise la fenêtre. Un fournisseur déjà démarré n'est jamais interrompu à l'heure de fin. -

-
- )} - - {/* ── Round en cours ── */} - {state && (state.enCours || state.finAt) && ( -
-
- {state.enCours - ? - : } - - {state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"} - - {state.enCours && state.fournisseurCourant && ( - - — {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant} - - )} -
-
- SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`} - Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`} - {state.debutAt && Début {fmtDate(state.debutAt)}} - {state.tourTermine && Tour bouclé — tout le monde est à jour} -
- {state.derniereErreur && ( -

Dernière erreur : {state.derniereErreur}

- )} -
- )} - - {/* ── Résumé ── */} - {resume && ( -
- {[ - { 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) => ( - - {s.l} {s.v} - - ))} -
- )} - - {/* ── Filtres et actions de masse ── */} -
-
- - 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)" }} /> -
- {(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => ( - - ))} -
- {visibles.length} affichés : - - - - + const colonnes: DataColumn[] = [ + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.nomFournisseur ?? r.codeFournisseur, + cell: (r) => ( +
+
{r.nomFournisseur ?? r.codeFournisseur}
+
{r.codeFournisseur}
- - {/* ── Tableau ── */} -
- {chargement ? ( -
- Chargement… -
- ) : visibles.length === 0 ? ( -
- Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel. -
- ) : ( -
- - - - {["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => ( - - ))} - - - - {visibles.map((r) => ( - - - - - - - - - ))} - -
{h}
-
- {r.nomFournisseur ?? r.codeFournisseur} -
-
{r.codeFournisseur}
-
- basculer([r.codeFournisseur], "actifSql", e.target.checked)} /> - - {" "} - {fmtDate(r.dernierSqlAt)} - {r.dernierSqlLignes != null && · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}} - - basculer([r.codeFournisseur], "actifQlik", e.target.checked)} /> - - {" "} - {fmtDate(r.dernierQlikAt)} - {r.dernierQlikCodes != null && · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes} - - {r.desactiveMotif} -
+ ), + }, + { + 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) => ( + basculer([r.codeFournisseur], "actifSql", e.target.checked)} + /> + ), + }, + { + id: "dernierSql", + header: "Dernière mise à jour des ventes", + sortValue: (r) => r.dernierSqlAt, + cell: (r) => ( +
+
+ + {fmtDate(r.dernierSqlAt)} +
+ {r.dernierSqlLignes != null && ( +
+ {fmtEntier(r.dernierSqlLignes)} lignes{r.dernierSqlMs != null && <> en {fmtDuree(r.dernierSqlMs)}}
)}
+ ), + }, + { + 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) => ( + basculer([r.codeFournisseur], "actifQlik", e.target.checked)} + /> + ), + }, + { + id: "dernierQlik", + header: "Dernière mise à jour réseau", + sortValue: (r) => r.dernierQlikAt, + cell: (r) => ( +
+
+ + {fmtDate(r.dernierQlikAt)} +
+ {r.dernierQlikCodes != null && ( +
{fmtEntier(r.dernierQlikCodes)} produits
+ )} +
+ ), + }, + { + id: "remarque", + header: "Remarque", + cell: (r) => (r.desactiveMotif + ? {r.desactiveMotif} + : null), + }, + ]; + + if (chargement) { + return ( +
+ +
+ + + +
+ ); + } + + if (!settings && erreur) { + return ( +
+ + Réessayer} + /> +
+ ); + } + + return ( +
+ 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" ? : } + Arrêter + + ) : ( + + )} + /> + + {erreur && ( +
+ + + Les informations n'ont pas pu être actualisées : {erreur} + + +
+ )} + + {/* ── Réglages ── */} + {settings && ( + + + + + +
+
+ +
+ de + majSettingsDifferee({ heureDebut: Number(e.target.value) })} + className="w-16 text-center tabular-nums" /> + h à + majSettingsDifferee({ heureFin: Number(e.target.value) })} + className="w-16 text-center tabular-nums" /> + h +
+

+ Aucun fournisseur n'est commencé après l'heure de fin ; celui en cours va jusqu'au bout. +

+
+ +
+ + majSettingsDifferee({ qlikParNuit: Number(e.target.value) })} + className="w-20 text-center tabular-nums" /> +

+ Une extraction des données du réseau (Qlik) peut durer 8 minutes : ce plafond évite qu'elles occupent toute la nuit. +

+
+ +
+ +
+ Actualiser les données réseau de plus de + majSettingsDifferee({ qlikMinJours: Number(e.target.value) })} + className="w-20 text-center tabular-nums" /> + jours +
+

+ Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien. +

+
+
+

Les modifications sont enregistrées automatiquement.

+
+
+ )} + + {/* ── Lancement en cours ou dernier lancement ── */} + {state && ( + + +
+ {state.enCours + ? + : state.finAt + ? + : } + + {state.enCours + ? `Lancement${libelleOrigine(state.origine)} en cours` + : state.finAt + ? `Dernier lancement${libelleOrigine(state.origine)} terminé` + : "Aucun lancement depuis le démarrage du serveur"} + + {state.enCours && state.fournisseurCourant && ( + + : {state.etape === "qlik" ? RESEAU : VENTES}, {state.fournisseurCourant} + + )} + {state.tourTermine && ( + Tous les fournisseurs ont été traités + )} +
+ + {(state.enCours || state.finAt) && ( +
+ + + {VENTES} : {fmtEntier(state.sqlFaits)} à jour + {state.sqlEchecs > 0 && , {fmtEntier(state.sqlEchecs)} en échec} + + + + {RESEAU} : {fmtEntier(state.qlikFaits)} à jour + {state.qlikEchecs > 0 && , {fmtEntier(state.qlikEchecs)} en échec} + + {state.debutAt && Commencé le {fmtDate(state.debutAt)}} + {!state.enCours && state.finAt && Terminé le {fmtDate(state.finAt)}} +
+ )} + + {state.derniereErreur && ( +

Dernière erreur : {state.derniereErreur}

+ )} + +

+ {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."} +

+
+
+ )} + + {/* ── Résumé ── */} + {resume && ( +
+ + + + + + Retirés automatiquement + + } + value={fmtEntier(resume.desactivesAuto)} + hint="aucun article dans la base FF" + /> + +
+ )} + + {/* ── Fournisseurs ── */} + + action("seed")} disabled={actionEnCours === "seed"} + title="Ajoute les nouveaux fournisseurs du référentiel FF et met à jour leurs noms"> + {actionEnCours === "seed" ? : } + Actualiser la liste des fournisseurs + + } + /> + +
+ + Pour les {fmtEntier(visibles.length)} fournisseur{visibles.length > 1 ? "s" : ""} de la liste ci-dessous : + + + + +
+ + r.codeFournisseur} + pageSize={0} + unite="fournisseurs" + toolbar={ + <> + + + + } + 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 »."} + /> +
+
); } + +const DESCRIPTION = + "Mise à jour nocturne des données des fournisseurs : leurs ventes dans nos magasins (base FF) et dans le réseau (Qlik)."; diff --git a/src/app/(dashboard)/admin/synchronisation/page.tsx b/src/app/(dashboard)/admin/synchronisation/page.tsx index 7956321..ce8c239 100644 --- a/src/app/(dashboard)/admin/synchronisation/page.tsx +++ b/src/app/(dashboard)/admin/synchronisation/page.tsx @@ -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. diff --git a/src/app/(dashboard)/analytics/client.tsx b/src/app/(dashboard)/analytics/client.tsx index 06f507b..4291bf9 100644 --- a/src/app/(dashboard)/analytics/client.tsx +++ b/src/app/(dashboard)/analytics/client.tsx @@ -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 N/A; +/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */ +const CHAMPS_MAGASIN: Record> = { + "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[] = [ + { 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 {fmtEur0(v)}; +} + +/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */ +function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) { return ( - - {isPositive ? "+" : ""} - {value.toFixed(1)}% + + {groupe} + {children} ); } @@ -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[]>(() => [ + { + id: "label", + header: libelleColonne, + sortValue: (r) => r.label, + cell: (r) => {r.label}, + footer: () => "Total", + className: "min-w-[220px]", + }, + ...GROUPES.flatMap((g): DataColumn[] => { + const total = g.id === "total"; + const bord = "border-l border-[var(--border)]"; + return [ + { + id: `${g.id}-ca`, + header: {libelleMois}, + hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`, + align: "right", + sortValue: (r) => r[g.ca], + cell: (r) => , + footer: (rows) => , + className: cn(bord, total && "font-semibold"), + headerClassName: bord, + }, + { + id: `${g.id}-n1`, + header: ( + + {libelleMoisN1} · + + ), + 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) => , + footer: (rows) => , + className: cn(total && "font-semibold"), + }, + { + id: `${g.id}-evolution`, + header: Évolution, + 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) => , + footer: (rows) => , + }, + ]; + }), + ], [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 ( -
- {/* Controls */} -
-
- - -
- -
- - -
- -
- Comparaison N-1 : {moisN1} +
+ {/* Contrôles (relancent le calcul côté serveur) */} +
+
+ + Afficher par + +
+ 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" - /> - - {fournisseurs.map((f) => ( - +
+

Ajouter un rappel pour ce magasin

+
+
+ + 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" + /> + + {fournisseurs.map((f) => ( + +
+
+ + setSemaines(e.target.value)} + className="w-32" + /> +
+
-
- - 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" - /> -
- - {err && {err}} -
+ {erreur && ( + + )} + ); } -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) { - 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, messages: { succes: string; echec: string }): Promise { + 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 ( -
-
-

{label}

- - {rows.length} cadencé{rows.length !== 1 ? "s" : ""} - +
+
+

{nom}

+ + {pluriel(rows.length, "fournisseur")} suivi{rows.length > 1 ? "s" : ""} + {nbACommander > 0 && ( - - {nbACommander} à commander - + + {nbACommander} à commander + )}
- -
- - - - - - - - - - - - - - - {sorted.length === 0 && ( + {triees.length === 0 ? ( + + ) : ( +
+
CodeFournisseurToutes lesDernière réceptionProchaine commandeTemps restantStatutActions
+ - + + + + + + + - )} - {sorted.map((r) => { - const urgent = r.actif && r.statut === "a_commander"; - return ( - - - - - - - - - - - ); - })} - -
- Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus. - FournisseurRythmeDernière réception + + Prochaine commande + + Temps restantStatutActions
{r.codefou}{r.nomfou} - - { - 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" - /> - sem. - - {fmtDate(r.derniereReception)}{fmtDate(r.echeance)} - {tempsRestant(r.joursRestants)} - - - -
- - -
-
-
+ + + {triees.map((r) => { + const urgent = r.actif && r.statut === "a_commander"; + const texte = r.actif ? "text-[var(--text-primary)]" : "text-[var(--text-muted)]"; + return ( + + +
{r.nomfou}
+
Code {r.codefou}
+ + + + Toutes les + { + 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 + + + + {fmtDate(r.derniereReception)} + + + {fmtDate(r.echeance)} + + + {tempsRestant(r.joursRestants)} + + + + + +
+ + + + + + +
+ + + ); + })} + + +
+ )}
); } @@ -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(); for (const c of cadences) { if (!m.has(c.site)) m.set(c.site, []); @@ -328,19 +472,17 @@ export function CadencierClient({ return (
-

- Activez un fournisseur en alerte de commande sur un magasin et définissez sa fréquence - (toutes les X semaines). L'échéance est calculée à partir de la{" "} - dernière réception du fournisseur sur le magasin. -

- {SITES.map(({ code, label }) => ( - +
+ +

+ Le cadencier vous rappelle quand commander : pour chaque fournisseur suivi, + choisissez un rythme (toutes les X semaines). La prochaine commande est calculée à partir de la{" "} + dernière réception du fournisseur dans le magasin. Le statut passe à « Bientôt » dans les + 7 jours qui précèdent, puis à « À commander » le jour venu. +

+
+ {MAGASINS.map((m) => ( + ))}
); diff --git a/src/app/(dashboard)/commandes-auto/client.tsx b/src/app/(dashboard)/commandes-auto/client.tsx index f433855..6b0491a 100644 --- a/src/app/(dashboard)/commandes-auto/client.tsx +++ b/src/app/(dashboard)/commandes-auto/client.tsx @@ -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 = { + 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 Il manque {fmtEur2(ecart)}; + } + if (etat === "oui") { + return {ecart > 0 ? `Dépassé de ${fmtEur2(ecart)}` : "Atteint"}; + } + return —; +} + +const somme = (rows: readonly PgCommandeAutoRow[], valeur: (r: PgCommandeAutoRow) => number) => + rows.reduce((s, r) => s + (Number(valeur(r)) || 0), 0); + +const COLONNES: DataColumn[] = [ + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.nomfou, + cell: (r) => ( +
+
{r.nomfou || "Fournisseur sans nom"}
+ {r.codefou &&
Code {r.codefou}
} +
+ ), + 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) => {fmtEur2(Number(r.montant_cde))}, + footer: (rows) => fmtEur2(somme(rows, (r) => r.montant_cde)), + }, + { + id: "franco", + header: , + align: "right", + sortValue: (r) => (Number(r.franco) > 0 ? Number(r.franco) : null), + cell: (r) => + Number(r.franco) > 0 ? ( + fmtEur2(Number(r.franco)) + ) : ( + — + ), + }, + { + 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 {libelle}; + }, + }, + { + 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) => , + }, ]; -function fmt(n: number) { - return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; -} +const FILTRES: DataFilter[] = [ + { + 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 ; - return sortDir === "asc" - ? - : ; -} - -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 ( - - Pas de franco - - ); - } - if (atteint === null) { - // Franco connu mais statut non calculé par l'API - return ( - - N/A ({fmt(franco)}) - - ); - } - if (atteint) { - return ( - - Atteint - - ); - } - return ( - - −{fmt(Math.abs(ecart))} manquant - - ); -} - -function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) { - const [sortKey, setSortKey] = useState("nomfou"); - const [sortDir, setSortDir] = useState("asc"); - const [search, setSearch] = useState(""); - - function handleSort(key: SK) { - if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc"); - else { setSortKey(key); setSortDir("asc"); } - } - - const filtered = useMemo(() => { - const q = search.toLowerCase(); - return rows.filter(r => !q || r.nomfou.toLowerCase().includes(q) || r.codefou.toLowerCase().includes(q)); - }, [rows, search]); - - const sorted = useMemo(() => [...filtered].sort((a, b) => { - const av = a[sortKey]; - const bv = b[sortKey]; - if (NUMERIC.includes(sortKey)) { - return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); - } - return sortDir === "asc" - ? String(av ?? "").localeCompare(String(bv ?? ""), "fr") - : String(bv ?? "").localeCompare(String(av ?? ""), "fr"); - }), [filtered, sortKey, sortDir]); - - const nbFrancoAtteint = rows.filter(r => r.franco_atteint === true).length; - const totalMontant = rows.reduce((s, r) => s + Number(r.montant_cde), 0); - - const th = (key: SK, label: string, right = false) => ( - handleSort(key)} - > - {label} - - ); +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 ( -
- {/* En-tête magasin */} -
-
-

{label}

- - {rows.length} fournisseur{rows.length !== 1 ? "s" : ""} - - {nbFrancoAtteint > 0 && ( - - {nbFrancoAtteint} franco atteint - - )} - - Total : {fmt(totalMontant)} - -
- setSearch(e.target.value)} - className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]" - /> +
+
+

{nom}

+ + {fmtEntier(rows.length)} fournisseur{rows.length > 1 ? "s" : ""} + + {nbAtteint > 0 && ( + + {fmtEntier(nbAtteint)} franco{nbAtteint > 1 ? "s" : ""} atteint{nbAtteint > 1 ? "s" : ""} + + )} + + Montant total proposé :{" "} + {fmtEur2(totalMontant)} +
- - {/* Tableau */} -
- - - - {th("codefou", "Code")} - {th("nomfou", "Fournisseur")} - {th("nb_articles", "Articles", true)} - {th("montant_cde", "Montant CDE auto", true)} - {th("franco", "Franco", true)} - - - - - - {sorted.length === 0 && ( - - - - )} - {sorted.map(row => { - const francoAtteint = row.franco_atteint === true; - const hasFranco = row.franco > 0 && row.franco_atteint !== null; - return ( - - - - - - - - - ); - })} - -
- Statut franco -
- Aucun fournisseur en commande automatique -
{row.codefou}{row.nomfou}{row.nb_articles} - {fmt(Number(row.montant_cde))} - - {row.franco > 0 ? fmt(Number(row.franco)) : —} - - -
-
-
+ `${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." + /> + ); } export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) { - const bySite = useMemo(() => { + const parMagasin = useMemo(() => { const map = new Map(); 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 ( -
- Aucune commande automatique trouvée. -
+ ); } return ( -
- {SITES.map(({ code, label }) => { - const siteRows = bySite.get(code) ?? []; - return ( - - ); - })} +
+ {MAGASINS.map((m) => ( + + ))}
); } diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx index 3298dc4..f74f669 100644 --- a/src/app/(dashboard)/commandes-auto/page.tsx +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -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 ( -
-
-

Commandes automatiques

- -
+
+ +
); } diff --git a/src/app/(dashboard)/commandes-auto/tabs.tsx b/src/app/(dashboard)/commandes-auto/tabs.tsx index 4bb8e84..4ff1652 100644 --- a/src/app/(dashboard)/commandes-auto/tabs.tsx +++ b/src/app/(dashboard)/commandes-auto/tabs.tsx @@ -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("propositions"); + const [onglet, setOnglet] = useUrlTab("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) => ( - - ); + const onglets = [ + { value: "propositions", label: "Propositions de commande", icon: ShoppingCart }, + { + value: "cadencier", + label: ( + <> + Rythme de commande + {nbACommander > 0 && (commandes à passer)} + + ), + 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 (
-
- {tabBtn("propositions", "Propositions", ShoppingCart)} - {tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)} -
+ - {tab === "propositions" ? ( + {onglet === "propositions" ? ( ) : ( diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 40cf142..82ea4d4 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,759 +1,205 @@ -import { pgGetDashboardData, type DashboardTopItem, type DashboardSiteStats, type DashboardSiteTop10 } from "@/lib/pg-ff-client"; +import type { Metadata } from "next"; +import Link from "next/link"; import { unstable_cache } from "next/cache"; +import { BarChart3, Euro, House, Receipt, ShoppingBasket, ShoppingCart, Store } from "lucide-react"; +import { pgGetDashboardData, type DashboardSiteStats, type DashboardSiteTop10, type DashboardTopItem } from "@/lib/pg-ff-client"; +import { PageHeader } from "@/components/ui/page-header"; +import { StatCard } from "@/components/ui/stat-card"; +import { Card, CardHeader } from "@/components/ui/card"; +import { Badge, DeltaBadge } from "@/components/ui/badge"; +import { EmptyState, ErrorState } from "@/components/ui/states"; +import { Terme } from "@/components/ui/tooltip"; +import { LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; +import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format"; +import { couleurMarge } from "@/lib/marge"; + +export const metadata: Metadata = { title: "Accueil" }; const getCachedDashboardData = unstable_cache( pgGetDashboardData, ["dashboard-data"], { revalidate: 600 } // 10 minutes ); -import { TrendingUp, TrendingDown, Minus, Store, Euro, Users, ShoppingCart, Award, BarChart3 } from "lucide-react"; // --------------------------------------------------------------------------- -// Helpers +// Calculs // --------------------------------------------------------------------------- -const SITE_NAMES: Record = { - "F01": "Frouard", - "F02": "Houdemont", - "292": "Frouard", - "579": "Houdemont", -}; - -function fmtEur(n: number): string { - const abs = Math.abs(n); - if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} M€`; - return `${n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`; +/** Évolution en % ; `null` quand il n'y a pas de base de comparaison. */ +function evolution(actuel: number, precedent: number): number | null { + if (!precedent) return null; + return ((actuel - precedent) / Math.abs(precedent)) * 100; } -function fmtQte(n: number): string { - return n.toLocaleString("fr-FR", { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +function panierMoyen(ca: number, tickets: number): number | null { + return tickets > 0 ? ca / tickets : null; } -function fmtPct(n: number, signed = true): string { - const s = signed && n > 0 ? "+" : ""; - return `${s}${n.toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`; -} - -function delta(current: number, previous: number): number | null { - if (!previous || previous === 0) return null; - return ((current - previous) / Math.abs(previous)) * 100; -} - -function dateLabel(iso: string): string { - const [y, m, d] = iso.split("-"); - return `${d}/${m}/${y}`; +/** « 2026-10-02 » → « vendredi 2 octobre 2026 ». */ +function dateLongue(iso: string): string { + const d = new Date(`${iso}T12:00:00`); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long", year: "numeric" }); } // --------------------------------------------------------------------------- -// Design tokens (inline color helpers) +// Blocs // --------------------------------------------------------------------------- -const clrPositive = "#22c55e"; -const clrNegative = "#ef4444"; -const clrGold = "#f59e0b"; -const clrSilver = "#94a3b8"; -const clrBronze = "#cd7c2b"; - -const tableAccents = { - ca: { bar: "#3b82f6", header: "rgba(59,130,246,0.12)", border: "rgba(59,130,246,0.3)", label: "#60a5fa" }, - qte: { bar: "#8b5cf6", header: "rgba(139,92,246,0.12)", border: "rgba(139,92,246,0.3)", label: "#a78bfa" }, - marge: { bar: "#10b981", header: "rgba(16,185,129,0.12)", border: "rgba(16,185,129,0.3)", label: "#34d399" }, -}; - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -function DeltaBadge({ pct, large = false }: { pct: number | null; large?: boolean }) { - if (pct === null) return —; - const isPos = pct >= 0; - const Icon = pct > 0.5 ? TrendingUp : pct < -0.5 ? TrendingDown : Minus; - const color = isPos ? clrPositive : clrNegative; - const bg = isPos ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)"; - const borderClr = isPos ? "rgba(34,197,94,0.25)" : "rgba(239,68,68,0.25)"; - return ( - - - {fmtPct(pct)} - - ); -} - -function NetworkKpiCard({ - label, value, valueSub, delta: d, n1Value, n1Label, dateHier, dateN1, icon: Icon, accentColor, -}: { +function Indicateur({ label, valeur, valeurN1, delta }: { label: string; - value: string; - valueSub?: string; - delta?: number | null; - n1Value: string; - n1Label: string; - dateHier: string; - dateN1: string; - icon: React.ComponentType<{ style?: React.CSSProperties }>; - accentColor: string; + valeur: string; + valeurN1: string | null; + delta: number | null; }) { return ( -
- {/* Decorative glow */} -
- -
-
- - {label} - -
- - {value} - - {valueSub && ( - {valueSub} - )} -
-
-
- -
-
- - {/* Delta badge */} - {d !== undefined && ( -
- - - vs même jour N-1 - -
- )} - - {/* Divider */} -
- - {/* N-1 comparison row */} -
-
- - Hier — {dateHier} - - - {value} - -
-
-
- - N-1 — {dateN1} - - - {n1Value} - -
+
+
{label}
+
{valeur}
+
+ + {valeurN1 && N-1 : {valeurN1}}
); } -function StoreCard({ site, dateHier, dateN1 }: { site: DashboardSiteStats; dateHier: string; dateN1: string }) { - const name = SITE_NAMES[site.site] ?? site.site; - const dCa = delta(site.ca_hier, site.ca_n1); - const dTickets = delta(site.tickets_hier, site.tickets_n1); - const isPos = dCa !== null && dCa >= 0; - +function CarteMagasin({ site }: { site: DashboardSiteStats }) { + const panier = panierMoyen(site.ca_hier, site.tickets_hier); + const panierN1 = panierMoyen(site.ca_n1, site.tickets_n1); return ( -
- {/* Top accent bar */} -
- -
- {/* Store header */} -
-
-
- -
-
-
- {name} -
-
- Site {site.site} -
-
-
-
- - {/* Metrics row */} -
- {/* CA */} -
-
- - - Chiffre d'affaires - -
-
-
- {fmtEur(site.ca_hier)} -
- -
-
- N-1 : - - {site.ca_n1 > 0 ? fmtEur(site.ca_n1) : "—"} - -
-
- - {/* Tickets */} -
-
- - - Trafic tickets - -
-
- {fmtQte(site.tickets_hier)} -
-
- - {site.tickets_n1 > 0 && ( - - N-1 : {fmtQte(site.tickets_n1)} - - )} -
-
-
- - {/* Date footer */} -
- - Hier : {dateLabel(dateHier)} - - - N-1 : {dateLabel(dateN1)} - -
+ + {nomMagasin(site.site)}} + /> +
+ 0 ? fmtEur0(site.ca_n1) : null} + delta={evolution(site.ca_hier, site.ca_n1)} + /> + 0 ? fmtEntier(site.tickets_n1) : null} + delta={evolution(site.tickets_hier, site.tickets_n1)} + /> +
-
+ ); } -function RankBadge({ rank }: { rank: number }) { - if (rank === 0) return ( - 1 - ); - if (rank === 1) return ( - 2 - ); - if (rank === 2) return ( - 3 - ); - return ( - {rank + 1} - ); +type Critere = "ca" | "qte" | "marge"; + +const CRITERES: Record = { + ca: { titre: "Meilleur chiffre d'affaires", icone: Euro }, + qte: { titre: "Plus grandes quantités", icone: ShoppingCart }, + marge: { titre: "Meilleure marge", icone: BarChart3 }, +}; + +function valeurPrincipale(item: DashboardTopItem, critere: Critere): string { + if (critere === "qte") return `${fmtEntier(item.qte)} vendus`; + if (critere === "marge") return fmtEur0(item.marge); + return fmtEur0(item.ca); } -function Top10Table({ title, items, sortKey, accent }: { - title: string; - items: DashboardTopItem[]; - sortKey: "ca" | "qte" | "marge"; - accent: typeof tableAccents.ca; -}) { +function ListeTop10({ items, critere }: { items: DashboardTopItem[]; critere: Critere }) { + const { titre, icone: Icone } = CRITERES[critere]; return ( -
- {/* ── Header strip ── */} -
-
- {sortKey === "ca" && } - {sortKey === "qte" && } - {sortKey === "marge" && } -
- - {title} - - {/* column labels */} -
- {sortKey === "ca" && ( - <> - QTÉ - CA - - )} - {sortKey === "qte" && ( - QTÉ - )} - {sortKey === "marge" && ( - MARGE - )} -
+ +
+ +

{titre}

- - {/* ── Rows ── */} {items.length === 0 ? ( -
- Aucune donnée -
+

Aucune vente enregistrée.

) : ( - items.map((item, i) => { - const txMarge = item.ca > 0 ? (item.marge / item.ca) * 100 : 0; - const margeColor = txMarge > 25 ? "#22c55e" : txMarge > 15 ? "#f59e0b" : "var(--text-muted)"; - const margeBg = txMarge > 25 ? "rgba(34,197,94,0.1)" : txMarge > 15 ? "rgba(245,158,11,0.1)" : "rgba(100,116,139,0.1)"; - const margeBorder = txMarge > 25 ? "rgba(34,197,94,0.25)" : txMarge > 15 ? "rgba(245,158,11,0.25)" : "rgba(100,116,139,0.18)"; - const isEven = i % 2 === 0; - const isTopThree = i < 3; - - return ( -
- {/* ── Primary row: rank + name + main value ── */} -
- - +
    + {items.map((item, i) => { + const taux = item.ca > 0 ? (item.marge / item.ca) * 100 : null; + return ( +
  1. - {item.libelle1 ?? item.codein} + {i + 1} - - {/* Main sort-key value */} - - {sortKey === "qte" - ? `${fmtQte(item.qte)} pcs` - : sortKey === "marge" - ? fmtEur(item.marge) - : fmtEur(item.ca)} - -
- - {/* ── Tertiary row: fournisseur + stock ── */} -
- - {item.fournisseur} - - - Stk {fmtQte(item.stock)} - -
- - {/* ── Secondary info row ── */} -
- {/* For CA table: show qte + marge */} - {sortKey === "ca" && ( - <> - - {fmtQte(item.qte)} pcs +
+
+ + {item.libelle1 ?? item.codein} + + + {valeurPrincipale(item, critere)} - │ - - mg {fmtEur(item.marge)} +
+
+ {item.fournisseur} + {critere !== "ca" && CA {fmtEur0(item.ca)}} + {critere !== "qte" && {fmtEntier(item.qte)} vendus} + + Stock {fmtEntier(item.stock)} - - )} - - {/* For QTE table: show CA + marge */} - {sortKey === "qte" && ( - <> - - CA {fmtEur(item.ca)} - - │ - - mg {fmtEur(item.marge)} - - - )} - - {/* For Marge table: show CA + qte */} - {sortKey === "marge" && ( - <> - - CA {fmtEur(item.ca)} - - │ - - {fmtQte(item.qte)} pcs - - - )} - - {/* Taux de marge pill — always shown */} - - {fmtPct(txMarge, false)} - -
-
- ); - }) + {taux != null && ( + + Marge {fmtDecimal1(taux)} % + + )} +
+
+ + ); + })} + )} -
+ ); } -function SiteHitParade({ siteData, dateHier }: { siteData: DashboardSiteTop10; dateHier: string }) { - const name = SITE_NAMES[siteData.site] ?? siteData.site; - +function Top10Magasin({ siteData }: { siteData: DashboardSiteTop10 }) { return ( -
- {/* ── Section header ── */} -
- {/* subtle glow behind icon */} -
- -
- -
- -
-
-

- Hit Parade -

- - {name} - -
-

- Données du {dateLabel(dateHier)} · Site {siteData.site} -  · Top 10 produits par CA, Quantité et Marge -

-
- - {/* Store badge */} -
- - {name} -
+
+

+ + {nomMagasin(siteData.site)} +

+
+ + +
- - {/* ── Three tables side by side ── */} -
- - - -
-
+ ); } -// --------------------------------------------------------------------------- -// Section divider -// --------------------------------------------------------------------------- - -function SectionHeader({ label, sub }: { label: string; sub?: string }) { +function TitreSection({ titre, description }: { titre: string; description?: React.ReactNode }) { return ( -
-
-

- {label} -

- {sub &&

{sub}

} -
+
+

{titre}

+ {description &&

{description}

}
); } // --------------------------------------------------------------------------- -// Page principale +// Page // --------------------------------------------------------------------------- export default async function DashboardPage() { @@ -761,32 +207,15 @@ export default async function DashboardPage() { try { data = await getCachedDashboardData(); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); console.error("[Dashboard] pgGetDashboardData failed:", e); return ( -
-
- -
-

- Impossible de charger le tableau de bord -

-

- {msg.slice(0, 300)} -

+
+ +
); } @@ -797,131 +226,78 @@ export default async function DashboardPage() { const totalCaN1 = sites.reduce((s, r) => s + r.ca_n1, 0); const totalTickets = sites.reduce((s, r) => s + r.tickets_hier, 0); const totalTicketsN1 = sites.reduce((s, r) => s + r.tickets_n1, 0); - - const dCaReseau = delta(totalCa, totalCaN1); - const dTicketsReseau = delta(totalTickets, totalTicketsN1); - - const dHier = dateLabel(dateHier); - const dN1 = dateN1 ? dateLabel(dateN1) : "—"; + const panier = panierMoyen(totalCa, totalTickets); + const panierN1 = panierMoyen(totalCaN1, totalTicketsN1); return ( -
+
+ + Les ventes de {dateLongue(dateHier)} + {dateN1 && <>, comparées au même jour de la semaine l'an dernier ({dateLongue(dateN1)})}. + + } + actions={Données mises à jour chaque nuit} + /> - {/* ── PAGE HEADER ─────────────────────────────────────────────── */} -
- {/* subtle background pattern */} -
+ {sites.length === 0 ? ( + + ) : ( +
+
+ Total de nos magasins, évolution par rapport à .} + /> +
+ 0 ? `N-1 : ${fmtEur0(totalCaN1)}` : undefined} + icon={Euro} + /> + 0 ? `N-1 : ${fmtEntier(totalTicketsN1)}` : undefined} + icon={Receipt} + /> + +
+
-
-

- Tableau de Bord -

-

- Données du{" "} - {dHier} - {" · "} - Comparaison vs même jour de semaine N-1{" "} - ({dN1}) -

-
+
+ +
+ {sites.map((s) => )} +
+
-
- - - {sites.length} magasin{sites.length > 1 ? "s" : ""} actif{sites.length > 1 ? "s" : ""} - -
-
- - {/* ── KPI RÉSEAU ──────────────────────────────────────────────── */} -
- - -
- - -
-
- - {/* ── PAR MAGASIN ─────────────────────────────────────────────── */} - {sites.length > 0 && ( -
- SITE_NAMES[s.site] ?? s.site).join(" · ")}`} /> - -
- {sites.map(s => ( - - ))} -
-
- )} - - {/* ── HIT PARADE ──────────────────────────────────────────────── */} - {top10BySite.length > 0 && ( -
- - -
- {top10BySite.map((s, idx) => ( -
- {idx > 0 && ( -
- )} - + {top10BySite.length > 0 && ( +
+ +
+ {top10BySite.map((s) => )}
- ))} -
-
- )} - - {/* ── EMPTY STATE ─────────────────────────────────────────────── */} - {sites.length === 0 && ( -
- Aucune donnée disponible pour hier. + + )}
)}
diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index 32be563..b8d30a3 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -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} /> ); } diff --git a/src/app/(dashboard)/hit-parade/client.tsx b/src/app/(dashboard)/hit-parade/client.tsx index de939fe..d1fa6eb 100644 --- a/src/app/(dashboard)/hit-parade/client.tsx +++ b/src/app/(dashboard)/hit-parade/client.tsx @@ -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> = { + "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 = —; + +function CelluleMarge({ taux }: { taux: number | null }) { + if (taux === null) return vide; + return {fmtDecimal1(taux)} %; } -function formatPct(ca: number, marge: number) { - if (ca === 0) return "—"; - return ((marge / ca) * 100).toFixed(1) + " %"; + +function CelluleStock({ v }: { v: number }) { + return ( + + {fmtEntier(v)} + + ); } +/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */ +function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) { + return ( + + {groupe} + {children} + + ); +} + +function colonnesGroupe(g: Groupe): DataColumn[] { + const total = g.id === "total"; + const bord = "border-l border-[var(--border)]"; + return [ + { + id: `${g.id}-qte`, + header: Qté, + 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: CA TTC, + 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: % marge, + 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 => , + footer: rows => , + className: "whitespace-nowrap", + }, + { + id: `${g.id}-stock`, + header: Stock, + hint: `Stock actuel (${g.nom}). En rouge : stock négatif`, + align: "right", + sortValue: r => r[g.stock], + cell: r => , + footer: rows => fmtEntier(somme(rows, g.stock)), + }, + ]; +} + +const COLONNES: DataColumn[] = [ + { + id: "codein", + header: "Code", + sortValue: r => r.codein, + cell: r => {r.codein}, + footer: () => "Total", + className: "whitespace-nowrap", + }, + { + id: "libelle", + header: "Désignation", + sortValue: r => r.libelle.trim(), + cell: r => {r.libelle.trim()}, + footer: rows => {fmtEntier(rows.length)} articles, + className: "min-w-[220px]", + }, + { + id: "fournisseur", + header: "Fournisseur", + sortValue: r => r.fournisseur, + cell: r => {r.fournisseur}, + className: "whitespace-nowrap", + }, + { + id: "famille", + header: "Famille", + hint: "Famille de produits (nomenclature FF)", + sortValue: famille, + cell: r => + r.nomenclature_code ? ( + + {r.nomenclature_code} {r.nomenclature} + + ) : vide, + className: "min-w-[160px]", + }, + ...GROUPES.flatMap(colonnesGroupe), +]; + +const FILTRES: DataFilter[] = [ + { 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 ( - 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 && ( - - {isActive ? (sortDir === "desc" ? "↓" : "↑") : ↕} - - )} - - ); -} - export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { const router = useRouter(); - const [sortKey, setSortKey] = useState(DEFAULT_SORT); - const [sortDir, setSortDir] = useState(DEFAULT_DIR); - const [filterFournisseur, setFilterFournisseur] = useState("Tous"); - const [filterNomenclature, setFilterNomenclature] = useState("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(); // 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 ( -
- {/* Barre de contrôles */} -
- {/* Période */} -
-
- - 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" - /> -
-
- - 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" - /> -
- +
+ {/* Période (relance le calcul côté serveur) */} +
+
+ + setLocalDebut(e.target.value)} + />
- -
- - {/* Filtre fournisseur */} -
- - +
+ + setLocalFin(e.target.value)} + />
- -
- - {/* Filtre nomenclature */} -
- - -
- -
- - - {sorted.length} produits - - - {isSorted && ( - + + {periodeInvalide && ( +

+ La date de début doit précéder la date de fin. +

)} - -
- {/* Tableau */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - {sorted.map((row) => ( - - - - - - - - - - - - - - - - - - - ))} - - - - - - - - - - - - - - - - - - -
CodeDésignationFournisseurNom. - Frouard / Nancy — 292 - - Houdemont — 579 - - Total Réseau -
% Marge% Marge% Marge
{row.codein}{row.libelle.trim()}{row.fournisseur} - {row.nomenclature_code - ? {row.nomenclature_code} - : — - } - - {row.qte292 > 0 ? formatQte(row.qte292) : —} - - {row.ca292 > 0 ? formatCA(row.ca292) : —} - - {row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""} - - {row.stock292 > 0 ? formatQte(row.stock292) : —} - - {row.qte579 > 0 ? formatQte(row.qte579) : —} - - {row.ca579 > 0 ? formatCA(row.ca579) : —} - - {row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""} - - {row.stock579 > 0 ? formatQte(row.stock579) : —} - - {row.qteTotal > 0 ? formatQte(row.qteTotal) : —} - - {row.caTotal > 0 ? formatCA(row.caTotal) : —} - - {row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""} - - {row.stockTotal > 0 ? formatQte(row.stockTotal) : 0} -
- TOTAL — {sorted.length} articles - {formatQte(totals.qte292)}{formatCA(totals.ca292)}{formatPct(totals.ca292, totals.marge292)}{formatQte(totals.stock292)}{formatQte(totals.qte579)}{formatCA(totals.ca579)}{formatPct(totals.ca579, totals.marge579)}{formatQte(totals.stock579)}{formatQte(totals.qteTotal)}{formatCA(totals.caTotal)}{formatPct(totals.caTotal, totals.margeTotal)}{formatQte(totals.stockTotal)}
+
+ + 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} + />
); diff --git a/src/app/(dashboard)/hit-parade/page.tsx b/src/app/(dashboard)/hit-parade/page.tsx index 43d9d8b..be6ff04 100644 --- a/src/app/(dashboard)/hit-parade/page.tsx +++ b/src/app/(dashboard)/hit-parade/page.tsx @@ -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 ( -
-
-

Hit Parade

+
+ + {pivotted ? ( -
+ ) : ( + + Réessayer + + } + /> + )}
); } diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index b011ce0..70695fb 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -1,77 +1,114 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck, + ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck, Scale, RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info, + type LucideIcon, } from "lucide-react"; import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts"; -import { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; -import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months"; +import { + computeNetworkTrend, computeStoresSeries, trendLabel, NB_MAGASINS_RESEAU, type NetworkTrend, +} from "@/features/grid/lib/network-trend"; +import { formatMonthLabel, formatDate, ffMonthToQlik } from "@/features/grid/lib/months"; import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau"; import type { ProduitFiche } from "@/features/produits/types"; +import { Card, CardContent, CardHeader, Field } from "@/components/ui/card"; +import { StatCard } from "@/components/ui/stat-card"; +import { Badge, GammeBadge, StoreBadge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Segmented } from "@/components/ui/tabs"; +import { EmptyState } from "@/components/ui/states"; +import { Terme, Tooltip } from "@/components/ui/tooltip"; +import { toast } from "@/components/ui/feedback"; +import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; +import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format"; +import { couleurMarge } from "@/lib/marge"; +import { cn } from "@/lib/utils"; import { OpportunitesFamille } from "./opportunites"; -// ─── Formatteurs partagés ──────────────────────────────────────────────────── - -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); -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); - // ─── Briques de présentation ───────────────────────────────────────────────── -function Card({ title, icon: Icon, children, action }: { - title: string; - icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; - children: React.ReactNode; - action?: React.ReactNode; +/** Bloc de la fiche : carte du thème, titre avec icône, actions à droite. */ +function Bloc({ titre, icone: Icone, description, actions, children }: { + titre: ReactNode; + icone: LucideIcon; + description?: ReactNode; + actions?: ReactNode; + children: ReactNode; }) { return ( -
-
-

- - {title} -

- {action} -
-
{children}
-
+ + + + {titre} + + } + description={description} + actions={actions} + /> + {children} + ); } -function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { +/** Libellé avec une explication libre au survol (comme ``, hors glossaire). */ +function Aide({ texte, children }: { texte: ReactNode; children: ReactNode }) { return ( -
-
- {label} -
-
- {value ?? "—"} -
+ + + {children} + + + + ); +} + +/** Sous-titre d'une partie de bloc. */ +function SousTitre({ titre, description }: { titre: ReactNode; description?: ReactNode }) { + return ( +
+

{titre}

+ {description &&

{description}

}
); } -function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) { - return ( -
-
- {label} -
-
- {value} -
- {hint &&
{hint}
} -
- ); +/** Classes des petits tableaux mensuels (en-tête et première colonne figée). */ +const TH_COL = "whitespace-nowrap px-2 py-2 text-right text-[13px] font-semibold text-[var(--text-secondary)]"; +const TH_COIN = "sticky left-0 z-[1] whitespace-nowrap bg-[var(--bg-elevated)] px-3 py-2 text-left text-[13px] font-semibold text-[var(--text-secondary)]"; +const TH_LIGNE = "sticky left-0 z-[1] whitespace-nowrap bg-[var(--bg-surface)] px-3 py-2 text-left font-medium text-[var(--text-secondary)]"; +const TD = "whitespace-nowrap px-2 py-2 text-right tabular-nums text-[var(--text-primary)]"; + +/** Couleur de la tendance, prise dans le thème (lisible en clair comme en sombre). */ +const COULEUR_TENDANCE: Record = { + up: "var(--accent-success)", + down: "var(--accent-error)", + flat: "var(--text-secondary)", +}; + +/** Couleur du verdict de l'indice de performance, prise dans le thème. */ +const COULEUR_VERDICT: Record = { + "Forte surperformance": "var(--accent-success)", + Surperformance: "var(--accent-success)", + "Dans la moyenne réseau": "var(--text-primary)", + "Sous-performance": "var(--accent-warning)", + "Forte sous-performance": "var(--accent-error)", +}; + +/** Période Qlik `"2025-09_2026-08"` → « de septembre 2025 à août 2026 ». */ +function libellePeriode(periode: string | null | undefined): string { + const m = /^(\d{4})-(\d{2})_(\d{4})-(\d{2})$/.exec(periode ?? ""); + if (!m) return periode ? `période ${periode}` : "12 derniers mois"; + const mois = (annee: string, num: string) => + new Date(Number(annee), Number(num) - 1, 1).toLocaleDateString("fr-FR", { month: "long", year: "numeric" }); + return `de ${mois(m[1], m[2])} à ${mois(m[3], m[4])}`; } // ─── Détail mensuel réseau ─────────────────────────────────────────────────── @@ -89,15 +126,15 @@ const LIGNES_MENSUELLES_RESEAU: Array<{ get: (v: MoisReseau) => number | undefined; fmt: (n: number) => string; }> = [ - { label: "Quantité", get: (v) => v.qte, fmt: (n) => fmtQte(n) }, - { label: "Magasins", get: (v) => v.nbMag, fmt: (n) => fmtQte(n) }, - { label: "Qté / mag.", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: (n) => fmtDec(n) }, - { label: "CA", get: (v) => v.ca, fmt: (n) => Math.round(n).toLocaleString("fr-FR") }, - { label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: (n) => fmtEur2(n) }, - { label: "Marge %", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDec(n)} %` }, + { label: "Quantité vendue", get: (v) => v.qte, fmt: fmtEntier }, + { label: "Magasins vendeurs", get: (v) => v.nbMag, fmt: fmtEntier }, + { label: "Quantité par magasin", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: fmtDecimal1 }, + { label: "Chiffre d'affaires (€)", get: (v) => v.ca, fmt: fmtEntier }, + { label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: fmtEur2 }, + { label: "Taux de marge", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDecimal1(n)} %` }, ]; -// ─── Bouton d'extraction Qlik pour un seul code centrale ───────────────────── +// ─── Mise à jour des données du réseau pour un seul code centrale ─────────── function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { const router = useRouter(); @@ -106,29 +143,52 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { onSuccess: () => router.refresh(), }); + // Un échec n'est notifié que s'il suit un clic ici : la reprise d'un ancien + // job au chargement de la fiche ne doit pas afficher de message d'erreur. + const demandeRef = useRef(false); + useEffect(() => { + if (status === "error" && demandeRef.current) { + demandeRef.current = false; + toast.erreur(`La mise à jour des données du réseau a échoué. ${message}`.trim()); + } else if (status === "success") { + demandeRef.current = false; + } + }, [status, message]); + + const lancer = () => { + demandeRef.current = true; + void start(); + }; + return ( -
- {message && ( - - {message} - - )} - + {status === "running" ? : } + {status === "running" ? "Mise à jour…" : "Mettre à jour les données du réseau"} +
); } @@ -137,7 +197,7 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) { const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche; - const [magasin, setMagasin] = useState("TOTAL"); + const [magasin, setMagasin] = useState(CODE_TOUS_MAGASINS); const sitesDisponibles = useMemo(() => { const sites = new Set([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]); @@ -145,8 +205,9 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba }, [fiche.mensuelParSite, stock]); // Série affichée selon le magasin sélectionné. - const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {}); - const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 }); + const tous = magasin === CODE_TOUS_MAGASINS; + const serie = tous ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {}); + const totauxAffiches = tous ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 }); /** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */ const nbMagByMonth = useMemo(() => { @@ -171,7 +232,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const verdict = indiceVerdict(comparatif.indiceQte); const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0]; - /** Nom de fournisseur à afficher : catalogue Nancy d'abord, sinon champ Qlik. */ + /** Nom de fournisseur à afficher : notre catalogue d'abord, sinon celui du réseau. */ const fournisseurAffiche = fournisseurPrincipal?.nomfou || fournisseurReseau || null; const titre = detail?.libelle1 || libelleReseau || codeCentrale || "Sans libellé"; @@ -180,353 +241,379 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const prixMoyenReseau = reseau && reseau.qteReseau > 0 ? reseau.caReseau / reseau.qteReseau : null; const margeReseau = normalizeMargePct(reseau?.margePctReseau); + const lienGrille = (codefou: string) => `/grid?fournisseur=${encodeURIComponent(codefou)}&magasin=TOTAL`; + return ( -
- {/* En-tête */} +
+ {/* En-tête de la fiche */}
- - Retour aux résultats + + {backQuery ? "Retour aux résultats" : "Retour à la recherche"} -

- {titre} -

-

- {detail && <>{detail.codein}{" · "}} - {codeCentrale && <>centrale {codeCentrale}} - {fournisseurAffiche && <> · {fournisseurAffiche}} +

{titre}

+

+ {detail && ( + Code article {detail.codein} + )} + {codeCentrale && ( + Code centrale {codeCentrale} + )} + {fournisseurAffiche && Fournisseur : {fournisseurAffiche}}

{fournisseurPrincipal?.codefou && ( - - - Voir dans la Grille - + )}
{!detail && (
- +
- Ce produit est travaillé par le réseau mais n'est pas référencé dans notre catalogue. -
- Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous. + Ce produit est vendu dans le réseau mais n'est pas dans notre catalogue. +
+ Seules les données du réseau sont disponibles : pas de stock, de ventes ni de gamme chez nous.
)} - {/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */} - : undefined} + {/* ─── Réseau — la donnée qui motive la recherche ─────────────── */} + Ventes dans le réseau · 12 derniers mois} + icone={Globe} + actions={codeCentrale ? : undefined} > {!codeCentrale ? ( -

- Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik. +

+ Ce produit n'a pas de code centrale : il n'est pas suivi + par la centrale d'achat, il n'y a donc pas de données du réseau.

) : !reseau ? ( -
- -
- Aucune donnée réseau en cache pour ce produit. -
- Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes. -
-
-
+ ) : ( <> -
- + } + value={fmtEntier(reseau.nbMagasinsReseau)} + hint={`sur ${NB_MAGASINS_RESEAU}, soit ${fmtEntier((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100)} % du réseau`} /> - - + - - - - = 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined} + + } value={fmtEur0(reseau.caParMagasinReseau)} /> + Taux de marge} + value={ + margeReseau != null + ? {fmtDecimal1(margeReseau)} % + : "—" + } /> - = 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"} + + Tendance + + } + value={ + trend.pct != null + ? ( + + {trend.pct >= 0 ? "+" : ""}{fmtEntier(trend.pct * 100)} % + + ) + : "—" + } hint={trendLabel(trend.pct, trend.nouveau)} - color={TREND_COLOR[trend.direction]} />
-
- - +
+ {libelleReseau || "—"} + {fournisseurReseau || "—"}
-

- Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} · - {" "}mois en cours exclu (incomplet). +

+ Ventes {libellePeriode(reseau.periode)} · données du réseau mises à jour le {formatDate(reseau.fetchedAt)} · + {" "}le mois en cours n'est pas compté (incomplet).

{trend.hasData && ( -
-
- Tendance réseau — la qté/magasin d'abord, les volumes en contexte -
+
+ -
+ )} {/* Détail mensuel complet (CA, marge, magasins) si disponible */} {reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && ( -
-
- Détail mensuel réseau -
- - - - - {months.map((m) => ( - - ))} - - - - {LIGNES_MENSUELLES_RESEAU.map((row) => ( - - - {months.map((m) => { - const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)]; - const v = cell ? row.get(cell) : undefined; - return ( - - ); - })} +
+ +
+
Mesure - {formatMonthLabel(m)} -
- {row.label} - - {v != null ? row.fmt(v) : "—"} -
+ + + + {months.map((m) => ( + + ))} - ))} - -
Mesure{formatMonthLabel(m)}
-

- Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité. + + + {LIGNES_MENSUELLES_RESEAU.map((row) => ( + + {row.label} + {months.map((m) => { + const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)]; + const v = cell ? row.get(cell) : undefined; + return ( + + {v != null ? row.fmt(v) : —} + + ); + })} + + ))} + + +

+

+ Pour certains mois, les données du réseau ne donnent que la quantité : le chiffre + d'affaires et la marge y sont alors absents.{" "} + + Pourquoi ? +

-
+ )} )} -
+ {/* ─── Fournisseur ─────────────────────────────────────────────── */} - + {fournisseurs.length === 0 ? ( -

+

{fournisseurReseau - ? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique {fournisseurReseau}. - : "Aucun fournisseur référencé pour ce produit."} + ? <>Aucun fournisseur dans notre catalogue. Dans les données du réseau, le fournisseur est {fournisseurReseau}. + : "Aucun fournisseur connu pour ce produit."}

) : (
- {fournisseurs.map((f) => ( -
-
-
- {f.nomfou} - {f.principal && · principal} +
    + {fournisseurs.map((f) => ( +
  • +
    +
    + {f.nomfou} + {f.principal && Fournisseur principal} +
    +
    + Code fournisseur {f.codefou} +
    -
    {f.codefou}
    -
-
- Réf. {f.reference || "—"} - PCB {f.pcb != null ? fmtQte(f.pcb) : "—"} - - Grille - -
-
- ))} +
+ Référence : {f.reference || "—"} + + : + {f.pcb != null ? fmtEntier(f.pcb) : "—"} + + + Réviser l'assortiment + +
+ + ))} + {fournisseurReseau && ( -

- Fournisseur côté Qlik : {fournisseurReseau} +

+ Fournisseur indiqué par les données du réseau : {fournisseurReseau}

)}
)} - + {/* ─── Identité catalogue (produits référencés uniquement) ──────── */} {detail && ( - -
- - - - - - - - - - - - - 0 ? fmtQte(commandesEnCours) : "—"} /> + +
+ }>{detail.codein} + }>{detail.code_centrale || "—"} + {fournisseurAffiche ?? "—"} + }>{detail.gtin || "—"} + {detail.reference || "—"} + }>{detail.pcb != null ? fmtEntier(detail.pcb) : "—"} + }> + {detail.libelle1nom ?? "—"} + {detail.libelle2 ?? "—"} + {detail.libelle3 ?? "—"} + {detail.pa != null ? fmtEur2(detail.pa) : "—"} + + Prix de vente minimum (centrale) + + } + > + {detail.pv_central != null ? fmtEur2(detail.pv_central) : "—"} + + Quantité en commande} + > + {commandesEnCours > 0 ? fmtEntier(commandesEnCours) : "—"} +
{gammes.length > 1 && ( -
-
- Historique de gamme -
-
+
+ +
    {gammes.slice(0, 10).map((g, i) => ( - {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`} - {g.gamme_code} - + + ))} -
+
)} - + )} {/* ─── Stock ───────────────────────────────────────────────────── */} {detail && ( - + {stock.length === 0 ? ( -

Aucune ligne de stock pour ce produit.

+

Aucun stock enregistré pour ce produit dans nos magasins.

) : ( -
+
{stock.map((s) => ( -
-
- - {SITE_LABELS[s.site]?.nom ?? s.site} - - - {fmtQte(s.qte)} - +
+
+ +
+
Stock
+
+ {fmtEntier(s.qte)} +
+ {s.qte < 0 && } +
-
-
Dispo : {fmtQte(s.stockdispo)}
-
Valeur : {fmtEur(s.valstock)}
-
PRMP : {s.prmp > 0 ? fmtEur2(s.prmp) : "—"}
-
Dern. vente : {formatDate(s.dernierevente)}
-
Dern. réception : {formatDate(s.dernierereception)}
+
+ {fmtEntier(s.stockdispo)} + {fmtEur0(s.valstock)} + }>{s.prmp > 0 ? fmtEur2(s.prmp) : "—"} + {formatDate(s.dernierevente)} + {formatDate(s.dernierereception)}
))}
)} - + )} {/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */} {detail && ( - - {["TOTAL", ...sitesDisponibles].map((code) => ( - - ))} -
+ ({ value: code, label: nomMagasin(code, { court: true }) })), + ]} + value={magasin} + onChange={setMagasin} + /> } > -
- - - - + + + } value={fmtEur0(totauxAffiches.marge)} /> + = 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined} + value={ + totauxAffiches.ca > 0 + ? {fmtDecimal1(totauxAffiches.tauxMarge)} % + : "—" + } />
-
- - +
+
+ - + {months.map((m) => ( - + ))} - - + + {months.map((m) => ( - - - + + {months.map((m) => ( - ))} - - + + {months.map((m) => ( - ))} @@ -562,100 +645,119 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba serie[m]?.qte ?? 0)} - color="#0ea5e9" /> - + )} {/* ─── Comparatif local vs réseau ──────────────────────────────── */} {detail && comparatif.hasReseau && ( - -
- +
+ + Indice de performance + + } + value={ + comparatif.indiceQte != null + ? {fmtEntier(comparatif.indiceQte)} % + : "—" + } hint={verdict.label} - color={verdict.color} /> - 1 ? "s" : ""} · 12 mois`} /> - - Présence dans le réseau} + value={comparatif.tauxPresence != null ? `${fmtEntier(comparatif.tauxPresence * 100)} %` : "—"} + hint={`${fmtEntier(comparatif.nbMagasinsReseau)} magasins vendeurs sur ${NB_MAGASINS_RESEAU}`} />
-
-
-
- CA par magasin · indicatif -
-
- Nous : {fmtEur(comparatif.caLocalParMagasin)} - Réseau : {comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"} -
-
-
-
- Taux de marge · indicatif -
-
- Nous : {fmtDec(comparatif.tauxMargeLocal)} % - Réseau : {comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"} -
-
+
+ Chiffre d'affaires par magasin} + nous={fmtEur0(comparatif.caLocalParMagasin)} + reseau={comparatif.caReseauParMagasin != null ? fmtEur0(comparatif.caReseauParMagasin) : "—"} + /> + Taux de marge} + nous={{fmtDecimal1(comparatif.tauxMargeLocal)} %} + reseau={ + comparatif.tauxMargeReseau != null + ? {fmtDecimal1(comparatif.tauxMargeReseau)} % + : "—" + } + />
-

- L'indice compare les quantités par magasin, seule base commune fiable. - Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas - documentée — montants et marges sont donc donnés à titre indicatif. +

+ L'indice compare les quantités vendues par magasin, seule base commune fiable. + Notre chiffre d'affaires est TTC (caisses) ; la base de celui du réseau n'est pas connue : + montants et marges sont donc donnés à titre indicatif.

{comparatif.hasMonthly && ( -
-
- Quantité par magasin, mois par mois -
+
+ ffMonthToQlik(m.mois))} series={[ - { name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" }, - { name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" }, + { name: "Nous, par magasin", values: comparatif.monthly.map((m) => m.local), color: "var(--viz-1)" }, + { name: "Réseau, par magasin", values: comparatif.monthly.map((m) => m.reseau), color: "var(--viz-2)" }, ]} /> -
+ )} - + )} {/* ─── Opportunités de la même famille ─────────────────────────── */} {detail?.nom_no_id != null && ( - + - + )} {/* Sparkline discrète en pied de fiche, cohérente avec la Grille */} {trend.hasData && ( -
- Tendance réseau : +
+ Tendance dans le réseau :
)}
); } + +/** Deux valeurs côte à côte, nous et le réseau, à titre indicatif. */ +function Comparaison({ titre, nous, reseau }: { titre: ReactNode; nous: ReactNode; reseau: ReactNode }) { + return ( +
+
+ {titre} · indicatif +
+
+ Nous : {nous} + Réseau : {reseau} +
+
+ ); +} diff --git a/src/app/(dashboard)/produits/opportunites.tsx b/src/app/(dashboard)/produits/opportunites.tsx index f1ef7e3..5594d65 100644 --- a/src/app/(dashboard)/produits/opportunites.tsx +++ b/src/app/(dashboard)/produits/opportunites.tsx @@ -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[] = [ + { + id: "produit", + header: "Produit", + sortValue: (r) => r.libelle1 || r.codein, + cell: (r) => ( + + {r.libelle1 || r.codein} + + ), + }, + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.fournisseur, + cell: (r) => {r.fournisseur}, + }, + { + 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 ( + + {opportunite ? "+" : ""}{fmtDecimal1(r.ecart_par_magasin)} + + ); + }, + }, + { + id: "magasins", + header: "Magasins vendeurs", + hint: GLOSSAIRE.presenceReseau.definition, + align: "right", + sortValue: (r) => r.nb_magasins_reseau, + cell: (r) => {fmtEntier(r.nb_magasins_reseau)}, + }, +]; + +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 ( + + Réessayer + + } + /> + ); + } return ( -
-

- 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 ? +

+

+ 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 ?

- - {error &&

{error}

} +
); } @@ -65,71 +150,31 @@ export function OpportunitesFamille({ if (others.length === 0) { return ( -

- Aucune autre référence de cette famille n'a de données réseau en cache. - Synchronisez d'autres fournisseurs pour enrichir la comparaison. -

+ ); } return ( -
-
-
- Mois - Mois - {formatMonthLabel(m)} - {formatMonthLabel(m)}
- Ventes -
Quantité vendue + ))}
- CA (TTC) -
Chiffre d'affaires TTC (€) - {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} + + {serie[m]?.ca ? fmtEntier(serie[m].ca) : —}
- Stock fin de mois -
Stock en fin de mois - {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} + + {serie[m]?.stockFinMois ? fmtEntier(serie[m].stockFinMois) : —}
- - - - - - - - - - - - {others.map((r) => { - const opportunite = r.ecart_par_magasin > 0; - return ( - - - - - - - - - ); - })} - -
ProduitFournisseurRéseau / mag.Nous / mag.ÉcartMagasins
- - {r.libelle1 || r.codein} - - {r.fournisseur} - {fmtDec(r.qte_reseau_par_magasin)} - - {fmtDec(r.qte_locale_par_magasin)} - - {r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)} - - {fmtQte(r.nb_magasins_reseau)} -
-
-

- É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 de notre catalogue 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. +

+ r.codein} + searchIn={rechercherDans} + searchPlaceholder="Filtrer les produits…" + pageSize={25} + unite={others.length > 1 ? "produits" : "produit"} + maxHeight="60vh" + /> +

+ É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 de notre + catalogue qui ont déjà des données du réseau apparaissent ici : un produit absent de notre + catalogue ne peut pas y figurer.

); diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx index 59594d5..d9b4a75 100644 --- a/src/app/(dashboard)/produits/page.tsx +++ b/src/app/(dashboard)/produits/page.tsx @@ -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 ( -
-
-

- Recherche produit -

-

- Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue — - performance réseau et ventes locales sur 12 mois glissants. -

-
+
+ + Cherchez un produit par son nom ou son code, puis comparez ses ventes à celles des + magasins du réseau. + + } + /> - {/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} - +
+ {/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} + - {ficheDemandee && !fiche && ( -
- Aucun produit trouvé pour{" "} - {codein - ? <>le code article {codein} - : <>le code centrale {codeCentrale}}. - {codeCentrale && !codein && ( -
- Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche. -
- )} -
- )} + {ficheDemandee && !fiche && ( + Aucun produit de notre catalogue ne porte le code article {codein}. + ) : ( + <> + Le code centrale {codeCentrale} n'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 && } + {fiche && } - {!ficheDemandee && } + {!ficheDemandee && } +
); } diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index 4c1e823..0e65558 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -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> { 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 ( +
+ +
+ {children} + {detail && ( +
+ Détail technique +

{detail}

+
+ )} +
+
+ ); +} + /** * Résultats de recherche produit. * @@ -144,36 +176,31 @@ export function ProduitResults({ query }: { query: string }) { if (!query) { return ( -
- -

- Recherchez un produit dans le réseau -

-

- La recherche interroge Qlik Sense (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 »). -

-
+ + Saisissez le nom d'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'il est dans notre catalogue. + + } + /> ); } if (loading) { return (
- -

- {etape ?? "Interrogation de Qlik Sense…"} -

-

- Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. - Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte. + +

{libelleEtape(etape)}

+

+ Recherche des produits puis de leurs ventes sur les 12 derniers mois. + Cela peut prendre plus d'une minute : vous pouvez laisser la page ouverte.

); @@ -181,21 +208,20 @@ export function ProduitResults({ query }: { query: string }) { if (error) { return ( -
-
- -
-

- La recherche a échoué -

-

{error}

- -
-
-
+ + Réessayer + + } + /> ); } @@ -204,196 +230,227 @@ export function ProduitResults({ query }: { query: string }) { const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data; return ( -
-
-

- {source === "qlik" ? ( - - ) : ( - - )} - {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 +

+
+

+ {source === "qlik" + ? + : } + + {fmtEntier(rows.length)}{" "} + 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)"} + · recherche en {fmtDecimal1(dureeMs / 1000)} s +

- +
{source === "db" && ( -
- - - Qlik n'a pas pu être interrogé, la recherche s'est repliée sur le catalogue local - (les chiffres réseau affichés viennent du dernier cache). - {qlikError && {qlikError}} - -
+ + Les données du réseau n'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. + )} {source === "qlik" && qlikError && ( -
- - - {rows.length === 0 - ? qlikError - : `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${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."} + )} {rows.length === 0 ? ( -
- Aucun article du réseau ne correspond à « {query} ». -
+ Aucun produit ne correspond à « {query} »} + description="Vérifiez l'orthographe, essayez un mot plus court ou le code centrale du produit." + /> ) : ( - + <> + +

+ Chiffres des magasins du réseau sur les 12 derniers mois + (mois en cours exclu). Cliquez sur un produit pour ouvrir sa fiche. +

+ )} {locauxHorsReseau.length > 0 && ( -
- - {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) +
+ + {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) -
-
    - {locauxHorsReseau.slice(0, 30).map((l) => ( -
  • - - {l.libelle1 || l.codein} - - - {" · "}{l.fournisseur}{" · "}{l.codein} - -
  • - ))} -
-
+
    + {locauxHorsReseau.slice(0, 30).map((l) => ( +
  • + + {l.libelle1 || l.codein} + + + {" · "}{l.fournisseur}{" · "}code article {l.codein} + +
  • + ))} +
)}
); } +// ─── 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 ( -
- - - - - - - - - - - - - - - - - - {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 ( - - - - - - - - - - - - - - ); - })} - -
LibelléFournisseurCode centraleMagasinsQté réseauQté / mag.Prix moyenCA / mag.Marge %TendanceNous
- - {r.libelle || "—"} - - {r.nomenclature && ( -
{r.nomenclature}
- )} -
- {r.fournisseur || "—"} - - {r.codeCentrale} - - {fmtQte(r.nbMagasinsReseau)} - - {fmtDec(r.tauxPresence * 100, 0)} % - - - {fmtQte(r.qteReseau)} - - {fmtDec(r.qteParMagasinReseau)} - - {r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"} - - {r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"} - - {r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"} - - {trend.hasData ? : —} - - {r.codein ? ( - - stock {fmtQte(r.stockLocal ?? 0)} - - ) : ( - - non référencé - - )} -
-
+ // Tendance calculée une fois par ligne (et non à chaque tri ou rendu). + const lignes = useMemo( + () => rows.map((r) => ({ ...r, trend: computeNetworkTrend(r.qteByMonth, r.nbMagByMonth) })), + [rows], ); -} + + const colonnes = useMemo[]>(() => [ + { + 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 ( + <> + + {r.libelle || "—"} + + {r.nomenclature && ( +
{r.nomenclature}
+ )} + + ); + }, + }, + { + id: "fournisseur", + header: "Fournisseur", + sortValue: (r) => r.fournisseur, + cell: (r) => {r.fournisseur || "—"}, + }, + { + id: "codeCentrale", + header: "Code centrale", + hint: GLOSSAIRE.codeCentrale.definition, + cell: (r) => {r.codeCentrale}, + }, + { + id: "magasins", + header: "Magasins vendeurs", + hint: GLOSSAIRE.presenceReseau.definition, + align: "right", + sortValue: (r) => r.nbMagasinsReseau, + cell: (r) => ( + <> + {fmtEntier(r.nbMagasinsReseau)} + ({fmtEntier(r.tauxPresence * 100)} %) + + ), + }, + { + 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) => {fmtEntier(r.qteReseau)}, + }, + { + 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 ? ( + + {fmtDecimal1(r.margePctReseau)} % + + ) : "—", + }, + { + 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 ? : —), + }, + { + 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 ? ( + + Stock {fmtEntier(r.stockLocal ?? 0)} + + ) : ( + Non référencé + ), + }, + ], [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 ( - - {children} - + r.codeCentrale} + searchIn={rechercherDans} + searchPlaceholder="Filtrer les résultats…" + pageSize={50} + unite={lignes.length > 1 ? "produits" : "produit"} + emptyTitle="Aucun produit" + /> ); } diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx index 7d2aecd..dc55282 100644 --- a/src/app/(dashboard)/produits/search-bar.tsx +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -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 (
-
-
+ +
+
- 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 && ( )}
- +
{tooShort && ( -

+

Saisissez au moins 3 caractères.

)} diff --git a/src/app/(dashboard)/publicites/client.tsx b/src/app/(dashboard)/publicites/client.tsx index 4753017..4d842d4 100644 --- a/src/app/(dashboard)/publicites/client.tsx +++ b/src/app/(dashboard)/publicites/client.tsx @@ -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 ; - return sortDir === "asc" ? : ; -} - -const STATUT_LABELS: Record = { - en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" }, - passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" }, +const STATUTS: Record = { + 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 = { + 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(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 {s.libelle}; + return statut ? {statut} : —; +} + +/** Intitulé d'abord, code de l'opération en petit dessous. */ +function Operation({ intitule, code }: { intitule: string; code: string }) { return ( - - {cfg.label} +
+
{intitule || "Opération sans intitulé"}
+ {code &&
Code {code}
} +
+ ); +} + +/** Part du CA total : mise en avant à partir de 10 %, puis de 20 %. */ +function PartCa({ pct }: { pct: number }) { + return ( + = 20 ? "font-semibold text-[var(--accent)]" : pct >= 10 ? "font-semibold" : "text-[var(--text-secondary)]", + )} + > + {fmtDecimal1(pct)} % ); } +/** 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 —; + } + return ( + + {fmtDecimal1((caPub / caTotal) * 100)} % + + ); +} + 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", vueInitiale); - const [filterSite, setFilterSite] = useState("all"); - const [sortKey, setSortKey] = useState("intitule"); - const [sortDir, setSortDir] = useState("asc"); - - function nav(params: Record) { - const sp = new URLSearchParams({ - vue, statut, page: String(page), ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), - }); - router.push(`/publicites?${sp}`); + 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 = ( + + ); - 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 = 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[]>( + () => + 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[]>( + () => [ + { + id: "operation", + header: "Opération", + sortValue: (p) => p.intitule, + cell: (p) => , + 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) => , + }, + { + id: "site", + header: "Magasin", + sortValue: (p) => (p.site ? nomMagasin(p.site) : null), + cell: (p) => (p.site ? : —), + }, + { + 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: CA pub période, + align: "right", + sortValue: (p) => nombre(p.ca_pub_periode_pub), + cell: (p) => {fmtEur0(nombre(p.ca_pub_periode_pub))}, + footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_pub_periode_pub)), + }, + { + id: "partCa", + header: , + align: "right", + sortValue: (p) => nombre(p.pourc_capub_catotal), + cell: (p) => , + footer: (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: , + 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[]>( + () => [{ 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[]>( + () => [ + { + id: "operation", + header: "Opération", + sortValue: (h) => h.intitule, + cell: (h) => , + footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`, + }, + { + id: "statut", + header: "Statut", + sortValue: (h) => libelleStatut(h.statut), + cell: (h) => , + }, + { + 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) => {fmtEur0(nombre(h.ca_total))}, + 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 = ( + 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]" - > - - - - - -
- - {sites.length > 1 && ( -
- - -
- )} - -
- - - {total} publicité{total !== 1 ? "s" : ""} au total - - - )} - - {isHistorique && ( - - {historique.length} opération{historique.length !== 1 ? "s" : ""} - - )} -
- - {/* ── Vue Publicités ── */} - {!isHistorique && ( - <> -
- {filtered.length === 0 ? ( -
Aucune publicité trouvée.
- ) : ( - - - - - - - - - - - - - - - - - - - {filtered.map((pub, idx) => ( - - - - - - - - - - - - - - - ))} - - - - - - - - - -
N° - Opération - - Statut - - Magasin - Date débutDate fin - CA pub période - - % CA total - - Qté vendue - Clients - Taux sortie - Nb articles
{pub.tcr_code}{pub.intitule} - - {pub.site || "—"} - - {fmtDate(pub.date_debut)}{fmtDate(pub.date_fin)}{fmtCA(pub.ca_pub_periode_pub)} - = 20 ? "bg-orange-100 text-orange-700" - : Number(pub.pourc_capub_catotal) >= 10 ? "bg-amber-100 text-amber-700" - : "bg-gray-100 text-gray-600" - }`}> - {fmtPct(pub.pourc_capub_catotal)} - - {Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}{Number(pub.client_pub_periode).toLocaleString("fr-FR")}{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %{pub.nb_articles}
- TOTAL — {filtered.length} publicité{filtered.length !== 1 ? "s" : ""} (page {page}/{pages}) - - {fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))} - — - {filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")} - -
- )} -
- - {/* ── Pagination ── */} - {pages > 1 && ( -
- - Page {page} sur {pages} — {total} publicités - -
- - {/* Pages around current */} - {Array.from({ length: Math.min(pages, 7) }, (_, i) => { - const p = pages <= 7 ? i + 1 : Math.max(1, page - 3) + i; - if (p > pages) return null; - return ( - - ); - })} - -
-
- )} - - )} - - {/* ── Vue Historique ── */} - {isHistorique && ( -
- {historique.length === 0 ? ( -
Aucun historique disponible.
+
+ {vue === "publicites" ? ( + erreurPublicites ? ( + ) : ( - - - - - - - - - - - - - - - {historique.map((h, idx) => ( - - - - - - - - - - - ))} - - - - - - - - -
N°OpérationStatutDate débutDate finSitesCA totalQté totale
{h.tcr_code}{h.intitule}{fmtDate(h.date_debut)}{fmtDate(h.date_fin)}{h.nb_sites}{fmtCA(h.ca_total)}{Number(h.qte_totale).toLocaleString("fr-FR")}
- TOTAL — {historique.length} opérations - - {fmtCA(historique.reduce((s, h) => s + Number(h.ca_total), 0))} - - {historique.reduce((s, h) => s + Number(h.qte_totale), 0).toLocaleString("fr-FR")} -
- )} -
- )} +
+ {paginee && ( +

+ + + + Tri et filtre magasin appliqués à cette page. + {" "} + 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. + +

+ )} + `${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} + /> + naviguer({ page: p })} + unite="publicités" + /> +
+ ) + ) : erreurHistorique ? ( + + ) : ( + `${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." + /> + )} +
); } diff --git a/src/app/(dashboard)/publicites/page.tsx b/src/app/(dashboard)/publicites/page.tsx index 818fb37..259ea49 100644 --- a/src/app/(dashboard)/publicites/page.tsx +++ b/src/app/(dashboard)/publicites/page.tsx @@ -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 ( -
-
-

Publicités

- {error && ( -
- {error} -
- )} - -
+
+ +
); } diff --git a/src/app/(dashboard)/settings/client.tsx b/src/app/(dashboard)/settings/client.tsx new file mode 100644 index 0000000..dca2313 --- /dev/null +++ b/src/app/(dashboard)/settings/client.tsx @@ -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 | null = null; +function loadSavedConfig(force = false): ReturnType { + 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[] = [ + { 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 ( + + ); +} + +// ── Éléments de formulaire ─────────────────────────────────────────────────── + +function Champ({ id, label, aide, children, className }: { + id: string; + label: ReactNode; + aide?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+ + {children} + {aide &&

{aide}

} +
+ ); +} + +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 ( + + Connexion réussie + + ); + } + if (test.etat === "erreur") { + return ( +

+ + + Connexion impossible.{" "} + {test.message} + +

+ ); + } + return null; +} + +// ── PostgreSQL ─────────────────────────────────────────────────────────────── + +function SectionPostgres({ recharger }: { recharger: () => Promise }) { + const { + host, setHost, + port, setPort, + database, setDatabase, + user, setUser, + password, setPassword, + ssl, setSsl, + getDatabaseUrl, + } = useDbSettingsStore(); + + const [test, setTest] = useState({ 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 ( + + + +
+ + setHost(e.target.value)} /> + + + setPort(e.target.value)} /> + + + setDatabase(e.target.value)} /> + + + setUser(e.target.value)} autoComplete="off" /> + + + setPassword(e.target.value)} autoComplete="new-password" /> + +
+ +
+ +

+ À cocher si le serveur de base de données l'exige, en général quand il est hébergé à distance. +

+
+ +
+ + + + +
+
+
+ ); +} + +// ── 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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [url, setUrl] = useState(""); + const [savedUrl, setSavedUrl] = useState(null); + const [saving, setSaving] = useState(false); + const [testedUrl, setTestedUrl] = useState(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 ( + + + + + setUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void save(); }} + className="w-full font-mono" + /> + + +
+ + + +
+ + {/* 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 && ( +

+ Adresse testée : {testedUrl} + {!savedUrl && <> (adresse par défaut, aucun réglage enregistré)} +

+ )} + + {status && ( +
+

+ Dernière mise à jour complète des données :{" "} + {fmtDateHeure(status.lastSync)} +

+ {status.tables?.length > 0 && ( +
+ + + + + + + + + + + {status.tables.map((t) => ( + + + + + {/* Une table en erreur est précisément ce qu'on vient chercher ici. */} + + + ))} + +
Table de donnéesMise à jourLignesÉtat
{t.nom}{fmtDateHeure(t.derniereSync)} + {t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"} + + {t.statut === "ok" + ? OK + : {t.statut ?? "—"}} +
+
+ )} +
+ )} +
+
+ ); +} + +// ── 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({ 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 ( + + + Identifiants utilisés pour récupérer les ventes des magasins du{" "} + réseau dans l'outil de reporting Qlik Sense de La Foir'Fouille. + + } + /> + + + setHost(e.target.value)} className="w-full font-mono" /> + +
+ + setUser(e.target.value)} className="w-full font-mono" autoComplete="off" /> + + +
+ setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" /> + +
+
+
+ +
+ + + +
+
+
+ ); +} + +// ── Page ───────────────────────────────────────────────────────────────────── + +export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) { + const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions"; + const [onglet, setOnglet] = useUrlTab("onglet", initial); + const [visites, setVisites] = useState>(() => 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 ( +
+ + + + + {isMounted && ( + <> + + + + + + + + + + + + + + + + + + + + + )} +
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 73544e4..e0fbfa6 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -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 | null = null; -function loadSavedConfig(force = false): ReturnType { - 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 ( -
-
-

{title}

- {subtitle &&

{subtitle}

} -
-
- {children} -
-
- ); -} - -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ); -} - -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(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [url, setUrl] = useState(""); - const [savedUrl, setSavedUrl] = useState(null); - const [saving, setSaving] = useState(false); - const [testedUrl, setTestedUrl] = useState(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 ( -
- - setUrl(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") void save(); }} - className="apple-input w-full" - /> - - -
- - - {status && Connexion OK} - {error && {error}} -
- - {/* 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 && ( -

- Adresse testée : {testedUrl} - {!savedUrl && <> — valeur par défaut, aucun réglage enregistré} -

- )} - - {status && ( -
-

- Dernière sync globale : {(() => { const d = new Date(status.lastSync); return isNaN(d.getTime()) ? (status.lastSync ?? "—") : d.toLocaleString("fr-FR"); })()} -

- {status.tables?.length > 0 && ( -
- - - - - - - - - - - {status.tables.map((t) => ( - - - - - {/* Une table en erreur est précisément ce qu'on vient chercher ici. */} - - - ))} - -
TableSyncLignesÉtat
{t.nom} - {(() => { const d = new Date(t.derniereSync); return isNaN(d.getTime()) ? (t.derniereSync ?? "—") : d.toLocaleString("fr-FR"); })()} - - {t.nbLignes?.toLocaleString("fr-FR") ?? "—"} - - {t.statut === "ok" - ? ok - : {t.statut ?? "—"}} -
-
- )} -
- )} -
- ); -} - -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 ( -
- - setHost(e.target.value)} className="apple-input font-mono" /> - - - setUser(e.target.value)} className="apple-input font-mono" /> - - -
- setPassword(e.target.value)} className="apple-input font-mono pr-10" /> - -
-
- {status && ( -
- {status.type === "ok" ? : } - {status.msg} -
- )} -
- - -
-
- ); -} - -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(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 ( -
-
-

Paramètres

-

Configuration de la connexion et des services.

-
- - {/* PostgreSQL */} -
- - setHost(e.target.value)} - className="apple-input font-mono" - /> - -
- - setPort(e.target.value)} - className="apple-input font-mono" - /> - - - setDatabase(e.target.value)} - className="apple-input font-mono" - /> - -
-
- - setUser(e.target.value)} - className="apple-input font-mono" - /> - - - setPassword(e.target.value)} - className="apple-input font-mono" - /> - -
-
- setSsl(e.target.checked)} - className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer" - /> - -
-
- - {dbStatus === "error" && ( -
- Connexion refusée - {dbError && {dbError}} -
- )} - {dbStatus === "ok" && ( - Connexion réussie ! - )} -
-
- -
-
- - {/* API FF Nancy */} - - - - - {/* Apparence */} -
-
-
-

Thème

-

Interface claire ou sombre

-
- {/* Segment control */} -
- - -
-
- -
-
-

Densité de la grille

-

Hauteur de ligne par défaut

-
- -
-
- - {/* Journaux serveur — diagnostic des extractions Qlik */} -
- -
- - {/* Gestion des Utilisateurs */} -
- -
- - {/* API publique /api/v1 — connexion puis gestion des clés */} -
- -
- -
- -
- -
- -
- - {/* Save Button — Floating/Sticky style at bottom */} -
- -
-
- ); +export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) { + const { onglet } = await props.searchParams; + return ; } diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index e8a5082..995d440 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -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 { - key: Extract; - label: string; - /** Tri numérique au lieu du tri alphabétique. */ - numeric?: boolean; - render?: (row: T) => ReactNode; -} - interface ExcelSpec { sheet: string; filePrefix: string; @@ -82,432 +72,243 @@ interface ExcelSpec { row: (r: T) => (string | number)[]; } -interface StockTableProps { - rows: T[]; - magasin: string; - columns: Column[]; - 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; +async function exporterPourFF(excel: ExcelSpec, 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 ; - return dir === "asc" - ? - : ; -} +const EXCEL_NEGATIF: ExcelSpec = { + 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 ( -
- - -
- ); -} +const EXCEL_SANS_VENTE: ExcelSpec = { + 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 ( -
- - Lignes {from.toLocaleString("fr-FR")} - {" à "} - {to.toLocaleString("fr-FR")} - {" sur "} - {total.toLocaleString("fr-FR")} - -
- - - - Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")} - - - -
-
- ); -} - -function StockTable({ - rows, - magasin, - columns, - fournisseurOf, - anneeOf, - anneeLabel = "Année entrée", - searchIn, - emptyLabel, - excel, -}: StockTableProps) { - 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 | null>(null); - const [sortDir, setSortDir] = useState("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)[sortKey]; - const bv = (b as Record)[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) { - 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 ( -
- {exporting && ( - - )} - -
-
-
- - 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]" - /> -
- - {anneeOf && ( - - )} - {hasFilters && ( - - )} -
-
- - {total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""} - {hasFilters && total !== rows.length && ( - / {rows.length.toLocaleString("fr-FR")} - )} - - -
-
- -
- - - - {columns.map(c => ( - - ))} - - - - {pageRows.length === 0 && ( - - - - )} - {pageRows.map((row, i) => ( - ).codein)}-${String((row as Record).site)}-${i}`} - className="hover:bg-gray-50 transition-colors"> - {columns.map(c => ( - - ))} - - ))} - -
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} - -
{emptyLabel}
- {c.render - ? c.render(row) - : String((row as Record)[c.key] ?? "")} -
-
- - setPage(Math.min(Math.max(1, p), totalPages))} - /> -
- ); -} +const EXCEL_SANS_VENTE_6MOIS: ExcelSpec = { + 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) => {v}; -const cellSite = (v: string) => ( - {v} -); -const cellDate = (v: string | null) => {fmtDate(v)}; - -// --------------------------------------------------------------------------- -// 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 = Omit, "rows" | "magasin">; +interface LigneStock { + codein: string; + libelle1: string; + fournisseur: string; + site: string; +} -const NEGATIF_CONFIG: TabConfig = { - 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 => {r.libelle1} }, - { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, - { key: "site", label: "Magasin", render: r => cellSite(r.site) }, - { key: "stockdispo", label: "Stock", numeric: true, render: r => {r.stockdispo} }, - { 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 = { - 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 => {r.libelle1} }, - { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, - { key: "site", label: "Magasin", render: r => cellSite(r.site) }, - { key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => {r.stock_actuel} }, - { 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 = { - 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 => {r.libelle1} }, - { key: "fournisseur", label: "Fournisseur", render: r => {r.fournisseur} }, - { key: "site", label: "Magasin", render: r => cellSite(r.site) }, - { key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => {r.stock_actuel} }, - { key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) }, +/** Colonnes communes aux 3 onglets : code, libellé, fournisseur, magasin. */ +function colonnesIdentite(): DataColumn[] { + return [ { - key: "jours_sans_vente", - label: "Jours sans vente", - numeric: true, - render: r => ( - 365 ? "#ef4444" : "#6b7280" }} - > - {r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"} - - ), + id: "codein", + header: "Code article", + sortValue: r => r.codein, + cell: r => {r.codein}, + 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 => {r.fournisseur}, + }, + { + id: "site", + header: "Magasin", + sortValue: r => nomMagasin(r.site), + cell: r => , + }, + ]; +} + +/** Quantité en stock : rouge si négative, en clair sinon. */ +function celluleStock(v: number) { + return ( + + {fmtDecimal1(v)} + + ); +} + +const celluleDate = (v: string | null) => ( + {fmtDate(v)} +); + +const rechercheStock = (r: LigneStock) => [r.codein, r.libelle1]; + +function filtreFournisseur(): DataFilter { + return { id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" }; +} + +function filtreAnneeEntree(anneeOf: (r: T) => string): DataFilter { + return { id: "annee", label: "Année d'entrée", valueOf: anneeOf, allLabel: "Toutes les années", order: "desc" }; +} + +const COLONNES_NEGATIF: DataColumn[] = [ + ...colonnesIdentite(), + { + 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[] = [ + filtreFournisseur(), + filtreAnneeEntree(r => getYear(r.derniereentree)), +]; + +const COLONNES_SANS_VENTE: DataColumn[] = [ + ...colonnesIdentite(), + { + 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[] = [ + filtreFournisseur(), + filtreAnneeEntree(r => getYear(r.derniere_entree)), +]; + +const COLONNES_SANS_VENTE_6MOIS: DataColumn[] = [ + ...colonnesIdentite(), + { + 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 ? ( + 365 ? "text-[var(--accent-error)]" : "text-[var(--text-secondary)]", + )} + > + {fmtEntier(r.jours_sans_vente)} + + ) : ( + Jamais vendu + ), + }, + { 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[] = [filtreFournisseur()]; + +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 = { + "negatif": ( + <> + Produits dont le stock est négatif : des ventes ont été enregistrées sans + l'entrée de marchandise correspondante. Vérifiez s'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'ont jamais été vendus dans ce magasin. + Vérifiez qu'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'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) { - 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 = useMemo(() => ({ - "negatif": rowsNegatif.length, - "sans-vente": rowsSansVente.length, - "sans-vente-6mois": rowsSansVente6Mois.length, - }), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]); + const tabs: TabItem[] = 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 (
- {showLoader && } - - {/* Toolbar */} -
-
- - -
+ {/* Choix du magasin */} +
+ setPassword(e.target.value)} - placeholder="Min. 4 caractères" - className="apple-input pr-10" - required - /> - -
-
- - {/* Confirm */} -
- -
- setConfirm(e.target.value)} - placeholder="Répétez le mot de passe" - className="apple-input pr-10" - required - /> - -
-
- - {/* Password match indicator */} - {confirm.length > 0 && ( -
- - {password === confirm ? "Les mots de passe correspondent" : "Les mots de passe ne correspondent pas"} -
- )} - - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Actions */} -
- - -
- -
-
-
- ); -} diff --git a/src/components/shared/confirm-modal.tsx b/src/components/shared/confirm-modal.tsx deleted file mode 100644 index 7fbafd8..0000000 --- a/src/components/shared/confirm-modal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { X, AlertTriangle } from "lucide-react"; - -interface ConfirmModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - title?: string; - message: string; - confirmLabel?: string; - cancelLabel?: string; - variant?: "danger" | "default"; -} - -export function ConfirmModal({ - isOpen, - onClose, - onConfirm, - title = "Confirmation", - message, - confirmLabel = "OK", - cancelLabel = "Annuler", - variant = "default", -}: ConfirmModalProps) { - const [isVisible, setIsVisible] = useState(false); - const confirmBtnRef = useRef(null); - - useEffect(() => { - if (isOpen) { - setIsVisible(true); - setTimeout(() => confirmBtnRef.current?.focus(), 50); - } else { - setIsVisible(false); - } - }, [isOpen]); - - const handleClose = () => { - setIsVisible(false); - setTimeout(onClose, 250); - }; - - const handleConfirm = () => { - setIsVisible(false); - setTimeout(onConfirm, 250); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") handleClose(); - if (e.key === "Enter") handleConfirm(); - }; - - if (!isOpen && !isVisible) return null; - - const isDanger = variant === "danger"; - - return ( -
- {/* Backdrop */} -
- - {/* Panel */} -
- {/* Accent bar */} -
- - {/* Close button */} - - -
- {/* Icon + Title */} -
-
- -
-

{title}

-
- - {/* Message */} -

- {message} -

- - {/* Actions */} -
- - -
-
-
-
- ); -} diff --git a/src/components/shared/loading-modal.tsx b/src/components/shared/loading-modal.tsx deleted file mode 100644 index dc0fd02..0000000 --- a/src/components/shared/loading-modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -interface LoadingModalProps { - message?: string; - subMessage?: string; -} - -export default function LoadingModal({ - message = "Chargement des données", - subMessage = "Veuillez patienter...", -}: LoadingModalProps) { - return ( -
-
-
-
-
-
- - {message} - - - {subMessage} - -
-
-
- ); -} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 976ab12..c355d18 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -54,7 +54,7 @@ export function Sidebar() {
{/* Navigation */} -