From 0c890c84c5c17bf0b087cf7d26d80bcb4ad39100 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:45:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20lot=202=20=E2=80=94=20socle=20visue?= =?UTF-8?q?l,=20menu=20regroup=C3=A9=20et=20composants=20communs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thème - Clair par défaut ; les classes dark: suivent le bouton de thème (et non plus le réglage du système) ; animations tw-animate-css enfin importées. - Couleurs shadcn branchées sur les variables (survols des menus visibles), libellés gris au contraste AA dans les deux thèmes, fond plein pour le menu et l'en-tête. Composants partagés (src/components/ui) - PageHeader, Card/Field, StatCard, Badge/DeltaBadge/StoreBadge/GammeBadge, Tabs/Segmented/useUrlTab, Select/SearchInput, EmptyState/ErrorState/ Skeleton, Tooltip/Terme (glossaire), toast et confirmer, Pagination, DataTable (recherche, filtres, tri, pagination, export, totaux). - Sources uniques : magasins, gammes (avec leur sens), glossaire, seuils de marge, export Excel. Navigation - Menu groupé (Au quotidien / Analyses / Suivi / Administration), déplié par défaut, libellés en français. - En-tête : nom de la page, thème, menu du compte avec le vrai nom, la hauteur des lignes de la Grille et la déconnexion. La recherche globale, qui ne filtrait que la Grille, est retirée. - Page Historique (sessions + exports) ; /snapshots et /exports y redirigent. - Squelettes de chargement au lieu de la fenêtre plein écran, pages d'erreur et « introuvable » en français, arrivée sur l'Accueil après connexion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn --- docs/plan-optimisation-webui.md | 34 ++ src/app/(dashboard)/analytics/loading.tsx | 7 +- .../(dashboard)/commandes-auto/loading.tsx | 7 +- src/app/(dashboard)/dashboard/loading.tsx | 7 +- src/app/(dashboard)/error.tsx | 28 ++ src/app/(dashboard)/exports/loading.tsx | 5 - src/app/(dashboard)/exports/page.tsx | 24 +- src/app/(dashboard)/grid/loading.tsx | 7 +- src/app/(dashboard)/historique/page.tsx | 27 ++ src/app/(dashboard)/historique/tabs.tsx | 23 ++ src/app/(dashboard)/hit-parade/loading.tsx | 7 +- src/app/(dashboard)/layout.tsx | 2 +- src/app/(dashboard)/not-found.tsx | 17 + src/app/(dashboard)/produits/loading.tsx | 7 +- src/app/(dashboard)/publicites/loading.tsx | 7 +- src/app/(dashboard)/settings/loading.tsx | 7 +- src/app/(dashboard)/snapshots/loading.tsx | 5 - src/app/(dashboard)/snapshots/page.tsx | 24 +- src/app/(dashboard)/stock-negatif/loading.tsx | 7 +- src/app/globals.css | 59 ++- src/app/layout.tsx | 4 +- src/app/not-found.tsx | 19 + src/app/providers.tsx | 11 +- src/components/shared/header.tsx | 147 ++++--- src/components/shared/navigation.ts | 77 ++++ src/components/shared/sidebar.tsx | 214 +++++------ src/components/shared/theme-provider.tsx | 2 +- src/components/ui/badge.tsx | 77 ++++ src/components/ui/card.tsx | 45 +++ src/components/ui/data-table.tsx | 359 ++++++++++++++++++ src/components/ui/dialog.tsx | 4 +- src/components/ui/dropdown-menu.tsx | 4 +- src/components/ui/feedback.tsx | 162 ++++++++ src/components/ui/form-controls.tsx | 92 +++++ src/components/ui/page-header.tsx | 45 +++ src/components/ui/pagination.tsx | 50 +++ src/components/ui/stat-card.tsx | 45 +++ src/components/ui/states.tsx | 101 +++++ src/components/ui/tabs.tsx | 126 ++++++ src/components/ui/tooltip.tsx | 76 ++++ src/features/auth/api/login-action.ts | 2 +- src/lib/export-excel.ts | 53 +++ src/lib/gammes.ts | 66 ++++ src/lib/glossaire.ts | 97 +++++ src/lib/magasins.ts | 48 +++ src/lib/marge.ts | 33 ++ src/middleware.ts | 6 +- 47 files changed, 2004 insertions(+), 272 deletions(-) create mode 100644 src/app/(dashboard)/error.tsx delete mode 100644 src/app/(dashboard)/exports/loading.tsx create mode 100644 src/app/(dashboard)/historique/page.tsx create mode 100644 src/app/(dashboard)/historique/tabs.tsx create mode 100644 src/app/(dashboard)/not-found.tsx delete mode 100644 src/app/(dashboard)/snapshots/loading.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/components/shared/navigation.ts create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/data-table.tsx create mode 100644 src/components/ui/feedback.tsx create mode 100644 src/components/ui/form-controls.tsx create mode 100644 src/components/ui/page-header.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/stat-card.tsx create mode 100644 src/components/ui/states.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/export-excel.ts create mode 100644 src/lib/gammes.ts create mode 100644 src/lib/glossaire.ts create mode 100644 src/lib/magasins.ts create mode 100644 src/lib/marge.ts diff --git a/docs/plan-optimisation-webui.md b/docs/plan-optimisation-webui.md index 2719fca..f68e243 100644 --- a/docs/plan-optimisation-webui.md +++ b/docs/plan-optimisation-webui.md @@ -270,3 +270,37 @@ Règle commune à chaque page : - **Colonnes Qlik** : non modifié. `metricsByMonth` sert à la courbe « nombre de magasins vendeurs ». - **Carte KPI du tableau de bord** : non modifiée. La ligne « Hier / N-1 » est une comparaison voulue, pas un doublon. - **Préchauffage après la synchro** : reporté. L'invalidation suffit, la première visite recharge. + +## Suivi — Lot 2 réalisé + +- **Thème** : + - clair par défaut ; les classes `dark:` suivent le bouton de thème ; + - animations (`tw-animate-css`) réellement importées ; + - couleurs shadcn branchées sur les variables ; + - libellés gris au contraste AA dans les deux thèmes ; + - fond plein pour le menu et l'en-tête (le flou ne servait à rien). +- **Composants partagés**, dans `src/components/ui/` : + - `PageHeader`, `Card`, `Field`, `StatCard` ; + - `Badge`, `DeltaBadge`, `StoreBadge`, `GammeBadge` ; + - `Tabs`, `Segmented`, `useUrlTab` ; + - `Select`, `SearchInput`, `Input` ; + - `EmptyState`, `ErrorState`, `Skeleton`, `PageSkeleton` ; + - `Tooltip`, `Terme` (glossaire) ; + - `toast` et `confirmer` (`feedback.tsx`) ; + - `Pagination`, `DataTable`. +- **Sources uniques** : + - `lib/magasins.ts` (noms des magasins) ; + - `lib/gammes.ts` (A à Z, avec leur sens et leurs couleurs) ; + - `lib/glossaire.ts` ; + - `lib/marge.ts` (seuils 40 / 25 %, ceux de la Grille) ; + - `lib/export-excel.ts` (export unique, via exceljs). +- **Navigation** : + - menu groupé (Au quotidien / Analyses / Suivi / Administration), déplié par défaut, libellés en français ; + - en-tête : nom de la page, thème, menu du compte (vrai nom, hauteur des lignes de la Grille pour tous, déconnexion) ; + - la recherche globale, qui ne filtrait que la Grille, est retirée. +- **Historique** : nouvelle page `/historique` (sessions et exports), et redirection des anciennes adresses `/snapshots` et `/exports`. Les deux onglets sont visibles par tous : chacun n'y voit que ses propres enregistrements. +- **Chargements et erreurs** : + - squelettes de page au lieu de la fenêtre plein écran ; + - 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. diff --git a/src/app/(dashboard)/analytics/loading.tsx b/src/app/(dashboard)/analytics/loading.tsx index 1f90c2f..1a73e79 100644 --- a/src/app/(dashboard)/analytics/loading.tsx +++ b/src/app/(dashboard)/analytics/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function AnalyticsLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/commandes-auto/loading.tsx b/src/app/(dashboard)/commandes-auto/loading.tsx index 6d6405d..5116a85 100644 --- a/src/app/(dashboard)/commandes-auto/loading.tsx +++ b/src/app/(dashboard)/commandes-auto/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function CommandesAutoLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/dashboard/loading.tsx b/src/app/(dashboard)/dashboard/loading.tsx index f3b9911..fad163f 100644 --- a/src/app/(dashboard)/dashboard/loading.tsx +++ b/src/app/(dashboard)/dashboard/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function DashboardLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx new file mode 100644 index 0000000..133e216 --- /dev/null +++ b/src/app/(dashboard)/error.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { RotateCcw } from "lucide-react"; +import { ErrorState } from "@/components/ui/states"; +import { Button } from "@/components/ui/button"; + +/** + * Erreur inattendue dans une page : message compréhensible et bouton pour + * réessayer, au lieu de l'écran d'erreur générique de Next.js. Le menu et + * l'en-tête restent utilisables. + */ +export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error("[page] erreur non rattrapée :", error); + }, [error]); + + return ( +
+ Réessayer} + /> +
+ ); +} diff --git a/src/app/(dashboard)/exports/loading.tsx b/src/app/(dashboard)/exports/loading.tsx deleted file mode 100644 index 96505c3..0000000 --- a/src/app/(dashboard)/exports/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LoadingModal from "@/components/shared/loading-modal"; - -export default function ExportsLoading() { - return ; -} diff --git a/src/app/(dashboard)/exports/page.tsx b/src/app/(dashboard)/exports/page.tsx index b9bca62..151dddf 100644 --- a/src/app/(dashboard)/exports/page.tsx +++ b/src/app/(dashboard)/exports/page.tsx @@ -1,24 +1,6 @@ -import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; -import { FileDown } from "lucide-react"; +import { redirect } from "next/navigation"; +/** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function ExportsPage() { - return ( -
-
-
-
-
- -
-

Historique d'Arbitrage

-
-

- Retrouvez ici toutes vos sessions sauvegardées et exports réalisés. Vous pouvez restaurer une session pour reprendre votre travail ou supprimer les anciens enregistrements. -

-
-
- - -
- ); + redirect("/historique?vue=exports"); } diff --git a/src/app/(dashboard)/grid/loading.tsx b/src/app/(dashboard)/grid/loading.tsx index 9744063..bb7ba5f 100644 --- a/src/app/(dashboard)/grid/loading.tsx +++ b/src/app/(dashboard)/grid/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function GridLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/historique/page.tsx b/src/app/(dashboard)/historique/page.tsx new file mode 100644 index 0000000..2aefed6 --- /dev/null +++ b/src/app/(dashboard)/historique/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { History } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; +import { HistoriqueTabs } from "./tabs"; + +export const metadata: Metadata = { title: "Historique" }; + +/** + * Historique des sessions de révision et des exports Excel. + * + * Remplace les pages « Snapshots » et « Exports », qui affichaient la même liste + * filtrée différemment (et dont la seconde s'intitulait « Historique d'arbitrage »). + * Chacun ne voit que ses propres enregistrements. + */ +export default async function HistoriquePage(props: { searchParams: Promise<{ vue?: string }> }) { + const { vue } = await props.searchParams; + return ( +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/historique/tabs.tsx b/src/app/(dashboard)/historique/tabs.tsx new file mode 100644 index 0000000..e048aca --- /dev/null +++ b/src/app/(dashboard)/historique/tabs.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { FileDown, Save } from "lucide-react"; +import { Tabs, useUrlTab } from "@/components/ui/tabs"; +import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; + +type Vue = "sessions" | "exports"; + +const ONGLETS = [ + { value: "sessions", label: "Sessions enregistrées", icon: Save }, + { value: "exports", label: "Exports Excel", icon: FileDown }, +] as const; + +export function HistoriqueTabs({ initial }: { initial: Vue }) { + const [vue, setVue] = useUrlTab("vue", initial); + return ( +
+ + {/* `key` : chaque onglet recharge sa propre liste. */} + +
+ ); +} diff --git a/src/app/(dashboard)/hit-parade/loading.tsx b/src/app/(dashboard)/hit-parade/loading.tsx index 276e208..bb7ba5f 100644 --- a/src/app/(dashboard)/hit-parade/loading.tsx +++ b/src/app/(dashboard)/hit-parade/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function HitParadeLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 6d334a4..929f166 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -7,7 +7,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
-
+
{children}
diff --git a/src/app/(dashboard)/not-found.tsx b/src/app/(dashboard)/not-found.tsx new file mode 100644 index 0000000..d586a26 --- /dev/null +++ b/src/app/(dashboard)/not-found.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { SearchX } from "lucide-react"; +import { EmptyState } from "@/components/ui/states"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+ Retour à l'accueil} + /> +
+ ); +} diff --git a/src/app/(dashboard)/produits/loading.tsx b/src/app/(dashboard)/produits/loading.tsx index 64ecef1..06df9fd 100644 --- a/src/app/(dashboard)/produits/loading.tsx +++ b/src/app/(dashboard)/produits/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function ProduitsLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/publicites/loading.tsx b/src/app/(dashboard)/publicites/loading.tsx index 131b8b2..1a73e79 100644 --- a/src/app/(dashboard)/publicites/loading.tsx +++ b/src/app/(dashboard)/publicites/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function PublicitesLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/settings/loading.tsx b/src/app/(dashboard)/settings/loading.tsx index 7b00b11..06df9fd 100644 --- a/src/app/(dashboard)/settings/loading.tsx +++ b/src/app/(dashboard)/settings/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function SettingsLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/(dashboard)/snapshots/loading.tsx b/src/app/(dashboard)/snapshots/loading.tsx deleted file mode 100644 index 346c5ef..0000000 --- a/src/app/(dashboard)/snapshots/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LoadingModal from "@/components/shared/loading-modal"; - -export default function SnapshotsLoading() { - return ; -} diff --git a/src/app/(dashboard)/snapshots/page.tsx b/src/app/(dashboard)/snapshots/page.tsx index 5de7e02..5b117e0 100644 --- a/src/app/(dashboard)/snapshots/page.tsx +++ b/src/app/(dashboard)/snapshots/page.tsx @@ -1,24 +1,6 @@ -import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; -import { Camera } from "lucide-react"; +import { redirect } from "next/navigation"; +/** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function SnapshotsPage() { - return ( -
-
-
-
-
- -
-

Snapshots

-
-

- Historique de vos sessions d'arbitrage. Restaurez une sauvegarde pour retrouver vos modifications en cours sur une gamme spécifique. -

-
-
- - -
- ); + redirect("/historique"); } diff --git a/src/app/(dashboard)/stock-negatif/loading.tsx b/src/app/(dashboard)/stock-negatif/loading.tsx index f934a03..1a73e79 100644 --- a/src/app/(dashboard)/stock-negatif/loading.tsx +++ b/src/app/(dashboard)/stock-negatif/loading.tsx @@ -1,5 +1,6 @@ -import LoadingModal from "@/components/shared/loading-modal"; +import { PageSkeleton } from "@/components/ui/states"; -export default function StockNegatifLoading() { - return ; +/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */ +export default function Loading() { + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 9946d08..93f9810 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,14 @@ @import "tailwindcss"; +/* Animations utilisées par les menus, fenêtres et notifications (animate-in, + fade-in, zoom-in-95…). Le paquet était installé mais jamais importé : ces + classes ne produisaient rien. */ +@import "tw-animate-css"; + +/* Les variantes `dark:` suivent le thème choisi dans l'application (classe + `.dark` posée par next-themes sur ), et non le réglage du système : + sans cette ligne, un poste en clair affichait des éléments clairs dans + l'application en sombre, et inversement. */ +@custom-variant dark (&:where(.dark, .dark *)); /* Police de l'app : Inter (chargée via dans le layout, fallback système). Définie ici pour que l'utilitaire `font-sans` et var(--font-sans) la prennent @@ -7,6 +17,30 @@ --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; } +/* Couleurs « shadcn » (bg-accent, text-muted-foreground, bg-primary…) branchées + sur les variables de l'application : elles n'étaient pas définies, et les + survols des menus déroulants, entre autres, ne se voyaient pas. */ +@theme inline { + --color-background: var(--bg-surface); + --color-foreground: var(--text-primary); + --color-card: var(--bg-surface); + --color-card-foreground: var(--text-primary); + --color-popover: var(--bg-surface); + --color-popover-foreground: var(--text-primary); + --color-primary: var(--brand-solid); + --color-primary-foreground: #ffffff; + --color-secondary: var(--bg-elevated); + --color-secondary-foreground: var(--text-primary); + --color-muted: var(--bg-elevated); + --color-muted-foreground: var(--text-secondary); + --color-accent: var(--bg-elevated); + --color-accent-foreground: var(--text-primary); + --color-destructive: var(--accent-error); + --color-border: var(--border); + --color-input: var(--border-strong); + --color-ring: var(--accent); +} + /* ═══════════════════════════════════════════ CollectFlow — Apple-inspired Design System @@ -42,8 +76,13 @@ /* Slate 200 */ --text-secondary: #94a3b8; /* Slate 400 */ - --text-muted: #64748b; - /* Slate 500 */ + --text-muted: #8494ab; + /* Entre Slate 400 et 500 : ≥ 4,5:1 sur toutes les surfaces sombres (AA). + L'ancien Slate 500 tombait à 3:1 sur --bg-elevated. */ + + /* Alias de variables utilisées par quelques écrans mais jamais définies. */ + --border-subtle: var(--border); + --surface-tertiary: var(--bg-elevated); --accent: #2dd4bf; /* emerald-ish, Apple-tuned */ @@ -77,8 +116,8 @@ --bg-surface: #ffffff; --bg-elevated: #f0f0f5; --bg-overlay: rgba(255, 255, 255, 0.82); - --border: rgba(0, 0, 0, 0.07); - --border-strong: rgba(0, 0, 0, 0.12); + --border: rgba(0, 0, 0, 0.10); + --border-strong: rgba(0, 0, 0, 0.16); /* Mêmes teintes, pas choisis pour le fond clair (et non un simple éclaircissement des valeurs sombres). L'aqua passe sous 3:1 sur blanc : la « règle de relief » @@ -90,7 +129,9 @@ --text-primary: #1d1d1f; /* Apple dark */ --text-secondary: #515154; - --text-muted: #86868b; + /* ≥ 5:1 sur toutes les surfaces claires (AA). L'ancien #86868b, utilisé pour + la plupart des libellés, tombait à 3,2:1. */ + --text-muted: #636366; --accent: #0d9488; /* deeper teal in light */ @@ -187,11 +228,11 @@ padding: 20px; } -/* ── Glass panels (sidebar, header) ── */ +/* ── Panneaux fixes (menu, en-tête) ── + Fond plein : le flou d'arrière-plan ne floutait rien (rien ne défile sous ces + panneaux) mais coûtait un recalcul GPU à chaque image. */ .glass { - background: var(--bg-overlay); - backdrop-filter: saturate(180%) blur(20px); - -webkit-backdrop-filter: saturate(180%) blur(20px); + background: var(--bg-surface); } /* ── Apple border ── */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7fbdeef..adec33a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,8 +3,8 @@ import "./globals.css"; import { Providers } from "./providers"; export const metadata: Metadata = { - title: "CollectFlow — Dashboard Décisionnel", - description: "Révision d'Assortiment Haute Performance", + title: { default: "CollectFlow", template: "%s · CollectFlow" }, + description: "Révision d'assortiment et suivi des ventes des magasins", }; export default function RootLayout({ diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..801eb98 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; +import { SearchX } from "lucide-react"; +import { EmptyState } from "@/components/ui/states"; +import { Button } from "@/components/ui/button"; + +/** Adresse inconnue (hors des pages de l'application). */ +export default function NotFound() { + return ( +
+ Retour à l'accueil} + className="max-w-lg" + /> +
+ ); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 3e0acf0..74e9a73 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,12 +2,19 @@ import { SessionProvider } from "next-auth/react"; import { ThemeProvider } from "@/components/shared/theme-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { FeedbackHost } from "@/components/ui/feedback"; export function Providers({ children }: { children: React.ReactNode }) { return ( - + // La session n'est pas relue à chaque retour sur l'onglet : elle repartait + // sur le réseau et redessinait toute la page (Grille comprise) à chaque fois. + - {children} + + {children} + + ); diff --git a/src/components/shared/header.tsx b/src/components/shared/header.tsx index d618e6f..3a9bc0d 100644 --- a/src/components/shared/header.tsx +++ b/src/components/shared/header.tsx @@ -1,73 +1,116 @@ "use client"; -import { Search, X, Sun, Moon } from "lucide-react"; +import { ChevronDown, LogOut, Moon, Rows3, Sun, User as UserIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; import { useTheme } from "next-themes"; +import { signOut, useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useGridStore } from "@/features/grid/store/use-grid-store"; +import { pageCourante } from "@/components/shared/navigation"; +type Densite = "compact" | "normal" | "comfortable"; + +/** + * En-tête : nom de la page en cours, choix du thème et menu du compte. + * + * La recherche qui s'y trouvait ne filtrait que la Grille (même depuis les autres + * pages, et en silence) : elle est retirée, la Grille garde la sienne. Le nom + * affiché est celui de l'utilisateur connecté (il était écrit en dur). + */ export function Header() { + const pathname = usePathname(); const { resolvedTheme, setTheme } = useTheme(); - const setFilter = useGridStore((s) => s.setFilter); - const search = useGridStore((s) => s.filters.search); - const isDark = resolvedTheme === "dark"; + const { data: session } = useSession(); + const displayDensity = useGridStore((s) => s.displayDensity); + const setDisplayDensity = useGridStore((s) => s.setDisplayDensity); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + // Le thème n'est connu qu'au montage (lu dans le navigateur). + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsMounted(true); + }, []); + + const isDark = isMounted && resolvedTheme === "dark"; + const page = pageCourante(pathname); + const nom = session?.user?.name ?? ""; + const role = (session?.user as { role?: string } | undefined)?.role === "admin" ? "Administrateur" : "Utilisateur"; + const initiale = nom.trim().charAt(0).toUpperCase() || "?"; return ( -
- {/* Search */} -
- - setFilter("search", e.target.value)} - className="apple-input pr-8 h-8 text-[13px]" - style={{ paddingLeft: "32px" }} - /> - {search && ( - +
+
+ {page && ( +

+ {page.label} +

)}
- {/* Right controls */}
- {/* Theme toggle */} - {/* Divider */} -
- - {/* User */} -
-
- M -
- - Michael - -
+ + + + + + + {nom || "Compte"} + {role} + + + + Hauteur des lignes de la Grille + + setDisplayDensity(v as Densite)} + > + Compacte + Normale + Aérée + + + { + await signOut({ redirect: false }); + window.location.href = "/login"; + }} + > + Se déconnecter + + +
); diff --git a/src/components/shared/navigation.ts b/src/components/shared/navigation.ts new file mode 100644 index 0000000..425d918 --- /dev/null +++ b/src/components/shared/navigation.ts @@ -0,0 +1,77 @@ +import { + CalendarClock, + CalendarRange, + History, + House, + LayoutGrid, + Megaphone, + PackageMinus, + PackageSearch, + Settings, + ShoppingCart, + Trophy, + type LucideIcon, +} from "lucide-react"; + +export interface NavItem { + label: string; + href: string; + icon: LucideIcon; + /** Phrase d'aide affichée au survol du menu replié. */ + description: string; + adminOnly?: boolean; +} + +export interface NavGroup { + title: string; + items: NavItem[]; +} + +/** + * Menu principal, regroupé par usage. Les libellés disent ce que fait la page, + * en français (« Dashboard », « Analytics », « Snapshots » ont disparu). + * Les adresses ne changent pas : favoris et liens existants restent valables. + */ +export const NAV_GROUPS: NavGroup[] = [ + { + title: "Au quotidien", + items: [ + { label: "Accueil", href: "/dashboard", icon: House, description: "Les ventes d'hier et les meilleurs produits" }, + { label: "Révision d'assortiment", href: "/grid", icon: LayoutGrid, description: "Choisir la gamme de chaque produit d'un fournisseur" }, + { label: "Recherche produit", href: "/produits", icon: PackageSearch, description: "Trouver un produit et voir ses ventes dans le réseau" }, + { label: "Stocks à surveiller", href: "/stock-negatif", icon: PackageMinus, description: "Stocks négatifs et produits qui ne se vendent pas" }, + { label: "Commandes fournisseurs", href: "/commandes-auto", icon: ShoppingCart, description: "Commandes automatiques et rythme de commande" }, + ], + }, + { + title: "Analyses", + items: [ + { label: "Meilleures ventes", href: "/hit-parade", icon: Trophy, description: "Classement des produits les plus vendus sur une période" }, + { label: "Ventes par mois", href: "/analytics", icon: CalendarRange, description: "Chiffre d'affaires d'un mois comparé à l'an dernier" }, + { label: "Publicités", href: "/publicites", icon: Megaphone, description: "Résultats des opérations publicitaires" }, + ], + }, + { + title: "Suivi", + items: [ + { label: "Historique", href: "/historique", icon: History, description: "Sessions de révision enregistrées et exports" }, + ], + }, + { + title: "Administration", + items: [ + { label: "Synchronisation", href: "/admin/synchronisation", icon: CalendarClock, description: "Mise à jour nocturne des données", adminOnly: true }, + { label: "Paramètres", href: "/settings", icon: Settings, description: "Connexions, utilisateurs et clés d'accès", adminOnly: true }, + ], + }, +]; + +/** Entrée du menu correspondant à l'adresse courante (pour le titre de l'en-tête). */ +export function pageCourante(pathname: string): NavItem | undefined { + for (const g of NAV_GROUPS) { + for (const item of g.items) { + if (pathname === item.href || pathname.startsWith(`${item.href}/`)) return item; + } + } + return undefined; +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 613da03..976ab12 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,161 +1,139 @@ "use client"; -import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, CalendarClock } from "lucide-react"; +import { ChevronLeft, ChevronRight, Package } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; import { cn } from "@/lib/utils"; import { useGridStore } from "@/features/grid/store/use-grid-store"; -import { useEffect, useState } from "react"; -import { useSession, signOut } from "next-auth/react"; +import { NAV_GROUPS } from "@/components/shared/navigation"; +import { Tooltip } from "@/components/ui/tooltip"; -const NAV_ITEMS = [ - { icon: BarChart2, label: "Dashboard", href: "/dashboard" }, - { icon: LayoutGrid, label: "Grille", href: "/grid" }, - { icon: PackageSearch, label: "Produits", href: "/produits" }, - { icon: TrendingUp, label: "Analytics", href: "/analytics" }, - { icon: Trophy, label: "Hit Parade", href: "/hit-parade" }, - { icon: Megaphone, label: "Publicités", href: "/publicites" }, - { icon: PackageMinus, label: "Gestion de Stock", href: "/stock-negatif" }, - { icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" }, - { icon: Camera, label: "Snapshots", href: "/snapshots" }, - { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, - { icon: CalendarClock, label: "Synchronisation", href: "/admin/synchronisation", adminOnly: true }, - { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, -]; +const CLE_STOCKAGE = "sidebar_expanded"; +/** + * Menu principal : groupé par usage, déplié par défaut (libellés visibles). + * Le compte utilisateur et la déconnexion sont dans l'en-tête. + */ export function Sidebar() { const pathname = usePathname(); const { data: session } = useSession(); const activeGridQuery = useGridStore((s) => s.activeGridQuery); const [isMounted, setIsMounted] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); useEffect(() => { + // `isMounted` évite un écart d'hydratation (préférence lue dans le navigateur). + // eslint-disable-next-line react-hooks/set-state-in-effect setIsMounted(true); - // Load expanded state from local storage - const stored = localStorage.getItem("sidebar_expanded"); - if (stored === "true") setIsExpanded(true); + try { + // Seul un repli explicite est mémorisé : par défaut, le menu est déplié. + if (localStorage.getItem(CLE_STOCKAGE) === "false") setIsExpanded(false); + } catch { + /* stockage indisponible : on garde le menu déplié */ + } }, []); const toggleSidebar = () => { const next = !isExpanded; setIsExpanded(next); - localStorage.setItem("sidebar_expanded", String(next)); + try { + localStorage.setItem(CLE_STOCKAGE, String(next)); + } catch { + /* préférence non mémorisée, sans conséquence */ + } }; - const userRole = (session?.user as any)?.role; - const filteredItems = NAV_ITEMS.filter(item => !item.adminOnly || userRole === "admin"); + const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin"; + const groupes = NAV_GROUPS + .map((g) => ({ ...g, items: g.items.filter((i) => !i.adminOnly || isAdmin) })) + .filter((g) => g.items.length > 0); return ( ); } diff --git a/src/components/shared/theme-provider.tsx b/src/components/shared/theme-provider.tsx index a1eb978..b7556ec 100644 --- a/src/components/shared/theme-provider.tsx +++ b/src/components/shared/theme-provider.tsx @@ -6,7 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { return ( diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..8355752 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { TrendingDown, TrendingUp, Minus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { nomMagasin } from "@/lib/magasins"; +import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes"; +import { fmtDecimal1 } from "@/lib/format"; + +export type Ton = "neutre" | "accent" | "succes" | "alerte" | "erreur" | "info"; + +const TONS: Record = { + neutre: "bg-[var(--bg-elevated)] text-[var(--text-secondary)] border-[var(--border)]", + accent: "bg-[var(--accent-bg)] text-[var(--accent)] border-[var(--accent-border)]", + succes: "bg-[var(--accent-success-bg)] text-[var(--accent-success)] border-[var(--accent-success)]/30", + alerte: "bg-[var(--accent-warning-bg)] text-[var(--accent-warning)] border-[var(--accent-warning)]/30", + erreur: "bg-[var(--accent-error-bg)] text-[var(--accent-error)] border-[var(--accent-error)]/30", + info: "bg-[var(--accent-bg)] text-[var(--viz-1)] border-[var(--border)]", +}; + +export function Badge({ ton = "neutre", children, className, title }: { + ton?: Ton; + children: ReactNode; + className?: string; + title?: string; +}) { + return ( + + {children} + + ); +} + +/** + * Évolution en pourcentage (vs N-1) : flèche, signe et couleur. `null` (pas de + * base de comparaison) s'affiche « — » plutôt qu'un faux 0 %. + */ +export function DeltaBadge({ pct, className }: { pct: number | null | undefined; className?: string }) { + if (pct == null || !Number.isFinite(pct)) { + return —; + } + const ton: Ton = pct > 0.05 ? "succes" : pct < -0.05 ? "erreur" : "neutre"; + const Icone = pct > 0.05 ? TrendingUp : pct < -0.05 ? TrendingDown : Minus; + return ( + + + {pct > 0 ? "+" : ""}{fmtDecimal1(pct)} % + + ); +} + +/** Nom du magasin, jamais son code brut. */ +export function StoreBadge({ code, court = false }: { code: string | null | undefined; court?: boolean }) { + return {nomMagasin(code, { court })}; +} + +/** Pastille de gamme avec, au besoin, son nom (« A — Cœur »). */ +export function GammeBadge({ code, avecNom = false }: { code: string | null | undefined; avecNom?: boolean }) { + const g = trouverGamme(code); + return ( + + {g ? g.code : "—"} + {avecNom && {g ? g.nom : "Sans gamme"}} + + ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..17e9869 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,45 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** Conteneur de base : fond, bordure et ombre du thème. */ +export function Card({ className, ...props }: HTMLAttributes) { + return ( +
+ ); +} + +interface CardHeaderProps { + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; + className?: string; +} + +export function CardHeader({ title, description, actions, className }: CardHeaderProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} + +export function CardContent({ className, ...props }: HTMLAttributes) { + return
; +} + +/** Paire libellé / valeur dans une fiche (identité produit, réglages…). */ +export function Field({ label, children, className }: { label: ReactNode; children: ReactNode; className?: string }) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx new file mode 100644 index 0000000..cac422f --- /dev/null +++ b/src/components/ui/data-table.tsx @@ -0,0 +1,359 @@ +"use client"; + +/** + * Tableau de données commun : recherche, filtres, tri par colonne, pagination, + * export, état vide et ligne de totaux — identiques sur toutes les pages. + * + * Né du tableau de la page Stocks, qui avait déjà tout cela ; les autres pages + * en réimplémentaient chacune une partie (13 tableaux faits à la main). + */ + +import { useDeferredValue, useMemo, useState, type ReactNode } from "react"; +import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { SearchInput, Select } from "@/components/ui/form-controls"; +import { Pagination } from "@/components/ui/pagination"; +import { EmptyState } from "@/components/ui/states"; +import { Tooltip } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/feedback"; + +type Sens = "asc" | "desc"; + +export interface DataColumn { + id: string; + header: ReactNode; + /** Explication de la colonne, en infobulle sur l'en-tête. */ + hint?: ReactNode; + align?: "left" | "right" | "center"; + /** Valeur de tri ; sans elle, la colonne n'est pas triable. */ + sortValue?: (row: T) => string | number | null | undefined; + cell: (row: T) => ReactNode; + /** Cellule de la ligne de totaux (cf. `showFooter`). */ + footer?: (rows: readonly T[]) => ReactNode; + className?: string; + headerClassName?: string; +} + +export interface DataFilter { + id: string; + label: string; + valueOf: (row: T) => string | null | undefined; + /** Libellé affiché pour une valeur (ex. code magasin → nom). */ + optionLabel?: (value: string) => string; + /** Libellé de l'option « tout » (par défaut « Tous »). */ + allLabel?: string; + /** Ordre des options : alphabétique (défaut) ou décroissant (années…). */ + order?: Sens; +} + +interface DataTableProps { + rows: readonly T[]; + columns: readonly DataColumn[]; + rowKey: (row: T, index: number) => string; + /** Champs parcourus par la recherche ; sans eux, pas de champ de recherche. */ + searchIn?: (row: T) => (string | number | null | undefined)[]; + searchPlaceholder?: string; + filters?: readonly DataFilter[]; + /** Lignes par page ; 0 = tout afficher. */ + pageSize?: number; + /** Nom des lignes comptées : « articles », « fournisseurs »… */ + unite?: string; + emptyTitle?: ReactNode; + emptyDescription?: ReactNode; + /** Export des lignes filtrées et triées (toutes, pas seulement la page affichée). */ + onExport?: (rows: readonly T[]) => Promise | void; + exportLabel?: string; + /** Contrôles supplémentaires, placés avant la recherche. */ + toolbar?: ReactNode; + /** Affiche la ligne de totaux (colonnes avec `footer`). */ + showFooter?: boolean; + initialSort?: { id: string; dir: Sens }; + onRowClick?: (row: T) => void; + /** Hauteur maximale de la zone défilante (en-tête collant). */ + maxHeight?: string; + className?: string; +} + +const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" }); + +function comparer(a: string | number | null | undefined, b: string | number | null | undefined): number { + const videA = a === null || a === undefined || a === ""; + const videB = b === null || b === undefined || b === ""; + if (videA && videB) return 0; + if (videA) return 1; // valeurs absentes toujours en fin de liste + if (videB) return -1; + if (typeof a === "number" && typeof b === "number") return a - b; + return collator.compare(String(a), String(b)); +} + +export function DataTable({ + rows, + columns, + rowKey, + searchIn, + searchPlaceholder = "Rechercher…", + filters = [], + pageSize = 50, + unite = "lignes", + emptyTitle = "Aucun résultat", + emptyDescription, + onExport, + exportLabel = "Exporter en Excel", + toolbar, + showFooter = false, + initialSort, + onRowClick, + maxHeight = "70vh", + className, +}: DataTableProps) { + const [recherche, setRecherche] = useState(""); + const rechercheDifferee = useDeferredValue(recherche); + const [valeursFiltres, setValeursFiltres] = useState>({}); + const [tri, setTri] = useState<{ id: string; dir: Sens } | null>(initialSort ?? null); + const [page, setPage] = useState(1); + const [exportEnCours, setExportEnCours] = useState(false); + + const optionsFiltres = useMemo(() => { + const out: Record = {}; + for (const f of filters) { + const valeurs = new Set(); + for (const r of rows) { + const v = f.valueOf(r); + if (v) valeurs.add(v); + } + const triees = [...valeurs].sort((a, b) => (f.order === "desc" ? -1 : 1) * collator.compare(a, b)); + out[f.id] = triees.map((v) => ({ value: v, label: f.optionLabel ? f.optionLabel(v) : v })); + } + return out; + }, [filters, rows]); + + const filtrees = useMemo(() => { + const q = rechercheDifferee.trim().toLowerCase(); + const actifs = filters.filter((f) => valeursFiltres[f.id]); + if (!q && actifs.length === 0) return rows; + return rows.filter((r) => { + for (const f of actifs) if (f.valueOf(r) !== valeursFiltres[f.id]) return false; + if (q && searchIn) { + return searchIn(r).some((v) => v !== null && v !== undefined && String(v).toLowerCase().includes(q)); + } + return true; + }); + }, [rows, filters, valeursFiltres, rechercheDifferee, searchIn]); + + const triees = useMemo(() => { + if (!tri) return filtrees; + const col = columns.find((c) => c.id === tri.id); + if (!col?.sortValue) return filtrees; + const valeur = col.sortValue; + const sens = tri.dir === "asc" ? 1 : -1; + return [...filtrees].sort((a, b) => { + const va = valeur(a); + const vb = valeur(b); + const vide = va === null || va === undefined || va === "" || vb === null || vb === undefined || vb === ""; + // Les valeurs absentes restent en fin de liste dans les deux sens. + return vide ? comparer(va, vb) : comparer(va, vb) * sens; + }); + }, [filtrees, tri, columns]); + + // Tout changement de filtre, de recherche ou de tri ramène à la page 1 + // (ajusté pendant le rendu, sans effet ni rendu supplémentaire). + const [jeuPagine, setJeuPagine] = useState(triees); + if (jeuPagine !== triees) { + setJeuPagine(triees); + setPage(1); + } + + const total = triees.length; + const pagine = pageSize > 0; + const totalPages = pagine ? Math.max(1, Math.ceil(total / pageSize)) : 1; + const pageCourante = Math.min(page, totalPages); + const lignesAffichees = useMemo( + () => (pagine ? triees.slice((pageCourante - 1) * pageSize, pageCourante * pageSize) : triees), + [triees, pagine, pageCourante, pageSize], + ); + + const filtreActif = recherche !== "" || Object.values(valeursFiltres).some(Boolean); + + function basculerTri(id: string) { + // Premier clic : les plus grands chiffres d'abord, mais l'ordre A→Z pour du texte. + const col = columns.find((c) => c.id === id); + const exemple = rows.length > 0 ? col?.sortValue?.(rows[0]) : undefined; + const sensInitial: Sens = typeof exemple === "number" ? "desc" : "asc"; + setTri((t) => (t?.id === id ? { id, dir: t.dir === "asc" ? "desc" : "asc" } : { id, dir: sensInitial })); + } + + async function exporter() { + if (!onExport || exportEnCours) return; + setExportEnCours(true); + try { + await onExport(triees); + } catch (e) { + toast.erreur(`L'export a échoué : ${e instanceof Error ? e.message : String(e)}`); + } finally { + setExportEnCours(false); + } + } + + const alignement = (a?: "left" | "right" | "center") => + a === "right" ? "text-right justify-end" : a === "center" ? "text-center justify-center" : "text-left"; + + return ( +
+ {/* Barre d'outils */} +
+
+ {toolbar} + {searchIn && ( + + )} + {filters.map((f) => ( + onChange(e.target.value)} + className={cn(CHAMP, "pr-8 min-w-[160px] cursor-pointer", className)} + {...props} + > + {placeholder !== undefined && } + {options.map((o) => ( + + ))} + + ); + if (!label) return select; + return ( +
+ + {select} +
+ ); +} + +interface SearchInputProps extends Omit, "onChange" | "value"> { + value: string; + onChange: (value: string) => void; +} + +/** Champ de recherche avec loupe et bouton d'effacement. */ +export function SearchInput({ value, onChange, className, placeholder = "Rechercher…", ...props }: SearchInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={cn(CHAMP, "w-full min-w-[240px] pl-9 pr-8")} + {...props} + /> + {value && ( + + )} +
+ ); +} + +export function Input({ className, ...props }: InputHTMLAttributes) { + return ; +} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx new file mode 100644 index 0000000..9003833 --- /dev/null +++ b/src/components/ui/page-header.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface PageHeaderProps { + title: ReactNode; + /** Une phrase : à quoi sert la page, en langage courant. */ + description?: ReactNode; + icon?: LucideIcon; + /** Boutons ou filtres alignés à droite du titre. */ + actions?: ReactNode; + className?: string; +} + +/** + * En-tête commun à toutes les pages : même taille de titre, même place pour + * l'explication et pour les actions. Il y en avait sept variantes. + */ +export function PageHeader({ title, description, icon: Icon, actions, className }: PageHeaderProps) { + return ( +
+
+ {Icon && ( +
+ +
+ )} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..9ed0b15 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; + +interface PaginationProps { + page: number; + totalPages: number; + total: number; + pageSize: number; + onChange: (page: number) => void; + /** Nom des éléments comptés (« articles », « publicités »…). */ + unite?: string; +} + +/** Pagination commune : « 51 à 100 sur 1 234 articles » et quatre boutons. */ +export function Pagination({ page, totalPages, total, pageSize, onChange, unite = "lignes" }: PaginationProps) { + if (total === 0 || totalPages <= 1) return null; + const debut = (page - 1) * pageSize + 1; + const fin = Math.min(page * pageSize, total); + const aller = (p: number) => onChange(Math.min(Math.max(1, p), totalPages)); + const bouton = + "rounded-lg border p-1.5 border-[var(--border-strong)] bg-[var(--bg-surface)] text-[var(--text-secondary)] " + + "hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"; + + return ( + + ); +} diff --git a/src/components/ui/stat-card.tsx b/src/components/ui/stat-card.tsx new file mode 100644 index 0000000..d3c6424 --- /dev/null +++ b/src/components/ui/stat-card.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { DeltaBadge } from "@/components/ui/badge"; + +interface StatCardProps { + label: ReactNode; + value: ReactNode; + /** Précision sous la valeur (« dont 12 en promo », « hier »…). */ + hint?: ReactNode; + /** Évolution en % à afficher en pastille (vs N-1). */ + delta?: number | null; + deltaLabel?: ReactNode; + icon?: LucideIcon; + className?: string; +} + +/** Indicateur chiffré : libellé lisible, valeur en grand, évolution. */ +export function StatCard({ label, value, hint, delta, deltaLabel = "vs N-1", icon: Icon, className }: StatCardProps) { + return ( +
+
+ {label} + {Icon && } +
+
{value}
+ {(delta !== undefined || hint) && ( +
+ {delta !== undefined && ( + <> + + {deltaLabel} + + )} + {hint && {hint}} +
+ )} +
+ ); +} diff --git a/src/components/ui/states.tsx b/src/components/ui/states.tsx new file mode 100644 index 0000000..29f1688 --- /dev/null +++ b/src/components/ui/states.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from "react"; +import { AlertTriangle, Inbox, type LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface EmptyStateProps { + title: ReactNode; + description?: ReactNode; + icon?: LucideIcon; + action?: ReactNode; + className?: string; +} + +/** Rien à afficher : dire pourquoi, et quoi faire. */ +export function EmptyState({ title, description, icon: Icon = Inbox, action, className }: EmptyStateProps) { + return ( +
+ +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} + +interface ErrorStateProps { + title?: ReactNode; + /** Explication pour l'utilisateur ; le détail technique va dans `detail`. */ + description?: ReactNode; + /** Message technique, replié par défaut. */ + detail?: string; + action?: ReactNode; + className?: string; +} + +/** Un chargement a échoué : message clair, détail technique replié, action de reprise. */ +export function ErrorState({ + title = "Les données n'ont pas pu être chargées", + description = "Réessayez dans un instant. Si le problème persiste, prévenez un administrateur.", + detail, + action, + className, +}: ErrorStateProps) { + return ( +
+ +

{title}

+

{description}

+ {detail && ( +
+ Détail technique +
{detail}
+
+ )} + {action &&
{action}
} +
+ ); +} + +export function Skeleton({ className }: { className?: string }) { + return
; +} + +/** + * Silhouette d'une page pendant son chargement : la mise en page reste visible + * et rien ne bloque l'écran (contrairement à l'ancienne fenêtre plein écran). + */ +export function PageSkeleton({ cards = 0, rows = 8 }: { cards?: number; rows?: number }) { + return ( +
+
+ +
+ + +
+
+ {cards > 0 && ( +
+ {Array.from({ length: cards }, (_, i) => )} +
+ )} +
+ + {Array.from({ length: rows }, (_, i) => )} +
+
+ ); +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..a74d3b3 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface TabItem { + value: V; + label: ReactNode; + /** Compteur affiché à droite du libellé. */ + count?: number; + /** Pastille d'alerte (rouge) au lieu d'un compteur neutre. */ + alert?: boolean; + icon?: LucideIcon; +} + +interface TabsProps { + items: readonly TabItem[]; + value: V; + onChange: (value: V) => void; + disabled?: boolean; + className?: string; +} + +/** + * Onglets soulignés, identiques sur toutes les pages. L'appelant décide de la + * synchronisation avec l'URL (cf. `useUrlTab`) : un onglet doit pouvoir être + * partagé par lien et survivre au rechargement. + */ +export function Tabs({ items, value, onChange, disabled, className }: TabsProps) { + return ( +
+ {items.map((t) => { + const actif = t.value === value; + const Icone = t.icon; + return ( + + ); + })} +
+ ); +} + +/** + * Segment de choix court (2 à 4 options), pour les bascules du type + * « Fournisseur / Nomenclature » ou « Tous / Frouard / Houdemont ». + */ +export function Segmented({ items, value, onChange, disabled, className }: TabsProps) { + return ( +
+ {items.map((t) => { + const actif = t.value === value; + return ( + + ); + })} +
+ ); +} + +/** + * Onglet actif reflété dans l'URL (`?param=valeur`) sans navigation serveur : + * le lien se partage et survit au rechargement, et changer d'onglet ne relance + * aucune requête. `initial` vient de la page serveur (searchParams). + */ +export function useUrlTab(param: string, initial: V): [V, (value: V) => void] { + const [value, setValue] = useState(initial); + const change = (next: V) => { + setValue(next); + const url = new URL(window.location.href); + url.searchParams.set(param, next); + window.history.replaceState(window.history.state, "", url.toString()); + }; + return [value, change]; +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..c8ddbdd --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,76 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; +import { Info } from "lucide-react"; +import { GLOSSAIRE, type CleGlossaire } from "@/lib/glossaire"; +import { cn } from "@/lib/utils"; + +/** À placer une fois en haut de l'arbre (cf. app/providers.tsx). */ +export function TooltipProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +interface TooltipProps { + content: ReactNode; + children: ReactNode; + side?: "top" | "right" | "bottom" | "left"; +} + +/** Infobulle accessible (survol et focus clavier), lisible dans les deux thèmes. */ +export function Tooltip({ content, children, side = "top" }: TooltipProps) { + return ( + + {children} + + + {content} + + + + ); +} + +interface TermeProps { + /** Entrée du glossaire à expliquer. */ + id: CleGlossaire; + /** Texte affiché ; par défaut le libellé du glossaire. */ + children?: ReactNode; + className?: string; +} + +/** + * Terme métier avec sa définition au survol : un petit « i » signale qu'une + * explication existe, sans encombrer la lecture. + */ +export function Terme({ id, children, className }: TermeProps) { + const entree = GLOSSAIRE[id]; + return ( + + {entree.terme} + {entree.definition} + + } + > + + {children ?? entree.terme} + + + + ); +} diff --git a/src/features/auth/api/login-action.ts b/src/features/auth/api/login-action.ts index df29b2f..9b514f9 100644 --- a/src/features/auth/api/login-action.ts +++ b/src/features/auth/api/login-action.ts @@ -11,7 +11,7 @@ export async function loginAction(prevState: string | undefined, formData: FormD await signIn("credentials", { username: formData.get("username"), password: formData.get("password"), - redirectTo: "/grid" + redirectTo: "/dashboard" }); } catch (error) { if (error instanceof AuthError) { diff --git a/src/lib/export-excel.ts b/src/lib/export-excel.ts new file mode 100644 index 0000000..9f32259 --- /dev/null +++ b/src/lib/export-excel.ts @@ -0,0 +1,53 @@ +/** + * Export Excel côté navigateur, commun aux pages (Stocks, Meilleures ventes, + * Ventes par mois…). La bibliothèque n'est chargée qu'au moment de l'export. + * + * Il y avait trois mécanismes et deux bibliothèques (xlsx, exceljs) : une seule + * subsiste, avec la même mise en forme d'en-tête partout. + */ + +export type CelluleExcel = string | number | null | undefined; + +interface ExportExcel { + /** Nom de l'onglet du classeur. */ + feuille: string; + /** Nom du fichier, sans extension ni date (ajoutées automatiquement). */ + fichier: string; + entetes: string[]; + /** Largeurs de colonnes, en caractères. */ + largeurs?: number[]; + lignes: CelluleExcel[][]; + /** Ligne de totaux, mise en gras. */ + totaux?: CelluleExcel[]; +} + +export async function telechargerExcel({ feuille, fichier, entetes, largeurs, lignes, totaux }: ExportExcel): Promise { + const { Workbook } = await import("exceljs"); + const classeur = new Workbook(); + classeur.creator = "CollectFlow"; + classeur.created = new Date(); + const ws = classeur.addWorksheet(feuille.slice(0, 31)); // limite Excel + + ws.addRow(entetes); + const entete = ws.getRow(1); + entete.height = 22; + entete.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.views = [{ state: "frozen", ySplit: 1 }]; + + for (const l of lignes) ws.addRow(l.map((v) => v ?? "")); + if (totaux) ws.addRow(totaux.map((v) => v ?? "")).font = { bold: true }; + if (largeurs) ws.columns = largeurs.map((width) => ({ width })); + + const buffer = await classeur.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 = `${fichier}_${new Date().toISOString().slice(0, 10)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/lib/gammes.ts b/src/lib/gammes.ts new file mode 100644 index 0000000..81e1fb3 --- /dev/null +++ b/src/lib/gammes.ts @@ -0,0 +1,66 @@ +/** + * Les gammes d'assortiment : code, nom et couleurs — source unique. + * + * Les couleurs étaient définies trois fois (liste déroulante, filtre, actions + * groupées) et le sens des lettres n'apparaissait que dans la barre d'actions + * groupées : ailleurs, l'utilisateur ne voyait que « A », « B »… + */ + +export type CodeGamme = "A" | "B" | "C" | "Y" | "Z"; + +export interface Gamme { + code: CodeGamme; + /** Nom court : « Cœur », « Sortie »… */ + nom: string; + /** Phrase d'explication pour les infobulles. */ + description: string; + /** Classes de pastille (fond, bordure, texte) lisibles en clair comme en sombre. */ + classes: string; +} + +export const GAMMES: readonly Gamme[] = [ + { + code: "A", + nom: "Cœur", + description: "Produit permanent, au cœur de l'assortiment.", + classes: "border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300", + }, + { + code: "B", + nom: "Complémentaire", + description: "Produit qui complète l'offre du rayon.", + classes: "border-blue-500/40 bg-blue-500/10 text-blue-800 dark:text-blue-300", + }, + { + code: "C", + nom: "Saisonnier", + description: "Produit présent selon la saison.", + classes: "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300", + }, + { + code: "Y", + nom: "En veille", + description: "Produit mis en attente : il n'est plus commandé pour l'instant.", + classes: "border-violet-500/40 bg-violet-500/10 text-violet-800 dark:text-violet-300", + }, + { + code: "Z", + nom: "Sortie", + description: "Produit à retirer de l'assortiment.", + classes: "border-rose-500/40 bg-rose-500/10 text-rose-800 dark:text-rose-300", + }, +]; + +/** Pastille d'un produit sans gamme. */ +export const CLASSES_SANS_GAMME = "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300"; + +export function trouverGamme(code: string | null | undefined): Gamme | undefined { + if (!code) return undefined; + return GAMMES.find((g) => g.code === code.trim()); +} + +/** « A — Cœur », ou « Sans gamme ». */ +export function libelleGamme(code: string | null | undefined): string { + const g = trouverGamme(code); + return g ? `${g.code} — ${g.nom}` : "Sans gamme"; +} diff --git a/src/lib/glossaire.ts b/src/lib/glossaire.ts new file mode 100644 index 0000000..2e62ecb --- /dev/null +++ b/src/lib/glossaire.ts @@ -0,0 +1,97 @@ +/** + * Glossaire des termes métier affichés dans l'application. + * + * Une seule définition par terme, reprise par les infobulles (``) : un + * utilisateur occasionnel ne doit pas avoir à deviner ce que signifient + * « Franco », « PRMP » ou « Réseau ». + */ + +import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; + +export interface EntreeGlossaire { + /** Libellé affiché. */ + terme: string; + /** Explication en une ou deux phrases. */ + definition: string; +} + +export const GLOSSAIRE = { + gamme: { + terme: "Gamme", + definition: "Place du produit dans l'assortiment du magasin, de A (cœur de gamme) à Z (sortie).", + }, + gammeInit: { + terme: "Gamme actuelle", + definition: "Gamme enregistrée aujourd'hui dans FF, avant vos modifications.", + }, + reseau: { + terme: "Réseau", + definition: `Les ${NB_MAGASINS_RESEAU} magasins du réseau La Foir'Fouille (données Qlik).`, + }, + nosMagasins: { + terme: "Nos magasins", + definition: "Frouard (Nancy) et Houdemont.", + }, + presenceReseau: { + terme: "Magasins vendeurs", + definition: `Nombre de magasins du réseau (sur ${NB_MAGASINS_RESEAU}) qui ont vendu ce produit sur la période.`, + }, + caParMagasin: { + terme: "CA par magasin", + definition: "Chiffre d'affaires moyen d'un magasin du réseau qui vend ce produit.", + }, + franco: { + terme: "Franco", + definition: "Montant de commande à atteindre pour que le fournisseur livre sans frais de port.", + }, + commandeAuto: { + terme: "Commande automatique", + definition: "Proposition de commande calculée par FF à partir des ventes et du stock.", + }, + cadencier: { + terme: "Cadencier", + definition: "Rythme de commande choisi pour un fournisseur (toutes les X semaines) : l'alerte se déclenche quand l'échéance approche.", + }, + pcb: { + terme: "PCB", + definition: "« Par combien » : nombre d'unités dans un colis du fournisseur.", + }, + prmp: { + terme: "PRMP", + definition: "Prix de revient moyen pondéré : coût d'achat moyen d'une unité en stock.", + }, + gtin: { + terme: "Code-barres", + definition: "Code EAN / GTIN imprimé sur l'emballage du produit.", + }, + codeInterne: { + terme: "Code article", + definition: "Code interne de l'article dans FF.", + }, + codeCentrale: { + terme: "Code centrale", + definition: "Code de l'article à la centrale d'achat, commun à tous les magasins du réseau.", + }, + n1: { + terme: "N-1", + definition: "Même période l'année précédente.", + }, + marge: { + terme: "Marge", + definition: "Chiffre d'affaires moins le coût d'achat. Le taux de marge est la marge divisée par le chiffre d'affaires.", + }, + stockNegatif: { + terme: "Stock négatif", + definition: "Le stock calculé est inférieur à zéro : des ventes ont été enregistrées sans l'entrée de marchandise correspondante.", + }, + tauxSortie: { + terme: "Taux de sortie", + definition: "Part du stock de l'opération vendue pendant la publicité.", + }, + partCaTotal: { + terme: "Part du CA total", + definition: "Poids des ventes de l'opération dans le chiffre d'affaires total du magasin sur la même période.", + }, +} satisfies Record; + +export type CleGlossaire = keyof typeof GLOSSAIRE; diff --git a/src/lib/magasins.ts b/src/lib/magasins.ts new file mode 100644 index 0000000..04f5385 --- /dev/null +++ b/src/lib/magasins.ts @@ -0,0 +1,48 @@ +/** + * Les magasins suivis par l'application — source unique des noms affichés. + * + * Chaque page avait sa propre version (« 292 — Frouard / Nancy », « Frouard », + * « Nancy », « F », « Site 292 »…) : un même magasin portait jusqu'à huit noms. + * Toute page qui affiche un magasin passe désormais par `nomMagasin()`. + */ + +export interface Magasin { + /** Code FF du site (clé de toutes les requêtes). */ + code: string; + /** Nom complet, celui affiché par défaut. */ + nom: string; + /** Nom court, pour les en-têtes de colonnes étroites. */ + court: string; + /** Initiale, pour les pastilles. */ + initiale: string; +} + +export const MAGASINS: readonly Magasin[] = [ + { code: "292", nom: "Frouard (Nancy)", court: "Frouard", initiale: "F" }, + { code: "579", nom: "Houdemont", court: "Houdemont", initiale: "H" }, +]; + +/** Code utilisé par la Grille et l'API pour « tous nos magasins ». */ +export const CODE_TOUS_MAGASINS = "TOTAL"; +/** Libellé de l'ensemble de nos magasins (à ne pas confondre avec le réseau). */ +export const LIBELLE_TOUS_MAGASINS = "Nos 2 magasins"; + +/** Anciens codes encore présents dans certaines réponses de l'API FF. */ +const ALIAS: Record = { F01: "292", F02: "579" }; + +export function trouverMagasin(code: string | null | undefined): Magasin | undefined { + if (!code) return undefined; + const c = String(code).trim(); + return MAGASINS.find((m) => m.code === (ALIAS[c] ?? c)); +} + +/** + * Nom lisible d'un magasin. `TOTAL` (ou vide) donne « Nos 2 magasins » ; un code + * inconnu est renvoyé tel quel plutôt que masqué. + */ +export function nomMagasin(code: string | null | undefined, options: { court?: boolean } = {}): string { + if (!code || code === CODE_TOUS_MAGASINS) return LIBELLE_TOUS_MAGASINS; + const m = trouverMagasin(code); + if (!m) return String(code); + return options.court ? m.court : m.nom; +} diff --git a/src/lib/marge.ts b/src/lib/marge.ts new file mode 100644 index 0000000..3eb5ca2 --- /dev/null +++ b/src/lib/marge.ts @@ -0,0 +1,33 @@ +/** + * Seuils de couleur des taux de marge — une seule règle pour toute l'application. + * + * Le tableau de bord colorait à 25 / 15 %, la fiche produit à 40 / 25 %, la + * Grille à 30 / 15 % pour le réseau : un même taux changeait de couleur d'une + * page à l'autre. On retient la règle de la Grille (colonne Marge), l'écran + * le plus utilisé : 40 % et 25 %. + */ + +export const SEUILS_MARGE = { + /** À partir de ce taux : vert. */ + bon: 40, + /** À partir de ce taux : orange ; en dessous : rouge. */ + moyen: 25, +} as const; + +export type NiveauMarge = "bon" | "moyen" | "faible"; + +export function niveauMarge(tauxPct: number | null | undefined): NiveauMarge | null { + if (tauxPct == null || !Number.isFinite(tauxPct)) return null; + if (tauxPct >= SEUILS_MARGE.bon) return "bon"; + if (tauxPct >= SEUILS_MARGE.moyen) return "moyen"; + return "faible"; +} + +/** Couleur CSS (variable du thème) associée au niveau de marge. */ +export function couleurMarge(tauxPct: number | null | undefined): string { + const niveau = niveauMarge(tauxPct); + if (niveau === "bon") return "var(--accent-success)"; + if (niveau === "moyen") return "var(--accent-warning)"; + if (niveau === "faible") return "var(--accent-error)"; + return "var(--text-muted)"; +} diff --git a/src/middleware.ts b/src/middleware.ts index f7c6ca7..6f4a357 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -26,9 +26,9 @@ export default auth((req) => { return NextResponse.redirect(new URL("/login", nextUrl)); } - // 3. Rediriger vers le dashboard si déjà connecté et sur /login + // 3. Déjà connecté et sur /login : page d'accueil (la même que « / »). if (isLoggedIn && isPublicRoute) { - return NextResponse.redirect(new URL("/grid", nextUrl)); + return NextResponse.redirect(new URL("/dashboard", nextUrl)); } // 4. Protection par rôle (Admin seulement pour les paramètres) @@ -36,7 +36,7 @@ export default auth((req) => { const userRole = (req.auth?.user as any)?.role; if (isAdminRoute && userRole !== "admin") { - return NextResponse.redirect(new URL("/grid", nextUrl)); + return NextResponse.redirect(new URL("/dashboard", nextUrl)); } return NextResponse.next();