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
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<header
|
||||
className="h-12 flex items-center justify-between px-5 sticky top-0 z-20 glass"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="relative w-72">
|
||||
<Search
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
value={search}
|
||||
onChange={(e) => setFilter("search", e.target.value)}
|
||||
className="apple-input pr-8 h-8 text-[13px]"
|
||||
style={{ paddingLeft: "32px" }}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setFilter("search", "")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="w-3 h-3" style={{ color: "var(--text-muted)" }} />
|
||||
</button>
|
||||
<header className="h-14 shrink-0 flex items-center justify-between gap-4 px-6 glass border-b border-[var(--border)]">
|
||||
<div className="min-w-0">
|
||||
{page && (
|
||||
<p className="truncate text-sm font-semibold text-[var(--text-primary)]" title={page.description}>
|
||||
{page.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Mode clair" : "Mode sombre"}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all duration-150"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
className="flex h-9 items-center gap-2 rounded-lg px-3 text-[13px] font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||
title={isDark ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||
>
|
||||
{isDark
|
||||
? <Sun className="w-[15px] h-[15px]" strokeWidth={1.8} />
|
||||
: <Moon className="w-[15px] h-[15px]" strokeWidth={1.8} />}
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="hidden md:inline">{isDark ? "Mode clair" : "Mode sombre"}</span>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-5 mx-1" style={{ background: "var(--border)" }} />
|
||||
|
||||
{/* User */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[26px] h-[26px] rounded-full bg-emerald-500 dark:bg-emerald-600 flex items-center justify-center text-[11px] font-bold text-white shadow-sm border border-white/20">
|
||||
M
|
||||
</div>
|
||||
<span className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Michael
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 rounded-lg pl-1.5 pr-2 hover:bg-[var(--bg-elevated)]"
|
||||
aria-label="Menu du compte"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent)] text-xs font-bold text-white">
|
||||
{session ? initiale : <UserIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="hidden sm:block max-w-[160px] truncate text-[13px] font-medium text-[var(--text-primary)]">
|
||||
{nom}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>
|
||||
<span className="block text-sm font-semibold text-[var(--text-primary)]">{nom || "Compte"}</span>
|
||||
<span className="block text-xs font-normal text-[var(--text-muted)]">{role}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-medium text-[var(--text-secondary)]">
|
||||
<Rows3 className="h-3.5 w-3.5" /> Hauteur des lignes de la Grille
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={displayDensity}
|
||||
onValueChange={(v) => setDisplayDensity(v as Densite)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="compact">Compacte</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="normal">Normale</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="comfortable">Aérée</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={async () => {
|
||||
await signOut({ redirect: false });
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
<LogOut /> Se déconnecter
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-all duration-300 relative",
|
||||
isExpanded ? "w-60" : "w-16 items-center"
|
||||
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-[width] duration-200 relative border-r border-[var(--border)]",
|
||||
isExpanded ? "w-[272px]" : "w-16",
|
||||
)}
|
||||
style={{ borderRight: "1px solid var(--border)" }}
|
||||
aria-label="Menu principal"
|
||||
>
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="absolute -right-3 top-6 w-6 h-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-full flex items-center justify-center shadow-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-300 transition-colors z-40"
|
||||
aria-label={isExpanded ? "Replier le menu" : "Déplier le menu"}
|
||||
title={isExpanded ? "Replier le menu" : "Déplier le menu"}
|
||||
className="absolute -right-3 top-5 z-40 flex h-6 w-6 items-center justify-center rounded-full border shadow-sm bg-[var(--bg-surface)] border-[var(--border-strong)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{isExpanded ? <ChevronLeft className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
{isExpanded ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className={cn("py-[18px] w-full flex", isExpanded ? "px-5" : "justify-center")} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-[var(--accent)] flex items-center justify-center shrink-0 shadow-sm border border-white/10" title="CollectFlow">
|
||||
<Package className="w-[18px] h-[18px] text-white" strokeWidth={2.2} />
|
||||
<div className={cn("flex h-14 items-center border-b border-[var(--border)]", isExpanded ? "px-5" : "justify-center")}>
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5" title="CollectFlow — accueil">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--accent)] shadow-sm">
|
||||
<Package className="h-[18px] w-[18px] text-white" strokeWidth={2.2} />
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<span className="text-[15px] font-semibold tracking-[-0.3px] text-[var(--text-primary)] whitespace-nowrap overflow-hidden">
|
||||
CollectFlow
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && <span className="text-base font-semibold text-[var(--text-primary)]">CollectFlow</span>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className={cn("w-full flex-1 flex flex-col py-4 space-y-2", isExpanded ? "px-2" : "items-center px-0")}>
|
||||
{filteredItems.map(({ icon: Icon, label, href }) => {
|
||||
const isActive = pathname.startsWith(href);
|
||||
|
||||
let resolvedHref = href;
|
||||
if (href === "/grid" && isMounted && activeGridQuery) {
|
||||
resolvedHref = `/grid${activeGridQuery}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={resolvedHref}
|
||||
title={!isExpanded ? label : undefined}
|
||||
style={{
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
background: isActive ? "var(--bg-elevated)" : "transparent",
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center rounded-xl transition-all duration-150 overflow-hidden",
|
||||
isExpanded ? "px-3 py-[7px] gap-2.5 w-full" : "justify-center w-10 h-10",
|
||||
!isActive && "hover:bg-[var(--bg-elevated)]"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("shrink-0", isExpanded ? "w-[15px] h-[15px]" : "w-[18px] h-[18px]")} strokeWidth={1.8} />
|
||||
{isExpanded && <span className="text-[13px] font-medium whitespace-nowrap">{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Profile & Logout */}
|
||||
<div className={cn("w-full pb-4 flex flex-col gap-3", isExpanded ? "px-3" : "items-center")}>
|
||||
<div className={cn(
|
||||
"rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] flex items-center shadow-sm overflow-hidden",
|
||||
isExpanded ? "px-3 py-3 gap-3 w-full" : "justify-center w-10 h-10"
|
||||
)}
|
||||
title={!isExpanded ? (session?.user?.name || "Profil") : undefined}
|
||||
>
|
||||
<div className={cn(
|
||||
"rounded-lg bg-[var(--accent-bg)] flex items-center justify-center border border-[var(--accent-border)] shrink-0",
|
||||
isExpanded ? "w-8 h-8" : "w-6 h-6 border-transparent bg-transparent"
|
||||
)}>
|
||||
<UserIcon className={cn("text-[var(--accent)]", isExpanded ? "w-4 h-4" : "w-5 h-5")} />
|
||||
<nav className={cn("flex-1 overflow-y-auto py-3", isExpanded ? "px-3" : "px-2")}>
|
||||
{groupes.map((groupe, gi) => (
|
||||
<div key={groupe.title} className={cn(gi > 0 && "mt-4")}>
|
||||
{isExpanded ? (
|
||||
<p className="mb-1 px-3 text-xs font-semibold text-[var(--text-muted)]">{groupe.title}</p>
|
||||
) : (
|
||||
gi > 0 && <div className="mx-2 mb-3 border-t border-[var(--border)]" aria-hidden />
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{groupe.items.map(({ icon: Icon, label, href, description }) => {
|
||||
const isActive = pathname === href || pathname.startsWith(`${href}/`);
|
||||
// Retour à la Grille : on rouvre le dernier fournisseur consulté.
|
||||
const resolvedHref = href === "/grid" && isMounted && activeGridQuery
|
||||
? `/grid${activeGridQuery}`
|
||||
: href;
|
||||
const lien = (
|
||||
<Link
|
||||
href={resolvedHref}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
isExpanded ? "gap-3 px-3 py-2" : "h-10 w-10 justify-center mx-auto",
|
||||
isActive
|
||||
? "bg-[var(--accent-bg)] text-[var(--text-primary)] font-semibold"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn("h-[18px] w-[18px] shrink-0", isActive && "text-[var(--accent)]")}
|
||||
strokeWidth={1.9}
|
||||
aria-hidden
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<span className="truncate text-sm">{label}</span>
|
||||
) : (
|
||||
<span className="sr-only">{label}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<li key={href}>
|
||||
{isExpanded ? lien : (
|
||||
<Tooltip
|
||||
side="right"
|
||||
content={<><span className="font-semibold">{label}</span><span className="block text-[var(--text-secondary)]">{description}</span></>}
|
||||
>
|
||||
{lien}
|
||||
</Tooltip>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex-1 min-w-0">
|
||||
{session?.user ? (
|
||||
<>
|
||||
<p className="text-[12px] font-bold text-[var(--text-primary)] truncate">{session.user.name}</p>
|
||||
<p className="text-[10px] uppercase font-black text-[var(--text-muted)] tracking-wider">
|
||||
{userRole === "admin" ? "Administrateur" : "Utilisateur"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-[var(--accent)]" />
|
||||
<p className="text-[10px] text-[var(--text-muted)] animate-pulse">Session...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut({ redirect: false });
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
title={!isExpanded ? "Déconnexion" : undefined}
|
||||
className={cn(
|
||||
"flex items-center text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] transition-colors rounded-xl overflow-hidden",
|
||||
isExpanded ? "w-full gap-2.5 px-3 py-2 text-[13px] font-medium" : "justify-center w-10 h-10"
|
||||
)}
|
||||
>
|
||||
<LogOut className={cn("shrink-0", isExpanded ? "w-[15px] h-[15px]" : "w-[18px] h-[18px]")} strokeWidth={1.8} />
|
||||
{isExpanded && "Déconnexion"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
storageKey="collectflow-theme"
|
||||
>
|
||||
|
||||
@@ -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<Ton, string> = {
|
||||
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 (
|
||||
<span
|
||||
title={title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-semibold",
|
||||
TONS[ton],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* É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 <Badge className={className} title="Pas de comparaison possible">—</Badge>;
|
||||
}
|
||||
const ton: Ton = pct > 0.05 ? "succes" : pct < -0.05 ? "erreur" : "neutre";
|
||||
const Icone = pct > 0.05 ? TrendingUp : pct < -0.05 ? TrendingDown : Minus;
|
||||
return (
|
||||
<Badge ton={ton} className={cn("tabular-nums", className)}>
|
||||
<Icone className="h-3.5 w-3.5" aria-hidden />
|
||||
{pct > 0 ? "+" : ""}{fmtDecimal1(pct)} %
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nom du magasin, jamais son code brut. */
|
||||
export function StoreBadge({ code, court = false }: { code: string | null | undefined; court?: boolean }) {
|
||||
return <Badge ton="accent">{nomMagasin(code, { court })}</Badge>;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
title={g ? `${g.code} — ${g.nom} : ${g.description}` : "Aucune gamme"}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-bold",
|
||||
g ? g.classes : CLASSES_SANS_GAMME,
|
||||
)}
|
||||
>
|
||||
{g ? g.code : "—"}
|
||||
{avecNom && <span className="font-medium">{g ? g.nom : "Sans gamme"}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-xl border bg-[var(--bg-surface)] border-[var(--border)] shadow-[var(--shadow-sm)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, description, actions, className }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-start justify-between gap-3 px-5 pt-4 pb-3 border-b border-[var(--border)]", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-[13px] text-[var(--text-secondary)]">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** Paire libellé / valeur dans une fiche (identité produit, réglages…). */
|
||||
export function Field({ label, children, className }: { label: ReactNode; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<div className="text-xs font-medium text-[var(--text-muted)]">{label}</div>
|
||||
<div className="mt-0.5 text-sm text-[var(--text-primary)] break-words">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<T> {
|
||||
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<T> {
|
||||
rows: readonly T[];
|
||||
columns: readonly DataColumn<T>[];
|
||||
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<T>[];
|
||||
/** 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> | 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<T>({
|
||||
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<T>) {
|
||||
const [recherche, setRecherche] = useState("");
|
||||
const rechercheDifferee = useDeferredValue(recherche);
|
||||
const [valeursFiltres, setValeursFiltres] = useState<Record<string, string>>({});
|
||||
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<string, { value: string; label: string }[]> = {};
|
||||
for (const f of filters) {
|
||||
const valeurs = new Set<string>();
|
||||
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 (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Barre d'outils */}
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{toolbar}
|
||||
{searchIn && (
|
||||
<SearchInput value={recherche} onChange={setRecherche} placeholder={searchPlaceholder} aria-label="Rechercher dans le tableau" />
|
||||
)}
|
||||
{filters.map((f) => (
|
||||
<Select
|
||||
key={f.id}
|
||||
aria-label={f.label}
|
||||
label={f.label}
|
||||
value={valeursFiltres[f.id] ?? ""}
|
||||
onChange={(v) => setValeursFiltres((s) => ({ ...s, [f.id]: v }))}
|
||||
placeholder={f.allLabel ?? "Tous"}
|
||||
options={optionsFiltres[f.id] ?? []}
|
||||
/>
|
||||
))}
|
||||
{filtreActif && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => { setRecherche(""); setValeursFiltres({}); }}
|
||||
>
|
||||
<RotateCcw /> Effacer les filtres
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[13px] text-[var(--text-secondary)] tabular-nums">
|
||||
<span className="font-semibold text-[var(--text-primary)]">{total.toLocaleString("fr-FR")}</span> {unite}
|
||||
{filtreActif && total !== rows.length && <> sur {rows.length.toLocaleString("fr-FR")}</>}
|
||||
</span>
|
||||
{onExport && (
|
||||
<Button variant="outline" onClick={exporter} disabled={exportEnCours || total === 0}>
|
||||
{exportEnCours ? <Loader2 className="animate-spin" /> : <Download />}
|
||||
{exportEnCours ? "Export en cours…" : exportLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
{total === 0 ? (
|
||||
<EmptyState
|
||||
title={filtreActif ? "Aucune ligne ne correspond aux filtres" : emptyTitle}
|
||||
description={filtreActif ? "Modifiez la recherche ou effacez les filtres." : emptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-elevated)]">
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const triable = Boolean(c.sortValue);
|
||||
const actif = tri?.id === c.id;
|
||||
const contenu = (
|
||||
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}>
|
||||
{c.header}
|
||||
{triable && (
|
||||
actif
|
||||
? (tri!.dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />)
|
||||
: <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<th
|
||||
key={c.id}
|
||||
scope="col"
|
||||
aria-sort={actif ? (tri!.dir === "asc" ? "ascending" : "descending") : undefined}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-[13px] font-semibold text-[var(--text-secondary)]",
|
||||
alignement(c.align),
|
||||
c.headerClassName,
|
||||
)}
|
||||
>
|
||||
{triable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => basculerTri(c.id)}
|
||||
className="hover:text-[var(--text-primary)]"
|
||||
title={typeof c.hint === "string" ? c.hint : undefined}
|
||||
>
|
||||
{contenu}
|
||||
</button>
|
||||
) : c.hint ? (
|
||||
<Tooltip content={c.hint}><span className="cursor-help">{contenu}</span></Tooltip>
|
||||
) : contenu}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignesAffichees.map((r, i) => (
|
||||
<tr
|
||||
key={rowKey(r, i)}
|
||||
onClick={onRowClick ? () => onRowClick(r) : undefined}
|
||||
className={cn(
|
||||
"border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--bg-elevated)]",
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[var(--text-primary)]",
|
||||
c.align === "right" && "text-right tabular-nums",
|
||||
c.align === "center" && "text-center",
|
||||
c.className,
|
||||
)}
|
||||
>
|
||||
{c.cell(r)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{showFooter && (
|
||||
<tfoot className="sticky bottom-0 bg-[var(--bg-elevated)] font-semibold">
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"border-t border-[var(--border-strong)] px-3 py-2.5 text-[var(--text-primary)]",
|
||||
c.align === "right" && "text-right tabular-nums",
|
||||
c.align === "center" && "text-center",
|
||||
)}
|
||||
>
|
||||
{c.footer ? c.footer(triees) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagine && (
|
||||
<Pagination
|
||||
page={pageCourante}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={setPage}
|
||||
unite={unite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function DialogContent({
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">Fermer</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
@@ -111,7 +111,7 @@ function DialogFooter({
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">Fermer</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-2 text-[var(--text-primary)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-lg border border-[var(--border-strong)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-primary)] shadow-[var(--shadow-md)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-1 text-[var(--text-primary)] shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-[var(--border-strong)] bg-[var(--bg-surface)] p-1 text-[var(--text-primary)] shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Retours à l'utilisateur, communs à toute l'application :
|
||||
* - `toast.succes / toast.erreur / toast.info` : message discret en bas à droite,
|
||||
* qui disparaît seul (remplace les fenêtres de succès et les `alert()`).
|
||||
* - `confirmer({...})` : demande de confirmation, renvoie une promesse de booléen
|
||||
* (remplace `window.confirm`, au rendu non maîtrisé et en anglais selon le poste).
|
||||
*
|
||||
* Le rendu est assuré par un unique `<FeedbackHost />` (cf. app/providers.tsx).
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TonToast = "succes" | "erreur" | "info";
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
ton: TonToast;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DemandeConfirmation {
|
||||
titre: string;
|
||||
message?: string;
|
||||
libelleConfirmer: string;
|
||||
danger: boolean;
|
||||
resoudre: (ok: boolean) => void;
|
||||
}
|
||||
|
||||
interface FeedbackState {
|
||||
toasts: ToastItem[];
|
||||
confirmation: DemandeConfirmation | null;
|
||||
}
|
||||
|
||||
const useFeedback = create<FeedbackState>(() => ({ toasts: [], confirmation: null }));
|
||||
|
||||
let prochainId = 1;
|
||||
|
||||
function ajouterToast(ton: TonToast, message: string, dureeMs: number) {
|
||||
const id = prochainId++;
|
||||
useFeedback.setState((s) => ({ toasts: [...s.toasts.slice(-3), { id, ton, message }] }));
|
||||
window.setTimeout(() => retirerToast(id), dureeMs);
|
||||
}
|
||||
|
||||
function retirerToast(id: number) {
|
||||
useFeedback.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
succes: (message: string) => ajouterToast("succes", message, 3500),
|
||||
erreur: (message: string) => ajouterToast("erreur", message, 7000),
|
||||
info: (message: string) => ajouterToast("info", message, 4500),
|
||||
};
|
||||
|
||||
interface OptionsConfirmation {
|
||||
titre: string;
|
||||
message?: string;
|
||||
/** Libellé du bouton de validation : un verbe (« Supprimer »), jamais « OK ». */
|
||||
libelleConfirmer?: string;
|
||||
/** Action irréversible : bouton rouge. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function confirmer(options: OptionsConfirmation): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// Une demande encore ouverte est considérée comme refusée.
|
||||
useFeedback.getState().confirmation?.resoudre(false);
|
||||
useFeedback.setState({
|
||||
confirmation: {
|
||||
titre: options.titre,
|
||||
message: options.message,
|
||||
libelleConfirmer: options.libelleConfirmer ?? "Confirmer",
|
||||
danger: options.danger ?? false,
|
||||
resoudre: resolve,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function repondre(ok: boolean) {
|
||||
const demande = useFeedback.getState().confirmation;
|
||||
if (!demande) return;
|
||||
useFeedback.setState({ confirmation: null });
|
||||
demande.resoudre(ok);
|
||||
}
|
||||
|
||||
const ICONES = { succes: CheckCircle2, erreur: XCircle, info: Info } as const;
|
||||
const COULEURS: Record<TonToast, string> = {
|
||||
succes: "var(--accent-success)",
|
||||
erreur: "var(--accent-error)",
|
||||
info: "var(--accent)",
|
||||
};
|
||||
|
||||
export function FeedbackHost() {
|
||||
const toasts = useFeedback((s) => s.toasts);
|
||||
const confirmation = useFeedback((s) => s.confirmation);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[300] flex w-[min(380px,calc(100vw-2rem))] flex-col gap-2"
|
||||
>
|
||||
{toasts.map((t) => {
|
||||
const Icone = ICONES[t.ton];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role={t.ton === "erreur" ? "alert" : "status"}
|
||||
className="pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-[var(--shadow-lg)] animate-in fade-in-0 slide-in-from-bottom-2"
|
||||
style={{ background: "var(--bg-surface)", borderColor: "var(--border-strong)" }}
|
||||
>
|
||||
<Icone className="mt-0.5 h-5 w-5 shrink-0" style={{ color: COULEURS[t.ton] }} aria-hidden />
|
||||
<p className="flex-1 text-sm text-[var(--text-primary)]">{t.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retirerToast(t.id)}
|
||||
aria-label="Fermer le message"
|
||||
className="rounded p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmation !== null} onOpenChange={(open) => { if (!open) repondre(false); }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmation?.titre}</DialogTitle>
|
||||
{confirmation?.message && (
|
||||
<DialogDescription className="text-[var(--text-secondary)]">{confirmation.message}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => repondre(false)}>Annuler</Button>
|
||||
<Button
|
||||
variant={confirmation?.danger ? "destructive" : "default"}
|
||||
onClick={() => repondre(true)}
|
||||
className={cn(confirmation?.danger && "dark:bg-destructive")}
|
||||
>
|
||||
{confirmation?.libelleConfirmer}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CHAMP =
|
||||
"h-9 rounded-lg border px-3 text-sm bg-[var(--bg-surface)] border-[var(--border-strong)] text-[var(--text-primary)] " +
|
||||
"placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-bg)] " +
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed";
|
||||
|
||||
/** Libellé au-dessus d'un champ (et non à côté) : plus lisible en colonne étroite. */
|
||||
export function Label({ children, htmlFor, className }: { children: ReactNode; htmlFor?: string; className?: string }) {
|
||||
return (
|
||||
<label htmlFor={htmlFor} className={cn("block mb-1 text-[13px] font-medium text-[var(--text-secondary)]", className)}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
|
||||
label?: ReactNode;
|
||||
options: readonly SelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
/** Option vide en tête (« Tous », « Tous les fournisseurs »…). */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Liste déroulante native, au style du thème. */
|
||||
export function Select({ label, options, onChange, placeholder, className, id, ...props }: SelectProps) {
|
||||
const select = (
|
||||
<select
|
||||
id={id}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(CHAMP, "pr-8 min-w-[160px] cursor-pointer", className)}
|
||||
{...props}
|
||||
>
|
||||
{placeholder !== undefined && <option value="">{placeholder}</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (!label) return select;
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{select}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "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 (
|
||||
<div className={cn("relative", className)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(CHAMP, "w-full min-w-[240px] pl-9 pr-8")}
|
||||
{...props}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={cn(CHAMP, className)} {...props} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<header className={cn("flex flex-wrap items-start justify-between gap-4 mb-6", className)}>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{Icon && (
|
||||
<div
|
||||
className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border"
|
||||
style={{ background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }}
|
||||
>
|
||||
<Icon className="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm max-w-3xl" style={{ color: "var(--text-secondary)" }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav aria-label="Pagination" className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-[13px] text-[var(--text-secondary)] tabular-nums">
|
||||
{debut.toLocaleString("fr-FR")} à {fin.toLocaleString("fr-FR")} sur{" "}
|
||||
<span className="font-semibold text-[var(--text-primary)]">{total.toLocaleString("fr-FR")}</span> {unite}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button type="button" className={bouton} onClick={() => aller(1)} disabled={page <= 1} aria-label="Première page">
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" className={bouton} onClick={() => aller(page - 1)} disabled={page <= 1} aria-label="Page précédente">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="px-2 text-[13px] text-[var(--text-secondary)] tabular-nums">
|
||||
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
<button type="button" className={bouton} onClick={() => aller(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" className={bouton} onClick={() => aller(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-4 flex flex-col gap-2 bg-[var(--bg-surface)] border-[var(--border)] shadow-[var(--shadow-sm)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--text-secondary)]">{label}</span>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0 text-[var(--text-muted)]" aria-hidden />}
|
||||
</div>
|
||||
<div className="text-2xl font-bold tabular-nums tracking-tight text-[var(--text-primary)]">{value}</div>
|
||||
{(delta !== undefined || hint) && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-[13px] text-[var(--text-muted)]">
|
||||
{delta !== undefined && (
|
||||
<>
|
||||
<DeltaBadge pct={delta} />
|
||||
<span>{deltaLabel}</span>
|
||||
</>
|
||||
)}
|
||||
{hint && <span>{hint}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center",
|
||||
"border-[var(--border-strong)] bg-[var(--bg-surface)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-8 w-8 text-[var(--text-muted)]" aria-hidden />
|
||||
<p className="text-base font-semibold text-[var(--text-primary)]">{title}</p>
|
||||
{description && <p className="max-w-md text-sm text-[var(--text-secondary)]">{description}</p>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-xl border px-6 py-10 text-center",
|
||||
"border-[var(--accent-error)]/40 bg-[var(--accent-error-bg)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-8 w-8 text-[var(--accent-error)]" aria-hidden />
|
||||
<p className="text-base font-semibold text-[var(--text-primary)]">{title}</p>
|
||||
<p className="max-w-md text-sm text-[var(--text-secondary)]">{description}</p>
|
||||
{detail && (
|
||||
<details className="mt-1 max-w-xl text-left text-xs text-[var(--text-muted)]">
|
||||
<summary className="cursor-pointer">Détail technique</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{detail}</pre>
|
||||
</details>
|
||||
)}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("animate-pulse rounded-md bg-[var(--bg-elevated)]", className)} aria-hidden />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="space-y-6" aria-busy="true" aria-label="Chargement de la page">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-56" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
</div>
|
||||
{cards > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: cards }, (_, i) => <Skeleton key={i} className="h-28 rounded-xl" />)}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-4 space-y-3">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
{Array.from({ length: rows }, (_, i) => <Skeleton key={i} className="h-7 w-full" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<V extends string> {
|
||||
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<V extends string> {
|
||||
items: readonly TabItem<V>[];
|
||||
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<V extends string>({ items, value, onChange, disabled, className }: TabsProps<V>) {
|
||||
return (
|
||||
<div role="tablist" className={cn("flex shrink-0 gap-1 overflow-x-auto border-b border-[var(--border)]", className)}>
|
||||
{items.map((t) => {
|
||||
const actif = t.value === value;
|
||||
const Icone = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={actif}
|
||||
disabled={disabled}
|
||||
onClick={() => !actif && onChange(t.value)}
|
||||
className={cn(
|
||||
"-mb-px inline-flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors disabled:cursor-wait",
|
||||
actif
|
||||
? "border-[var(--accent)] text-[var(--text-primary)]"
|
||||
: "border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-strong)]",
|
||||
)}
|
||||
>
|
||||
{Icone && <Icone className="h-4 w-4" aria-hidden />}
|
||||
{t.label}
|
||||
{t.count !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
|
||||
t.alert && t.count > 0
|
||||
? "bg-[var(--accent-error)] text-white"
|
||||
: actif
|
||||
? "bg-[var(--accent-bg)] text-[var(--accent)]"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-secondary)]",
|
||||
)}
|
||||
>
|
||||
{t.count.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment de choix court (2 à 4 options), pour les bascules du type
|
||||
* « Fournisseur / Nomenclature » ou « Tous / Frouard / Houdemont ».
|
||||
*/
|
||||
export function Segmented<V extends string>({ items, value, onChange, disabled, className }: TabsProps<V>) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
className={cn("inline-flex w-fit shrink-0 gap-0.5 rounded-lg border p-0.5 bg-[var(--bg-elevated)] border-[var(--border)]", className)}
|
||||
>
|
||||
{items.map((t) => {
|
||||
const actif = t.value === value;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={actif}
|
||||
disabled={disabled}
|
||||
onClick={() => !actif && onChange(t.value)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors whitespace-nowrap disabled:cursor-wait",
|
||||
actif
|
||||
? "bg-[var(--bg-surface)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{t.count !== undefined && (
|
||||
<span className="ml-1.5 text-xs tabular-nums text-[var(--text-muted)]">{t.count.toLocaleString("fr-FR")}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<V extends string>(param: string, initial: V): [V, (value: V) => void] {
|
||||
const [value, setValue] = useState<V>(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];
|
||||
}
|
||||
@@ -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 (
|
||||
<TooltipPrimitive.Provider delayDuration={250} skipDelayDuration={100}>
|
||||
{children}
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TooltipPrimitive.Root>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
side={side}
|
||||
sideOffset={6}
|
||||
collisionPadding={8}
|
||||
className="z-[200] max-w-xs rounded-lg border px-3 py-2 text-[13px] leading-snug shadow-[var(--shadow-md)] animate-in fade-in-0 zoom-in-95"
|
||||
style={{ background: "var(--bg-surface)", borderColor: "var(--border-strong)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{content}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
<span className="font-semibold">{entree.terme}</span>
|
||||
<span className="block mt-0.5" style={{ color: "var(--text-secondary)" }}>{entree.definition}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className={cn("inline-flex items-center gap-1 cursor-help outline-none", className)}
|
||||
>
|
||||
{children ?? entree.terme}
|
||||
<Info className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Glossaire des termes métier affichés dans l'application.
|
||||
*
|
||||
* Une seule définition par terme, reprise par les infobulles (`<Terme>`) : 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<string, EntreeGlossaire>;
|
||||
|
||||
export type CleGlossaire = keyof typeof GLOSSAIRE;
|
||||
@@ -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<string, string> = { 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;
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
+3
-3
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user