mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-23 15:08:12 +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
@@ -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];
|
||||
}
|
||||
Reference in new issue
Block a user