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

@@ -8,31 +8,42 @@
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
*/
import { useEffect, useState } from "react";
import { Copy, Check, ExternalLink, Bot } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Copy, Check, ExternalLink, Bot, ChevronRight } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/feedback";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const copier = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.erreur("La copie automatique a échoué : sélectionnez le texte et copiez-le à la main.");
}
};
return (
<button
<Button
type="button"
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="shrink-0 rounded-lg px-2 py-1.5"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
variant="outline"
size="icon-sm"
onClick={copier}
aria-label="Copier"
title="Copier"
className="shrink-0"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
{copied ? <Check className="text-[var(--accent-success)]" /> : <Copy />}
</Button>
);
}
function CodeLine({ children, copy }: { children: string; copy?: string }) {
return (
<div className="flex items-start gap-2">
<code
className="flex-1 rounded-lg px-2.5 py-1.5 text-[11px] font-mono break-all whitespace-pre-wrap"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
>
<code className="flex-1 whitespace-pre-wrap break-all rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-1.5 font-mono text-[13px] text-[var(--text-primary)]">
{children}
</code>
<CopyButton text={copy ?? children} />
@@ -40,25 +51,47 @@ function CodeLine({ children, copy }: { children: string; copy?: string }) {
);
}
function Bloc({ titre, children }: { titre: ReactNode; children: ReactNode }) {
return (
<section className="space-y-2">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{titre}</h3>
{children}
</section>
);
}
/** Référence technique repliée : utile aux développeurs, superflue pour les autres. */
function Repli({ titre, children }: { titre: ReactNode; children: ReactNode }) {
return (
<details className="group rounded-lg border border-[var(--border)]">
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2.5 text-sm font-semibold text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] [&::-webkit-details-marker]:hidden">
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--text-muted)] transition-transform group-open:rotate-90" aria-hidden />
{titre}
</summary>
<div className="space-y-2 border-t border-[var(--border)] p-3">{children}</div>
</details>
);
}
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nb d'articles et CA" },
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la date de calcul de leur grille" },
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de la grille d'un fournisseur" },
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nombre d'articles et chiffre d'affaires" },
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche de produits, tous fournisseurs confondus" },
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
{ method: "GET", path: "/openapi.json", desc: "Spécification lisible par machine" },
{ method: "GET", path: "/network/{codeCentrale}", desc: "Ventes du réseau (Qlik) et courbe sur 12 mois" },
{ method: "GET", path: "/openapi.json", desc: "Description de l'API lisible par un programme (format OpenAPI)" },
];
const PARAMS: Array<{ name: string; desc: string }> = [
{ name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100" },
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
{ name: "nomenclature", desc: "Préfixe de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)" },
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature" },
{ name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" },
{ name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" },
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" },
{ name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100." },
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc." },
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale." },
{ name: "nomenclature", desc: "Début du code de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)." },
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature." },
{ name: "fields", desc: "Champs à conserver, séparés par des virgules : allège fortement la réponse." },
{ name: "enrich", desc: "1 par défaut : ventes du réseau (Qlik) et gamme enregistrée relues à chaque appel. 0 pour s'en dispenser." },
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer tout de suite." },
];
export function ApiConnectionInfo() {
@@ -70,128 +103,150 @@ export function ApiConnectionInfo() {
const base = `${origin || "https://votre-domaine"}/api/v1`;
return (
<div className="space-y-5">
{/* Connexion */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>URL de base</p>
<CodeLine>{base}</CodeLine>
<p className="text-[12px] font-semibold pt-1" style={{ color: "var(--text-primary)" }}>Authentification</p>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Une clé créée ci-dessous, dans l&apos;un ou l&apos;autre de ces en-têtes. Depuis un navigateur
connecté, la session suffit — aucune clé n&apos;est nécessaire.
</p>
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
</div>
<Card>
<CardHeader
title="Se connecter à l'API"
description="L'API permet à un script ou à un outil externe de lire les données de CollectFlow : grilles des fournisseurs, recherche de produits, ventes du réseau. Elle ne permet aucune modification."
/>
<CardContent className="space-y-6">
<Bloc titre="Adresse de base">
<CodeLine>{base}</CodeLine>
</Bloc>
{/* Endpoints */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Endpoints</p>
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<tbody>
{ENDPOINTS.map((e, i) => (
<tr key={e.path} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 font-mono font-bold align-top" style={{ color: "var(--accent)", width: 40 }}>{e.method}</td>
<td className="px-2.5 py-1.5 font-mono align-top" style={{ color: "var(--text-primary)" }}>{e.path}</td>
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{e.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Bloc titre="Identification">
<p className="text-[13px] text-[var(--text-secondary)]">
Chaque appel doit porter une clé (à créer dans le cadre « Clés d&apos;accès à l&apos;API » ci-dessous),
dans l&apos;un ou l&apos;autre de ces en-têtes. Depuis un navigateur déjà connecté à CollectFlow,
aucune clé n&apos;est nécessaire.
</p>
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
</Bloc>
{/* Paramètres */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Paramètres communs</p>
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<tbody>
{PARAMS.map((p, i) => (
<tr key={p.name} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 font-mono align-top whitespace-nowrap" style={{ color: "var(--text-primary)" }}>{p.name}</td>
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Exemples */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Exemples</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
Tout un fournisseur en un seul appel — il suffit d&apos;omettre <code className="font-mono">limit</code>,
et <code className="font-mono">meta.complet</code> confirme qu&apos;il ne reste rien à lire :
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
Gros fournisseur (plusieurs dizaines de milliers d&apos;articles) : lister d&apos;abord les
postes de nomenclature, puis les traiter un par un.
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
</div>
{/* Branchement d'une IA externe (ChatGPT) */}
<div className="space-y-2">
<p className="text-[12px] font-semibold flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
<Bot className="w-4 h-4" style={{ color: "var(--accent)" }} />
Connecter une IA externe (ChatGPT)
</p>
<ol className="text-[11px] space-y-1.5 list-decimal pl-4" style={{ color: "var(--text-secondary)" }}>
<li>Créez une clé d&apos;API dans la section ci-dessous et copiez-la.</li>
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
<li>
Cliquez sur <strong>Importer depuis une URL</strong> et collez l&apos;adresse du schéma :
<div className="mt-1"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
<span style={{ color: "var(--text-muted)" }}>
Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l&apos;importer.
Votre application doit être joignable depuis Internet.
{/* Branchement d'une IA externe (ChatGPT) */}
<Bloc
titre={
<span className="inline-flex items-center gap-2">
<Bot className="h-4 w-4 text-[var(--accent)]" aria-hidden />
Connecter une IA externe (ChatGPT)
</span>
</li>
<li>
Dans <strong>Authentification</strong>, choisissez <strong>Clé d&apos;API</strong>, type{" "}
<strong>Personnalisé</strong>, nom d&apos;en-tête <code className="font-mono">X-API-Key</code>,
et collez votre clé.
</li>
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
</ol>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Si votre domaine public diffère de celui affiché ici, renseignez la variable
d&apos;environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l&apos;URL
déclarée dans le schéma.
</p>
</div>
}
>
<ol className="list-decimal space-y-2 pl-5 text-[13px] text-[var(--text-secondary)]">
<li>Créez une clé dans le cadre « Clés d&apos;accès à l&apos;API » ci-dessous et copiez-la.</li>
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
<li>
Cliquez sur <strong>Importer depuis une URL</strong> et collez l&apos;adresse de la description de l&apos;API :
<div className="mt-1.5"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
<span className="mt-1 block text-[var(--text-muted)]">
Cette description est publique (elle ne contient aucune donnée) pour que ChatGPT puisse la lire.
L&apos;application doit être joignable depuis Internet.
</span>
</li>
<li>
Dans <strong>Authentification</strong>, choisissez <strong>Clé d&apos;API</strong>, type{" "}
<strong>Personnalisé</strong>, nom d&apos;en-tête <code className="font-mono">X-API-Key</code>,
et collez votre clé.
</li>
<li>Testez avec une question du type « cherche les produits tapis » : le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
</ol>
<p className="text-xs text-[var(--text-muted)]">
Si l&apos;adresse publique de l&apos;application diffère de celle affichée ici, renseignez la variable
d&apos;environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l&apos;adresse
annoncée dans la description.
</p>
</Bloc>
{/* Comportement à connaître */}
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
<p><strong>Aucun recalcul.</strong> L&apos;API lit l&apos;instantané de la grille, écrit quand la Grille
est ouverte dans l&apos;application. Un fournisseur jamais consulté renvoie <code className="font-mono">202</code>{" "}
<code className="font-mono">not_ready</code> plutôt que de faire attendre.</p>
<p><strong>Métriques Qlik</strong> (<code className="font-mono">network</code>) et{" "}
<strong>gamme serveur non modifiée</strong> (<code className="font-mono">codeGammeServeur</code>) sont
relues à chaque appel. <code className="font-mono">network</code> vaut{" "}
<code className="font-mono">null</code> quand le produit n&apos;a pas de données réseau.</p>
<p>Erreurs : <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec{" "}
<code className="font-mono">401</code> (clé absente/invalide/révoquée),{" "}
<code className="font-mono">400</code>, <code className="font-mono">404</code>.</p>
</div>
<div className="space-y-2">
<Repli titre="Adresses disponibles (endpoints)">
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
<tr>
<th scope="col" className="px-3 py-2 text-left">Méthode</th>
<th scope="col" className="px-3 py-2 text-left">Adresse</th>
<th scope="col" className="px-3 py-2 text-left">Contenu</th>
</tr>
</thead>
<tbody>
{ENDPOINTS.map((e) => (
<tr key={e.path} className="border-t border-[var(--border)] align-top">
<td className="px-3 py-2 font-mono text-sm font-semibold text-[var(--accent)]">{e.method}</td>
<td className="px-3 py-2 font-mono text-sm text-[var(--text-primary)]">{e.path}</td>
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{e.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</Repli>
<a
href="/api/v1/openapi.json"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--accent)" }}
>
Ouvrir la spécification OpenAPI <ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
<Repli titre="Paramètres communs">
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
<tr>
<th scope="col" className="px-3 py-2 text-left">Paramètre</th>
<th scope="col" className="px-3 py-2 text-left">Effet</th>
</tr>
</thead>
<tbody>
{PARAMS.map((p) => (
<tr key={p.name} className="border-t border-[var(--border)] align-top">
<td className="whitespace-nowrap px-3 py-2 font-mono text-sm text-[var(--text-primary)]">{p.name}</td>
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</Repli>
<Repli titre="Exemples d'appels">
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
Tout un fournisseur en un seul appel : il suffit d&apos;omettre <code className="font-mono">limit</code>,
et <code className="font-mono">meta.complet</code> confirme qu&apos;il ne reste rien à lire.
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
Gros fournisseur (plusieurs dizaines de milliers d&apos;articles) : lister d&apos;abord les
postes de nomenclature, puis les traiter un par un.
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
</Repli>
</div>
{/* Comportement à connaître */}
<div className="space-y-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] p-4 text-[13px] text-[var(--text-secondary)]">
<p className="font-semibold text-[var(--text-primary)]">Bon à savoir</p>
<p>
<strong>L&apos;API lit des grilles déjà calculées</strong>, enregistrées quand la Grille est ouverte dans
l&apos;application ou par le préchauffage ci-dessous. Un fournisseur jamais calculé l&apos;est au premier
appel, qui est alors plus lent ; avec <code className="font-mono">compute=0</code>, l&apos;API répond
aussitôt <code className="font-mono">202 not_ready</code>.
</p>
<p>
<strong>Les ventes du réseau</strong> (<code className="font-mono">network</code>) et <strong>la gamme
enregistrée</strong> (<code className="font-mono">codeGammeServeur</code>) sont relues à chaque appel.{" "}
<code className="font-mono">network</code> vaut <code className="font-mono">null</code> quand le produit
n&apos;a pas de données réseau.
</p>
<p>
En cas d&apos;erreur, la réponse a la forme <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec
le code <code className="font-mono">401</code> (clé absente, invalide ou révoquée), <code className="font-mono">400</code>{" "}
(paramètre incorrect) ou <code className="font-mono">404</code> (introuvable).
</p>
</div>
<Button asChild variant="outline">
<a href="/api/v1/openapi.json" target="_blank" rel="noreferrer">
Ouvrir la description complète de l&apos;API (OpenAPI) <ExternalLink />
</a>
</Button>
</CardContent>
</Card>
);
}
@@ -7,33 +7,55 @@
* création, puis irrécupérable (seul son hachage est stocké côté base).
*/
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, type FormEvent } from "react";
import { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input, Label, Select } from "@/components/ui/form-controls";
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
import { confirmer, toast } from "@/components/ui/feedback";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
type Role = "admin" | "user";
const ROLES = [
{ value: "user", label: "Utilisateur" },
{ value: "admin", label: "Administrateur" },
] as const;
const LIBELLE_ROLE: Record<string, string> = { admin: "Administrateur", user: "Utilisateur" };
function fmtDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
}
function messageErreur(e: unknown): string {
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
return e instanceof Error ? e.message : String(e);
}
export function ApiKeyManagement() {
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
const [loading, setLoading] = useState(true);
const [erreurChargement, setErreurChargement] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [role, setRole] = useState<Role>("user");
const [freshKey, setFreshKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [enCours, setEnCours] = useState<number | null>(null);
// Pas de setState synchrone ici : `loading` démarre déjà à true, et le premier
// statement est un await. Cela évite une cascade de rendus au montage.
const refresh = useCallback(async () => {
try {
setKeys(await getApiKeys());
setError(null);
setErreurChargement(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur de chargement");
setErreurChargement(messageErreur(e));
} finally {
setLoading(false);
}
@@ -41,177 +63,233 @@ export function ApiKeyManagement() {
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
// d'un état dérivé. `refresh` n'appelle setState qu'après un await.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => { void refresh(); }, [refresh]);
const handleCreate = async (e: React.FormEvent) => {
const reessayer = () => {
setLoading(true);
void refresh();
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
if (!name.trim() || creating) return;
const nom = name.trim();
if (!nom || creating) return;
setCreating(true);
setError(null);
const res = await createApiKey(name.trim(), role);
if (res.success) {
setFreshKey(res.key);
setName("");
setCopied(false);
await refresh();
} else {
setError(res.error);
try {
const res = await createApiKey(nom, role);
if (res.success) {
setFreshKey(res.key);
setName("");
setCopied(false);
toast.succes(`Clé « ${nom} » créée. Copiez-la maintenant : elle ne sera plus affichée.`);
await refresh();
} else {
toast.erreur(res.error);
}
} catch (err) {
toast.erreur(`La clé n'a pas pu être créée : ${messageErreur(err)}`);
} finally {
setCreating(false);
}
setCreating(false);
};
const handleRevoke = async (id: number) => {
await revokeApiKey(id);
await refresh();
const copier = async () => {
if (!freshKey) return;
try {
await navigator.clipboard.writeText(freshKey);
setCopied(true);
toast.succes("Clé copiée dans le presse-papiers.");
} catch {
toast.erreur("La copie automatique a échoué : sélectionnez la clé et copiez-la à la main.");
}
};
const handleDelete = async (id: number) => {
await deleteApiKey(id);
await refresh();
const handleRevoke = async (k: ApiKeyRow) => {
const ok = await confirmer({
titre: `Révoquer la clé « ${k.name} » ?`,
message: "Les scripts et outils qui l'utilisent seront refusés dès leur prochain appel. La clé reste dans la liste pour garder la trace de son utilisation.",
libelleConfirmer: "Révoquer",
danger: true,
});
if (!ok) return;
setEnCours(k.id);
try {
const res = await revokeApiKey(k.id);
if (res.success) toast.succes(`La clé « ${k.name} » est révoquée.`);
else toast.erreur(res.error ?? "La clé n'a pas pu être révoquée.");
await refresh();
} catch (err) {
toast.erreur(`La clé n'a pas pu être révoquée : ${messageErreur(err)}`);
} finally {
setEnCours(null);
}
};
const handleDelete = async (k: ApiKeyRow) => {
const ok = await confirmer({
titre: `Supprimer définitivement la clé « ${k.name} » ?`,
message: "Elle disparaîtra de la liste, avec la trace de sa dernière utilisation.",
libelleConfirmer: "Supprimer",
danger: true,
});
if (!ok) return;
setEnCours(k.id);
try {
const res = await deleteApiKey(k.id);
if (res.success) toast.succes(`La clé « ${k.name} » a été supprimée.`);
else toast.erreur(res.error ?? "La clé n'a pas pu être supprimée.");
await refresh();
} catch (err) {
toast.erreur(`La clé n'a pas pu être supprimée : ${messageErreur(err)}`);
} finally {
setEnCours(null);
}
};
return (
<div className="space-y-4">
<p className="text-[12px] text-[var(--text-secondary)]">
Les clés donnent accès à l&apos;API de lecture <code className="font-mono">/api/v1</code> (grille,
recherche, métriques réseau) depuis un script ou un outil externe, via l&apos;en-tête{" "}
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
</p>
{/* Clé fraîchement créée — visible une seule fois */}
{freshKey && (
<div className="rounded-xl p-3.5 space-y-2"
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
<Card>
<CardHeader
title="Clés d'accès à l'API"
description={
<>
Une clé permet à un script ou à un outil externe (ChatGPT, tableur…) de lire les données de
CollectFlow sans se connecter. Depuis un navigateur déjà connecté, aucune clé n&apos;est nécessaire.
</>
}
/>
<CardContent className="space-y-5">
{/* Clé fraîchement créée — visible une seule fois */}
{freshKey && (
<div className="space-y-3 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)] p-4">
<p className="flex items-start gap-2 text-sm font-semibold text-[var(--text-primary)]">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent)]" aria-hidden />
Copiez cette clé maintenant : elle ne sera plus jamais affichée.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 break-all rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2 font-mono text-[13px] text-[var(--text-primary)]">
{freshKey}
</code>
<Button variant="outline" onClick={copier}>
{copied ? <Check className="text-[var(--accent-success)]" /> : <Copy />}
{copied ? "Copiée" : "Copier"}
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setFreshKey(null)}>
J&apos;ai copié la clé, la masquer
</Button>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-lg px-2.5 py-2 text-[12px] font-mono break-all"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
{freshKey}
</code>
<button
type="button"
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
className="rounded-lg px-2.5 py-2 shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
title="Copier"
>
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
</button>
)}
{/* Création */}
<form onSubmit={handleCreate} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end">
<div>
<Label htmlFor="cle-nom">Nom de la clé</Label>
<Input
id="cle-nom"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="ex. Export comptabilité"
className="w-full"
autoComplete="off"
/>
</div>
<button
type="button"
onClick={() => setFreshKey(null)}
className="text-[11px] underline"
style={{ color: "var(--text-muted)" }}
>
J&apos;ai copié la clé, masquer
</button>
</div>
)}
<Select
id="cle-role"
label="Droits"
value={role}
onChange={(v) => setRole(v as Role)}
options={ROLES}
/>
<Button type="submit" disabled={creating || !name.trim()}>
{creating ? <Loader2 className="animate-spin" /> : <Plus />}
Créer la clé
</Button>
</form>
<p className="-mt-2 text-xs text-[var(--text-muted)]">
Donnez un nom qui dit à quoi sert la clé : vous saurez laquelle révoquer le jour où l&apos;outil n&apos;est plus utilisé.
La clé s&apos;envoie dans l&apos;en-tête <code className="font-mono">X-API-Key</code> de chaque appel.
</p>
{/* Création */}
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nom de la clé (ex. Export comptabilité)"
className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as "admin" | "user")}
className="rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
>
<option value="user">Rôle : utilisateur</option>
<option value="admin">Rôle : administrateur</option>
</select>
<button
type="submit"
disabled={creating || !name.trim()}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Créer
</button>
</form>
{error && (
<p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>
)}
{/* Liste */}
{loading ? (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Chargement…
</div>
) : keys.length === 0 ? (
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune clé pour le moment.</p>
) : (
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Nom</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Préfixe</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Rôle</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Créée</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Dernier usage</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id} style={{ borderTop: "1px solid var(--border)", opacity: k.revokedAt ? 0.55 : 1 }}>
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>
<span className="inline-flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
{k.name}
</span>
{k.revokedAt && (
<span className="ml-2 rounded px-1.5 py-px text-[9px] font-bold uppercase"
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)" }}>
Révoquée
</span>
)}
</td>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-secondary)" }}>{k.keyPrefix}…</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{k.role}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.createdAt)}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.lastUsedAt)}</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{!k.revokedAt ? (
<button
onClick={() => handleRevoke(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--accent-error)" }}
title="Révoquer"
>
<Ban className="w-3.5 h-3.5" /> Révoquer
</button>
) : (
<button
onClick={() => handleDelete(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--text-muted)" }}
title="Supprimer définitivement"
>
<Trash2 className="w-3.5 h-3.5" /> Supprimer
</button>
)}
</td>
{/* Liste */}
{loading ? (
<div className="space-y-2" aria-busy="true" aria-label="Chargement des clés">
{Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : erreurChargement ? (
<ErrorState
title="La liste des clés n'a pas pu être chargée"
detail={erreurChargement}
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
/>
) : keys.length === 0 ? (
<EmptyState
icon={KeyRound}
title="Aucune clé pour le moment"
description="Créez une clé ci-dessus pour permettre à un outil externe d'interroger l'API."
/>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
<tr>
<th scope="col" className="px-3 py-2 text-left">Nom</th>
<th scope="col" className="px-3 py-2 text-left">Début de la clé</th>
<th scope="col" className="px-3 py-2 text-left">Droits</th>
<th scope="col" className="px-3 py-2 text-left">Créée le</th>
<th scope="col" className="px-3 py-2 text-left">Dernière utilisation</th>
<th scope="col" className="px-3 py-2 text-right"><span className="sr-only">Actions</span></th>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id} className="border-t border-[var(--border)]">
<td className="px-3 py-2 text-sm font-medium text-[var(--text-primary)]">
<span className={k.revokedAt ? "inline-flex items-center gap-2 opacity-60" : "inline-flex items-center gap-2"}>
<KeyRound className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
{k.name}
</span>
{k.revokedAt && (
<Badge ton="erreur" className="ml-2" title={`Révoquée le ${fmtDate(k.revokedAt)}`}>Révoquée</Badge>
)}
</td>
<td className="px-3 py-2 font-mono text-sm text-[var(--text-secondary)]">{k.keyPrefix}…</td>
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{LIBELLE_ROLE[k.role] ?? k.role}</td>
<td className="px-3 py-2 text-sm whitespace-nowrap text-[var(--text-secondary)]">{fmtDate(k.createdAt)}</td>
<td className="px-3 py-2 text-sm whitespace-nowrap text-[var(--text-secondary)]">
{k.lastUsedAt ? fmtDate(k.lastUsedAt) : "Jamais"}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{!k.revokedAt ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleRevoke(k)}
disabled={enCours === k.id}
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
>
{enCours === k.id ? <Loader2 className="animate-spin" /> : <Ban />}
Révoquer
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(k)}
disabled={enCours === k.id}
className="text-[var(--text-secondary)]"
>
{enCours === k.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
Supprimer
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
+85 -71
View File
@@ -10,7 +10,11 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
import { Loader2, Play, CheckCircle2, AlertTriangle, Database } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/feedback";
import { fmtEntier } from "@/lib/format";
interface WarmupState {
status: "idle" | "running" | "success" | "error";
@@ -26,7 +30,7 @@ interface WarmupState {
export function GridWarmup() {
const [state, setState] = useState<WarmupState | null>(null);
const [error, setError] = useState<string | null>(null);
const [demarrage, setDemarrage] = useState(false);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const running = state?.status === "running";
@@ -57,92 +61,102 @@ export function GridWarmup() {
}, [running, fetchState]);
const start = async () => {
setError(null);
setDemarrage(true);
let echec: string | null = null;
try {
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
const data = await res.json();
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
setState(data);
if (res.ok) setState(data);
else echec = data.error ?? `erreur ${res.status}`;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
echec = e instanceof Error ? e.message : String(e);
}
setDemarrage(false);
if (echec) toast.erreur(`Le préchauffage n'a pas pu démarrer : ${echec}`);
};
const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
return (
<div className="space-y-3">
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
L&apos;API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
l&apos;application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
Ce bouton calcule <strong>tous les fournisseurs</strong> ; ceux déjà à jour depuis moins de 24 h sont
sautés, il est donc sans risque de le relancer.
</p>
<Card>
<CardHeader
title="Préchauffage des données de l'API"
description="Préchauffage : calcule à l'avance les grilles de tous les fournisseurs pour que l'API réponde immédiatement."
/>
<CardContent className="space-y-4">
<p className="text-sm text-[var(--text-secondary)]">
L&apos;API lit les grilles déjà calculées, enregistrées quand un fournisseur est ouvert dans la Grille.
Sans préchauffage, un outil externe ne trouverait tout de suite que les fournisseurs déjà consultés.
Les fournisseurs calculés il y a moins de 24 h sont sautés : vous pouvez relancer sans risque.
</p>
<div className="flex items-center gap-2.5">
<button
onClick={start}
disabled={running}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
</button>
{state && state.total > 0 && (
<span className="text-[12px] tabular-nums" style={{ color: "var(--text-secondary)" }}>
{traites} / {state.total} ({pct} %)
</span>
)}
</div>
{running && state && (
<div className="space-y-1.5">
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
<div className="h-full transition-all" style={{ width: `${pct}%`, background: "var(--accent)" }} />
</div>
{state.currentFournisseur && (
<p className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>
En cours : {state.currentFournisseur}
</p>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={start} disabled={running || demarrage}>
{running || demarrage ? <Loader2 className="animate-spin" /> : <Play />}
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
</Button>
{state && state.total > 0 && (
<span className="text-[13px] tabular-nums text-[var(--text-secondary)]">
{fmtEntier(traites)} fournisseurs sur {fmtEntier(state.total)} ({pct} %)
</span>
)}
</div>
)}
{state?.status === "success" && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
<span>
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
<strong>{state.skipped}</strong> déjà à jour
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
</span>
</div>
)}
{running && state && (
<div className="space-y-1.5">
<div
className="h-2 overflow-hidden rounded-full bg-[var(--bg-elevated)]"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-label="Avancement du préchauffage"
>
<div className="h-full bg-[var(--accent)] transition-all" style={{ width: `${pct}%` }} />
</div>
{state.currentFournisseur && (
<p className="truncate text-[13px] text-[var(--text-muted)]">
En cours : {state.currentFournisseur}
</p>
)}
</div>
)}
{state && state.failed > 0 && state.lastErrors.length > 0 && (
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
<ul className="mt-1 pl-4 list-disc space-y-0.5">
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
</ul>
</details>
)}
{state?.status === "success" && (
<p className="flex items-start gap-2 text-sm text-[var(--text-secondary)]">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent-success)]" aria-hidden />
<span>
Terminé : <strong className="text-[var(--text-primary)]">{fmtEntier(state.done)}</strong> fournisseur(s) calculé(s),{" "}
<strong className="text-[var(--text-primary)]">{fmtEntier(state.skipped)}</strong> déjà à jour
{state.failed > 0 && <>, <strong className="text-[var(--accent-error)]">{fmtEntier(state.failed)} en échec</strong></>}.
</span>
</p>
)}
{(error || state?.error) && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
<span>{error ?? state?.error}</span>
</div>
)}
{state && state.failed > 0 && state.lastErrors.length > 0 && (
<details className="text-[13px] text-[var(--text-secondary)]">
<summary className="cursor-pointer font-medium">Voir les échecs ({state.lastErrors.length} premiers)</summary>
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-xs">
{state.lastErrors.map((e, i) => <li key={i} className="break-all font-mono">{e}</li>)}
</ul>
</details>
)}
{state?.status === "idle" && (
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
<Database className="w-3.5 h-3.5" />
Aucun préchauffage lancé depuis le démarrage de l&apos;application.
</p>
)}
</div>
{state?.error && (
<p role="alert" className="flex items-start gap-2 text-sm text-[var(--accent-error)]">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<span>{state.error}</span>
</p>
)}
{state?.status === "idle" && (
<p className="flex items-center gap-2 text-[13px] text-[var(--text-muted)]">
<Database className="h-4 w-4" aria-hidden />
Aucun préchauffage lancé depuis le démarrage de l&apos;application.
</p>
)}
</CardContent>
</Card>
);
}
+311 -186
View File
@@ -1,209 +1,334 @@
"use client";
import { useState, useEffect } from "react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { KeyRound, Loader2, Shield, Trash2, User, UserPlus } from "lucide-react";
import { getUsers, createUser, deleteUser, updatePassword } from "../api/user-actions";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input, Label, Select } from "@/components/ui/form-controls";
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
import { confirmer, toast } from "@/components/ui/feedback";
import {
UserPlus,
Trash2,
Shield,
User,
Loader2,
Check,
Key,
} from "lucide-react";
import { SuccessModal } from "@/components/shared/success-modal";
import { ChangePasswordModal } from "@/components/shared/change-password-modal";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
export function UserManagement() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState<"admin" | "user">("user");
const [modal, setModal] = useState({ isOpen: false, title: "", message: "" });
const [pwdModal, setPwdModal] = useState<{ isOpen: boolean; userId: number; username: string }>({
isOpen: false, userId: 0, username: ""
});
type Utilisateur = Awaited<ReturnType<typeof getUsers>>[number];
type Role = "admin" | "user";
const fetchUsers = async () => {
setLoading(true);
try {
const data = await getUsers();
setUsers(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const ROLES = [
{ value: "user", label: "Utilisateur" },
{ value: "admin", label: "Administrateur" },
] as const;
useEffect(() => {
fetchUsers();
}, []);
function messageErreur(e: unknown): string {
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
return e instanceof Error ? e.message : String(e);
}
const handleCreate = async (e: React.FormEvent) => {
function fmtJour(valeur: Date | string | null | undefined): string {
if (!valeur) return "—";
const d = new Date(valeur);
return isNaN(d.getTime()) ? "—" : d.toLocaleDateString("fr-FR");
}
/**
* Changement du mot de passe d'un compte. Monté à l'ouverture (clé = compte) :
* les champs repartent vides à chaque fois.
*/
function DialogueMotDePasse({ cible, onFermer }: { cible: Utilisateur; onFermer: () => void }) {
const [motDePasse, setMotDePasse] = useState("");
const [confirmation, setConfirmation] = useState("");
const [erreur, setErreur] = useState<string | null>(null);
const [envoi, setEnvoi] = useState(false);
const valider = async (e: FormEvent) => {
e.preventDefault();
setIsCreating(true);
const res = await createUser(newUsername, newPassword, newRole);
if (res.success) {
setModal({
isOpen: true,
title: "Utilisateur Créé",
message: `L'utilisateur ${newUsername} a été ajouté avec succès.`
});
setNewUsername("");
setNewPassword("");
fetchUsers();
} else {
alert(res.error);
setErreur(null);
if (motDePasse.length < 4) {
setErreur("Le mot de passe doit faire au moins 4 caractères.");
return;
}
setIsCreating(false);
};
const handleUpdatePassword = (id: number, username: string) => {
setPwdModal({ isOpen: true, userId: id, username });
};
const handleConfirmPassword = async (newPassword: string) => {
const res = await updatePassword(pwdModal.userId, newPassword);
if (res.success) {
setModal({
isOpen: true,
title: "Mot de passe mis à jour",
message: `Le mot de passe de ${pwdModal.username} a été modifié avec succès.`
});
} else {
throw new Error(res.error ?? "Erreur lors de la mise à jour.");
if (motDePasse !== confirmation) {
setErreur("Les deux mots de passe ne sont pas identiques.");
return;
}
setEnvoi(true);
try {
const res = await updatePassword(cible.id, motDePasse);
if (!res.success) {
setErreur(res.error ?? "Erreur lors de la mise à jour.");
return;
}
toast.succes(`Le mot de passe de « ${cible.username} » a été modifié.`);
onFermer();
} catch (err) {
setErreur(messageErreur(err));
} finally {
setEnvoi(false);
}
};
const handleDelete = async (id: number, username: string) => {
if (!window.confirm(`Supprimer l'utilisateur ${username} ?`)) return;
const res = await deleteUser(id);
if (res.success) {
setUsers(users.filter(u => u.id !== id));
} else {
alert(res.error);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-12 space-y-4">
<Loader2 className="w-8 h-8 text-[var(--accent)] animate-spin" />
<p className="text-[var(--text-muted)] text-sm">Chargement des utilisateurs...</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Formulaire de création */}
<div className="bg-[var(--bg-elevated)] border border-[var(--border)] rounded-2xl p-6">
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
<UserPlus className="w-6 h-6 text-[var(--accent)]" />
<Dialog open onOpenChange={(open) => { if (!open && !envoi) onFermer(); }}>
<DialogContent className="sm:max-w-md">
<form onSubmit={valider} className="grid gap-4">
<DialogHeader>
<DialogTitle>Changer le mot de passe</DialogTitle>
<DialogDescription className="text-[var(--text-secondary)]">
Compte « {cible.username} ». Le nouveau mot de passe sera demandé dès la prochaine connexion.
</DialogDescription>
</DialogHeader>
<div>
<Label htmlFor="mdp-nouveau">Nouveau mot de passe</Label>
<Input id="mdp-nouveau" type="password" className="w-full" autoFocus autoComplete="new-password"
value={motDePasse} onChange={(e) => setMotDePasse(e.target.value)} />
<p className="mt-1 text-xs text-[var(--text-muted)]">Au moins 4 caractères.</p>
</div>
<div>
<h2 className="text-lg font-bold text-[var(--text-primary)]">Nouvel Utilisateur</h2>
<p className="text-[var(--text-secondary)] text-xs">Ajoutez un collaborateur à la plateforme.</p>
<Label htmlFor="mdp-confirmation">Confirmer le mot de passe</Label>
<Input id="mdp-confirmation" type="password" className="w-full" autoComplete="new-password"
value={confirmation} onChange={(e) => setConfirmation(e.target.value)} />
</div>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Utilisateur</label>
<input
required
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className="apple-input"
placeholder="ex: jean.dupont"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Mot de passe</label>
<input
required
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="apple-input"
placeholder="••••••••"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Rôle</label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value as any)}
className="apple-input"
>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
</div>
<button
type="submit"
disabled={isCreating}
className="apple-btn-primary"
>
{isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Créer le compte
</button>
{erreur && (
<p role="alert" className="text-[13px] text-[var(--accent-error)]">{erreur}</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onFermer} disabled={envoi}>Annuler</Button>
<Button type="submit" disabled={envoi}>
{envoi && <Loader2 className="animate-spin" />}
Enregistrer le mot de passe
</Button>
</DialogFooter>
</form>
</div>
{/* Liste des utilisateurs */}
<div className="grid gap-3">
<h3 className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1 mb-1">Liste des comptes</h3>
{users.map((u) => (
<div key={u.id} className="flex items-center gap-5 p-4 rounded-xl bg-[var(--bg-surface)] border border-[var(--border)] hover:border-[var(--text-muted)] transition-all group">
<div className="w-10 h-10 rounded-xl bg-[var(--bg-elevated)] flex items-center justify-center shrink-0 border border-[var(--border)]">
{u.role === "admin" ? <Shield className="w-5 h-5 text-amber-500" /> : <User className="w-5 h-5 text-[var(--text-muted)]" />}
</DialogContent>
</Dialog>
);
}
export function UserManagement() {
const [users, setUsers] = useState<Utilisateur[]>([]);
const [chargement, setChargement] = useState(true);
const [erreurChargement, setErreurChargement] = useState<string | null>(null);
const [creation, setCreation] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState<Role>("user");
const [suppression, setSuppression] = useState<number | null>(null);
const [cibleMotDePasse, setCibleMotDePasse] = useState<Utilisateur | null>(null);
// Aucun setState avant le premier await : appelable depuis l'effet de montage.
const charger = useCallback(async () => {
try {
setUsers(await getUsers());
setErreurChargement(null);
} catch (e) {
setErreurChargement(messageErreur(e));
} finally {
setChargement(false);
}
}, []);
useEffect(() => { void charger(); }, [charger]);
const reessayer = () => {
setChargement(true);
void charger();
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
setCreation(true);
try {
const res = await createUser(newUsername, newPassword, newRole);
if (res.success) {
toast.succes(`Le compte « ${newUsername} » a été créé : il peut se connecter dès maintenant.`);
setNewUsername("");
setNewPassword("");
void charger();
} else {
toast.erreur(res.error ?? "Le compte n'a pas pu être créé.");
}
} catch (err) {
toast.erreur(`Le compte n'a pas pu être créé : ${messageErreur(err)}`);
} finally {
setCreation(false);
}
};
const handleDelete = async (u: Utilisateur) => {
const ok = await confirmer({
titre: `Supprimer le compte « ${u.username} » ?`,
message: "Cette personne ne pourra plus se connecter à CollectFlow. La suppression est définitive.",
libelleConfirmer: "Supprimer",
danger: true,
});
if (!ok) return;
setSuppression(u.id);
try {
const res = await deleteUser(u.id);
if (res.success) {
setUsers((prev) => prev.filter((x) => x.id !== u.id));
toast.succes(`Le compte « ${u.username} » a été supprimé.`);
} else {
toast.erreur(res.error ?? "Le compte n'a pas pu être supprimé.");
}
} catch (err) {
toast.erreur(`Le compte n'a pas pu être supprimé : ${messageErreur(err)}`);
} finally {
setSuppression(null);
}
};
return (
<>
{/* Création */}
<Card>
<CardHeader
title="Ajouter un utilisateur"
description="Le compte peut se connecter dès sa création, avec cet identifiant et ce mot de passe."
/>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div>
<Label htmlFor="nouvel-identifiant">Identifiant</Label>
<Input
id="nouvel-identifiant"
required
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className="w-full"
placeholder="ex. jean.dupont"
autoComplete="off"
/>
</div>
<div>
<Label htmlFor="nouveau-mdp">Mot de passe</Label>
<Input
id="nouveau-mdp"
required
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full"
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<div>
<Select
id="nouveau-role"
label="Rôle"
value={newRole}
onChange={(v) => setNewRole(v as Role)}
options={ROLES}
className="w-full"
/>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Un administrateur accède en plus à la synchronisation et aux paramètres.
</p>
</div>
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-[var(--text-primary)]">{u.username}</h4>
<span className="text-[10px] uppercase font-black text-[var(--text-muted)] tracking-wider">
{u.role === "admin" ? "Administrateur" : "Utilisateur Standard"}
</span>
<Button type="submit" disabled={creation}>
{creation ? <Loader2 className="animate-spin" /> : <UserPlus />}
Créer le compte
</Button>
</form>
</CardContent>
</Card>
{/* Liste */}
<Card>
<CardHeader
title="Comptes"
description="Les personnes autorisées à se connecter à CollectFlow."
actions={!chargement && !erreurChargement ? <Badge>{users.length} compte{users.length > 1 ? "s" : ""}</Badge> : undefined}
/>
<CardContent>
{chargement ? (
<div className="space-y-2" aria-busy="true" aria-label="Chargement des comptes">
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleUpdatePassword(u.id, u.username)}
className="p-2.5 rounded-lg hover:bg-[var(--accent-bg)] text-[var(--text-muted)] hover:text-[var(--accent)] transition-colors"
title="Changer le mot de passe"
>
<Key className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(u.id, u.username)}
className="p-2.5 rounded-lg hover:bg-[var(--accent-error-bg)] text-[var(--text-muted)] hover:text-[var(--accent-error)] transition-colors"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</button>
) : erreurChargement ? (
<ErrorState
title="La liste des comptes n'a pas pu être chargée"
detail={erreurChargement}
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
/>
) : users.length === 0 ? (
<EmptyState
icon={User}
title="Aucun compte"
description="Créez le premier compte avec le formulaire ci-dessus."
/>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
<tr>
<th scope="col" className="px-3 py-2 text-left">Identifiant</th>
<th scope="col" className="px-3 py-2 text-left">Rôle</th>
<th scope="col" className="px-3 py-2 text-left">Créé le</th>
<th scope="col" className="px-3 py-2 text-right"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-[var(--border)] hover:bg-[var(--bg-elevated)]">
<td className="px-3 py-2 text-sm font-medium text-[var(--text-primary)]">
<span className="inline-flex items-center gap-2">
{u.role === "admin"
? <Shield className="h-4 w-4 text-[var(--accent)]" aria-hidden />
: <User className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />}
{u.username}
</span>
</td>
<td className="px-3 py-2 text-sm">
{u.role === "admin"
? <Badge ton="accent">Administrateur</Badge>
: <Badge>Utilisateur</Badge>}
</td>
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{fmtJour(u.createdAt)}</td>
<td className="px-3 py-2 text-right">
<div className="inline-flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => setCibleMotDePasse(u)}>
<KeyRound /> Mot de passe
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(u)}
disabled={suppression === u.id}
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
>
{suppression === u.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
Supprimer
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
<SuccessModal
isOpen={modal.isOpen}
onClose={() => setModal({ ...modal, isOpen: false })}
title={modal.title}
message={modal.message}
/>
<ChangePasswordModal
isOpen={pwdModal.isOpen}
onClose={() => setPwdModal({ ...pwdModal, isOpen: false })}
onConfirm={handleConfirmPassword}
username={pwdModal.username}
/>
</div>
)}
</CardContent>
</Card>
{cibleMotDePasse && (
<DialogueMotDePasse
key={cibleMotDePasse.id}
cible={cibleMotDePasse}
onFermer={() => setCibleMotDePasse(null)}
/>
)}
</>
);
}
+42 -43
View File
@@ -2,88 +2,87 @@
import { useActionState } from "react";
import { loginAction } from "../api/login-action";
import { Loader2, Lock, User, ShieldCheck } from "lucide-react";
import { AlertCircle, Loader2, Lock, ShieldCheck, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/form-controls";
export function LoginForm() {
const [error, action, isPending] = useActionState(loginAction, undefined);
return (
<div className="w-full max-w-md p-8 rounded-[2rem] bg-[var(--bg-surface)] border border-[var(--border)] shadow-lg" style={{ boxShadow: "var(--shadow-md)" }}>
<div className="flex flex-col items-center mb-10 text-center">
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center mb-6">
<ShieldCheck className="w-8 h-8 text-[var(--accent)]" />
<div className="w-full max-w-sm rounded-2xl border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--shadow-md)]">
<div className="mb-8 flex flex-col items-center text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)]">
<ShieldCheck className="h-6 w-6 text-[var(--accent)]" aria-hidden />
</div>
<h1 className="text-3xl font-black text-[var(--text-primary)] tracking-tight mb-2">
CollectFlow
</h1>
<p className="text-[var(--text-secondary)] text-sm font-medium">
Connectez-vous pour accéder à votre espace d&apos;arbitrage.
<h1 className="text-2xl font-bold tracking-tight text-[var(--text-primary)]">CollectFlow</h1>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Connectez-vous avec votre identifiant et votre mot de passe.
</p>
</div>
<form action={action} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">
Utilisateur
<form action={action} className="space-y-5">
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-[var(--text-primary)]">
Identifiant
</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)] group-focus-within:text-[var(--accent)] transition-colors" />
<input
<div className="relative">
<User 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
id="username"
name="username"
type="text"
required
placeholder="admin"
className="w-full pl-12 pr-4 py-4 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus:border-[var(--accent)] outline-none transition-all text-[var(--text-primary)] font-medium"
autoComplete="username"
placeholder="Votre identifiant"
className="h-11 w-full pl-10"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">
<div className="space-y-1.5">
<label htmlFor="password" className="block text-sm font-medium text-[var(--text-primary)]">
Mot de passe
</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)] group-focus-within:text-[var(--accent)] transition-colors" />
<input
<div className="relative">
<Lock 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
id="password"
name="password"
type="password"
required
placeholder="••••••••"
className="w-full pl-12 pr-4 py-4 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus:border-[var(--accent)] outline-none transition-all text-[var(--text-primary)] font-medium"
autoComplete="current-password"
placeholder="Votre mot de passe"
className="h-11 w-full pl-10"
/>
</div>
</div>
{error && (
<div className="p-4 rounded-xl bg-[var(--accent-error-bg)] border border-[var(--accent-error)] text-[var(--accent-error)] text-xs font-bold flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
<div className="w-1.5 h-1.5 rounded-full bg-[var(--accent-error)] shrink-0" />
<div
role="alert"
className="flex items-start gap-2.5 rounded-lg border border-[var(--accent-error)]/40 bg-[var(--accent-error-bg)] px-3 py-2.5 text-sm text-[var(--text-primary)]"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent-error)]" aria-hidden />
{error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-4 rounded-xl bg-[var(--accent)] hover:opacity-90 disabled:opacity-50 text-white font-black transition-all active:scale-[0.98] shadow-lg flex items-center justify-center gap-2 group"
style={{ background: "var(--accent)" }}
>
<Button type="submit" size="lg" disabled={isPending} className="h-11 w-full">
{isPending ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Connexion...
<Loader2 className="animate-spin" />
Connexion…
</>
) : (
"Se connecter"
)}
</button>
</Button>
</form>
<div className="mt-8 text-center border-t border-[var(--border)] pt-8">
<p className="text-[10px] text-[var(--text-muted)] uppercase font-black tracking-widest leading-relaxed">
Plateforme d&apos;Arbitrage Magasin<br />
Propulsée par l&apos;Intelligence Artificielle
</p>
</div>
<p className="mt-6 border-t border-[var(--border)] pt-5 text-center text-[13px] text-[var(--text-muted)]">
Révision d&apos;assortiment et suivi des ventes des magasins
</p>
</div>
);
}
@@ -307,7 +307,7 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
{/* Réglages : quel magasin, et jusqu'où dans le classement */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasin</span>
<span className="text-[13px] font-medium" style={{ color: "var(--text-muted)" }}>Magasin</span>
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
{sites.map((s) => (
<button
@@ -327,7 +327,7 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Profondeur</span>
<span className="text-[13px] font-medium" style={{ color: "var(--text-muted)" }}>Profondeur</span>
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
{PROFONDEURS.map((p) => (
<button
@@ -345,7 +345,7 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
))}
</div>
{classement.length < profondeur && (
<span className="text-[11.5px]" style={{ color: "var(--text-muted)" }}>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
({classement.length} lignes affichées seulement)
</span>
)}
@@ -420,13 +420,13 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
<tr key={row.codein} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: "var(--text-muted)" }}>{rang}</td>
<td className="px-2.5 py-1.5 tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>{row.codein}</td>
<td className="px-2.5 py-1.5 font-mono text-[11.5px] whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
<td className="px-2.5 py-1.5 font-mono text-xs whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{row.reference || "—"}
</td>
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }} title={row.libelle1}>
<span className="font-semibold">{row.libelle1}</span>
{row.libelle3 && (
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--text-muted)" }}>{row.libelle3}</span>
<span className="ml-1.5 text-xs" style={{ color: "var(--text-muted)" }}>{row.libelle3}</span>
)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
@@ -449,7 +449,7 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
</div>
)}
<p className="text-[11.5px] leading-snug" style={{ color: "var(--text-muted)" }}>
<p className="text-xs leading-snug" style={{ color: "var(--text-muted)" }}>
Non travaillé = aucune vente sur les 12 mois glissants ET aucun stock au dernier mois connu, à {nomMagasin}.
Un produit sans vente mais en stock n&apos;apparaît pas ici : le magasin le détient, c&apos;est un invendu, pas un trou d&apos;assortiment.
</p>
@@ -3,14 +3,10 @@
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import { GAMMES } from "@/lib/gammes";
import { Button } from "@/components/ui/button";
const GAMME_OPTIONS: { label: string; code: GammeCode; color: string }[] = [
{ label: "A — Cœur", code: "A", color: "border-emerald-600 text-emerald-400 hover:bg-emerald-900/20" },
{ label: "B — Complémentaire", code: "B", color: "border-blue-600 text-blue-400 hover:bg-blue-900/20" },
{ label: "C — Saisonnier", code: "C", color: "border-amber-600 text-amber-400 hover:bg-amber-900/20" },
{ label: "Y — En veille", code: "Y", color: "border-violet-600 text-violet-400 hover:bg-violet-900/20" },
{ label: "Z — Sortie", code: "Z", color: "border-rose-600 text-rose-400 hover:bg-rose-900/20" },
];
interface BulkActionToolbarProps {
selectedCodeins: string[];
@@ -32,30 +28,28 @@ export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkAct
};
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
<div
role="region"
aria-label="Actions sur la sélection"
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border px-4 py-2 border-[var(--accent-border)] bg-[var(--accent-bg)] animate-in fade-in slide-in-from-top-2 duration-200"
>
<span className="text-sm font-semibold text-[var(--text-primary)]">
{selectedCodeins.length} produit{selectedCodeins.length > 1 ? "s" : ""} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
</span>
<div className="h-4 w-px bg-slate-700/50" />
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-tight">Appliquer en masse :</span>
{GAMME_OPTIONS.map(({ label, code, color }) => (
<span className="text-[13px] text-[var(--text-secondary)]">— leur donner la gamme :</span>
{GAMMES.map((g) => (
<button
key={code}
onClick={() => applyBulk(code)}
className={cn(
"px-3 py-1 text-xs font-bold border rounded transition-colors",
color
)}
key={g.code}
onClick={() => applyBulk(g.code as GammeCode)}
title={g.description}
className={cn("rounded-md border px-3 py-1 text-[13px] font-semibold transition-opacity hover:opacity-80", g.classes)}
>
{label}
{g.code} — {g.nom}
</button>
))}
<button
onClick={onClearSelection}
className="ml-auto text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Annuler
</button>
<Button variant="ghost" size="sm" onClick={onClearSelection} className="ml-auto">
Annuler la sélection
</Button>
</div>
);
}
@@ -1,244 +0,0 @@
"use client";
import { useState } from "react";
import { Download, FileSpreadsheet, FileText, ChevronDown, Table2 } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
// exceljs, jspdf et jspdf-autotable (~1 Mo) ne sont chargés qu'au clic : importés
// en tête de fichier, ils alourdissaient la page Grille pour tous les utilisateurs.
// Les lignes sont lues dans le store au moment de l'export, sans abonnement : le
// menu n'a pas à se redessiner à chaque modification de la Grille.
export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string }) {
const [isOpen, setIsOpen] = useState(false);
const handleExportAllProducts = async () => {
setIsOpen(false);
const { rows, draftChanges } = useGridStore.getState();
if (rows.length === 0) {
alert("Aucune donnée à exporter.");
return;
}
const supplier = nomFournisseur || rows[0]?.nomFournisseur || "Fournisseur";
const payload = {
nomFournisseur: supplier,
rows: rows.map(r => ({
codein: r.codein,
libelle1: r.libelle1 || "",
code3: r.code3 || "",
libelle3: r.libelle3 || "",
codeGamme: (draftChanges[r.codein] ?? r.codeGamme) as string | null,
totalQuantite: r.totalQuantite,
totalCa: r.totalCa,
tauxMarge: r.tauxMarge,
gammeAvant: r.codeGammeInit ?? null,
})),
};
try {
const res = await fetch("/api/export/excel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Erreur lors de l'export");
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Assortiment_Complet_${supplier.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
alert("Une erreur s'est produite lors de l'export Excel.");
}
};
const handleExportExcel = async () => {
setIsOpen(false);
const { rows, draftChanges } = useGridStore.getState();
const { Workbook } = await import("exceljs");
const workbook = new Workbook();
const worksheet = workbook.addWorksheet("Gamme A");
worksheet.columns = [
{ header: "Magasin(s)", key: "magasins", width: 25 },
{ header: "Gencode", key: "gtin", width: 15 },
{ header: "Référence", key: "reference", width: 20 },
{ header: "Libellé", key: "libelle", width: 45 },
{ header: "Gamme", key: "gamme", width: 10 },
];
// Format Header
worksheet.getRow(1).font = { bold: true };
// Filter for effective Gamme A
const gammeARows = rows.filter((row) => {
const effectiveGamme = draftChanges[row.codein] ?? row.codeGamme;
return effectiveGamme === "A";
});
gammeARows.forEach((row) => {
worksheet.addRow({
magasins: row.workingStores.join(", "),
gtin: row.gtin || row.codein, // Fallback if no true gtin
reference: row.reference || "",
libelle: row.libelle1 || "",
gamme: draftChanges[row.codein] ?? row.codeGamme,
});
});
const buffer = await workbook.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 = `Export_Gamme_A_${new Date().toISOString().split("T")[0]}.xlsx`;
a.click();
URL.revokeObjectURL(url);
};
const handlePrintPDF = async () => {
setIsOpen(false);
const { rows, draftChanges } = useGridStore.getState();
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
import("jspdf"),
import("jspdf-autotable"),
]);
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
doc.setFontSize(16);
doc.text("Analyse d'Assortiment", 14, 15);
doc.setFontSize(14);
doc.setTextColor(50);
if (nomFournisseur) {
doc.text(nomFournisseur, 14, 23);
}
doc.setFontSize(10);
doc.setTextColor(100);
doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30);
const head = [
["Gencode", "Code In", "Réf.", "Libellé", "Mag. Rés.", "Vol.", "CA", "Marge", "Gamme"]
];
const body = rows.map(r => {
const effectiveGamme = draftChanges[r.codein] ?? r.codeGamme;
return [
r.gtin || r.codein,
r.codein,
r.reference || "-",
r.libelle1 ? r.libelle1.substring(0, 60) + (r.libelle1.length > 60 ? "..." : "") : "",
r.nbMagasinsReseau != null ? r.nbMagasinsReseau.toString() : "-",
Math.round(r.totalQuantite).toLocaleString("fr-FR"),
`${Math.round(r.totalCa).toLocaleString("fr-FR")} €`,
`${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`,
effectiveGamme || "-"
];
});
// @ts-ignore (autotable acts as a plugin)
autoTable(doc, {
head,
body,
startY: 35,
styles: { fontSize: 8, cellPadding: 2, lineColor: [200, 200, 200], lineWidth: 0.1 },
headStyles: { fillColor: [15, 23, 42], textColor: [255, 255, 255], fontStyle: "bold", halign: "center" },
columnStyles: {
0: { cellWidth: 28 }, // Gencode
1: { cellWidth: 18, fontStyle: "bold", halign: "center" }, // Code In
2: { cellWidth: 25 }, // Réf
3: { cellWidth: 80 }, // Libellé
4: { cellWidth: 12, halign: "center", fontStyle: "bold", textColor: [16, 185, 129] }, // Score
},
theme: "grid",
didDrawPage: (data: any) => {
// Footer pagination
doc.setFontSize(8);
doc.text(
`Page ${data.pageNumber}`,
data.settings.margin.left,
doc.internal.pageSize.height - 10
);
}
});
doc.save(`Assortiment_${new Date().toISOString().split("T")[0]}.pdf`);
};
return (
<div className="relative z-50 print:hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="btn-action btn-action-secondary flex items-center gap-2"
title="Exporter les données"
>
<Download className="w-[14px] h-[14px]" />
<span className="text-[13px] font-semibold">Exporter</span>
<ChevronDown className="w-[12px] h-[12px] opacity-60" />
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-56 rounded-xl border z-50 overflow-hidden shadow-lg animate-in fade-in slide-in-from-top-2"
style={{
background: "var(--bg-elevated)",
borderColor: "var(--border-strong)"
}}
>
<div className="p-1">
<button
onClick={handleExportExcel}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
>
<FileSpreadsheet className="w-4 h-4 text-emerald-500" />
<div>
<div className="font-semibold text-[var(--text-primary)]">Gamme A vers Excel</div>
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Gencode, Réf, Libellé...</div>
</div>
</button>
<div className="my-1 h-px bg-[var(--border)] mx-2" />
<button
onClick={handleExportAllProducts}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
>
<Table2 className="w-4 h-4 text-blue-400" />
<div>
<div className="font-semibold text-[var(--text-primary)]">Tous les produits (Excel)</div>
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Gamme avant/après, delta, stats 12m</div>
</div>
</button>
<div className="my-1 h-px bg-[var(--border)] mx-2" />
<button
onClick={handlePrintPDF}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
>
<FileText className="w-4 h-4 text-rose-500" />
<div>
<div className="font-semibold text-[var(--text-primary)]">Tableau en PDF</div>
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Mise en page épurée (Réf. incluse)</div>
</div>
</button>
</div>
</div>
</>
)}
</div>
);
}
@@ -1,52 +0,0 @@
"use client";
interface FinancialCellProps {
totalCa: number;
totalMarge: number;
tauxMarge: number;
}
export function FinancialCell({ totalCa, totalMarge, tauxMarge }: FinancialCellProps) {
const isHighMargin = tauxMarge >= 45;
const isMedMargin = tauxMarge >= 20 && tauxMarge < 45;
let textAccent = "var(--accent-error)";
let bgAccent = "var(--accent-error-bg)";
if (isHighMargin) {
textAccent = "var(--accent-success)";
bgAccent = "var(--accent-success-bg)";
} else if (isMedMargin) {
textAccent = "var(--accent-warning)";
bgAccent = "var(--accent-warning-bg)";
}
return (
<div
className="flex flex-col items-center justify-center rounded-lg py-1 shadow-sm w-full max-w-[130px] mx-auto transition-colors border-apple"
style={{
background: bgAccent,
borderColor: "transparent"
}}
>
<span
className="font-mono-nums text-[13px] font-bold whitespace-nowrap"
style={{ color: textAccent }}
>
{totalCa.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
<div className="flex items-center gap-1.5 mt-0.5 opacity-80">
<span
className="font-mono-nums text-[10px] px-1 py-0.5 rounded font-bold whitespace-nowrap bg-white/20 dark:bg-black/20"
style={{ color: textAccent }}
>
{tauxMarge.toFixed(1)}%
</span>
<span className="font-mono-nums text-[10px] font-semibold text-slate-500 dark:text-slate-400 whitespace-nowrap">
{totalMarge.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
</div>
</div>
);
}
@@ -1,38 +1,55 @@
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSession } from "next-auth/react";
"use client";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { SyncQlikButton } from "./sync-qlik-button";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } from "lucide-react";
import { ChevronDown, Download, FileSpreadsheet, FileText, History, Loader2, RotateCcw, Save, Table2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
import { SuccessModal } from "@/components/shared/success-modal";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { confirmer, toast } from "@/components/ui/feedback";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import {
exporterFichierGammes,
exporterGammeA,
exporterTousProduits,
imprimerPdf,
lignesModifiees,
} from "@/features/grid/lib/exports";
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
// Determine color based on label to match the prototype
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
function Stat({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) {
return (
// Barre maintenant dans le flux : chaque pixel de hauteur est pris au
// tableau. Ces chiffres sont déjà repris en gros dans l'en-tête de page,
// ils n'ont pas besoin d'y être une seconde fois en 20 px.
<div className="flex flex-col items-center leading-tight">
<span className="text-slate-500 text-[10px] uppercase font-black tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-base ${valColor}`}>{value}</span>
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
<div className="flex flex-col leading-tight">
<span className="text-xs text-[var(--text-muted)]">{label}</span>
<span className="text-base font-bold tabular-nums text-[var(--text-primary)]">
{value}
{sub && <span className="ml-1.5 text-[13px] font-semibold" style={{ color: subColor }}>{sub}</span>}
</span>
</div>
);
}
export function FloatingSummaryBar() {
const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
function messageErreur(err: unknown): string {
if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE;
return err instanceof Error ? err.message : String(err);
}
/**
* Barre du bas de la Grille : totaux du périmètre affiché et TOUTES les actions
* sur les gammes (annuler, enregistrer, exporter). Il y avait des doublons dans
* l'en-tête, avec des droits différents.
*/
export function FloatingSummaryBar({ isAdmin, nomFournisseur }: { isAdmin: boolean; nomFournisseur: string }) {
const summary = useGridStore((s) => s.summary);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const rows = useGridStore((s) => s.rows);
@@ -40,21 +57,8 @@ export function FloatingSummaryBar() {
// Magasin affiché (bascule de la Grille). `filters.magasin` n'est jamais
// renseigné : les sessions et validations étaient toutes notées « TOTAL ».
const activeMagasin = useGridStore((s) => s.activeMagasin);
const draftChanges = useGridStore((s) => s.draftChanges);
const [isPending, startTransition] = useTransition();
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const [modal, setModal] = useState<{
isOpen: boolean;
title: string;
message: string;
variant?: "success" | "error";
action?: { label: string; onClick: () => void };
}>({
isOpen: false,
title: "",
message: ""
});
const [enCours, setEnCours] = useState<string | null>(null);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
@@ -70,42 +74,52 @@ export function FloatingSummaryBar() {
const handleSave = () => {
startTransition(async () => {
const result = await save();
setSaveStatus(result.success ? "success" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
try {
const result = await save();
if (result.success) {
toast.succes(`${result.saved} gamme${result.saved > 1 ? "s" : ""} enregistrée${result.saved > 1 ? "s" : ""}.`);
} else {
toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`);
}
} catch (err) {
toast.erreur(`Enregistrement impossible : ${messageErreur(err)}`);
}
});
};
const handleReset = () => {
if (window.confirm("Es-tu sûr de vouloir annuler tous les changements non enregistrés (gammes) ?")) {
const handleReset = async () => {
const ok = await confirmer({
titre: "Annuler les modifications ?",
message: `Les ${count} gamme${count > 1 ? "s" : ""} modifiée${count > 1 ? "s" : ""} et non enregistrée${count > 1 ? "s" : ""} reviendront à leur valeur précédente.`,
libelleConfirmer: "Annuler les modifications",
danger: true,
});
if (ok) {
resetDrafts();
toast.info("Modifications annulées.");
}
};
/**
* Enregistre une copie de la session dans l'Historique.
* Renvoie l'identifiant créé, ou `null` en cas d'échec (déjà signalé).
*/
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot") => {
if (rows.length === 0) return;
setIsSavingSnapshot(true);
if (rows.length === 0) return null;
const { draftChanges } = useGridStore.getState();
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
const changes = Object.fromEntries(
lignesModifiees().map(r => [
r.codein,
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
])
);
try {
// Détecter TOUS les changements : drafts actifs + changements déjà validés (codeGamme mis à jour)
const modifiedRows = rows.filter(r => {
const effectiveGamme = draftChanges[r.codein] ?? r.codeGamme;
return effectiveGamme !== r.codeGammeInit;
});
const changes = Object.fromEntries(
modifiedRows.map(r => [
r.codein,
{
before: r.codeGammeInit,
after: (draftChanges[r.codein] ?? r.codeGamme) as string
}
])
);
const res = await saveSnapshot({
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
nomFournisseur: rows[0].nomFournisseur,
magasin: activeMagasin || "TOTAL",
label: labelOverride || `${type === 'export' ? 'Export' : 'Session'} ${rows[0].nomFournisseur} — ${new Date().toLocaleTimeString()}`,
label: labelOverride || `Session ${rows[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
changes,
type,
summary: {
@@ -113,263 +127,148 @@ export function FloatingSummaryBar() {
totalCa: summary.totalCa,
totalMarge: summary.totalMarge,
tauxMargeGlobal: summary.tauxMargeGlobal,
}
},
});
if (res.success) {
if (!labelOverride) {
setModal({
isOpen: true,
title: "Snapshot Enregistré",
message: "Votre session de travail a été sauvegardée avec succès."
});
}
return res.snapshotId;
} else {
throw new Error(res.error);
}
if (!res.success) throw new Error(res.error);
if (!labelOverride) toast.succes("Session enregistrée dans l'Historique.");
return res.snapshotId;
} catch (err) {
console.error(err);
if (!labelOverride) {
// Onglet resté ouvert pendant un déploiement : les identifiants de
// Server Action ont changé côté serveur. Rien à réparer côté
// métier, il faut recharger le bundle client.
if (isStaleServerActionError(err)) {
setModal({
isOpen: true,
title: "Page à recharger",
message: STALE_ACTION_MESSAGE,
variant: "error",
action: { label: "Recharger la page", onClick: () => window.location.reload() },
});
} else {
const msg = err instanceof Error ? err.message : String(err);
setModal({
isOpen: true,
title: "Erreur Snapshot",
message: `Impossible de créer le snapshot : ${msg}`,
variant: "error",
});
}
}
if (!labelOverride) toast.erreur(`La session n'a pas pu être enregistrée : ${messageErreur(err)}`);
return null;
}
};
/** Lance un export avec indicateur et messages d'erreur communs. */
const lancer = async (cle: string, action: () => Promise<boolean>, vide: string) => {
if (enCours) return;
setEnCours(cle);
try {
const ok = await action();
if (!ok) toast.info(vide);
} catch (err) {
toast.erreur(`L'export a échoué : ${messageErreur(err)}`);
} finally {
setIsSavingSnapshot(false);
setEnCours(null);
}
};
return (
<div
/*
* Dans le flux, plus en `fixed`.
*
* En `fixed` elle ne réservait aucune hauteur : seul un `pb-12` posé sur
* la page compensait, et il était trop court d'une quarantaine de pixels.
* Elle recouvrait donc la dernière ligne du tableau ET la barre de
* défilement horizontale — d'où l'impression qu'aucun ascenseur n'existait.
*
* Le `left-[264px]` était en prime calé sur une barre latérale dépliée,
* alors qu'elle est repliée par défaut (64 px) : 200 px de vide à gauche.
* En flux, la grille cède exactement la hauteur nécessaire et il n'y a
* plus aucune valeur magique à tenir en phase.
* Dans le flux, plus en `fixed` : la grille cède exactement la hauteur
* nécessaire, sans recouvrir la dernière ligne ni l'ascenseur horizontal.
*/
className="shrink-0 px-4 py-2.5 flex flex-wrap justify-between items-center gap-x-4 gap-y-2 shadow-sm border rounded-xl transition-colors bg-slate-100 dark:bg-slate-800"
style={{
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
}}
className="shrink-0 px-4 py-2.5 flex flex-wrap justify-between items-center gap-x-6 gap-y-2 rounded-xl border bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
style={{ borderColor: hasDrafts ? "var(--accent-warning)" : "var(--border-strong)" }}
>
<div className="flex items-center bg-white dark:bg-slate-900 shadow-sm border border-slate-200 dark:border-slate-800 rounded-full px-1.5 py-1.5 h-10">
<button
onClick={handleReset}
className="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors rounded-full"
title="Réinitialiser la page"
>
<RotateCcw className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-slate-200 dark:bg-slate-700 mx-1" />
<button
onClick={() => handleSnapshot()}
disabled={isSavingSnapshot}
className="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors rounded-full disabled:opacity-50"
title="Prendre un snapshot de la session"
>
{isSavingSnapshot ? <Loader2 className="w-4 h-4 animate-spin" /> : <Camera className="w-4 h-4" />}
</button>
{hasDrafts && (
<>
<div className="w-px h-4 bg-slate-200 dark:bg-slate-700 mx-1" />
<span className="ml-1 px-3 h-7 flex items-center justify-center rounded-full text-[11px] font-black tracking-wide text-[#b45309] dark:text-[#78350f] bg-[#fdf08a] hover:bg-[#fde047] transition-colors border border-[#fde047]/50 shadow-sm uppercase">
{count} MODIF.
</span>
</>
)}
</div>
<div className="flex space-x-12 items-center">
<div className="flex flex-wrap items-center gap-x-8 gap-y-1">
<Stat label="Produits actifs" value={fmtEntier(summary.totalRows)} sub={summary.totalProducts !== summary.totalRows ? `sur ${fmtEntier(summary.totalProducts)}` : undefined} subColor="var(--text-muted)" />
<Stat label="Quantités vendues (12 mois)" value={fmtEntier(summary.totalQuantite)} />
<Stat label="Chiffre d'affaires" value={fmtEur0(summary.totalCa)} />
<Stat
label="Nombre de Produits"
value={summary.totalProducts.toLocaleString("fr-FR")}
/>
<Stat
label="Volume Total Vendu"
value={Math.round(summary.totalQuantite).toLocaleString("fr-FR")}
/>
<Stat
label="CA Global Généré"
value={summary.totalCa.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
/>
<Stat
label="Taux de Marge Moyen"
value={`${summary.tauxMargeGlobal.toFixed(1)}%`}
sub={summary.totalMarge.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
label="Marge"
value={fmtEur0(summary.totalMarge)}
sub={`${fmtDecimal1(summary.tauxMargeGlobal)} %`}
subColor={couleurMarge(summary.tauxMargeGlobal)}
/>
</div>
<div className="flex space-x-3 items-center">
<div className="flex flex-wrap items-center gap-2">
{isAdmin && <SyncQlikButton codeFournisseur={supplierCode} lastUpdate={lastQlikUpdate} />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="btn-action btn-action-secondary flex items-center gap-1.5">
Export Excel
<ChevronDown className="w-3.5 h-3.5" />
</button>
<Button variant="outline" disabled={rows.length === 0 || enCours !== null}>
{enCours ? <Loader2 className="animate-spin" /> : <Download />}
Exporter
<ChevronDown className="opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="text-xs font-medium text-[var(--text-secondary)]">Fichier d&apos;import des gammes</DropdownMenuLabel>
<DropdownMenuItem
onClick={async () => {
const currentState = useGridStore.getState();
const currentDrafts = currentState.draftChanges;
const currentRows = currentState.rows;
const modifiedRows = currentRows.filter(r => {
const currentGamme = currentDrafts[r.codein] ?? r.codeGamme;
return currentGamme !== r.codeGammeInit;
});
if (modifiedRows.length === 0) {
alert("Aucun changement détecté par rapport à l'état initial.");
return;
}
const changes = modifiedRows.map(r => ({
codein: r.codein,
gtin: r.gtin,
codeFournisseur: r.codeFournisseur,
gamme: (currentDrafts[r.codein] ?? r.codeGamme) as string,
}));
const nomFournisseur = modifiedRows[0].nomFournisseur;
// Auto-save snapshot for history
await handleSnapshot(`Export ${nomFournisseur} — ${new Date().toLocaleTimeString()}`, "export");
try {
const res = await fetch("/api/export/modified-gammes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomFournisseur, changes }),
});
if (!res.ok) throw new Error("Erreur lors de l'export");
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Modifications_Gammes_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
alert("Une erreur s'est produite lors de l'export Excel.");
}
}}
className="cursor-pointer"
onSelect={() => lancer("modifs", async () => {
if (lignesModifiees().length === 0) return false;
const nom = lignesModifiees()[0].nomFournisseur;
// Copie dans l'Historique, onglet « Exports »
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export");
return exporterFichierGammes(true);
}, "Aucune gamme n'a été modifiée par rapport à FF.")}
>
<FileSpreadsheet />
<div className="flex flex-col">
<span className="font-medium">Seulement les changements</span>
<span className="text-xs text-muted-foreground">Exporter uniquement les gammes modifiées</span>
<span className="font-medium">Gammes modifiées seulement</span>
<span className="text-xs text-[var(--text-muted)]">Les produits dont la gamme a changé</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
const currentState = useGridStore.getState();
const currentDrafts = currentState.draftChanges;
const currentRows = currentState.rows;
if (currentRows.length === 0) {
alert("Aucune donnée à exporter.");
return;
}
const nomFournisseur = currentRows[0].nomFournisseur;
// Toutes les lignes au format Excel (CODE FOURNISSEUR, GENCOD, GAMME)
const changes = currentRows.map(r => ({
codein: r.codein,
gtin: r.gtin,
codeFournisseur: r.codeFournisseur,
gamme: (currentDrafts[r.codein] ?? r.codeGamme) as string,
}));
try {
const res = await fetch("/api/export/modified-gammes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomFournisseur, changes }),
});
if (!res.ok) throw new Error("Erreur lors de l'export");
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Export_Complet_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
alert("Une erreur s'est produite lors de l'export Excel.");
}
}}
className="cursor-pointer"
onSelect={() => lancer("complet", () => exporterFichierGammes(false), "Aucun produit à exporter.")}
>
<FileSpreadsheet />
<div className="flex flex-col">
<span className="font-medium">Export complet</span>
<span className="text-xs text-muted-foreground">Exporter toutes les lignes du fournisseur</span>
<span className="font-medium">Toutes les gammes</span>
<span className="text-xs text-[var(--text-muted)]">Tous les produits du fournisseur</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-medium text-[var(--text-secondary)]">Documents de travail</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("tous", () => exporterTousProduits(nomFournisseur), "Aucun produit à exporter.")}
>
<Table2 />
<div className="flex flex-col">
<span className="font-medium">Tableau complet (Excel)</span>
<span className="text-xs text-[var(--text-muted)]">Gamme avant / après et chiffres sur 12 mois</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("gammeA", exporterGammeA, "Aucun produit en gamme A.")}
>
<FileSpreadsheet />
<div className="flex flex-col">
<span className="font-medium">Liste des produits en gamme A (Excel)</span>
<span className="text-xs text-[var(--text-muted)]">Code-barres, référence, libellé, magasins</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("pdf", () => imprimerPdf(nomFournisseur), "Aucun produit à imprimer.")}
>
<FileText />
<div className="flex flex-col">
<span className="font-medium">Tableau imprimable (PDF)</span>
<span className="text-xs text-[var(--text-muted)]">Mise en page paysage</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" onSelect={() => { void handleSnapshot(); }}>
<History />
<div className="flex flex-col">
<span className="font-medium">Garder une copie dans l&apos;Historique</span>
<span className="text-xs text-[var(--text-muted)]">Pour reprendre cette session plus tard</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={handleSave}
disabled={!hasDrafts || isPending}
className="btn-action btn-action-primary"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "success" ? (
<CheckCircle className="w-4 h-4" />
) : saveStatus === "error" ? (
<AlertCircle className="w-4 h-4" />
) : null}
{isPending ? "Validation..." : "Valider"}
</button>
{hasDrafts && (
<Button variant="ghost" onClick={handleReset} disabled={isPending}>
<RotateCcw /> Annuler
</Button>
)}
<Button onClick={handleSave} disabled={!hasDrafts || isPending}>
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
{isPending
? "Enregistrement…"
: hasDrafts
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
: "Aucune modification"}
</Button>
</div>
<SuccessModal
isOpen={modal.isOpen}
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
title={modal.title}
message={modal.message}
variant={modal.variant}
action={modal.action}
/>
</div>
);
}
+28 -26
View File
@@ -2,6 +2,7 @@
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import { CLASSES_SANS_GAMME, GAMMES, trouverGamme } from "@/lib/gammes";
interface GammeSelectProps {
value: GammeCode | null;
@@ -9,37 +10,38 @@ interface GammeSelectProps {
onChange: (gamme: GammeCode) => void;
}
const GAMME_STYLES: Record<string, string> = {
A: "border-[1px] border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/20 dark:text-emerald-400",
B: "border-[1px] border-blue-500/30 bg-blue-500/10 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/20 dark:text-blue-400",
C: "border-[1px] border-amber-500/30 bg-amber-500/10 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/20 dark:text-amber-400",
Y: "border-[1px] border-violet-500/30 bg-violet-500/10 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/20 dark:text-violet-400",
Z: "border-[1px] border-rose-500/30 bg-rose-500/10 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/20 dark:text-rose-400",
Aucune: "border-[1px] border-slate-500/30 bg-slate-500/10 text-slate-700 dark:border-slate-500/30 dark:bg-slate-500/20 dark:text-slate-400",
};
/**
* Choix de la gamme dans une ligne de la Grille.
*
* La case affiche la lettre (la colonne est étroite) mais la liste déroulante
* donne le sens de chaque gamme (« A — Cœur », « Z — Sortie »…) : une liste
* native transparente est posée sur la pastille.
*/
export function GammeSelect({ value, isDraft, onChange }: GammeSelectProps) {
const displayValue = (!value || value.trim() === "") ? "Aucune" : value;
const gamme = trouverGamme(value);
return (
<select
value={displayValue}
onChange={(e) => onChange(e.target.value as GammeCode)}
<div
className={cn(
"w-full text-xs font-bold rounded-lg py-1.5 px-2 outline-none cursor-pointer shadow-sm transition-all text-center",
"focus:ring-2 focus:ring-brand-500/20",
GAMME_STYLES[displayValue] || "border-[1px] border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-500",
isDraft && "shadow-[0_0_8px_rgba(251,191,36,0.3)] border-amber-400/50"
"relative w-full rounded-lg border py-1.5 px-2 text-center text-xs font-bold shadow-sm",
gamme ? gamme.classes : CLASSES_SANS_GAMME,
isDraft && "ring-2 ring-[var(--accent-warning)]/60",
)}
title={gamme ? `${gamme.code} — ${gamme.nom} : ${gamme.description}` : "Aucune gamme"}
>
<option value="Aucune" disabled={displayValue !== "Aucune" && displayValue !== ""}>
{displayValue === "Aucune" || displayValue === "" ? "— (Aucune)" : "—"}
</option>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
<option value="Y">Y</option>
<option value="Z">Z</option>
</select>
<span aria-hidden>{gamme ? gamme.code : "—"}</span>
<select
aria-label="Gamme du produit"
value={gamme ? gamme.code : ""}
onChange={(e) => onChange(e.target.value as GammeCode)}
onClick={(e) => e.stopPropagation()}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
>
<option value="" disabled>— Aucune gamme</option>
{GAMMES.map((g) => (
<option key={g.code} value={g.code}>{g.code} — {g.nom}</option>
))}
</select>
</div>
);
}
+62 -164
View File
@@ -1,16 +1,15 @@
"use client";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useEffect, useRef, useState } from "react";
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { ExportDropdown } from "@/features/grid/components/export-dropdown";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { useSession } from "next-auth/react";
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { nomMagasin } from "@/lib/magasins";
import { useSearchParams } from "next/navigation";
import type { GridFilters } from "@/types/grid";
@@ -22,6 +21,8 @@ interface GridClientProps {
magasins: { code: string; nom: string }[];
magasin: string;
filters: Pick<GridFilters, "code1" | "code2" | "code3">;
/** Calculé côté serveur : les droits ne changent plus d'un rendu à l'autre. */
isAdmin: boolean;
}
type GridRowsStreamMessage =
@@ -34,14 +35,14 @@ type GridRowsStreamMessage =
function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
return (
<div
className="flex flex-col items-start px-2.5 py-1 rounded-lg shrink-0"
className="flex flex-col items-start px-3 py-1.5 rounded-lg shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<span className="text-[8.5px] font-semibold uppercase tracking-wider leading-none mb-1" style={{ color: "var(--text-muted)" }}>
<span className="text-xs leading-none mb-1" style={{ color: "var(--text-muted)" }}>
{label}
</span>
<span
className="text-[12px] font-bold leading-none tabular-nums max-w-[160px] truncate"
className="text-sm font-semibold leading-none tabular-nums max-w-[180px] truncate"
style={{ color: accent ? "var(--accent)" : "var(--text-primary)" }}
title={value}
>
@@ -51,11 +52,11 @@ function MetricPill({ label, value, accent = false }: { label: string; value: st
);
}
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, isAdmin }: GridClientProps) {
const setRows = useGridStore((s) => s.setRows);
const rows = useGridStore((s) => s.rows);
// Seul le nombre de lignes sert ici : un abonnement au tableau entier
// redessinait toute la page à chaque modification de gamme.
const nbRows = useGridStore((s) => s.rows.length);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
@@ -63,16 +64,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const searchParams = useSearchParams();
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const [isLoadingRows, setIsLoadingRows] = useState(false);
const [rowsLoaded, setRowsLoaded] = useState(0);
const [totalRows, setTotalRows] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [showStartOverlay, setShowStartOverlay] = useState(false);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
const [isMounted, setIsMounted] = useState(false);
@@ -126,7 +121,6 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
setTotalRows(null);
setLoadError(null);
setIsLoadingRows(true);
setShowStartOverlay(true);
// Chaque mise à jour du store reconstruit tout le tableau (index, tri,
// filtres, compteurs) : la faire à chaque paquet de 150 lignes rendait le
@@ -213,14 +207,6 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
setActiveMagasin(magasin || "TOTAL");
}, [magasin, setActiveMagasin, isMounted]);
const handleSave = () => {
startTransition(async () => {
const result = await save();
setSaveStatus(result.success ? "success" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
});
};
// Force le rechargement des données depuis le serveur en ignorant le cache
// 10 min (`refresh=1` → forceRefresh) et remonte l'état serveur à jour (INIT).
const handleForceRefresh = () => {
@@ -229,92 +215,73 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
};
if (!isMounted) {
return <div className="p-8 text-center animate-pulse text-muted italic">Initialisation de la grille...</div>;
return <div className="p-8 text-center text-sm text-[var(--text-secondary)]">Préparation de la grille…</div>;
}
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
const nbReferences = totalRows ?? nbRows;
const progression = totalRows ? Math.round((rowsLoaded / totalRows) * 100) : null;
return (
<div className="flex flex-col h-full space-y-3 min-h-0">
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-3.5 min-w-0">
<div
className="h-10 w-[3px] rounded-full shrink-0"
style={{ background: "linear-gradient(to bottom, var(--accent), var(--accent-success))" }}
/>
<header className="shrink-0 flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-[var(--border)]">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<div className="text-[10px] font-bold uppercase tracking-[0.18em] leading-none mb-1.5" style={{ color: "var(--accent)" }}>
Révision d&apos;assortiment
</div>
<h1 className="text-[19px] font-bold tracking-tight truncate leading-none" style={{ color: "var(--text-primary)" }}>
<p className="text-[13px] text-[var(--text-secondary)]">Révision d&apos;assortiment</p>
<h1 className="text-xl font-bold tracking-tight truncate text-[var(--text-primary)]" title={nomFournisseur}>
{nomFournisseur}
</h1>
</div>
<div className="hidden md:flex items-center gap-2 ml-1 pl-3 shrink-0" style={{ borderLeft: "1px solid var(--border)" }}>
<MetricPill label="Magasin" value={activeStoreNom} />
<MetricPill label="Références" value={(totalRows ?? rows.length).toLocaleString("fr-FR")} accent />
<div className="hidden md:flex items-center gap-2 pl-4 shrink-0 border-l border-[var(--border)]">
<MetricPill label="Magasin" value={nomMagasin(magasin)} />
<MetricPill label="Produits" value={nbReferences.toLocaleString("fr-FR")} accent />
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{(isLoadingRows || loadError) && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tabular-nums"
style={{
background: loadError ? "var(--accent-error-bg)" : "var(--accent-bg)",
border: `1px solid ${loadError ? "var(--accent-error)" : "var(--accent-border)"}`,
color: loadError ? "var(--accent-error)" : "var(--accent)",
}}
>
{isLoadingRows && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{loadError
? loadError
: totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} / ${totalRows.toLocaleString("fr-FR")}`
: "Chargement…"}
</div>
<div className="flex items-center gap-2 shrink-0">
{loadError && (
<span className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-[13px] font-medium border-[var(--accent-error)] bg-[var(--accent-error-bg)] text-[var(--accent-error)]">
<AlertCircle className="h-4 w-4" /> {loadError}
</span>
)}
<button
<Button
variant="outline"
size="sm"
onClick={handleForceRefresh}
disabled={isLoadingRows}
title="Forcer le rafraîchissement — recharge les données depuis le serveur en ignorant le cache (met à jour la colonne INIT)"
className="flex items-center gap-2 px-3 h-9 text-[13px] font-semibold rounded-xl disabled:opacity-50 transition-all hover:brightness-110 active:scale-[0.97]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
title="Recharger les données depuis le serveur (gammes FF à jour comprises)"
>
<RefreshCw className={`w-4 h-4 ${isLoadingRows ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">Rafraîchir</span>
</button>
<RefreshCw className={isLoadingRows ? "animate-spin" : undefined} />
Actualiser
</Button>
<div id="grid-toolbar-actions"></div>
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
{isAdmin && hasDrafts && (
<button
onClick={handleSave}
disabled={isPending}
className="flex items-center gap-2 px-4 h-9 text-white text-[13px] font-semibold rounded-xl disabled:opacity-60 transition-all hover:brightness-110 active:scale-[0.97]"
style={{
background: "linear-gradient(180deg, var(--brand-solid), var(--brand-hover))",
boxShadow: "0 4px 12px rgba(16,185,129,0.25), inset 0 1px 1px rgba(255,255,255,0.2)",
}}
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "success" ? (
<CheckCircle className="w-4 h-4" />
) : saveStatus === "error" ? (
<AlertCircle className="w-4 h-4" />
) : null}
{isPending
? "Sauvegarde…"
: saveStatus === "success"
? "Sauvegardé !"
: saveStatus === "error"
? "Erreur"
: `Valider ${count} changement${count > 1 ? "s" : ""}`}
</button>
)}
</div>
</header>
{/* Chargement : bandeau discret, la grille reste utilisable. */}
{isLoadingRows && (
<div
role="status"
className="shrink-0 rounded-lg border px-4 py-2 border-[var(--accent-border)] bg-[var(--accent-bg)]"
>
<div className="flex items-center justify-between gap-3 text-[13px] text-[var(--text-primary)]">
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent)]" />
Chargement des produits de {nomFournisseur}…
</span>
<span className="tabular-nums text-[var(--text-secondary)]">
{totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} sur ${totalRows.toLocaleString("fr-FR")}`
: "Calcul en cours sur le serveur"}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[var(--bg-elevated)]">
<div
className={progression == null ? "h-full w-1/3 animate-pulse rounded-full bg-[var(--accent)]" : "h-full rounded-full bg-[var(--accent)] transition-[width] duration-300"}
style={progression == null ? undefined : { width: `${progression}%` }}
/>
</div>
</div>
)}
{/* Filters */}
<div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
@@ -339,77 +306,8 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
{/* Summary bar */}
<div className="print:hidden">
<FloatingSummaryBar />
<FloatingSummaryBar isAdmin={isAdmin} nomFournisseur={nomFournisseur} />
</div>
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
{showStartOverlay && isLoadingRows && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-md bg-slate-950/30 dark:bg-black/40 animate-fade-in">
<div
className="w-full max-w-md rounded-2xl border p-6 shadow-2xl flex flex-col items-center text-center space-y-5 transition-transform duration-300 transform scale-100"
style={{
background: "rgba(255, 255, 255, 0.75)",
borderColor: "var(--border)",
backdropFilter: "blur(20px)",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
}}
>
{/* Header avec logo ou icône animée */}
<div className="relative flex items-center justify-center w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Loader2 className="w-8 h-8 animate-spin" />
<div className="absolute inset-0 rounded-full border border-emerald-500/20 animate-ping duration-1000" />
</div>
{/* Titre et descriptions */}
<div className="space-y-1">
<h3 className="text-lg font-bold tracking-tight text-emerald-800 dark:text-emerald-400">
Flux d'Assortiment en cours
</h3>
<p className="text-xs text-muted-foreground max-w-[280px] mx-auto animate-pulse" style={{ color: "var(--text-muted)" }}>
Chargement progressif et en temps réel des données pour <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{nomFournisseur}</span>.
</p>
</div>
{/* Barre de progression dynamique */}
<div className="w-full space-y-2">
<div className="flex justify-between text-xs font-semibold" style={{ color: "var(--text-secondary)" }}>
<span>Progression</span>
<span>
{totalRows
? `${Math.round((rowsLoaded / totalRows) * 100)}%`
: `${rowsLoaded.toLocaleString("fr-FR")} réfs`}
</span>
</div>
<div className="w-full h-2 rounded-full overflow-hidden bg-slate-100 dark:bg-slate-800" style={{ border: "1px solid var(--border)" }}>
<div
className="h-full bg-gradient-to-r from-emerald-500 to-teal-500 transition-all duration-300 ease-out rounded-full"
style={{
width: totalRows ? `${(rowsLoaded / totalRows) * 100}%` : "40%",
}}
/>
</div>
<p className="text-[11px] font-medium animate-pulse" style={{ color: "var(--text-muted)" }}>
{totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} sur ${totalRows.toLocaleString("fr-FR")} références chargées`
: `${rowsLoaded.toLocaleString("fr-FR")} références récupérées...`}
</p>
</div>
{/* Boutons d'action */}
<div className="w-full pt-2 flex flex-col gap-2">
<button
onClick={() => setShowStartOverlay(false)}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-500 active:scale-95 hover:shadow-lg hover:shadow-emerald-500/20 transition-all duration-200"
>
Explorer les premières données
</button>
<p className="text-[10px] text-muted italic" style={{ color: "var(--text-muted)" }}>
Le chargement continuera en arrière-plan sans bloquer votre navigation.
</p>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,9 +1,10 @@
"use client";
import React, { useMemo } from "react";
import { Search, X, RotateCw } from "lucide-react";
import { Search, X } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { cn } from "@/lib/utils";
import { trouverGamme } from "@/lib/gammes";
import { SupplierCombobox } from "./supplier-combobox";
import { StoreCombobox } from "./store-combobox";
@@ -24,7 +25,6 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
const router = useRouter();
const searchParams = useSearchParams();
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [searchValue, setSearchValue] = React.useState(filters.search);
React.useEffect(() => {
@@ -104,30 +104,9 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
router.replace(`/grid?${params.toString()}`, { scroll: false });
};
const requestRefresh = useGridStore((s) => s.requestRefresh);
const handleRefresh = () => {
setIsRefreshing(true);
requestRefresh();
// Visual feedback delay
setTimeout(() => setIsRefreshing(false), 800);
};
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Refresh Button */}
<button
onClick={handleRefresh}
title="Rafraîchir les données SQL"
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg transition-all",
"bg-[var(--bg-elevated)] border border-[var(--border)]",
"hover:bg-[var(--bg-surface)] hover:text-emerald-500",
isRefreshing && "opacity-50"
)}
>
<RotateCw className={cn("w-4 h-4", isRefreshing && "animate-spin")} />
</button>
{/* Supplier Selector */}
<SupplierCombobox
fournisseurs={fournisseurs}
@@ -141,7 +120,6 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
magasins={magasins}
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
onSelect={handleStoreSelect}
className="w-[170px] xl:w-[220px]"
/>
{/* Search */}
@@ -152,7 +130,8 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
/>
<input
type="search"
placeholder="Rechercher (code, désignation...)"
placeholder="Rechercher un produit (code, nom…)"
aria-label="Rechercher un produit dans la grille"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
// La croix native de `type="search"` (WebKit/Blink) est masquée :
@@ -184,43 +163,36 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const count = value === null ? rows.length : gammeCounts[value] || 0;
const isActive = filters.codeGamme === value;
// Semantic colors for the badge based on Gamme
const getBadgeStyles = () => {
if (!isActive) return { background: "rgba(0,0,0,0.05)", color: "var(--text-muted)" };
switch (value) {
case "A": return { background: "rgba(16, 185, 129, 0.15)", color: "rgb(5, 150, 105)" }; // Emerald
case "B": return { background: "rgba(59, 130, 246, 0.15)", color: "rgb(37, 99, 235)" }; // Blue
case "C": return { background: "rgba(245, 158, 11, 0.15)", color: "rgb(217, 119, 6)" }; // Amber
case "Y": return { background: "rgba(139, 92, 246, 0.15)", color: "rgb(109, 40, 217)" }; // Violet
case "Z": return { background: "rgba(244, 63, 94, 0.15)", color: "rgb(225, 29, 72)" }; // Rose
case "Aucune": return { background: "rgba(100, 116, 139, 0.15)", color: "rgb(71, 85, 105)" }; // Slate
default: return { background: "rgba(0,0,0,0.1)", color: "var(--text-primary)" };
}
};
const badgeStyles = getBadgeStyles();
const gamme = value ? trouverGamme(value) : undefined;
const titre = value === null
? "Toutes les gammes"
: gamme
? `${gamme.code} — ${gamme.nom} : ${gamme.description}`
: "Produits sans gamme";
return (
<button
key={label}
onClick={() => setFilter("codeGamme", value)}
title={titre}
aria-pressed={isActive}
className={cn(
"flex items-center gap-2 px-2.5 py-1 text-[11px] font-semibold rounded-[6px] transition-all duration-200",
"flex items-center gap-1.5 px-2.5 py-1 text-[13px] font-semibold rounded-md transition-colors",
isActive
? "shadow-[0_1px_2px_rgba(0,0,0,0.05)]"
: "hover:bg-[var(--bg-surface)] opacity-70 hover:opacity-100"
: "hover:bg-[var(--bg-surface)]"
)}
style={{
background: isActive ? "var(--bg-surface)" : "transparent",
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
<span>{label}</span>
<span
className="inline-flex items-center justify-center min-w-[20px] px-1.5 py-0.5 rounded-full text-[9px] font-bold tabular-nums tracking-tight transition-colors"
style={badgeStyles}
>
{count}
{gamme ? (
<span className={cn("rounded border px-1 text-xs font-bold", gamme.classes)}>{gamme.code}</span>
) : null}
<span>{value === null ? "Toutes" : gamme ? gamme.nom : "Sans gamme"}</span>
<span className="text-xs font-medium tabular-nums text-[var(--text-muted)]">
{count.toLocaleString("fr-FR")}
</span>
</button>
);
+121 -95
View File
@@ -47,6 +47,9 @@ import {
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
import { couleurMarge } from "@/lib/marge";
import { Button } from "@/components/ui/button";
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
@@ -211,17 +214,13 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme;
if (!isAdmin) {
const gamme = trouverGamme(displayValue);
return (
<div className={[
"w-full text-xs font-bold rounded-lg py-1.5 px-2 text-center",
displayValue === "A" ? "bg-emerald-500/10 text-emerald-700" :
displayValue === "B" ? "bg-blue-500/10 text-blue-700" :
displayValue === "C" ? "bg-amber-500/10 text-amber-700" :
displayValue === "Y" ? "bg-violet-500/10 text-violet-700" :
displayValue === "Z" ? "bg-rose-500/10 text-rose-700" :
"bg-slate-500/10 text-slate-500"
].join(" ")}>
{displayValue === "Aucune" ? "—" : displayValue}
<div
className={cn("w-full rounded-lg border py-1.5 px-2 text-center text-xs font-bold", gamme ? gamme.classes : CLASSES_SANS_GAMME)}
title={gamme ? `${gamme.code} — ${gamme.nom} : ${gamme.description}` : "Aucune gamme"}
>
{gamme ? gamme.code : "—"}
</div>
);
}
@@ -243,6 +242,38 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
});
GammeCell.displayName = "GammeCell";
/**
* Libellés du menu « Colonnes ». Les en-têtes de colonnes sont des fonctions
* (deux lignes, infobulles) : le menu affichait leur identifiant technique
* (« caReseau », « gammeInitial »…).
*/
const LIBELLES_COLONNES: Record<string, string> = {
codein: "Code article",
libelle1: "Désignation",
reference: "Référence",
gtin: "Code-barres",
libelle3: "Famille",
caReseau: "CA réseau",
qteReseau: "Quantité réseau",
nbMagasinsReseau: "Magasins vendeurs (réseau)",
tendanceReseau: "Tendance réseau (12 mois)",
prixMoyenReseau: "Prix moyen réseau",
prixVente: "Prix de vente de nos magasins",
caParMagasinReseau: "CA par magasin (réseau)",
margePctReseau: "Marge % réseau",
totalQuantite: "Total 12 mois",
totalCa: "Chiffre d'affaires",
totalMarge: "Marge",
gammeInitial: "Gamme actuelle (FF)",
gamme: "Gamme",
};
function libelleColonne(id: string, header: unknown): string {
if (LIBELLES_COLONNES[id]) return LIBELLES_COLONNES[id];
if (id.startsWith("month_")) return `Ventes ${formatMonthLabel(id.replace("month_", ""))}`;
return typeof header === "string" ? header : id;
}
import { Cell, Column, Row } from "@tanstack/react-table";
import { VirtualItem } from "@tanstack/react-virtual";
@@ -317,7 +348,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
className={cn(
// Pas de `transition-all` : la ligne est positionnée par `transform`
// et chaque défilement l'aurait animée pendant 200 ms.
"absolute w-full flex items-center cursor-pointer transition-[opacity,filter] duration-200 group/row",
"absolute top-0 left-0 w-full flex items-center cursor-pointer transition-[opacity,filter] duration-200 group/row",
effectiveGamme === "Z" && "opacity-40 grayscale-[0.5] hover:grayscale-0 hover:opacity-100"
)}
style={{
@@ -345,7 +376,10 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
// pas par le voile `bg-white/5` des cellules ordinaires.
estFige
? (isSelected
? "bg-[var(--accent-bg)]"
// Teinte de sélection rendue OPAQUE (mélangée au fond) : la
// variable --accent-bg est translucide, et les cellules qui
// défilent dessous apparaissaient à travers.
? "bg-[color-mix(in_srgb,var(--accent)_12%,var(--bg-surface))]"
: "bg-[var(--bg-surface)] group-hover/row:bg-[var(--bg-elevated)]")
: cn("group-hover/row:bg-white/5", isSelected && "bg-transparent"),
)}
@@ -482,7 +516,7 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: "var(--text-primary)" }}>
{d.qty != null ? Math.round(d.qty).toLocaleString("fr-FR") : "—"}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Ventes</span>
<span className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>Ventes</span>
</div>
{/* Entrées */}
@@ -497,7 +531,7 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: hasReceptions ? "rgb(16,185,129)" : "var(--text-primary)" }}>
{hasReceptions ? `+${Math.round(d.receptions!).toLocaleString("fr-FR")}` : "—"}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Entrées</span>
<span className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>Entrées</span>
</div>
{/* Stock fin de mois */}
@@ -506,7 +540,7 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: "var(--text-primary)" }}>
{d.stock != null ? Math.round(d.stock).toLocaleString("fr-FR") : "—"}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-center" style={{ color: "var(--text-muted)" }}>Stock fin mois</span>
<span className="text-xs font-medium text-center" style={{ color: "var(--text-muted)" }}>Stock fin de mois</span>
</div>
</div>
@@ -532,7 +566,7 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
return (
<div className="mt-3 rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Magasin</th>
@@ -677,7 +711,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
},
{
accessorKey: "codein",
header: "Code interne",
header: "Code article",
size: 90,
cell: ({ getValue }) => (
<CopiableCell
@@ -722,7 +756,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
<div
key={magasin}
title={tooltipTitle}
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-xs font-bold border shadow-sm"
style={{
background: config.bg,
borderColor: config.border,
@@ -754,7 +788,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
},
{
accessorKey: "gtin",
header: "EAN / GTIN",
header: "Code-barres",
size: 120,
cell: ({ getValue }) => (
<CopiableCell
@@ -784,7 +818,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
id: "caReseau",
accessorFn: (row) => valeurTriable(row.caReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
header: () => <div className="text-center w-full" title="Chiffre d'affaires du produit dans les magasins du réseau, sur 12 mois">CA<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 90,
cell: ({ row }) => {
const val = row.original.caReseau;
@@ -799,7 +833,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
id: "qteReseau",
accessorFn: (row) => valeurTriable(row.qteReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
header: () => <div className="text-center w-full" title="Quantités vendues dans les magasins du réseau, sur 12 mois">Qté<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 80,
cell: ({ row }) => {
const val = row.original.qteReseau;
@@ -814,7 +848,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
id: "nbMagasinsReseau",
accessorFn: (row) => valeurTriable(row.nbMagasinsReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Magasins<br/><span className="text-[9px] opacity-60">/ 270</span></div>,
header: () => <div className="text-center w-full" title={`Nombre de magasins du réseau (sur ${NB_MAGASINS_RESEAU}) qui vendent ce produit`}>Magasins<br/><span className="text-xs font-normal opacity-75">sur {NB_MAGASINS_RESEAU}</span></div>,
size: 80,
cell: ({ row }) => {
const val = row.original.nbMagasinsReseau;
@@ -836,7 +870,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
if (t.nouveau) return Number.POSITIVE_INFINITY;
return t.pct ?? Number.NEGATIVE_INFINITY;
},
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
header: () => <div className="text-center w-full" title="Évolution des ventes du réseau sur 12 mois — cliquer pour le détail">Tendance<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 78,
cell: ({ row }) => {
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
@@ -867,7 +901,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
// Sous-titre tenu court : les en-têtes ne rognent pas leur texte et
// un libellé trop long chevauche la colonne voisine. Le détail
// (« CA réseau ÷ quantité ») est dans l'infobulle des cellules.
header: () => <div className="text-center w-full">PV moyen<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
header: () => <div className="text-center w-full" title="Prix de vente moyen constaté dans le réseau">Prix moyen<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const val = prixMoyenReseau(row.original);
@@ -888,7 +922,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
id: "prixVente",
accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">PV<br/><span className="text-[9px] opacity-60">Magasin</span></div>,
header: () => <div className="text-center w-full" title="Prix de vente pratiqué dans nos magasins (≠ : prix différents selon le magasin)">Prix vente<br/><span className="text-xs font-normal opacity-75">magasin</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin);
@@ -903,7 +937,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
{valeur != null ? fmtEuro2(valeur) : "-"}
{/* Prix différents d'un magasin à l'autre : le signaler, la
cellule ne peut en afficher qu'un. */}
{divergent && <span className="text-[10px] font-black" style={{ color: "var(--accent-warning)" }}>≠</span>}
{divergent && <span className="text-xs font-bold" style={{ color: "var(--accent-warning)" }}>≠</span>}
</div>
);
},
@@ -913,7 +947,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
id: "caParMagasinReseau",
accessorFn: (row) => valeurTriable(row.caParMagasinReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">CA / Mag<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
header: () => <div className="text-center w-full" title="Chiffre d'affaires moyen d'un magasin du réseau qui vend ce produit">CA / magasin<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 85,
cell: ({ row }) => {
const val = row.original.caParMagasinReseau;
@@ -928,7 +962,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
id: "margePctReseau",
accessorFn: (row) => valeurTriable(row.margePctReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Marge %<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
header: () => <div className="text-center w-full" title="Taux de marge du produit dans le réseau">Marge %<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 75,
cell: ({ row }) => {
const val = row.original.margePctReseau;
@@ -977,7 +1011,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
{
id: "totalQuantite",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full">Tot. 12m</div>,
header: () => <div className="text-center w-full" title="Quantités vendues sur les 12 derniers mois — cliquer pour le détail mois par mois">Total 12 m</div>,
size: 90,
cell: ({ row }: { row: { original: ProductRow } }) => {
const qty = activeMagasin === "TOTAL"
@@ -1008,7 +1042,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
{
id: "totalCa",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalCa : (row.caByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full">CA</div>,
header: () => <div className="text-center w-full" title="Chiffre d'affaires sur 12 mois">CA</div>,
size: 90,
cell: ({ row }: { row: { original: ProductRow } }) => {
const ca = activeMagasin === "TOTAL"
@@ -1024,7 +1058,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
{
id: "totalMarge",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalMarge : (row.margeByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full">Marge</div>,
header: () => <div className="text-center w-full" title="Marge en euros et taux de marge sur 12 mois">Marge</div>,
size: 110,
cell: ({ row }: { row: { original: ProductRow } }) => {
const marge = activeMagasin === "TOTAL"
@@ -1039,9 +1073,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{fmtEntier(marge)}&nbsp;€
</span>
<span className="tabular-nums text-[10px] font-bold opacity-70" style={{
color: taux >= 40 ? "var(--accent-success)" : taux >= 25 ? "var(--accent-warning)" : "var(--accent-error)"
}}>
<span className="tabular-nums text-xs font-semibold" style={{ color: couleurMarge(taux) }}>
{taux.toFixed(1)}%
</span>
</div>
@@ -1050,7 +1082,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
},
{
id: "gammeInitial",
header: () => <div className="text-center w-full print:hidden">Init.</div>,
header: () => <div className="text-center w-full print:hidden" title="Gamme enregistrée aujourd'hui dans FF, avant vos modifications">Actuelle</div>,
size: 56,
cell: ({ row }) => (
<div className="text-center font-bold text-[12px] print:hidden" style={{ color: "var(--text-secondary)" }}>
@@ -1060,7 +1092,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
},
{
id: "gamme",
header: () => <div className="text-center w-full">Gamme</div>,
header: () => <div className="text-center w-full" title="Gamme choisie : A Cœur, B Complémentaire, C Saisonnier, Y En veille, Z Sortie">Gamme</div>,
size: 110,
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
},
@@ -1173,7 +1205,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
border: "1px solid var(--border)",
}}
>
<div className="text-muted text-sm italic opacity-50">Chargement de la grille...</div>
<div className="text-sm text-[var(--text-secondary)]">Préparation de la grille…</div>
</div>
);
}
@@ -1182,41 +1214,38 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
<>
<div className="h-full w-full relative">
{portalContainer && createPortal(
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-2">
{/* Trous d'assortiment — lit le classement affiché, tri compris. */}
<button
<Button
variant="outline"
size="sm"
onClick={() => setGapsOpen(true)}
disabled={tableRows.length === 0}
title="Voir les produits qu'un magasin ne travaille pas — ni vente ni stock — en haut du classement affiché"
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold border transition-all shadow-sm apple-btn-secondary disabled:opacity-50"
style={{ background: "var(--action-secondary-bg)", borderColor: "var(--border-strong)", color: "var(--action-secondary-text)" }}
title="Les produits du haut du classement affiché qu'un de nos magasins ne vend pas et n'a pas en stock"
>
<PackageX className="w-4 h-4" />
<PackageX />
<span className="hidden lg:inline">Non travaillés</span>
</button>
</Button>
{/*
* « Vues » replie des BLOCS de colonnes d'un geste, là où le menu
* « Colonnes » voisin agit colonne par colonne. Douze cellules
* mensuelles ou deux prix ne se masquent pas une par une.
* « Blocs de colonnes » replie des BLOCS de colonnes d'un geste, là
* où le menu « Colonnes » voisin agit colonne par colonne. Douze
* cellules mensuelles ou deux prix ne se masquent pas une par une.
*/}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold border transition-all shadow-sm apple-btn-secondary"
style={nbVues > 0
? { background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }
: { background: "var(--action-secondary-bg)", borderColor: "var(--border-strong)", color: "var(--action-secondary-text)" }}
title="Afficher ou masquer les blocs de colonnes : ventes mensuelles, prix"
<Button
variant="outline"
size="sm"
title="Afficher ou masquer les ventes mois par mois et les prix"
aria-label="Afficher ou masquer les blocs de colonnes"
>
{nbVues > 0 ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
<span className="hidden lg:inline">Vues</span>
<span className="text-[11px] font-bold tabular-nums opacity-70">{nbVues}/2</span>
</button>
{nbVues > 0 ? <Eye /> : <EyeOff />}
<span className="hidden lg:inline">Affichage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] z-50">
<DropdownMenuLabel className="text-[11px] uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Blocs de colonnes
<DropdownMenuContent align="end" className="w-[300px] z-50">
<DropdownMenuLabel className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
Blocs de colonnes affichés
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showMonthlySales}
@@ -1225,11 +1254,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<CalendarRange className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<CalendarRange className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-xs font-semibold">Ventes mensuelles</span>
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
12 mois · détail au clic sur le total
<span className="text-sm font-medium">Ventes mois par mois</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Les 12 derniers mois
</span>
</span>
</span>
@@ -1241,11 +1270,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<Tag className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-xs font-semibold">Prix</span>
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
PV moyen réseau · PV central
<span className="text-sm font-medium">Prix</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Prix moyen du réseau et prix de vente de nos magasins
</span>
</span>
</span>
@@ -1254,32 +1283,28 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold bg-[var(--action-secondary-bg)] text-[var(--action-secondary-text)] border border-[var(--border-strong)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-all shadow-sm apple-btn-secondary"
aria-label="Afficher/masquer les colonnes"
>
<SlidersHorizontal className="w-4 h-4" />
Colonnes
</button>
<Button variant="outline" size="sm" aria-label="Choisir les colonnes affichées">
<SlidersHorizontal />
<span className="hidden lg:inline">Colonnes</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[220px] max-h-[50vh] overflow-y-auto z-50">
<DropdownMenuContent align="end" className="w-[260px] max-h-[60vh] overflow-y-auto z-50">
<DropdownMenuLabel className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
Colonnes affichées
</DropdownMenuLabel>
{table
.getAllLeafColumns()
.filter((column: Column<ProductRow, unknown>) => column.getCanHide())
.filter((column: Column<ProductRow, unknown>) => column.getCanHide() && column.id !== "select")
.map((column: Column<ProductRow, unknown>) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize text-xs cursor-pointer font-medium"
className="text-sm cursor-pointer"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(e: Event) => e.preventDefault()}
>
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: column.id.startsWith('month_')
? formatMonthLabel(column.id.replace('month_', ''))
: column.id}
{libelleColonne(column.id, column.columnDef.header)}
</DropdownMenuCheckboxItem>
);
})}
@@ -1324,7 +1349,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
<th
key={header.id}
className={cn(
"px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors relative group/header",
"px-2 py-2 text-xs font-semibold leading-tight whitespace-nowrap select-none flex items-center transition-colors relative group/header",
!enteteFigee && "hover:bg-white/5",
)}
style={{
@@ -1332,7 +1357,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-muted)",
color: "var(--text-secondary)",
justifyContent: isCenter ? "center" : "flex-start",
...(enteteFigee ? {
position: "sticky" as const,
@@ -1393,16 +1418,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
const isSelected = row.getIsSelected();
return (
<div key={row.id} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
<GridRow
virtualRow={virtualRow}
row={row}
rowHeight={rowHeight}
isSelected={isSelected}
columnsKey={columnsKey}
columnSizing={columnSizing}
/>
</div>
// Pas de <div> autour de la ligne : un <div> dans un <tbody>
// est du HTML invalide (erreur d'hydratation signalée par React).
<GridRow
key={row.id}
virtualRow={virtualRow}
row={row}
rowHeight={rowHeight}
isSelected={isSelected}
columnsKey={columnsKey}
columnSizing={columnSizing}
/>
);
})}
</tbody>
@@ -40,12 +40,12 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
</div>
{pct != null && (
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
<span className="text-xs font-bold tabular-nums leading-none" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
{pct == null && nouveau && (
<span className="text-[9px] font-bold leading-none" style={{ color }}>Nouveau</span>
<span className="text-xs font-bold leading-none" style={{ color }}>Nouveau</span>
)}
</div>
);
@@ -432,7 +432,7 @@ export function DualLineChart({
);
})}
{labels.map((lab, i) => (
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={12} fill="var(--text-muted)">
{fmtMonthShort(lab)}
</text>
))}
@@ -103,7 +103,7 @@ export function NomenclatureFilter({ options, className }: NomenclatureFilterPro
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
/>
</div>
<div className="flex items-center gap-2 text-[11px]">
<div className="flex items-center gap-2 text-xs">
<button
type="button"
onClick={() => setCode3Filter(null)}
@@ -183,7 +183,7 @@ function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLign
return (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }} aria-label={aria}>
<table className="w-full text-xs" style={{ minWidth: 600 }} aria-label={aria}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
@@ -210,7 +210,7 @@ function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLign
<div className="font-semibold">{l.nom}</div>
{/* L'information secondaire (dernière entrée, mois avec
vente) est écrite, pas réservée au survol. */}
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{l.sous}</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{l.sous}</div>
</td>
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
{cumul && (
+1 -1
View File
@@ -21,7 +21,7 @@ export function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
}) {
return (
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
<div className="text-[13px] font-medium" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="flex items-center gap-1.5 mt-1">
+21 -94
View File
@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Search, Building2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { Segmented } from "@/components/ui/tabs";
import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
interface Store {
code: string;
@@ -16,98 +16,25 @@ interface StoreComboboxProps {
className?: string;
}
/**
* Choix du magasin affiché dans la Grille : trois boutons visibles d'un coup
* d'œil (nos 2 magasins, puis chacun), au lieu d'une liste avec recherche pour
* deux magasins qui affichait « TOTAL » et des identifiants techniques.
*/
export function StoreCombobox({ magasins, selectedCode, onSelect, className }: StoreComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const options = React.useMemo(() => [
{ code: "TOTAL", nom: "Tous les magasins (Total)" },
...magasins
], [magasins]);
const selectedStore = options.find((s) => s.code === (selectedCode || "TOTAL"));
const filtered = React.useMemo(() => {
if (!search) return options;
const s = search.toLowerCase();
return options.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50);
}, [options, search]);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const items = useMemo(
() => [
{ value: CODE_TOUS_MAGASINS, label: LIBELLE_TOUS_MAGASINS },
...magasins.map((m) => ({ value: m.code, label: nomMagasin(m.code) })),
],
[magasins],
);
return (
<div className={cn("relative w-[280px]", className)} ref={containerRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-blue-500/50 transition-all shadow-sm group"
>
<div className="flex items-center gap-2 min-w-0">
<Building2 className="w-3.5 h-3.5 text-slate-400 group-hover:text-blue-500 transition-colors" />
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedStore ? selectedStore.nom : "TOTAL"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<input
autoFocus
placeholder="Rechercher un magasin..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
</div>
) : (
filtered.map((s) => (
<button
key={s.code}
onClick={() => {
onSelect(s.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
s.code === (selectedCode || "TOTAL")
? "bg-blue-500/10 text-blue-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
)}
>
<div className="flex flex-col min-w-0 pr-2">
<span className="truncate">{s.nom}</span>
<span className="text-[10px] opacity-50 font-mono italic">{s.code === "TOTAL" ? "Agrégation nationale" : `Magasin ID: ${s.code}`}</span>
</div>
{s.code === (selectedCode || "TOTAL") && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
<Segmented
items={items}
value={selectedCode || CODE_TOUS_MAGASINS}
onChange={onSelect}
className={className}
/>
);
}
@@ -14,24 +14,30 @@ interface SupplierComboboxProps {
selectedCode: string | null;
onSelect: (code: string) => void;
className?: string;
/** Libellé du bouton quand rien n'est choisi. */
placeholder?: string;
}
export function SupplierCombobox({ fournisseurs, selectedCode, onSelect, className }: SupplierComboboxProps) {
/** Nombre de fournisseurs listés : la liste complète (plusieurs milliers) figeait l'ouverture. */
const AFFICHAGE_MAX = 50;
/** Choix d'un fournisseur, avec recherche par nom ou par code. */
export function SupplierCombobox({ fournisseurs, selectedCode, onSelect, className, placeholder = "Choisir un fournisseur…" }: SupplierComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const selectedSupplier = fournisseurs.find((f) => f.code === selectedCode);
const filtered = React.useMemo(() => {
if (!search) return fournisseurs;
const s = search.toLowerCase();
return fournisseurs.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50); // Limit display for performance
const { affiches, total } = React.useMemo(() => {
const s = search.trim().toLowerCase();
const trouves = s
? fournisseurs.filter((f) => f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s))
: fournisseurs;
return { affiches: trouves.slice(0, AFFICHAGE_MAX), total: trouves.length };
}, [fournisseurs, search]);
// Handle click outside to close
// Fermeture au clic à l'extérieur
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -47,60 +53,62 @@ export function SupplierCombobox({ fournisseurs, selectedCode, onSelect, classNa
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-emerald-500/50 transition-all shadow-sm group"
aria-haspopup="listbox"
aria-expanded={open}
className="flex h-9 w-full items-center justify-between rounded-lg border px-3 text-sm shadow-sm bg-[var(--bg-surface)] border-[var(--border-strong)] hover:border-[var(--accent)]"
>
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedSupplier ? selectedSupplier.nom : "Sélectionner un fournisseur..."}
<span className={cn("truncate", selectedSupplier ? "font-medium text-[var(--text-primary)]" : "text-[var(--text-muted)]")}>
{selectedSupplier ? selectedSupplier.nom : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 text-[var(--text-muted)]" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<div className="absolute left-0 top-full z-50 mt-1 w-full min-w-[300px] overflow-hidden rounded-xl border shadow-[var(--shadow-lg)] bg-[var(--bg-surface)] border-[var(--border-strong)] animate-in fade-in slide-in-from-top-1 duration-150">
<div className="flex items-center gap-2 border-b border-[var(--border)] p-2">
<Search className="h-4 w-4 shrink-0 text-[var(--text-muted)]" />
<input
autoFocus
placeholder="Rechercher..."
placeholder="Nom ou code du fournisseur…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
className="w-full border-none bg-transparent py-1 text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)]"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
<div role="listbox" className="max-h-[320px] overflow-auto py-1">
{affiches.length === 0 ? (
<div className="px-4 py-3 text-center text-sm text-[var(--text-muted)]">
Aucun fournisseur ne correspond.
</div>
) : (
filtered.map((f) => (
affiches.map((f) => (
<button
key={f.code}
role="option"
aria-selected={f.code === selectedCode}
onClick={() => {
onSelect(f.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
"flex w-full items-center justify-between px-3 py-2 text-left text-sm",
f.code === selectedCode
? "bg-emerald-500/10 text-emerald-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
? "bg-[var(--accent-bg)] font-semibold text-[var(--text-primary)]"
: "text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]",
)}
>
<div className="flex flex-col min-w-0 pr-2">
<div className="flex min-w-0 flex-col pr-2">
<span className="truncate">{f.nom}</span>
<span className="text-[10px] opacity-50 font-mono">Code: {f.code}</span>
<span className="text-xs text-[var(--text-muted)]">Code {f.code}</span>
</div>
{f.code === selectedCode && (
<Check className="h-4 w-4 shrink-0" />
)}
{f.code === selectedCode && <Check className="h-4 w-4 shrink-0 text-[var(--accent)]" />}
</button>
))
)}
{fournisseurs.length > 50 && !search && (
<div className="px-4 py-2 text-[10px] text-center text-slate-400 uppercase tracking-widest font-bold bg-slate-50/50 dark:bg-slate-800/50">
Saisissez pour filtrer les {fournisseurs.length} entrées
{total > affiches.length && (
<div className="border-t border-[var(--border)] px-4 py-2 text-center text-xs text-[var(--text-muted)]">
{affiches.length} premiers sur {total.toLocaleString("fr-FR")} — précisez la recherche
</div>
)}
</div>
@@ -1,8 +1,11 @@
"use client";
import * as React from "react";
import { SupplierCombobox } from "./supplier-combobox";
import { LayoutGrid } from "lucide-react";
import { useRouter } from "next/navigation";
import { SupplierCombobox } from "./supplier-combobox";
import { GAMMES } from "@/lib/gammes";
import { cn } from "@/lib/utils";
interface Supplier {
code: string;
@@ -13,37 +16,48 @@ interface SupplierSelectionLandingProps {
fournisseurs: Supplier[];
}
/** Entrée de la Grille : une seule question, « quel fournisseur ? ». */
export function SupplierSelectionLanding({ fournisseurs }: SupplierSelectionLandingProps) {
const router = useRouter();
const handleSelect = (code: string) => {
router.push(`/grid?fournisseur=${code}`);
router.push(`/grid?fournisseur=${encodeURIComponent(code)}`);
};
return (
<div className="h-full flex flex-col items-center justify-center p-6 text-center animate-in fade-in zoom-in duration-500">
<div className="max-w-md space-y-6">
<div className="space-y-4">
<div className="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center mx-auto mb-2 text-emerald-600">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2" />
</svg>
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<div className="w-full max-w-xl space-y-6">
<div className="space-y-3">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-[var(--accent-border)] bg-[var(--accent-bg)] text-[var(--accent)]">
<LayoutGrid className="h-7 w-7" />
</div>
<h1 className="text-4xl font-black tracking-tight text-slate-900 dark:text-white">CollectFlow</h1>
<p className="text-lg text-slate-500 dark:text-slate-400">
Optimisez votre assortiment en quelques clics. Sélectionnez un fournisseur pour commencer.
<h1 className="text-3xl font-bold tracking-tight text-[var(--text-primary)]">Révision d&apos;assortiment</h1>
<p className="text-base text-[var(--text-secondary)]">
Choisissez un fournisseur : vous verrez tous ses produits avec leurs ventes, et vous pourrez
attribuer à chacun sa gamme.
</p>
</div>
<div className="flex justify-center pt-4">
<div className="flex justify-center">
<SupplierCombobox
fournisseurs={fournisseurs}
selectedCode={null}
onSelect={handleSelect}
className="w-[400px] !h-12 text-lg shadow-xl"
className="w-full max-w-md"
/>
</div>
<div className="pt-8 text-[11px] text-slate-400 uppercase tracking-[0.2em] font-bold">
Accès Rapide • {fournisseurs.length} Fournisseurs Disponibles
<p className="text-[13px] text-[var(--text-muted)]">
{fournisseurs.length.toLocaleString("fr-FR")} fournisseurs disponibles
</p>
<div className="flex flex-wrap justify-center gap-2 pt-2" aria-label="Signification des gammes">
{GAMMES.map((g) => (
<span
key={g.code}
title={g.description}
className={cn("inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[13px]", g.classes)}
>
<span className="font-bold">{g.code}</span> {g.nom}
</span>
))}
</div>
</div>
</div>
@@ -1,73 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, Building2, ChevronRight } from "lucide-react";
interface Supplier {
code: string;
nom: string;
}
export function SupplierSelection({ fournisseurs }: { fournisseurs: Supplier[] }) {
const router = useRouter();
const [search, setSearch] = useState("");
const filtered = fournisseurs.filter((f) =>
f.nom.toLowerCase().includes(search.toLowerCase()) ||
f.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="max-w-4xl mx-auto py-12 px-6">
<div className="mb-10 text-center">
<h1 className="text-[32px] font-bold tracking-tight mb-2">Choisir un fournisseur</h1>
<p className="text-[15px]" style={{ color: "var(--text-secondary)" }}>
Sélectionnez un fournisseur pour commencer la révision de l&apos;assortiment.
</p>
</div>
<div className="relative mb-8 max-w-xl mx-auto">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Rechercher par nom ou code..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="apple-input pl-12 h-14 text-lg w-full"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((f) => (
<button
key={f.code}
onClick={() => router.push(`/grid?fournisseur=${f.code}`)}
className="flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 hover:border-emerald-500/50 hover:bg-emerald-50/10 transition-all text-left shadow-sm group active:scale-[0.98]"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-500 group-hover:bg-emerald-500/10 group-hover:text-emerald-500 transition-colors">
<Building2 className="w-6 h-6" />
</div>
<div>
<div className="text-[17px] font-bold tracking-tight text-slate-900 dark:text-white">
{f.nom}
</div>
<div className="text-[13px] font-medium text-slate-500">
Code: {f.code}
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-emerald-500 transition-colors" />
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-20 bg-slate-50/50 dark:bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<p className="text-slate-500 font-medium">Aucun fournisseur trouvé pour &quot;{search}&quot;</p>
</div>
)}
</div>
);
}
@@ -38,21 +38,23 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
return (
<div className="flex items-center gap-2">
<span
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
className="text-xs leading-tight text-right whitespace-nowrap hidden xl:block"
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message || undefined}
>
{status === "error"
? message
: status === "running"
? (message || `Extraction Qlik…`)
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
? (message || "Récupération des données réseau…")
: lastUpdate
? `Réseau mis à jour le ${formatLastUpdate(lastUpdate)}`
: "Réseau jamais mis à jour"}
</span>
<button
onClick={start}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
title={message || `Mettre à jour les ventes du réseau (Qlik) pour ce fournisseur — dernière mise à jour : ${formatLastUpdate(lastUpdate)}`}
>
{status === "running" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -63,7 +65,7 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
{status === "running" ? "Sync en cours…" : "Sync Qlik"}
{status === "running" ? "Mise à jour…" : "Mettre à jour le réseau"}
</button>
</div>
);
+6 -4
View File
@@ -11,7 +11,7 @@ import { GammeCode } from "@/types/grid";
*/
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const draftChanges = useGridStore((s) => s.draftChanges);
const rows = useGridStore((s) => s.rows);
const rowsByCodein = useGridStore((s) => s.rowsByCodein);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const clearDrafts = useGridStore((s) => s.clearDrafts);
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
@@ -31,8 +31,10 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const count = Object.keys(activeDrafts).length;
const save = useCallback(async () => {
// Index par code article : `rows.find` dans la boucle coûtait
// (modifications × lignes) comparaisons sur un gros fournisseur.
const changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => {
const row = rows.find((r) => r.codein === codein);
const row = rowsByCodein[codein];
return {
codein,
codeGammeBefore: row?.codeGammeInit ?? row?.codeGamme ?? null,
@@ -43,7 +45,7 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
if (changes.length === 0) return { success: true, saved: 0 };
// Derive supplier info from the first matching row
const firstRow = rows.find((r) => changes.some((c) => c.codein === r.codein));
const firstRow = changes.length > 0 ? rowsByCodein[changes[0].codein] : undefined;
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
@@ -60,7 +62,7 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
}
}
return result;
}, [activeDrafts, rows, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
}, [activeDrafts, rowsByCodein, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
return { save, hasDrafts: count > 0, count };
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Exports de la Grille — regroupés en un seul menu.
*
* Il y avait deux menus d'export (en-tête et barre du bas) aux droits différents.
* Les bibliothèques lourdes (exceljs, jspdf) ne sont chargées qu'au clic.
* Les lignes sont lues dans le store au moment de l'export.
*/
import { useGridStore } from "@/features/grid/store/use-grid-store";
import type { ProductRow } from "@/types/grid";
function telecharger(blob: Blob, nomFichier: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = nomFichier;
a.click();
URL.revokeObjectURL(url);
}
const aujourdhui = () => new Date().toISOString().slice(0, 10);
const nomFichierSur = (nom: string) => nom.replace(/\s+/g, "_");
function gammeEffective(r: ProductRow, drafts: Record<string, string>): string | null {
return (drafts[r.codein] ?? r.codeGamme) as string | null;
}
/** Lignes dont la gamme diffère de la gamme enregistrée dans FF. */
export function lignesModifiees(): ProductRow[] {
const { rows, draftChanges } = useGridStore.getState();
return rows.filter((r) => gammeEffective(r, draftChanges) !== r.codeGammeInit);
}
/**
* Fichier d'import des gammes (CODE FOURNISSEUR, GENCOD, GAMME), généré par le
* serveur. `seulementModifiees` : uniquement les gammes changées.
* Renvoie `false` s'il n'y a rien à exporter.
*/
export async function exporterFichierGammes(seulementModifiees: boolean): Promise<boolean> {
const { rows, draftChanges } = useGridStore.getState();
const lignes = seulementModifiees ? lignesModifiees() : rows;
if (lignes.length === 0) return false;
const nomFournisseur = lignes[0].nomFournisseur;
const changes = lignes.map((r) => ({
codein: r.codein,
gtin: r.gtin,
codeFournisseur: r.codeFournisseur,
gamme: gammeEffective(r, draftChanges) as string,
}));
const res = await fetch("/api/export/modified-gammes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomFournisseur, changes }),
});
if (!res.ok) throw new Error(`le serveur a répondu ${res.status}`);
const prefixe = seulementModifiees ? "Modifications_Gammes" : "Export_Complet";
telecharger(await res.blob(), `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}.xlsx`);
return true;
}
/** Tous les produits avec gamme avant/après et chiffres sur 12 mois (Excel, généré par le serveur). */
export async function exporterTousProduits(nomFournisseur: string): Promise<boolean> {
const { rows, draftChanges } = useGridStore.getState();
if (rows.length === 0) return false;
const supplier = nomFournisseur || rows[0]?.nomFournisseur || "Fournisseur";
const res = await fetch("/api/export/excel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nomFournisseur: supplier,
rows: rows.map((r) => ({
codein: r.codein,
libelle1: r.libelle1 || "",
code3: r.code3 || "",
libelle3: r.libelle3 || "",
codeGamme: gammeEffective(r, draftChanges),
totalQuantite: r.totalQuantite,
totalCa: r.totalCa,
tauxMarge: r.tauxMarge,
gammeAvant: r.codeGammeInit ?? null,
})),
}),
});
if (!res.ok) throw new Error(`le serveur a répondu ${res.status}`);
telecharger(await res.blob(), `Assortiment_Complet_${nomFichierSur(supplier)}_${aujourdhui()}.xlsx`);
return true;
}
/** Liste des produits en gamme A, pour les magasins (Excel). */
export async function exporterGammeA(): Promise<boolean> {
const { rows, draftChanges } = useGridStore.getState();
const gammeA = rows.filter((r) => gammeEffective(r, draftChanges) === "A");
if (gammeA.length === 0) return false;
const { Workbook } = await import("exceljs");
const workbook = new Workbook();
const worksheet = workbook.addWorksheet("Gamme A");
worksheet.columns = [
{ header: "Magasin(s)", key: "magasins", width: 25 },
{ header: "Gencode", key: "gtin", width: 15 },
{ header: "Référence", key: "reference", width: 20 },
{ header: "Libellé", key: "libelle", width: 45 },
{ header: "Gamme", key: "gamme", width: 10 },
];
worksheet.getRow(1).font = { bold: true };
for (const row of gammeA) {
worksheet.addRow({
magasins: row.workingStores.join(", "),
gtin: row.gtin || row.codein,
reference: row.reference || "",
libelle: row.libelle1 || "",
gamme: gammeEffective(row, draftChanges),
});
}
const buffer = await workbook.xlsx.writeBuffer();
telecharger(
new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }),
`Export_Gamme_A_${aujourdhui()}.xlsx`,
);
return true;
}
/** Tableau imprimable (PDF paysage). */
export async function imprimerPdf(nomFournisseur: string): Promise<boolean> {
const { rows, draftChanges } = useGridStore.getState();
if (rows.length === 0) return false;
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
import("jspdf"),
import("jspdf-autotable"),
]);
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
doc.setFontSize(16);
doc.text("Analyse d'assortiment", 14, 15);
doc.setFontSize(14);
doc.setTextColor(50);
if (nomFournisseur) doc.text(nomFournisseur, 14, 23);
doc.setFontSize(10);
doc.setTextColor(100);
doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30);
const head = [["Code-barres", "Code article", "Réf.", "Libellé", "Magasins réseau", "Qté", "CA", "Marge", "Gamme"]];
const body = rows.map((r) => [
r.gtin || r.codein,
r.codein,
r.reference || "-",
r.libelle1 ? r.libelle1.substring(0, 60) + (r.libelle1.length > 60 ? "..." : "") : "",
r.nbMagasinsReseau != null ? r.nbMagasinsReseau.toString() : "-",
Math.round(r.totalQuantite).toLocaleString("fr-FR"),
`${Math.round(r.totalCa).toLocaleString("fr-FR")} €`,
`${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`,
gammeEffective(r, draftChanges) || "-",
]);
autoTable(doc, {
head,
body,
startY: 35,
styles: { fontSize: 8, cellPadding: 2, lineColor: [200, 200, 200], lineWidth: 0.1 },
headStyles: { fillColor: [15, 23, 42], textColor: [255, 255, 255], fontStyle: "bold", halign: "center" },
columnStyles: {
0: { cellWidth: 28 },
1: { cellWidth: 18, fontStyle: "bold", halign: "center" },
2: { cellWidth: 25 },
3: { cellWidth: 80 },
4: { cellWidth: 12, halign: "center", fontStyle: "bold", textColor: [16, 185, 129] },
},
theme: "grid",
didDrawPage: (data: { pageNumber: number; settings: { margin: { left: number } } }) => {
doc.setFontSize(8);
doc.text(`Page ${data.pageNumber}`, data.settings.margin.left, doc.internal.pageSize.height - 10);
},
});
doc.save(`Assortiment_${aujourdhui()}.pdf`);
return true;
}
+6 -5
View File
@@ -18,6 +18,8 @@
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
* entre le rendu serveur et le rendu client.
*/
import { MAGASINS } from "@/lib/magasins";
export function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
@@ -87,11 +89,10 @@ export function qlikMonthToFf(key: string): string {
return key.replace("-", "");
}
/** Les deux magasins FF Nancy suivis par l'application. */
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
"292": { label: "F", nom: "Frouard (Nancy)" },
"579": { label: "H", nom: "Houdemont" },
};
/** Les deux magasins FF Nancy suivis par l'application (source : lib/magasins). */
export const SITE_LABELS: Record<string, { label: string; nom: string }> = Object.fromEntries(
MAGASINS.map((m) => [m.code, { label: m.initiale, nom: m.nom }]),
);
/**
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
@@ -1,7 +1,12 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Download, Loader2, RefreshCw, FileText, AlertCircle } from "lucide-react";
import { Download, Loader2, RefreshCw, FileText } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
import { fmtDecimal1 } from "@/lib/format";
interface LogEntry {
id: string;
@@ -13,7 +18,7 @@ interface LogEntry {
function formatTaille(octets: number): string {
if (octets < 1024) return `${octets} o`;
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
return `${fmtDecimal1(octets / (1024 * 1024))} Mo`;
}
function formatDate(iso: string): string {
@@ -80,69 +85,61 @@ export function ServerLogs() {
}, [appliquer]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-[12px] text-[var(--text-secondary)]">
Journal complet de chaque extraction Qlik (le plus récent en premier).
Conservés le temps de la vie du serveur, 20 au maximum.
</p>
<button
onClick={recharger}
disabled={etat === "chargement"}
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0 disabled:opacity-60"
>
{etat === "chargement"
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <RefreshCw className="w-3.5 h-3.5" />}
Actualiser
</button>
</div>
{etat === "erreur" && (
<div className="flex items-center gap-2 text-[12px] text-[var(--accent-error)]">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{erreur}</span>
</div>
)}
{etat !== "erreur" && logs.length === 0 && (
<p className="text-[12px] text-[var(--text-muted)]">
Aucun journal pour l&apos;instant — lancez une synchronisation Qlik depuis la Grille.
</p>
)}
{logs.length > 0 && (
<ul className="divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)]">
{logs.map((log) => (
<li key={log.id} className="flex items-center gap-3 px-3 py-2">
<FileText className="w-4 h-4 shrink-0 text-[var(--text-muted)]" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate">
{log.id}
</span>
{log.enCours && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--brand-solid)] text-white shrink-0">
en cours
<Card>
<CardHeader
title="Journal serveur"
description="Le détail de chaque mise à jour des données du réseau (extraction Qlik), à télécharger pour comprendre une erreur. Les 20 derniers journaux sont conservés jusqu'au prochain redémarrage du serveur ; le plus récent est en premier."
actions={
<Button variant="outline" size="sm" onClick={recharger} disabled={etat === "chargement"}>
{etat === "chargement" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Actualiser
</Button>
}
/>
<CardContent>
{etat === "erreur" ? (
<ErrorState
title="Les journaux n'ont pas pu être chargés"
detail={erreur}
action={<Button variant="outline" onClick={recharger}>Réessayer</Button>}
/>
) : etat === "chargement" && logs.length === 0 ? (
<div className="space-y-2" aria-busy="true" aria-label="Chargement des journaux">
{Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : logs.length === 0 ? (
<EmptyState
icon={FileText}
title="Aucun journal pour l'instant"
description="Un journal est créé à chaque mise à jour des données du réseau : lancez-en une depuis la Grille (bouton « Mettre à jour le réseau ») ou attendez la synchronisation nocturne."
/>
) : (
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)]">
{logs.map((log) => (
<li key={log.id} className="flex flex-wrap items-center gap-3 px-3 py-2.5">
<FileText className="h-4 w-4 shrink-0 text-[var(--text-muted)]" aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-sm font-medium text-[var(--text-primary)]">
{log.id}
</span>
)}
{log.enCours && <Badge ton="accent">En cours</Badge>}
</div>
<span className="text-[13px] text-[var(--text-muted)]">
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
</span>
</div>
<span className="text-[11px] text-[var(--text-muted)]">
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
</span>
</div>
<a
href={`/api/logs?id=${encodeURIComponent(log.id)}`}
download
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0"
>
<Download className="w-3.5 h-3.5" />
Télécharger
</a>
</li>
))}
</ul>
)}
</div>
<Button asChild variant="outline" size="sm">
<a href={`/api/logs?id=${encodeURIComponent(log.id)}`} download>
<Download />
Télécharger
</a>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -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>
);
}