feat(ui): lot 2 — socle visuel, menu regroupé et composants communs

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 20:45:03 +00:00
1 parent 7da4c6d5fd
commit 0c890c84c5
47 files changed
+2004 -272

No files matched your search

+4 -3
View File
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function AnalyticsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des statistiques analytiques..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={10} />;
}
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function CommandesAutoLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des commandes automatiques..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={8} />;
}
+4 -3
View File
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function DashboardLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération du tableau de bord..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton cards={4} rows={6} />;
}
+28
View File
@@ -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 (
<div className="mx-auto w-full max-w-3xl py-10">
<ErrorState
title="Cette page n'a pas pu s'afficher"
description="Un problème est survenu pendant le chargement des données. Réessayez ; si l'erreur revient, prévenez un administrateur."
detail={error.digest ? `${error.message}\nRéférence : ${error.digest}` : error.message}
action={<Button onClick={reset}><RotateCcw /> Réessayer</Button>}
/>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import LoadingModal from "@/components/shared/loading-modal";
export default function ExportsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des exports..." />;
}
+3 -21
View File
@@ -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 (
<div className="space-y-8 max-w-6xl mx-auto px-4 py-6">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
<FileDown className="w-5 h-5 text-[var(--accent)]" />
</div>
<h1 className="text-2xl font-black tracking-tight text-[var(--text-primary)] uppercase">Historique d&apos;Arbitrage</h1>
</div>
<p className="text-sm text-[var(--text-secondary)] max-w-xl">
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.
</p>
</div>
</div>
<SnapshotList type="export" />
</div>
);
redirect("/historique?vue=exports");
}
+4 -3
View File
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function GridLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des articles et calcul des scores..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={12} />;
}
+27
View File
@@ -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 (
<div className="mx-auto w-full max-w-6xl">
<PageHeader
icon={History}
title="Historique"
description="Vos sessions de révision enregistrées et vos exports Excel. Rouvrez une session pour reprendre les modifications de gammes là où vous les aviez laissées."
/>
<HistoriqueTabs initial={vue === "exports" ? "exports" : "sessions"} />
</div>
);
}
+23
View File
@@ -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>("vue", initial);
return (
<div className="space-y-5">
<Tabs items={ONGLETS} value={vue} onChange={setVue} />
{/* `key` : chaque onglet recharge sa propre liste. */}
<SnapshotList key={vue} type={vue === "exports" ? "export" : "snapshot"} />
</div>
);
}
+4 -3
View File
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function HitParadeLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération du classement des ventes..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={12} />;
}
+1 -1
View File
@@ -7,7 +7,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<Sidebar />
<main className="flex-1 flex flex-col h-full min-w-0 overflow-hidden">
<Header />
<div className="flex-1 overflow-y-auto flex flex-col p-4 md:p-8">
<div className="flex-1 overflow-y-auto flex flex-col p-4 md:p-6">
{children}
</div>
</main>
+17
View File
@@ -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 (
<div className="mx-auto w-full max-w-3xl py-10">
<EmptyState
icon={SearchX}
title="Page introuvable"
description="L'adresse demandée n'existe pas ou n'existe plus. Utilisez le menu à gauche pour retrouver votre page."
action={<Button asChild><Link href="/dashboard">Retour à l&apos;accueil</Link></Button>}
/>
</div>
);
}
+4 -3
View File
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function ProduitsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={6} />;
}
+4 -3
View File
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function PublicitesLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des publicités..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={10} />;
}
+4 -3
View File
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function SettingsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des paramètres..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={6} />;
}
@@ -1,5 +0,0 @@
import LoadingModal from "@/components/shared/loading-modal";
export default function SnapshotsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des snapshots..." />;
}
+3 -21
View File
@@ -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 (
<div className="space-y-8 max-w-6xl mx-auto px-4 py-6">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
<Camera className="w-5 h-5 text-[var(--accent)]" />
</div>
<h1 className="text-2xl font-black tracking-tight text-[var(--text-primary)] uppercase">Snapshots</h1>
</div>
<p className="text-sm text-[var(--text-secondary)] max-w-xl">
Historique de vos sessions d&apos;arbitrage. Restaurez une sauvegarde pour retrouver vos modifications en cours sur une gamme spécifique.
</p>
</div>
</div>
<SnapshotList type="snapshot" />
</div>
);
redirect("/historique");
}
@@ -1,5 +1,6 @@
import LoadingModal from "@/components/shared/loading-modal";
import { PageSkeleton } from "@/components/ui/states";
export default function StockNegatifLoading() {
return <LoadingModal message="Chargement des données" subMessage="Récupération des stocks négatifs..." />;
/** Silhouette de la page pendant le chargement, sans bloquer l'écran. */
export default function Loading() {
return <PageSkeleton rows={10} />;
}
+50 -9
View File
@@ -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 <html>), 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 <link> 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 ── */
+2 -2
View File
@@ -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({
+19
View File
@@ -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 (
<main className="flex min-h-screen items-center justify-center p-6 bg-[var(--bg-base)]">
<EmptyState
icon={SearchX}
title="Page introuvable"
description="L'adresse demandée n'existe pas ou n'existe plus."
action={<Button asChild><Link href="/dashboard">Retour à l&apos;accueil</Link></Button>}
className="max-w-lg"
/>
</main>
);
}
+9 -2
View File
@@ -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 (
<SessionProvider>
// 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.
<SessionProvider refetchOnWindowFocus={false}>
<ThemeProvider>
{children}
<TooltipProvider>
{children}
<FeedbackHost />
</TooltipProvider>
</ThemeProvider>
</SessionProvider>
);