mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
47 files changed
+2004
-272
No files matched your search
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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..." />;
|
||||
}
|
||||
@@ -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'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");
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'accueil</Link></Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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..." />;
|
||||
}
|
||||
@@ -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'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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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'accueil</Link></Button>}
|
||||
className="max-w-lg"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user