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();