feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair

Chaque page reprend le socle du lot 2 : en-tête avec une phrase
d'explication, couleurs du thème (plus de pages figées en blanc), textes
de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par
toast / confirmation, états vides et d'erreur expliqués.

- Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10
  avec lien vers la fiche produit.
- Révision d'assortiment : un seul endroit pour enregistrer et exporter
  (menu Export unique, mêmes droits pour tous), annulation confirmée, sens
  des gammes affiché partout, magasin choisi en un clic, colonnes nommées
  en clair, bandeau de progression au lieu de la fenêtre floue, droits lus
  côté serveur, ligne sélectionnée opaque sur les colonnes figées.
- Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures
  ventes, Ventes par mois (dernier mois complet par défaut), Publicités,
  Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation
  et Connexion migrés sur DataTable / Tabs / Card.
- Le nom de l'utilisateur est transmis à la session (affiché dans
  l'en-tête).
- Composants devenus inutiles supprimés (anciennes fenêtres modales,
  export-dropdown, financial-cell, supplier-selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 21:05:46 +00:00
1 parent 0c890c84c5
commit 220ca2263a
61 files changed
+6101 -6864

No files matched your search

@@ -1,271 +1,267 @@
"use client";
import React, { useState, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Calendar,
ChevronRight,
FileDown,
History,
Loader2,
PencilLine,
Save,
Trash2,
} from "lucide-react";
import { getSnapshots } from "../api/get-snapshots";
import { deleteSnapshot } from "../api/delete-snapshot";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useRouter } from "next/navigation";
import {
Trash2,
UploadCloud,
Calendar,
Store,
History,
ChevronRight,
Loader2,
AlertCircle,
} from "lucide-react";
import { SuccessModal } from "@/components/shared/success-modal";
import { Button } from "@/components/ui/button";
import { StoreBadge } from "@/components/ui/badge";
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
import { confirmer, toast } from "@/components/ui/feedback";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import { ConfirmModal } from "@/components/shared/confirm-modal";
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
}).format(date);
};
type Snapshot = Awaited<ReturnType<typeof getSnapshots>>[number];
/** Modifications enregistrées : codein → gamme avant / après. */
type Changements = Record<string, { before?: string | null; after: string }>;
interface Resume { totalCa?: number; tauxMargeGlobal?: number }
const fmtDateLongue = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function changementsDe(s: Snapshot): Changements {
return (s.changes ?? {}) as Changements;
}
function resumeDe(s: Snapshot): Resume | null {
return (s.summaryJson as Resume | null) ?? null;
}
function titreDe(s: Snapshot): string {
return s.label ?? s.nomFournisseur ?? s.codeFournisseur;
}
/**
* Onglet resté ouvert pendant un déploiement : les identifiants de Server
* Action ont changé côté serveur. On propose le rechargement au lieu
* d'afficher une erreur technique incompréhensible.
*/
async function proposerRechargement() {
const ok = await confirmer({
titre: "Page à recharger",
message: STALE_ACTION_MESSAGE,
libelleConfirmer: "Recharger la page",
});
if (ok) window.location.reload();
}
interface SnapshotListProps {
type?: "snapshot" | "export";
}
export function SnapshotList({ type }: SnapshotListProps) {
const [snapshots, setSnapshots] = useState<any[]>([]);
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ perime: boolean; detail: string } | null>(null);
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [modal, setModal] = useState<{
isOpen: boolean;
title: string;
message: string;
variant?: "success" | "error";
action?: { label: string; onClick: () => void };
}>({
isOpen: false,
title: "",
message: ""
});
/**
* Onglet resté ouvert pendant un déploiement : les identifiants de Server
* Action ont changé côté serveur. On propose le rechargement au lieu
* d'afficher une erreur technique incompréhensible.
*/
const modalErreur = (err: unknown, titre: string, message: string) =>
isStaleServerActionError(err)
? {
isOpen: true,
title: "Page à recharger",
message: STALE_ACTION_MESSAGE,
variant: "error" as const,
action: { label: "Recharger la page", onClick: () => window.location.reload() },
}
: { isOpen: true, title: titre, message, variant: "error" as const };
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
variant?: "danger" | "default";
onConfirm: () => void;
}>({ isOpen: false, title: "", message: "", onConfirm: () => { } });
const restoreSnapshot = useGridStore(state => state.restoreSnapshot);
const restoreSnapshot = useGridStore((state) => state.restoreSnapshot);
const router = useRouter();
const fetchSnapshots = async () => {
setLoading(true);
setError(null);
const estExport = type === "export";
// Aucun setState avant le premier await : appelable depuis l'effet de montage.
const fetchSnapshots = useCallback(async () => {
try {
const data = await getSnapshots(type);
setSnapshots(data);
setError(null);
} catch (err) {
setError(isStaleServerActionError(err) ? STALE_ACTION_MESSAGE : "Impossible de charger les données.");
setError({
perime: isStaleServerActionError(err),
detail: err instanceof Error ? err.message : String(err),
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSnapshots();
}, [type]);
const handleDelete = (id: number) => {
setConfirmModal({
isOpen: true,
title: "Supprimer",
message: `Supprimer cet ${type === 'export' ? 'export' : 'snapshot'} définitivement ?`,
variant: "danger",
onConfirm: async () => {
setIsDeleting(id);
try {
const res = await deleteSnapshot(id);
if (res.success) {
setSnapshots(prev => prev.filter(s => s.id !== id));
setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." });
} else {
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément.", variant: "error" });
}
} catch (err) {
setModal(modalErreur(err, "Erreur Technique", "Une erreur est survenue lors de la suppression."));
} finally {
setIsDeleting(null);
}
},
});
useEffect(() => { void fetchSnapshots(); }, [fetchSnapshots]);
const reessayer = () => {
setLoading(true);
void fetchSnapshots();
};
const handleLoad = (snapshot: any) => {
setConfirmModal({
isOpen: true,
title: "Restaurer la session",
message: `Charger la session "${snapshot.label}" ? Cela remplacera vos brouillons actuels.`,
variant: "default",
onConfirm: () => {
const changes: Record<string, string> = {};
Object.entries(snapshot.changes as any).forEach(([codein, delta]: [string, any]) => {
changes[codein] = delta.after;
});
restoreSnapshot(changes);
const query = new URLSearchParams();
if (snapshot.codeFournisseur) query.set("fournisseur", snapshot.codeFournisseur);
if (snapshot.magasin) query.set("magasin", snapshot.magasin);
router.push(`/grid?${query.toString()}`);
},
const handleDelete = async (s: Snapshot) => {
const ok = await confirmer({
titre: estExport ? "Retirer cet export de l'historique ?" : "Supprimer cette session ?",
message: estExport
? `« ${titreDe(s)} » sera retiré définitivement de votre historique. Le fichier Excel déjà téléchargé n'est pas concerné.`
: `« ${titreDe(s)} » sera supprimée définitivement : vous ne pourrez plus la reprendre dans la Grille.`,
libelleConfirmer: "Supprimer",
danger: true,
});
if (!ok) return;
setIsDeleting(s.id);
try {
const res = await deleteSnapshot(s.id);
if (res.success) {
setSnapshots((prev) => prev.filter((x) => x.id !== s.id));
toast.succes(estExport ? "L'export a été retiré de votre historique." : "La session a été supprimée de votre historique.");
} else {
toast.erreur(`La suppression a échoué : ${res.error ?? "erreur inconnue"}.`);
}
} catch (err) {
if (isStaleServerActionError(err)) void proposerRechargement();
else toast.erreur("Une erreur est survenue pendant la suppression. Réessayez dans un instant.");
} finally {
setIsDeleting(null);
}
};
const handleLoad = async (s: Snapshot) => {
const ok = await confirmer({
titre: "Reprendre dans la Grille ?",
message: `Vos modifications en cours dans la Grille seront remplacées par celles de ${estExport ? "cet export" : "cette session"}.`,
libelleConfirmer: "Reprendre dans la Grille",
});
if (!ok) return;
const changes: Record<string, string> = {};
Object.entries(changementsDe(s)).forEach(([codein, delta]) => {
changes[codein] = delta.after;
});
restoreSnapshot(changes);
const query = new URLSearchParams();
if (s.codeFournisseur) query.set("fournisseur", s.codeFournisseur);
if (s.magasin) query.set("magasin", s.magasin);
router.push(`/grid?${query.toString()}`);
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-20 space-y-4">
<Loader2 className="w-8 h-8 text-[var(--accent)] animate-spin" />
<p className="text-[var(--text-muted)] text-sm">Récupération de l&apos;historique...</p>
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique">
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-24 w-full rounded-xl" />)}
</div>
);
}
if (error && snapshots.length === 0) {
return (
<div className="border border-[var(--accent-error)] rounded-2xl p-10 text-center bg-[var(--accent-error-bg)]">
<AlertCircle className="w-8 h-8 mx-auto mb-3 text-[var(--accent-error)]" />
<h3 className="text-[var(--text-primary)] font-bold mb-1">L&apos;historique n&apos;a pas pu être chargé</h3>
<p className="text-[var(--text-secondary)] text-sm mb-4">{error}</p>
<button onClick={fetchSnapshots} className="btn-action btn-action-secondary">
Réessayer
</button>
</div>
return error.perime ? (
<ErrorState
title="Page à recharger"
description={STALE_ACTION_MESSAGE}
action={<Button onClick={() => window.location.reload()}>Recharger la page</Button>}
/>
) : (
<ErrorState
title="L'historique n'a pas pu être chargé"
detail={error.detail}
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
/>
);
}
if (snapshots.length === 0) {
return (
<div className="border border-dashed border-[var(--border-strong)] rounded-2xl p-16 text-center bg-[var(--bg-elevated)]/30">
<div className="w-16 h-16 rounded-full bg-[var(--bg-elevated)] flex items-center justify-center mx-auto mb-4 border border-[var(--border)]">
<History className="w-8 h-8 text-[var(--text-muted)]" />
</div>
<h3 className="text-[var(--text-primary)] font-bold mb-1">
{type === "export" ? "Aucun export pour le moment" : "Aucun snapshot pour le moment"}
</h3>
<p className="text-[var(--text-secondary)] text-sm max-w-xs mx-auto">
{type === "export"
? "Vos exports Excel apparaîtront ici pour historique."
: "Capturez l'état de votre travail depuis la grille pour le retrouver ici ultérieurement."}
</p>
</div>
return estExport ? (
<EmptyState
icon={FileDown}
title="Aucun export"
description="Dans la Grille, chaque export des gammes modifiées (menu « Exporter », « Gammes modifiées seulement ») est noté ici, pour retrouver ce qui a été envoyé."
/>
) : (
<EmptyState
icon={History}
title="Aucune session enregistrée"
description="Dans la Grille, ouvrez le menu « Exporter » puis choisissez « Garder une copie dans l'Historique » : vos modifications de gammes seront enregistrées ici et vous pourrez les reprendre plus tard."
/>
);
}
return (
<div className="grid gap-3">
{snapshots.map((s) => (
<div
key={s.id}
className="group relative flex items-center gap-6 p-5 rounded-xl border transition-all hover:shadow-lg bg-[var(--bg-surface)] border-[var(--border)] hover:border-[var(--accent-border)]"
>
<div className="w-11 h-11 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center shrink-0">
<Store className="w-5 h-5 text-[var(--accent)]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-sm font-bold text-[var(--text-primary)] truncate">{s.label}</h3>
<span className="px-2 py-0.5 rounded-full bg-[var(--accent-bg)] border border-[var(--accent-border)] text-[10px] font-black uppercase text-[var(--accent)] tracking-wider">
{s.magasin || "Magasin Inconnu"}
</span>
</div>
<div className="flex items-center gap-4 text-[11px] text-[var(--text-muted)] font-medium">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 opacity-60" />
{formatDate(new Date(s.createdAt))}
</span>
<span className="flex items-center gap-1.5">
<UploadCloud className="w-3.5 h-3.5 opacity-60" />
{Object.keys(s.changes as any).length} modifications enregistrées
</span>
</div>
</div>
{s.summaryJson && (
<div className="hidden xl:flex items-center gap-8 border-l border-[var(--border)] pl-8 mr-4">
<div className="flex flex-col">
<span className="text-[9px] uppercase font-black text-[var(--text-muted)] tracking-wider">CA</span>
<span className="text-xs font-bold text-[var(--accent-success)]">
{(s.summaryJson.totalCa || 0).toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
</span>
<ul className="grid gap-3">
{snapshots.map((s) => {
const nbModifs = Object.keys(changementsDe(s)).length;
const resume = resumeDe(s);
const taux = resume?.tauxMargeGlobal;
const IconeType = estExport ? FileDown : Save;
return (
<li
key={s.id}
className="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:border-[var(--accent-border)]"
>
<div className="flex min-w-0 flex-1 basis-72 items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)]">
<IconeType className="h-5 w-5 text-[var(--accent)]" aria-hidden />
</div>
<div className="flex flex-col">
<span className="text-[9px] uppercase font-black text-[var(--text-muted)] tracking-wider">Marge</span>
<span className="text-xs font-bold text-[var(--text-secondary)]">
{s.summaryJson.tauxMargeGlobal?.toFixed(1)}%
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--text-primary)]">
{titreDe(s)}
</h3>
<StoreBadge code={s.magasin} />
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] text-[var(--text-secondary)]">
{s.createdAt && (
<span className="inline-flex items-center gap-1.5">
<Calendar className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
{fmtDateLongue.format(new Date(s.createdAt))}
</span>
)}
<span className="inline-flex items-center gap-1.5">
<PencilLine className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
{fmtEntier(nbModifs)} changement{nbModifs > 1 ? "s" : ""} de gamme
</span>
</div>
</div>
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={() => handleLoad(s)}
className="apple-btn-primary flex items-center gap-2"
>
Restaurer
<ChevronRight className="w-4 h-4" />
</button>
{resume && (
<dl className="flex items-center gap-6 md:border-l md:border-[var(--border)] md:pl-6">
<div>
<dt className="text-xs font-medium text-[var(--text-muted)]">Chiffre d&apos;affaires</dt>
<dd className="text-sm font-semibold tabular-nums text-[var(--text-primary)]">
{fmtEur0(resume.totalCa ?? 0)}
</dd>
</div>
{taux != null && Number.isFinite(taux) && (
<div>
<dt className="text-xs font-medium text-[var(--text-muted)]">Taux de marge</dt>
<dd className="text-sm font-semibold tabular-nums" style={{ color: couleurMarge(taux) }}>
{fmtDecimal1(taux)} %
</dd>
</div>
)}
</dl>
)}
<button
onClick={() => handleDelete(s.id)}
disabled={isDeleting === s.id}
className="p-2.5 rounded-xl border border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:border-[var(--accent-error)] transition-all active:scale-95"
title="Supprimer"
>
{isDeleting === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</div>
</div>
))}
<SuccessModal
isOpen={modal.isOpen}
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
title={modal.title}
message={modal.message}
variant={modal.variant}
action={modal.action}
/>
<ConfirmModal
isOpen={confirmModal.isOpen}
onClose={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={confirmModal.onConfirm}
title={confirmModal.title}
message={confirmModal.message}
variant={confirmModal.variant}
confirmLabel={confirmModal.variant === "danger" ? "Supprimer" : "Confirmer"}
/>
</div>
<div className="flex items-center gap-2">
<Button onClick={() => handleLoad(s)}>
Reprendre dans la Grille
<ChevronRight />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => handleDelete(s)}
disabled={isDeleting === s.id}
aria-label={estExport ? "Supprimer cet export" : "Supprimer cette session"}
title="Supprimer"
className="text-[var(--text-secondary)] hover:border-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
>
{isDeleting === s.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
</Button>
</div>
</li>
);
})}
</ul>
);
}