mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
61 files changed
+6101
-6864
No files matched your search
@@ -304,3 +304,32 @@ Règle commune à chaque page :
|
|||||||
- pages d'erreur (`error.tsx`) et « page introuvable » en français ;
|
- pages d'erreur (`error.tsx`) et « page introuvable » en français ;
|
||||||
- après connexion, arrivée sur l'Accueil, comme pour `/`.
|
- après connexion, arrivée sur l'Accueil, comme pour `/`.
|
||||||
- **Écart** : les en-têtes Excel de la page Stocks (CODEIN, GENCODE, QTE, CODMV, avec les codes 503 / 412) **ne seront pas renommés**. C'est vraisemblablement un format d'import de régularisation dans FF.
|
- **Écart** : les en-têtes Excel de la page Stocks (CODEIN, GENCODE, QTE, CODMV, avec les codes 503 / 412) **ne seront pas renommés**. C'est vraisemblablement un format d'import de régularisation dans FF.
|
||||||
|
|
||||||
|
## Suivi — Lot 3 réalisé
|
||||||
|
|
||||||
|
Toutes les pages utilisent désormais :
|
||||||
|
- `PageHeader` avec une phrase d'explication ;
|
||||||
|
- les couleurs du thème (plus de pages figées en blanc) ;
|
||||||
|
- des textes d'au moins 12 px ;
|
||||||
|
- les noms de magasins, `toast` et `confirmer`.
|
||||||
|
|
||||||
|
Vérifié dans un navigateur : sur les pages visitées, en clair et en sombre, aucun texte n'est sous 12 px.
|
||||||
|
|
||||||
|
| Page | Changements principaux |
|
||||||
|
|---|---|
|
||||||
|
| Accueil | Cartes « Nos 2 magasins » : CA, tickets et panier moyen, avec l'évolution N-1. Une carte par magasin. Top 10 lisible, avec un lien vers la fiche produit. |
|
||||||
|
| Révision d'assortiment | **Barre du bas** : seul endroit pour enregistrer et exporter, avec les mêmes droits pour tous. Le menu Export regroupe les deux anciens. Bouton « Annuler » avec confirmation. **Filtres** : sens des gammes affiché (A Cœur…), magasin choisi en un clic, menu « Colonnes » en clair. **Chargement** : bandeau de progression au lieu de la fenêtre floue. **Droits** : lus côté serveur. |
|
||||||
|
| Recherche produit | Fiche lisible (Secteur / Rayon / Famille), infobulles PCB, PRMP, code-barres, réseau. Plus de mention technique de Qlik. |
|
||||||
|
| Stocks à surveiller | `DataTable` avec trois onglets et une explication pour chacun. **Export Excel inchangé** (format d'import FF). |
|
||||||
|
| Meilleures ventes | `DataTable` avec un tri sur toutes les colonnes et une ligne de totaux. Export via exceljs (`xlsx` n'est plus utilisé). |
|
||||||
|
| Ventes par mois | Dernier mois complet par défaut, mois en clair, bascule Fournisseur / Famille, recherche, tri, export. |
|
||||||
|
| Publicités | Onglets, statuts lisibles, part du CA calculée en pied de tableau. Le tri ne porte que sur la page affichée, et la page le signale. |
|
||||||
|
| Commandes fournisseurs | Onglets dans l'URL, confirmation avant suppression, retours de toutes les actions. |
|
||||||
|
| Historique | « Reprendre dans la Grille », chiffres visibles, états vides et d'erreur. |
|
||||||
|
| Paramètres | Onglets Connexions / Utilisateurs / Accès API / Journal serveur, un bouton Enregistrer par section. |
|
||||||
|
| Synchronisation | Vocabulaire clair, unités affichées, résumé en indicateurs. |
|
||||||
|
| Connexion | Libellés lisibles, fond sobre. |
|
||||||
|
|
||||||
|
Autres changements :
|
||||||
|
- **Composants retirés** : `SuccessModal`, `ConfirmModal`, `ChangePasswordModal`, `LoadingModal`, `export-dropdown`, `financial-cell` et `supplier-selection`, qui n'étaient plus utilisés.
|
||||||
|
- **Correction** : le nom de l'utilisateur est maintenant transmis à la session, et l'en-tête l'affiche.
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Connexion" };
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-6 relative overflow-hidden">
|
<main className="flex min-h-screen items-center justify-center bg-[var(--bg-base)] p-6">
|
||||||
{/* Éléments de design en arrière-plan pour l'effet "Apple Premium" */}
|
<LoginForm />
|
||||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
|
||||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-500/10 dark:bg-indigo-500/5 blur-[120px] rounded-full" />
|
|
||||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-emerald-500/10 dark:bg-emerald-500/5 blur-[120px] rounded-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 w-full flex justify-center">
|
|
||||||
<LoginForm />
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,20 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
||||||
CheckCircle, Database, Network, Search,
|
CheckCircle2, Database, Network, Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge, type Ton } from "@/components/ui/badge";
|
||||||
|
import { Input, Label, SearchInput } from "@/components/ui/form-controls";
|
||||||
|
import { Segmented, type TabItem } from "@/components/ui/tabs";
|
||||||
|
import { DataTable, type DataColumn } from "@/components/ui/data-table";
|
||||||
|
import { StatCard } from "@/components/ui/stat-card";
|
||||||
|
import { ErrorState, Skeleton } from "@/components/ui/states";
|
||||||
|
import { Tooltip } from "@/components/ui/tooltip";
|
||||||
|
import { confirmer, toast } from "@/components/ui/feedback";
|
||||||
|
import { fmtEntier } from "@/lib/format";
|
||||||
|
|
||||||
interface SyncSettings {
|
interface SyncSettings {
|
||||||
actif: boolean;
|
actif: boolean;
|
||||||
@@ -51,18 +62,46 @@ interface Resume {
|
|||||||
desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number;
|
desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtDate = (iso: string | null) =>
|
type Filtre = "tous" | "actifs" | "inactifs" | "echecs";
|
||||||
iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "jamais";
|
|
||||||
const fmtDuree = (ms: number | null) =>
|
|
||||||
ms == null ? "" : ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`;
|
|
||||||
|
|
||||||
function Pastille({ statut }: { statut: string | null }) {
|
/** Libellés des deux sources, identiques partout dans la page. */
|
||||||
if (!statut) return <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>—</span>;
|
const VENTES = "Ventes (base FF)";
|
||||||
const couleurs: Record<string, string> = {
|
const RESEAU = "Réseau (Qlik)";
|
||||||
succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500",
|
|
||||||
};
|
const fmtDate = (iso: string | null) =>
|
||||||
const libelles: Record<string, string> = { succes: "ok", echec: "échec", vide: "vide" };
|
iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "Jamais";
|
||||||
return <span className={cn("text-[11px] font-semibold", couleurs[statut] ?? "")}>{libelles[statut] ?? statut}</span>;
|
|
||||||
|
function fmtDuree(ms: number | null): string {
|
||||||
|
if (ms == null) return "";
|
||||||
|
if (ms < 1000) return `${ms} ms`;
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUTS: Record<string, { ton: Ton; libelle: string; aide: string }> = {
|
||||||
|
succes: { ton: "succes", libelle: "OK", aide: "Dernière mise à jour réussie" },
|
||||||
|
echec: { ton: "erreur", libelle: "Échec", aide: "La dernière mise à jour a échoué" },
|
||||||
|
vide: { ton: "alerte", libelle: "Vide", aide: "Aucune donnée reçue lors de la dernière mise à jour" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatutBadge({ statut }: { statut: string | null }) {
|
||||||
|
if (!statut) return null;
|
||||||
|
const s = STATUTS[statut];
|
||||||
|
return s ? <Badge ton={s.ton} title={s.aide}>{s.libelle}</Badge> : <Badge>{statut}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CASE = "h-4 w-4 cursor-pointer accent-[var(--accent)]";
|
||||||
|
|
||||||
|
function correspond(r: FournisseurRow, f: Filtre): boolean {
|
||||||
|
if (f === "actifs") return r.actifSql || r.actifQlik;
|
||||||
|
if (f === "inactifs") return !r.actifSql && !r.actifQlik;
|
||||||
|
if (f === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « Lancement automatique » (planifié la nuit) ou « manuel » (bouton). */
|
||||||
|
function libelleOrigine(origine: SyncState["origine"]): string {
|
||||||
|
return origine === "auto" ? " automatique" : origine === "manuel" ? " manuel" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SynchronisationClient() {
|
export function SynchronisationClient() {
|
||||||
@@ -74,7 +113,8 @@ export function SynchronisationClient() {
|
|||||||
const [erreur, setErreur] = useState<string | null>(null);
|
const [erreur, setErreur] = useState<string | null>(null);
|
||||||
const [chargement, setChargement] = useState(true);
|
const [chargement, setChargement] = useState(true);
|
||||||
const [recherche, setRecherche] = useState("");
|
const [recherche, setRecherche] = useState("");
|
||||||
const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous");
|
const [filtre, setFiltre] = useState<Filtre>("tous");
|
||||||
|
const [actionEnCours, setActionEnCours] = useState<"start" | "stop" | "seed" | null>(null);
|
||||||
|
|
||||||
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
|
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
|
||||||
const lireEtat = useCallback(async () => {
|
const lireEtat = useCallback(async () => {
|
||||||
@@ -107,7 +147,12 @@ export function SynchronisationClient() {
|
|||||||
|
|
||||||
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
|
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
|
||||||
|
|
||||||
// Suivi rapproché pendant un round : l'état change à chaque fournisseur.
|
const reessayer = () => {
|
||||||
|
setChargement(true);
|
||||||
|
rafraichirTout();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suivi rapproché pendant un lancement : l'état change à chaque fournisseur.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state?.enCours) return;
|
if (!state?.enCours) return;
|
||||||
const id = setInterval(rafraichirEtat, 3000);
|
const id = setInterval(rafraichirEtat, 3000);
|
||||||
@@ -119,7 +164,6 @@ export function SynchronisationClient() {
|
|||||||
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const majSettings = async (patch: Partial<SyncSettings>) => {
|
const majSettings = async (patch: Partial<SyncSettings>) => {
|
||||||
setErreur(null);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/sync", {
|
const res = await fetch("/api/admin/sync", {
|
||||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
|
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
|
||||||
@@ -128,7 +172,9 @@ export function SynchronisationClient() {
|
|||||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||||
// Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse.
|
// Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse.
|
||||||
setSettings({ ...d.settings, ...patchEnAttente.current });
|
setSettings({ ...d.settings, ...patchEnAttente.current });
|
||||||
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
|
} catch (e) {
|
||||||
|
toast.erreur(`Le réglage n'a pas pu être enregistré : ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur
|
// Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur
|
||||||
@@ -147,19 +193,41 @@ export function SynchronisationClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
|
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
|
||||||
setErreur(null);
|
setActionEnCours(act);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/sync", {
|
const res = await fetch("/api/admin/sync", {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }),
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }),
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||||
|
if (act === "seed") {
|
||||||
|
toast.succes(`Liste des fournisseurs actualisée (${fmtEntier(Number(d.fournisseurs) || 0)} dans le référentiel FF).`);
|
||||||
|
} else if (act === "stop") {
|
||||||
|
toast.info("Arrêt demandé : la mise à jour s'arrête après le fournisseur en cours.");
|
||||||
|
}
|
||||||
rafraichirTout();
|
rafraichirTout();
|
||||||
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
|
} catch (e) {
|
||||||
|
const libelles = { start: "Le lancement a échoué", stop: "L'arrêt a échoué", seed: "La liste n'a pas pu être actualisée" };
|
||||||
|
toast.erreur(`${libelles[act]} : ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
} finally {
|
||||||
|
setActionEnCours(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lancer = async () => {
|
||||||
|
if (!dansLaFenetre) {
|
||||||
|
const ok = await confirmer({
|
||||||
|
titre: "Lancer la mise à jour maintenant ?",
|
||||||
|
message: "Nous sommes en dehors de la plage horaire nocturne : la mise à jour va solliciter la base FF et Qlik en pleine journée, ce qui peut ralentir l'application pour les magasins.",
|
||||||
|
libelleConfirmer: "Lancer maintenant",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
void action("start", !dansLaFenetre);
|
||||||
};
|
};
|
||||||
|
|
||||||
const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => {
|
const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => {
|
||||||
setErreur(null);
|
if (codes.length === 0) return;
|
||||||
// Mise à jour optimiste : la liste peut compter des centaines de lignes,
|
// Mise à jour optimiste : la liste peut compter des centaines de lignes,
|
||||||
// un rechargement complet à chaque case cochée serait pénible.
|
// un rechargement complet à chaque case cochée serait pénible.
|
||||||
setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur)
|
setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur)
|
||||||
@@ -172,7 +240,7 @@ export function SynchronisationClient() {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErreur(e instanceof Error ? e.message : String(e));
|
toast.erreur(`La modification n'a pas pu être enregistrée : ${e instanceof Error ? e.message : String(e)}`);
|
||||||
rafraichirTout(); // l'optimisme était infondé : on resynchronise
|
rafraichirTout(); // l'optimisme était infondé : on resynchronise
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -181,254 +249,385 @@ export function SynchronisationClient() {
|
|||||||
const q = recherche.trim().toLowerCase();
|
const q = recherche.trim().toLowerCase();
|
||||||
return rows.filter((r) => {
|
return rows.filter((r) => {
|
||||||
if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false;
|
if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false;
|
||||||
if (filtre === "actifs") return r.actifSql || r.actifQlik;
|
return correspond(r, filtre);
|
||||||
if (filtre === "inactifs") return !r.actifSql && !r.actifQlik;
|
|
||||||
if (filtre === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec";
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
}, [rows, recherche, filtre]);
|
}, [rows, recherche, filtre]);
|
||||||
|
|
||||||
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
|
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
|
||||||
|
|
||||||
return (
|
const basculerEnMasse = async (champ: "actifSql" | "actifQlik", valeur: boolean) => {
|
||||||
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
|
const n = codesVisibles.length;
|
||||||
<div className="mx-auto max-w-screen-xl">
|
if (n === 0) return;
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
if (!valeur) {
|
||||||
<CalendarClock className="w-6 h-6" style={{ color: "var(--accent)" }} />
|
const quoi = champ === "actifSql" ? "des ventes (base FF)" : "des données du réseau (Qlik)";
|
||||||
Synchronisation nocturne
|
const ok = await confirmer({
|
||||||
</h1>
|
titre: `Désactiver la mise à jour ${quoi} ?`,
|
||||||
<p className="text-[13px] mt-1 mb-5" style={{ color: "var(--text-muted)" }}>
|
message: `${fmtEntier(n)} fournisseur${n > 1 ? "s" : ""} de la liste affichée ne ser${n > 1 ? "ont" : "a"} plus mis à jour la nuit pour cette source. Vous pourrez les réactiver à tout moment.`,
|
||||||
Les fournisseurs sont traités du plus anciennement synchronisé au plus récent. Si la fenêtre
|
libelleConfirmer: "Désactiver",
|
||||||
se referme avant la fin, la nuit suivante reprend là où on s'est arrêté ; quand tout le
|
danger: true,
|
||||||
monde est passé, un nouveau tour recommence.
|
});
|
||||||
</p>
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
void basculer(codesVisibles, champ, valeur);
|
||||||
|
};
|
||||||
|
|
||||||
{erreur && (
|
const filtres: readonly TabItem<Filtre>[] = useMemo(() => [
|
||||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
{ value: "tous", label: "Tous", count: rows.length },
|
||||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
{ value: "actifs", label: "Actifs", count: rows.filter((r) => correspond(r, "actifs")).length },
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
{ value: "inactifs", label: "Inactifs", count: rows.filter((r) => correspond(r, "inactifs")).length },
|
||||||
<span>{erreur}</span>
|
{ value: "echecs", label: "En échec", count: rows.filter((r) => correspond(r, "echecs")).length },
|
||||||
</div>
|
], [rows]);
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Réglages ── */}
|
const colonnes: DataColumn<FournisseurRow>[] = [
|
||||||
{settings && (
|
{
|
||||||
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
id: "fournisseur",
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
header: "Fournisseur",
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
sortValue: (r) => r.nomFournisseur ?? r.codeFournisseur,
|
||||||
<input type="checkbox" checked={settings.actif}
|
cell: (r) => (
|
||||||
onChange={(e) => majSettings({ actif: e.target.checked })} className="w-4 h-4" />
|
<div className="min-w-[180px]">
|
||||||
<span className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
<div className="font-medium text-[var(--text-primary)]">{r.nomFournisseur ?? r.codeFournisseur}</div>
|
||||||
Synchronisation nocturne active
|
<div className="text-xs text-[var(--text-muted)]">{r.codeFournisseur}</div>
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
|
|
||||||
<input type="number" min={0} max={23} value={settings.heureDebut}
|
|
||||||
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
|
|
||||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
|
||||||
</div>
|
|
||||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h à</span>
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
|
|
||||||
<input type="number" min={0} max={23} value={settings.heureFin}
|
|
||||||
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
|
|
||||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
|
||||||
</div>
|
|
||||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
|
|
||||||
<input type="number" min={0} max={200} value={settings.qlikParNuit}
|
|
||||||
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
|
|
||||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
|
|
||||||
<input type="number" min={0} max={365} value={settings.qlikMinJours}
|
|
||||||
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
|
|
||||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{state?.enCours ? (
|
|
||||||
<button onClick={() => action("stop")} className="rounded-xl px-4 py-2 text-[13px] font-semibold flex items-center gap-2"
|
|
||||||
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)", border: "1px solid var(--accent-error)" }}>
|
|
||||||
<Square className="w-3.5 h-3.5" /> Arrêter
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => action("start", !dansLaFenetre)} className="rounded-xl px-4 py-2 text-[13px] font-semibold text-white flex items-center gap-2"
|
|
||||||
style={{ background: "var(--accent)" }}>
|
|
||||||
<Play className="w-3.5 h-3.5" /> Lancer maintenant
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{dansLaFenetre
|
|
||||||
? "Nous sommes actuellement dans la fenêtre."
|
|
||||||
: "Hors fenêtre — « Lancer maintenant » forcera un round immédiat."}
|
|
||||||
{" "}Une extraction Qlik peut durer 8 minutes : le plafond par nuit évite qu'elle
|
|
||||||
monopolise la fenêtre. Un fournisseur déjà démarré n'est jamais interrompu à l'heure de fin.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Round en cours ── */}
|
|
||||||
{state && (state.enCours || state.finAt) && (
|
|
||||||
<div className="rounded-2xl p-4 mb-4 text-[13px]"
|
|
||||||
style={{ background: state.enCours ? "var(--accent-bg)" : "var(--bg-surface)", border: `1px solid ${state.enCours ? "var(--accent-border)" : "var(--border)"}` }}>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{state.enCours
|
|
||||||
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: "var(--accent)" }} />
|
|
||||||
: <CheckCircle className="w-4 h-4 text-emerald-500" />}
|
|
||||||
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"}
|
|
||||||
</span>
|
|
||||||
{state.enCours && state.fournisseurCourant && (
|
|
||||||
<span style={{ color: "var(--text-secondary)" }}>
|
|
||||||
— {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
<span><Database className="w-3 h-3 inline mr-1" />SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`}</span>
|
|
||||||
<span><Network className="w-3 h-3 inline mr-1" />Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`}</span>
|
|
||||||
{state.debutAt && <span>Début {fmtDate(state.debutAt)}</span>}
|
|
||||||
{state.tourTermine && <span className="text-emerald-500 font-semibold">Tour bouclé — tout le monde est à jour</span>}
|
|
||||||
</div>
|
|
||||||
{state.derniereErreur && (
|
|
||||||
<p className="mt-2 text-[11px] text-red-400">Dernière erreur : {state.derniereErreur}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Résumé ── */}
|
|
||||||
{resume && (
|
|
||||||
<div className="flex flex-wrap gap-2 mb-3 text-[12px]">
|
|
||||||
{[
|
|
||||||
{ l: "Fournisseurs", v: resume.total },
|
|
||||||
{ l: "Actifs SQL", v: resume.actifsSql },
|
|
||||||
{ l: "Actifs Qlik", v: resume.actifsQlik },
|
|
||||||
{ l: "Jamais synchronisés", v: resume.jamaisSql },
|
|
||||||
{ l: "Désactivés auto", v: resume.desactivesAuto },
|
|
||||||
{ l: "En échec", v: resume.enEchecSql + resume.enEchecQlik },
|
|
||||||
].map((s) => (
|
|
||||||
<span key={s.l} className="rounded-lg px-2.5 py-1"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
|
||||||
{s.l} <strong style={{ color: "var(--text-primary)" }}>{s.v}</strong>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Filtres et actions de masse ── */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
|
||||||
<input value={recherche} onChange={(e) => setRecherche(e.target.value)} placeholder="Code ou nom"
|
|
||||||
className="rounded-lg pl-8 pr-3 py-1.5 text-[12px] w-56"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
|
||||||
</div>
|
|
||||||
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => (
|
|
||||||
<button key={f} onClick={() => setFiltre(f)}
|
|
||||||
className="rounded-full px-3 py-1 text-[12px] font-semibold"
|
|
||||||
style={{
|
|
||||||
background: filtre === f ? "var(--accent)" : "var(--bg-elevated)",
|
|
||||||
color: filtre === f ? "#fff" : "var(--text-secondary)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}>
|
|
||||||
{f === "echecs" ? "en échec" : f}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{visibles.length} affichés :</span>
|
|
||||||
<button onClick={() => basculer(codesVisibles, "actifSql", true)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
|
||||||
Activer SQL
|
|
||||||
</button>
|
|
||||||
<button onClick={() => basculer(codesVisibles, "actifSql", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
|
||||||
Désactiver SQL
|
|
||||||
</button>
|
|
||||||
<button onClick={() => basculer(codesVisibles, "actifQlik", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
|
||||||
Désactiver Qlik
|
|
||||||
</button>
|
|
||||||
<button onClick={() => action("seed")} title="Récupérer les nouveaux fournisseurs du référentiel"
|
|
||||||
className="rounded-lg px-2.5 py-1 text-[11px] flex items-center gap-1"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
|
||||||
<RefreshCw className="w-3 h-3" /> Actualiser la liste
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
{/* ── Tableau ── */}
|
},
|
||||||
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
{
|
||||||
{chargement ? (
|
id: "actifSql",
|
||||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
header: VENTES,
|
||||||
<Loader2 className="w-4 h-4 animate-spin inline mr-2" /> Chargement…
|
hint: "Cochez pour mettre à jour chaque nuit les ventes de ce fournisseur dans nos magasins.",
|
||||||
</div>
|
align: "center",
|
||||||
) : visibles.length === 0 ? (
|
sortValue: (r) => (r.actifSql ? 1 : 0),
|
||||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
cell: (r) => (
|
||||||
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
|
<input
|
||||||
</div>
|
type="checkbox"
|
||||||
) : (
|
checked={r.actifSql}
|
||||||
<div className="overflow-x-auto">
|
className={CASE}
|
||||||
<table className="w-full text-[12px] border-collapse">
|
aria-label={`Mettre à jour les ventes de ${r.nomFournisseur ?? r.codeFournisseur}`}
|
||||||
<thead>
|
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)}
|
||||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
/>
|
||||||
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => (
|
),
|
||||||
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
},
|
||||||
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{h}</th>
|
{
|
||||||
))}
|
id: "dernierSql",
|
||||||
</tr>
|
header: "Dernière mise à jour des ventes",
|
||||||
</thead>
|
sortValue: (r) => r.dernierSqlAt,
|
||||||
<tbody>
|
cell: (r) => (
|
||||||
{visibles.map((r) => (
|
<div className="whitespace-nowrap">
|
||||||
<tr key={r.codeFournisseur} style={{ borderBottom: "1px solid var(--border)" }}>
|
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||||
<td className="px-3 py-2">
|
<StatutBadge statut={r.dernierSqlStatut} />
|
||||||
<div className="font-medium" style={{ color: "var(--text-primary)" }}>
|
{fmtDate(r.dernierSqlAt)}
|
||||||
{r.nomFournisseur ?? r.codeFournisseur}
|
</div>
|
||||||
</div>
|
{r.dernierSqlLignes != null && (
|
||||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{r.codeFournisseur}</div>
|
<div className="text-xs text-[var(--text-muted)]">
|
||||||
</td>
|
{fmtEntier(r.dernierSqlLignes)} lignes{r.dernierSqlMs != null && <> en {fmtDuree(r.dernierSqlMs)}</>}
|
||||||
<td className="px-3 py-2">
|
|
||||||
<input type="checkbox" checked={r.actifSql} className="w-4 h-4"
|
|
||||||
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
<Pastille statut={r.dernierSqlStatut} />{" "}
|
|
||||||
{fmtDate(r.dernierSqlAt)}
|
|
||||||
{r.dernierSqlLignes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<input type="checkbox" checked={r.actifQlik} className="w-4 h-4"
|
|
||||||
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
<Pastille statut={r.dernierQlikStatut} />{" "}
|
|
||||||
{fmtDate(r.dernierQlikAt)}
|
|
||||||
{r.dernierQlikCodes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{r.desactiveMotif}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actifQlik",
|
||||||
|
header: RESEAU,
|
||||||
|
hint: "Cochez pour mettre à jour les ventes de ce fournisseur dans les magasins du réseau. Ces extractions sont longues : leur nombre par nuit est plafonné.",
|
||||||
|
align: "center",
|
||||||
|
sortValue: (r) => (r.actifQlik ? 1 : 0),
|
||||||
|
cell: (r) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={r.actifQlik}
|
||||||
|
className={CASE}
|
||||||
|
aria-label={`Mettre à jour les données réseau de ${r.nomFournisseur ?? r.codeFournisseur}`}
|
||||||
|
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dernierQlik",
|
||||||
|
header: "Dernière mise à jour réseau",
|
||||||
|
sortValue: (r) => r.dernierQlikAt,
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||||
|
<StatutBadge statut={r.dernierQlikStatut} />
|
||||||
|
{fmtDate(r.dernierQlikAt)}
|
||||||
|
</div>
|
||||||
|
{r.dernierQlikCodes != null && (
|
||||||
|
<div className="text-xs text-[var(--text-muted)]">{fmtEntier(r.dernierQlikCodes)} produits</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remarque",
|
||||||
|
header: "Remarque",
|
||||||
|
cell: (r) => (r.desactiveMotif
|
||||||
|
? <span className="text-[13px] text-[var(--text-secondary)]">{r.desactiveMotif}</span>
|
||||||
|
: null),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (chargement) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
|
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
|
||||||
|
<div className="space-y-4" aria-busy="true" aria-label="Chargement">
|
||||||
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-28 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-96 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings && erreur) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
|
<PageHeader icon={CalendarClock} title="Synchronisation" description={DESCRIPTION} />
|
||||||
|
<ErrorState
|
||||||
|
title="Les réglages de la synchronisation n'ont pas pu être chargés"
|
||||||
|
detail={erreur}
|
||||||
|
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={CalendarClock}
|
||||||
|
title="Synchronisation"
|
||||||
|
description={DESCRIPTION}
|
||||||
|
actions={state?.enCours ? (
|
||||||
|
<Button variant="outline" onClick={() => action("stop")} disabled={actionEnCours === "stop"}
|
||||||
|
className="border-[var(--accent-error)] text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]">
|
||||||
|
{actionEnCours === "stop" ? <Loader2 className="animate-spin" /> : <Square />}
|
||||||
|
Arrêter
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={lancer} disabled={actionEnCours === "start" || !settings}>
|
||||||
|
{actionEnCours === "start" ? <Loader2 className="animate-spin" /> : <Play />}
|
||||||
|
Lancer maintenant
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{erreur && (
|
||||||
|
<div role="alert" className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--accent-error)]/40 bg-[var(--accent-error-bg)] px-4 py-3 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-[var(--accent-error)]" aria-hidden />
|
||||||
|
<span className="flex-1 text-[var(--text-primary)]">
|
||||||
|
Les informations n'ont pas pu être actualisées : <span className="text-[var(--text-secondary)]">{erreur}</span>
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={rafraichirTout}>Réessayer</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Réglages ── */}
|
||||||
|
{settings && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Réglages"
|
||||||
|
description="Chaque nuit, dans la plage horaire choisie, les fournisseurs sont mis à jour un par un, en commençant par ceux dont les données sont les plus anciennes. Si la nuit ne suffit pas, la suivante reprend là où la précédente s'est arrêtée."
|
||||||
|
/>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<label htmlFor="sync-actif" className="inline-flex cursor-pointer items-center gap-2.5">
|
||||||
|
<input id="sync-actif" type="checkbox" checked={settings.actif}
|
||||||
|
onChange={(e) => majSettings({ actif: e.target.checked })} className={CASE} />
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||||
|
Synchronisation nocturne activée
|
||||||
|
</span>
|
||||||
|
<Badge ton={settings.actif ? "succes" : "neutre"}>{settings.actif ? "Activée" : "Désactivée"}</Badge>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sync-debut">Plage horaire</Label>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<span>de</span>
|
||||||
|
<Input id="sync-debut" type="number" min={0} max={23} value={settings.heureDebut}
|
||||||
|
aria-label="Heure de début"
|
||||||
|
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
|
||||||
|
className="w-16 text-center tabular-nums" />
|
||||||
|
<span>h à</span>
|
||||||
|
<Input id="sync-fin" type="number" min={0} max={23} value={settings.heureFin}
|
||||||
|
aria-label="Heure de fin"
|
||||||
|
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
|
||||||
|
className="w-16 text-center tabular-nums" />
|
||||||
|
<span>h</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Aucun fournisseur n'est commencé après l'heure de fin ; celui en cours va jusqu'au bout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sync-qlik-nuit">Extractions réseau par nuit</Label>
|
||||||
|
<Input id="sync-qlik-nuit" type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||||
|
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
|
||||||
|
className="w-20 text-center tabular-nums" />
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Une extraction des données du réseau (Qlik) peut durer 8 minutes : ce plafond évite qu'elles occupent toute la nuit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sync-qlik-jours">Fraîcheur des données réseau</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<span>Actualiser les données réseau de plus de</span>
|
||||||
|
<Input id="sync-qlik-jours" type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||||
|
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
|
||||||
|
className="w-20 text-center tabular-nums" />
|
||||||
|
<span>jours</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">Les modifications sont enregistrées automatiquement.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Lancement en cours ou dernier lancement ── */}
|
||||||
|
{state && (
|
||||||
|
<Card className={state.enCours ? "border-[var(--accent-border)] bg-[var(--accent-bg)]" : undefined}>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{state.enCours
|
||||||
|
? <Loader2 className="h-5 w-5 animate-spin text-[var(--accent)]" aria-hidden />
|
||||||
|
: state.finAt
|
||||||
|
? <CheckCircle2 className="h-5 w-5 text-[var(--accent-success)]" aria-hidden />
|
||||||
|
: <Clock className="h-5 w-5 text-[var(--text-muted)]" aria-hidden />}
|
||||||
|
<span className="text-base font-semibold text-[var(--text-primary)]">
|
||||||
|
{state.enCours
|
||||||
|
? `Lancement${libelleOrigine(state.origine)} en cours`
|
||||||
|
: state.finAt
|
||||||
|
? `Dernier lancement${libelleOrigine(state.origine)} terminé`
|
||||||
|
: "Aucun lancement depuis le démarrage du serveur"}
|
||||||
|
</span>
|
||||||
|
{state.enCours && state.fournisseurCourant && (
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
|
: {state.etape === "qlik" ? RESEAU : VENTES}, {state.fournisseurCourant}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.tourTermine && (
|
||||||
|
<Badge ton="succes">Tous les fournisseurs ont été traités</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(state.enCours || state.finAt) && (
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[13px] text-[var(--text-secondary)]">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Database className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
|
||||||
|
{VENTES} : {fmtEntier(state.sqlFaits)} à jour
|
||||||
|
{state.sqlEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.sqlEchecs)} en échec</span>}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Network className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
|
||||||
|
{RESEAU} : {fmtEntier(state.qlikFaits)} à jour
|
||||||
|
{state.qlikEchecs > 0 && <span className="text-[var(--accent-error)]">, {fmtEntier(state.qlikEchecs)} en échec</span>}
|
||||||
|
</span>
|
||||||
|
{state.debutAt && <span>Commencé le {fmtDate(state.debutAt)}</span>}
|
||||||
|
{!state.enCours && state.finAt && <span>Terminé le {fmtDate(state.finAt)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.derniereErreur && (
|
||||||
|
<p className="text-[13px] text-[var(--accent-error)]">Dernière erreur : {state.derniereErreur}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
{dansLaFenetre
|
||||||
|
? "Nous sommes actuellement dans la plage horaire nocturne."
|
||||||
|
: "Hors de la plage horaire : « Lancer maintenant » démarre quand même une mise à jour immédiate."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Résumé ── */}
|
||||||
|
{resume && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||||
|
<StatCard label="Fournisseurs" value={fmtEntier(resume.total)} />
|
||||||
|
<StatCard label={`${VENTES} activées`} value={fmtEntier(resume.actifsSql)} />
|
||||||
|
<StatCard label={`${RESEAU} activé`} value={fmtEntier(resume.actifsQlik)} />
|
||||||
|
<StatCard label="Ventes jamais mises à jour" value={fmtEntier(resume.jamaisSql)} />
|
||||||
|
<StatCard
|
||||||
|
label={
|
||||||
|
<Tooltip content="Fournisseurs sans aucun article dans la base FF : ils ont été retirés de la mise à jour nocturne. Cochez-les à nouveau pour les réintégrer.">
|
||||||
|
<span tabIndex={0} className="cursor-help underline decoration-dotted underline-offset-2">Retirés automatiquement</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
value={fmtEntier(resume.desactivesAuto)}
|
||||||
|
hint="aucun article dans la base FF"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="En échec"
|
||||||
|
value={fmtEntier(resume.enEchecSql + resume.enEchecQlik)}
|
||||||
|
hint={`${fmtEntier(resume.enEchecSql)} ventes, ${fmtEntier(resume.enEchecQlik)} réseau`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Fournisseurs ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Fournisseurs"
|
||||||
|
description="Cochez, pour chaque fournisseur, les données à mettre à jour chaque nuit."
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={() => action("seed")} disabled={actionEnCours === "seed"}
|
||||||
|
title="Ajoute les nouveaux fournisseurs du référentiel FF et met à jour leurs noms">
|
||||||
|
{actionEnCours === "seed" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
|
Actualiser la liste des fournisseurs
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2">
|
||||||
|
<span className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Pour les <strong className="text-[var(--text-primary)]">{fmtEntier(visibles.length)}</strong> fournisseur{visibles.length > 1 ? "s" : ""} de la liste ci-dessous :
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||||
|
onClick={() => basculerEnMasse("actifSql", true)}>
|
||||||
|
Activer les ventes
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||||
|
onClick={() => basculerEnMasse("actifSql", false)}>
|
||||||
|
Désactiver les ventes
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={visibles.length === 0}
|
||||||
|
onClick={() => basculerEnMasse("actifQlik", false)}>
|
||||||
|
Désactiver le réseau
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
rows={visibles}
|
||||||
|
columns={colonnes}
|
||||||
|
rowKey={(r) => r.codeFournisseur}
|
||||||
|
pageSize={0}
|
||||||
|
unite="fournisseurs"
|
||||||
|
toolbar={
|
||||||
|
<>
|
||||||
|
<Segmented items={filtres} value={filtre} onChange={setFiltre} />
|
||||||
|
<SearchInput
|
||||||
|
value={recherche}
|
||||||
|
onChange={setRecherche}
|
||||||
|
placeholder="Code ou nom du fournisseur"
|
||||||
|
aria-label="Rechercher un fournisseur"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
emptyTitle={rows.length === 0 ? "Aucun fournisseur" : "Aucun fournisseur ne correspond"}
|
||||||
|
emptyDescription={rows.length === 0
|
||||||
|
? "Utilisez « Actualiser la liste des fournisseurs » pour importer le référentiel FF."
|
||||||
|
: "Modifiez la recherche ou choisissez « Tous »."}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
"Mise à jour nocturne des données des fournisseurs : leurs ventes dans nos magasins (base FF) et dans le réseau (Qlik).";
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { SynchronisationClient } from "./client";
|
import { SynchronisationClient } from "./client";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata: Metadata = { title: "Synchronisation" };
|
||||||
title: "Synchronisation nocturne — CollectFlow",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paramétrage de la synchronisation nocturne des fournisseurs.
|
* Paramétrage de la synchronisation nocturne des fournisseurs.
|
||||||
|
|||||||
@@ -1,55 +1,95 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useTransition, type ReactNode } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { DataTable, type DataColumn } from "@/components/ui/data-table";
|
||||||
|
import { Segmented, type TabItem } from "@/components/ui/tabs";
|
||||||
|
import { Select } from "@/components/ui/form-controls";
|
||||||
|
import { DeltaBadge } from "@/components/ui/badge";
|
||||||
|
import { Terme } from "@/components/ui/tooltip";
|
||||||
|
import { MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||||
|
import { fmtEur0 } from "@/lib/format";
|
||||||
|
import { telechargerExcel } from "@/lib/export-excel";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ModeAnalytics = "fournisseur" | "nomenclature";
|
||||||
|
|
||||||
|
export interface OptionMois {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
ca292: number;
|
||||||
|
caN1_292: number;
|
||||||
|
ca579: number;
|
||||||
|
caN1_579: number;
|
||||||
|
caTotal: number;
|
||||||
|
caN1Total: number;
|
||||||
|
evolutionTotal: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface AnalyticsClientProps {
|
interface AnalyticsClientProps {
|
||||||
mode: string;
|
mode: ModeAnalytics;
|
||||||
|
/** Mois affiché, « AAAA-MM ». */
|
||||||
mois: string;
|
mois: string;
|
||||||
moisN1: string;
|
/** Mois en clair : « octobre 2026 ». */
|
||||||
currentMois: string;
|
libelleMois: string;
|
||||||
pivotted: Array<{
|
libelleMoisN1: string;
|
||||||
key: string;
|
moisDisponibles: OptionMois[];
|
||||||
label: string;
|
pivotted: AnalyticsRow[];
|
||||||
ca292: number;
|
|
||||||
caN1_292: number;
|
|
||||||
ca579: number;
|
|
||||||
caN1_579: number;
|
|
||||||
caTotal: number;
|
|
||||||
caN1Total: number;
|
|
||||||
evolutionTotal: number | null;
|
|
||||||
}>;
|
|
||||||
totals: {
|
|
||||||
ca292: number;
|
|
||||||
caN1_292: number;
|
|
||||||
ca579: number;
|
|
||||||
caN1_579: number;
|
|
||||||
caTotal: number;
|
|
||||||
caN1Total: number;
|
|
||||||
};
|
|
||||||
evolutionTotal292: number | null;
|
|
||||||
evolutionTotal579: number | null;
|
|
||||||
evolutionTotalReseau: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCA(value: number): string {
|
/** Champs numériques de la ligne pivotée. */
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
type Champ = { [K in keyof AnalyticsRow]: AnalyticsRow[K] extends number ? K : never }[keyof AnalyticsRow];
|
||||||
style: "currency",
|
|
||||||
currency: "EUR",
|
interface Groupe {
|
||||||
maximumFractionDigits: 0,
|
id: string;
|
||||||
}).format(value);
|
titre: string;
|
||||||
|
ca: Champ;
|
||||||
|
caN1: Champ;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EvolutionBadge({ value }: { value: number | null }) {
|
/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */
|
||||||
if (value === null) return <span className="text-gray-400">N/A</span>;
|
const CHAMPS_MAGASIN: Record<string, Pick<Groupe, "ca" | "caN1">> = {
|
||||||
|
"292": { ca: "ca292", caN1: "caN1_292" },
|
||||||
|
"579": { ca: "ca579", caN1: "caN1_579" },
|
||||||
|
};
|
||||||
|
|
||||||
const isPositive = value >= 0;
|
const GROUPES: Groupe[] = [
|
||||||
const bgColor = isPositive ? "bg-green-100" : "bg-red-100";
|
...MAGASINS.filter((m) => CHAMPS_MAGASIN[m.code]).map((m) => ({
|
||||||
const textColor = isPositive ? "text-green-700" : "text-red-700";
|
id: m.code,
|
||||||
|
titre: nomMagasin(m.code),
|
||||||
|
...CHAMPS_MAGASIN[m.code],
|
||||||
|
})),
|
||||||
|
{ id: "total", titre: "Total nos magasins", ca: "caTotal", caN1: "caN1Total" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODES: TabItem<ModeAnalytics>[] = [
|
||||||
|
{ value: "fournisseur", label: "Fournisseur" },
|
||||||
|
{ value: "nomenclature", label: "Famille" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const somme = (rows: readonly AnalyticsRow[], champ: Champ) => rows.reduce((acc, r) => acc + r[champ], 0);
|
||||||
|
|
||||||
|
/** Évolution en % par rapport à N-1 ; `null` sans chiffre d'affaires N-1. */
|
||||||
|
const evolution = (ca: number, caN1: number) => (caN1 > 0 ? ((ca - caN1) / caN1) * 100 : null);
|
||||||
|
|
||||||
|
const recherche = (r: AnalyticsRow) => [r.label, r.key];
|
||||||
|
|
||||||
|
function Montant({ v }: { v: number }) {
|
||||||
|
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur0(v)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
|
||||||
|
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className={`${bgColor} ${textColor} inline-block rounded px-2 py-1 text-sm font-semibold`}>
|
<span className="flex flex-col items-end leading-tight">
|
||||||
{isPositive ? "+" : ""}
|
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
|
||||||
{value.toFixed(1)}%
|
<span className="inline-flex items-center gap-1">{children}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,193 +97,159 @@ function EvolutionBadge({ value }: { value: number | null }) {
|
|||||||
export function AnalyticsClient({
|
export function AnalyticsClient({
|
||||||
mode,
|
mode,
|
||||||
mois,
|
mois,
|
||||||
moisN1,
|
libelleMois,
|
||||||
currentMois,
|
libelleMoisN1,
|
||||||
|
moisDisponibles,
|
||||||
pivotted,
|
pivotted,
|
||||||
totals,
|
|
||||||
evolutionTotal292,
|
|
||||||
evolutionTotal579,
|
|
||||||
evolutionTotalReseau,
|
|
||||||
}: AnalyticsClientProps) {
|
}: AnalyticsClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [navigation, demarrerNavigation] = useTransition();
|
||||||
|
|
||||||
const handleModeChange = (newMode: string) => {
|
const handleModeChange = (newMode: string) => {
|
||||||
router.push(`/analytics?mode=${newMode}&mois=${mois}`);
|
demarrerNavigation(() => {
|
||||||
|
router.push(`/analytics?mode=${newMode}&mois=${mois}`);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMonthChange = (newMois: string) => {
|
const handleMonthChange = (newMois: string) => {
|
||||||
router.push(`/analytics?mode=${mode}&mois=${newMois}`);
|
demarrerNavigation(() => {
|
||||||
|
router.push(`/analytics?mode=${mode}&mois=${newMois}`);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Générer liste des 24 derniers mois
|
const libelleColonne = mode === "fournisseur" ? "Fournisseur" : "Famille";
|
||||||
const months: { value: string; label: string }[] = [];
|
|
||||||
const now = new Date();
|
const colonnes = useMemo<DataColumn<AnalyticsRow>[]>(() => [
|
||||||
for (let i = 0; i < 24; i++) {
|
{
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i);
|
id: "label",
|
||||||
const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
header: libelleColonne,
|
||||||
const label = new Intl.DateTimeFormat("fr-FR", { year: "numeric", month: "long" }).format(d);
|
sortValue: (r) => r.label,
|
||||||
months.push({ value, label: label.charAt(0).toUpperCase() + label.slice(1) });
|
cell: (r) => <span className="font-medium">{r.label}</span>,
|
||||||
|
footer: () => "Total",
|
||||||
|
className: "min-w-[220px]",
|
||||||
|
},
|
||||||
|
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
|
||||||
|
const total = g.id === "total";
|
||||||
|
const bord = "border-l border-[var(--border)]";
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${g.id}-ca`,
|
||||||
|
header: <EnTete groupe={g.titre}>{libelleMois}</EnTete>,
|
||||||
|
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r[g.ca],
|
||||||
|
cell: (r) => <Montant v={r[g.ca]} />,
|
||||||
|
footer: (rows) => <Montant v={somme(rows, g.ca)} />,
|
||||||
|
className: cn(bord, total && "font-semibold"),
|
||||||
|
headerClassName: bord,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${g.id}-n1`,
|
||||||
|
header: (
|
||||||
|
<EnTete groupe={g.titre}>
|
||||||
|
{libelleMoisN1} · <Terme id="n1" />
|
||||||
|
</EnTete>
|
||||||
|
),
|
||||||
|
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMoisN1}, même mois l'année précédente`,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r[g.caN1],
|
||||||
|
cell: (r) => <Montant v={r[g.caN1]} />,
|
||||||
|
footer: (rows) => <Montant v={somme(rows, g.caN1)} />,
|
||||||
|
className: cn(total && "font-semibold"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${g.id}-evolution`,
|
||||||
|
header: <EnTete groupe={g.titre}>Évolution</EnTete>,
|
||||||
|
hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => evolution(r[g.ca], r[g.caN1]),
|
||||||
|
cell: (r) => <DeltaBadge pct={evolution(r[g.ca], r[g.caN1])} />,
|
||||||
|
footer: (rows) => <DeltaBadge pct={evolution(somme(rows, g.ca), somme(rows, g.caN1))} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
], [libelleColonne, libelleMois, libelleMoisN1]);
|
||||||
|
|
||||||
|
async function exporter(rows: readonly AnalyticsRow[]) {
|
||||||
|
const arrondi = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
const evol = (ca: number, caN1: number) => {
|
||||||
|
const e = evolution(ca, caN1);
|
||||||
|
return e === null ? null : Math.round(e * 10) / 10;
|
||||||
|
};
|
||||||
|
await telechargerExcel({
|
||||||
|
feuille: "Ventes par mois",
|
||||||
|
fichier: `ventes-par-mois_${mode === "fournisseur" ? "fournisseurs" : "familles"}_${mois}`,
|
||||||
|
entetes: [
|
||||||
|
libelleColonne,
|
||||||
|
...GROUPES.flatMap((g) => [
|
||||||
|
`${g.titre} — CA ${libelleMois}`,
|
||||||
|
`${g.titre} — CA ${libelleMoisN1} (N-1)`,
|
||||||
|
`${g.titre} — Évolution (%)`,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
largeurs: [40, ...GROUPES.flatMap(() => [22, 22, 16])],
|
||||||
|
lignes: rows.map((r) => [
|
||||||
|
r.label,
|
||||||
|
...GROUPES.flatMap((g) => [arrondi(r[g.ca]), arrondi(r[g.caN1]), evol(r[g.ca], r[g.caN1])]),
|
||||||
|
]),
|
||||||
|
totaux: [
|
||||||
|
"Total",
|
||||||
|
...GROUPES.flatMap((g) => {
|
||||||
|
const ca = somme(rows, g.ca);
|
||||||
|
const caN1 = somme(rows, g.caN1);
|
||||||
|
return [arrondi(ca), arrondi(caN1), evol(ca, caN1)];
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4" aria-busy={navigation}>
|
||||||
{/* Controls */}
|
{/* Contrôles (relancent le calcul côté serveur) */}
|
||||||
<div className="flex items-center gap-6 rounded-lg bg-white p-4 shadow">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div role="group" aria-labelledby="analytics-vue">
|
||||||
<label className="font-semibold text-gray-700">Vue :</label>
|
<span id="analytics-vue" className="mb-1 block text-[13px] font-medium text-[var(--text-secondary)]">
|
||||||
<select
|
Afficher par
|
||||||
value={mode}
|
</span>
|
||||||
onChange={(e) => handleModeChange(e.target.value)}
|
<Segmented items={MODES} value={mode} onChange={handleModeChange} disabled={navigation} />
|
||||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<option value="fournisseur">Fournisseur</option>
|
|
||||||
<option value="nomenclature">Nomenclature</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="font-semibold text-gray-700">Mois :</label>
|
|
||||||
<select
|
|
||||||
value={mois}
|
|
||||||
onChange={(e) => handleMonthChange(e.target.value)}
|
|
||||||
className="rounded border border-gray-300 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
{months.map((m) => (
|
|
||||||
<option key={m.value} value={m.value}>
|
|
||||||
{m.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
Comparaison N-1 : {moisN1}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Select
|
||||||
|
id="analytics-mois"
|
||||||
|
label="Mois"
|
||||||
|
value={mois}
|
||||||
|
onChange={handleMonthChange}
|
||||||
|
options={moisDisponibles}
|
||||||
|
disabled={navigation}
|
||||||
|
/>
|
||||||
|
{navigation && (
|
||||||
|
<span role="status" className="inline-flex h-9 items-center gap-2 text-[13px] text-[var(--text-secondary)]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
Chargement…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
<div className="overflow-x-auto rounded-lg bg-white shadow">
|
Chiffre d'affaires TTC de <strong className="font-semibold text-[var(--text-primary)]">{libelleMois}</strong>,
|
||||||
<table className="w-full">
|
comparé à <strong className="font-semibold text-[var(--text-primary)]">{libelleMoisN1}</strong> (même mois
|
||||||
<thead>
|
l'année précédente). Les retours clients sont déduits.
|
||||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
</p>
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
|
|
||||||
{mode === "fournisseur" ? "Fournisseur" : "Nomenclature"}
|
<div className={cn("transition-opacity", navigation && "pointer-events-none opacity-50")}>
|
||||||
</th>
|
<DataTable<AnalyticsRow>
|
||||||
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
|
key={mode}
|
||||||
Frouard/Nancy (292)
|
rows={pivotted}
|
||||||
</th>
|
columns={colonnes}
|
||||||
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
|
rowKey={(r) => `${r.key}|${r.label}`}
|
||||||
Houdemont (579)
|
searchIn={recherche}
|
||||||
</th>
|
searchPlaceholder={mode === "fournisseur" ? "Rechercher un fournisseur…" : "Rechercher une famille…"}
|
||||||
<th colSpan={3} className="px-4 py-3 text-center text-sm font-semibold text-gray-700">
|
pageSize={100}
|
||||||
Total Réseau
|
unite={mode === "fournisseur" ? "fournisseurs" : "familles"}
|
||||||
</th>
|
showFooter
|
||||||
</tr>
|
emptyTitle={`Aucune vente en ${libelleMois}`}
|
||||||
<tr className="border-b border-gray-200 bg-gray-50">
|
emptyDescription="Aucun chiffre d'affaires n'est enregistré pour ce mois ni pour le même mois de l'année précédente. Choisissez un autre mois."
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600"></th>
|
onExport={exporter}
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
/>
|
||||||
{mois}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
|
||||||
{moisN1}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
|
|
||||||
Évol.
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
|
||||||
{mois}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
|
||||||
{moisN1}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
|
|
||||||
Évol.
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
|
||||||
{mois}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">
|
|
||||||
{moisN1}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">
|
|
||||||
Évol.
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{pivotted.map((row, idx) => (
|
|
||||||
<tr
|
|
||||||
key={row.key}
|
|
||||||
className={idx % 2 === 0 ? "border-b border-gray-100 bg-white" : "border-b border-gray-100 bg-gray-50"}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{row.label}</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca292)}</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_292)}</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm">
|
|
||||||
<EvolutionBadge
|
|
||||||
value={
|
|
||||||
row.caN1_292 > 0
|
|
||||||
? ((row.ca292 - row.caN1_292) / row.caN1_292) * 100
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.ca579)}</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm text-gray-800">{formatCA(row.caN1_579)}</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm">
|
|
||||||
<EvolutionBadge
|
|
||||||
value={
|
|
||||||
row.caN1_579 > 0
|
|
||||||
? ((row.ca579 - row.caN1_579) / row.caN1_579) * 100
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
|
|
||||||
{formatCA(row.caTotal)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-900">
|
|
||||||
{formatCA(row.caN1Total)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm">
|
|
||||||
<EvolutionBadge value={row.evolutionTotal} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
|
||||||
<td className="px-4 py-3 text-sm font-bold text-gray-900">TOTAL</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
|
||||||
{formatCA(totals.ca292)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
|
||||||
{formatCA(totals.caN1_292)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm">
|
|
||||||
<EvolutionBadge value={evolutionTotal292} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
|
||||||
{formatCA(totals.ca579)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
|
||||||
{formatCA(totals.caN1_579)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm">
|
|
||||||
<EvolutionBadge value={evolutionTotal579} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
|
||||||
{formatCA(totals.caTotal)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900">
|
|
||||||
{formatCA(totals.caN1Total)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm">
|
|
||||||
<EvolutionBadge value={evolutionTotalReseau} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CalendarRange, RotateCcw } from "lucide-react";
|
||||||
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
|
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
|
||||||
import { AnalyticsClient } from "./client";
|
|
||||||
import { cachedFF } from "@/lib/ff-cache";
|
import { cachedFF } from "@/lib/ff-cache";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { ErrorState } from "@/components/ui/states";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AnalyticsClient, type AnalyticsRow, type ModeAnalytics, type OptionMois } from "./client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ventes par mois" };
|
||||||
|
|
||||||
|
const NOMS_MOIS = [
|
||||||
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** « 2026-10 » → « octobre 2026 » (sans dépendre du fuseau horaire). */
|
||||||
|
function libelleMois(mois: string): string {
|
||||||
|
const [year, month] = mois.split("-").map(Number);
|
||||||
|
const nom = NOMS_MOIS[month - 1];
|
||||||
|
return nom && Number.isFinite(year) ? `${nom} ${year}` : mois;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMois(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Calcul du mois N-1 (même mois année précédente)
|
// Calcul du mois N-1 (même mois année précédente)
|
||||||
function getMoisN1(mois: string): string {
|
function getMoisN1(mois: string): string {
|
||||||
@@ -15,17 +39,7 @@ function pivotData(
|
|||||||
mois: string,
|
mois: string,
|
||||||
moisN1: string,
|
moisN1: string,
|
||||||
mode: string
|
mode: string
|
||||||
): Array<{
|
): AnalyticsRow[] {
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
ca292: number;
|
|
||||||
caN1_292: number;
|
|
||||||
ca579: number;
|
|
||||||
caN1_579: number;
|
|
||||||
caTotal: number;
|
|
||||||
caN1Total: number;
|
|
||||||
evolutionTotal: number | null;
|
|
||||||
}> {
|
|
||||||
const map = new Map<string, { code: string; label: string; data: Record<string, number> }>();
|
const map = new Map<string, { code: string; label: string; data: Record<string, number> }>();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -72,77 +86,87 @@ export default async function AnalyticsPage(props: {
|
|||||||
searchParams: Promise<Record<string, string | string[]>>;
|
searchParams: Promise<Record<string, string | string[]>>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const mode = (searchParams.mode as string) || "fournisseur";
|
// Toute autre valeur que « fournisseur » affichait déjà la vue par famille.
|
||||||
|
const mode: ModeAnalytics = ((searchParams.mode as string) || "fournisseur") === "fournisseur" ? "fournisseur" : "nomenclature";
|
||||||
const moisParam = (searchParams.mois as string) || "";
|
const moisParam = (searchParams.mois as string) || "";
|
||||||
|
|
||||||
// Mois par défaut = mois courant (YYYY-MM)
|
// Mois par défaut = dernier mois complet (le mois précédent) : le mois en
|
||||||
|
// cours, incomplet, se comparerait mal à un mois N-1 entier.
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const currentMois = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
|
const currentMois = formatMois(today);
|
||||||
const mois = moisParam || currentMois;
|
const dernierMoisComplet = formatMois(new Date(today.getFullYear(), today.getMonth() - 1));
|
||||||
|
const mois = moisParam || dernierMoisComplet;
|
||||||
const moisN1 = getMoisN1(mois);
|
const moisN1 = getMoisN1(mois);
|
||||||
|
|
||||||
let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> =
|
// Liste des 24 derniers mois (calculée ici : même résultat côté serveur et navigateur).
|
||||||
[];
|
const moisDisponibles: OptionMois[] = [];
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
if (mode === "fournisseur") {
|
const value = formatMois(new Date(today.getFullYear(), today.getMonth() - i));
|
||||||
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
|
moisDisponibles.push({ value, label: libelleMois(value) + (value === currentMois ? " (en cours)" : "") });
|
||||||
rows = result.map((r) => ({
|
}
|
||||||
code: r.code,
|
if (!moisDisponibles.some((m) => m.value === mois)) {
|
||||||
label: r.nom,
|
moisDisponibles.push({ value: mois, label: libelleMois(mois) });
|
||||||
site: r.site,
|
|
||||||
mois: r.mois,
|
|
||||||
ca_ttc: r.ca_ttc,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
|
|
||||||
rows = result.map((r) => ({
|
|
||||||
code: r.code,
|
|
||||||
label: `${r.code} — ${r.libelle}`,
|
|
||||||
site: r.site,
|
|
||||||
mois: r.mois,
|
|
||||||
ca_ttc: r.ca_ttc,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pivotted = pivotData(rows, mois, moisN1, mode);
|
let pivotted: AnalyticsRow[] | null = null;
|
||||||
|
let erreur: string | null = null;
|
||||||
|
try {
|
||||||
|
let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> =
|
||||||
|
[];
|
||||||
|
|
||||||
// Calcul des totaux
|
if (mode === "fournisseur") {
|
||||||
const totals = pivotted.reduce(
|
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
|
||||||
(acc, row) => ({
|
rows = result.map((r) => ({
|
||||||
ca292: acc.ca292 + row.ca292,
|
code: r.code,
|
||||||
caN1_292: acc.caN1_292 + row.caN1_292,
|
label: r.nom,
|
||||||
ca579: acc.ca579 + row.ca579,
|
site: r.site,
|
||||||
caN1_579: acc.caN1_579 + row.caN1_579,
|
mois: r.mois,
|
||||||
caTotal: acc.caTotal + row.caTotal,
|
ca_ttc: r.ca_ttc,
|
||||||
caN1Total: acc.caN1Total + row.caN1Total,
|
}));
|
||||||
}),
|
} else {
|
||||||
{ ca292: 0, caN1_292: 0, ca579: 0, caN1_579: 0, caTotal: 0, caN1Total: 0 }
|
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
|
||||||
);
|
rows = result.map((r) => ({
|
||||||
|
code: r.code,
|
||||||
|
label: `${r.code} — ${r.libelle}`,
|
||||||
|
site: r.site,
|
||||||
|
mois: r.mois,
|
||||||
|
ca_ttc: r.ca_ttc,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const evolutionTotal292 =
|
pivotted = pivotData(rows, mois, moisN1, mode);
|
||||||
totals.caN1_292 > 0 ? ((totals.ca292 - totals.caN1_292) / totals.caN1_292) * 100 : null;
|
} catch (e) {
|
||||||
const evolutionTotal579 =
|
console.error("[analytics] chargement impossible :", e);
|
||||||
totals.caN1_579 > 0 ? ((totals.ca579 - totals.caN1_579) / totals.caN1_579) * 100 : null;
|
erreur = e instanceof Error ? e.message : String(e);
|
||||||
const evolutionTotalReseau =
|
}
|
||||||
totals.caN1Total > 0 ? ((totals.caTotal - totals.caN1Total) / totals.caN1Total) * 100 : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
<div className="mx-auto max-w-7xl">
|
<PageHeader
|
||||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Analytics</h1>
|
icon={CalendarRange}
|
||||||
|
title="Ventes par mois"
|
||||||
|
description="Le chiffre d'affaires TTC d'un mois comparé au même mois de l'année précédente, par fournisseur ou par famille de produits, pour chacun de nos magasins."
|
||||||
|
/>
|
||||||
|
{pivotted ? (
|
||||||
<AnalyticsClient
|
<AnalyticsClient
|
||||||
mode={mode}
|
mode={mode}
|
||||||
mois={mois}
|
mois={mois}
|
||||||
moisN1={moisN1}
|
libelleMois={libelleMois(mois)}
|
||||||
currentMois={currentMois}
|
libelleMoisN1={libelleMois(moisN1)}
|
||||||
|
moisDisponibles={moisDisponibles}
|
||||||
pivotted={pivotted}
|
pivotted={pivotted}
|
||||||
totals={totals}
|
|
||||||
evolutionTotal292={evolutionTotal292}
|
|
||||||
evolutionTotal579={evolutionTotal579}
|
|
||||||
evolutionTotalReseau={evolutionTotalReseau}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<ErrorState
|
||||||
|
title="Les ventes du mois n'ont pas pu être chargées"
|
||||||
|
detail={erreur ?? undefined}
|
||||||
|
action={
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/analytics?mode=${mode}&mois=${mois}`}><RotateCcw /> Réessayer</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition, type FormEvent } from "react";
|
||||||
import {
|
import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
CalendarClock,
|
|
||||||
CheckCircle2,
|
|
||||||
HelpCircle,
|
|
||||||
Plus,
|
|
||||||
Power,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
upsertCadence,
|
upsertCadence,
|
||||||
toggleCadence,
|
toggleCadence,
|
||||||
@@ -17,41 +9,76 @@ import {
|
|||||||
type CadenceView,
|
type CadenceView,
|
||||||
type CadenceStatut,
|
type CadenceStatut,
|
||||||
} from "@/features/commandes-auto/actions";
|
} from "@/features/commandes-auto/actions";
|
||||||
|
import { Badge, type Ton } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { confirmer, toast } from "@/components/ui/feedback";
|
||||||
|
import { Input, Label } from "@/components/ui/form-controls";
|
||||||
|
import { EmptyState } from "@/components/ui/states";
|
||||||
|
import { Terme, Tooltip, InfoBulle } from "@/components/ui/tooltip";
|
||||||
|
import { MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const SITES: { code: string; label: string }[] = [
|
type Fournisseur = { code: string; nom: string };
|
||||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
type Resultat = { ok: boolean; error?: string };
|
||||||
{ code: "579", label: "579 — Houdemont" },
|
|
||||||
];
|
const SEMAINES_MIN = 1;
|
||||||
|
const SEMAINES_MAX = 104;
|
||||||
|
|
||||||
function fmtDate(iso: string | null): string {
|
function fmtDate(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluriel = (n: number, mot: string) => `${n} ${mot}${Math.abs(n) > 1 ? "s" : ""}`;
|
||||||
|
|
||||||
function tempsRestant(jours: number | null): string {
|
function tempsRestant(jours: number | null): string {
|
||||||
if (jours === null) return "Réception inconnue";
|
if (jours === null) return "Réception inconnue";
|
||||||
if (jours === 0) return "Aujourd'hui";
|
if (jours === 0) return "Aujourd'hui";
|
||||||
if (jours < 0) return `En retard de ${Math.abs(jours)} j`;
|
if (jours < 0) return `En retard de ${pluriel(Math.abs(jours), "jour")}`;
|
||||||
if (jours === 1) return "Demain";
|
if (jours === 1) return "Demain";
|
||||||
return `Dans ${jours} j`;
|
return `Dans ${pluriel(jours, "jour")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUT_META: Record<CadenceStatut, { label: string; cls: string; Icon: typeof CheckCircle2 }> = {
|
const STATUT_META: Record<CadenceStatut, { libelle: string; ton: Ton; explication: string }> = {
|
||||||
a_commander: { label: "À commander", cls: "bg-red-100 text-red-700", Icon: AlertTriangle },
|
a_commander: {
|
||||||
bientot: { label: "Bientôt", cls: "bg-amber-100 text-amber-700", Icon: CalendarClock },
|
libelle: "À commander",
|
||||||
ok: { label: "OK", cls: "bg-emerald-100 text-emerald-700", Icon: CheckCircle2 },
|
ton: "erreur",
|
||||||
inconnu: { label: "Inconnu", cls: "bg-gray-100 text-gray-500", Icon: HelpCircle },
|
explication: "L'échéance est atteinte ou dépassée : passez commande.",
|
||||||
|
},
|
||||||
|
bientot: {
|
||||||
|
libelle: "Bientôt",
|
||||||
|
ton: "alerte",
|
||||||
|
explication: "L'échéance tombe dans les 7 prochains jours.",
|
||||||
|
},
|
||||||
|
ok: {
|
||||||
|
libelle: "À jour",
|
||||||
|
ton: "succes",
|
||||||
|
explication: "L'échéance est dans plus de 7 jours.",
|
||||||
|
},
|
||||||
|
inconnu: {
|
||||||
|
libelle: "Inconnu",
|
||||||
|
ton: "neutre",
|
||||||
|
explication: "Aucune réception connue pour ce fournisseur dans ce magasin : l'échéance ne peut pas être calculée.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatutBadge({ statut }: { statut: CadenceStatut }) {
|
function StatutBadge({ cadence }: { cadence: CadenceView }) {
|
||||||
const { label, cls, Icon } = STATUT_META[statut];
|
if (!cadence.actif) {
|
||||||
|
return <Badge title="Rappel en pause : aucune alerte pour ce fournisseur.">Désactivé</Badge>;
|
||||||
|
}
|
||||||
|
const { libelle, ton, explication } = STATUT_META[cadence.statut];
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${cls}`}>
|
<Badge ton={ton} title={explication}>
|
||||||
<Icon className="h-3 w-3" /> {label}
|
{cadence.statut === "a_commander" && <AlertTriangle className="h-3.5 w-3.5" aria-hidden />}
|
||||||
</span>
|
{libelle}
|
||||||
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Libellé suivi d'un « i » : explication libre au survol (hors glossaire). */
|
||||||
|
|
||||||
/** Tri : à commander d'abord (plus en retard en tête), puis bientôt, ok, inconnu, et inactifs en dernier. */
|
/** Tri : à commander d'abord (plus en retard en tête), puis bientôt, ok, inconnu, et inactifs en dernier. */
|
||||||
function trier(rows: CadenceView[]): CadenceView[] {
|
function trier(rows: CadenceView[]): CadenceView[] {
|
||||||
const rank: Record<CadenceStatut, number> = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 };
|
const rank: Record<CadenceStatut, number> = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 };
|
||||||
@@ -64,248 +91,365 @@ function trier(rows: CadenceView[]): CadenceView[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddCadenceForm({
|
function FormulaireAjout({
|
||||||
site,
|
site,
|
||||||
fournisseurs,
|
fournisseurs,
|
||||||
existingCodes,
|
codesExistants,
|
||||||
onDone,
|
onAjouter,
|
||||||
pending,
|
pending,
|
||||||
}: {
|
}: {
|
||||||
site: string;
|
site: string;
|
||||||
fournisseurs: { code: string; nom: string }[];
|
fournisseurs: Fournisseur[];
|
||||||
existingCodes: Set<string>;
|
codesExistants: Set<string>;
|
||||||
onDone: (input: { codefou: string; nomfou: string; semaines: number }) => void;
|
onAjouter: (input: { codefou: string; nomfou: string; semaines: number }) => Promise<boolean>;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [value, setValue] = useState("");
|
const [saisie, setSaisie] = useState("");
|
||||||
const [semaines, setSemaines] = useState(4);
|
const [semaines, setSemaines] = useState("4");
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [erreur, setErreur] = useState<string | null>(null);
|
||||||
const listId = `fou-list-${site}`;
|
const idFournisseur = `cadence-fournisseur-${site}`;
|
||||||
|
const idSemaines = `cadence-semaines-${site}`;
|
||||||
|
const idListe = `cadence-liste-${site}`;
|
||||||
|
const idErreur = `cadence-erreur-${site}`;
|
||||||
|
|
||||||
// label affiché dans le datalist → fournisseur
|
// Libellé affiché dans la liste de suggestions → fournisseur
|
||||||
const byLabel = useMemo(() => {
|
const parLibelle = useMemo(() => {
|
||||||
const m = new Map<string, { code: string; nom: string }>();
|
const m = new Map<string, Fournisseur>();
|
||||||
for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f);
|
for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f);
|
||||||
return m;
|
return m;
|
||||||
}, [fournisseurs]);
|
}, [fournisseurs]);
|
||||||
|
|
||||||
function submit() {
|
async function ajouter(e: FormEvent<HTMLFormElement>) {
|
||||||
setErr(null);
|
e.preventDefault();
|
||||||
const f = byLabel.get(value.trim());
|
setErreur(null);
|
||||||
|
const f = parLibelle.get(saisie.trim());
|
||||||
if (!f) {
|
if (!f) {
|
||||||
setErr("Sélectionnez un fournisseur dans la liste.");
|
setErreur("Choisissez un fournisseur dans la liste proposée pendant la saisie.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (existingCodes.has(f.code)) {
|
if (codesExistants.has(f.code)) {
|
||||||
setErr("Ce fournisseur est déjà cadencé sur ce magasin.");
|
setErreur("Ce fournisseur a déjà un rappel dans ce magasin : modifiez son rythme dans le tableau ci-dessous.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(semaines) || semaines < 1 || semaines > 104) {
|
const n = Number(semaines);
|
||||||
setErr("Intervalle : 1 à 104 semaines.");
|
if (!Number.isInteger(n) || n < SEMAINES_MIN || n > SEMAINES_MAX) {
|
||||||
|
setErreur(`Indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onDone({ codefou: f.code, nomfou: f.nom, semaines });
|
// Le formulaire n'est vidé qu'une fois le rappel réellement enregistré.
|
||||||
setValue("");
|
if (await onAjouter({ codefou: f.code, nomfou: f.nom, semaines: n })) {
|
||||||
setSemaines(4);
|
setSaisie("");
|
||||||
|
setSemaines("4");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-3">
|
<form
|
||||||
<div className="flex flex-col">
|
onSubmit={ajouter}
|
||||||
<label className="mb-1 text-xs font-medium text-gray-500">Fournisseur</label>
|
className="rounded-xl border border-dashed border-[var(--border-strong)] bg-[var(--bg-surface)] p-4"
|
||||||
<input
|
>
|
||||||
list={listId}
|
<p className="mb-3 text-sm font-medium text-[var(--text-primary)]">Ajouter un rappel pour ce magasin</p>
|
||||||
value={value}
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
onChange={(e) => setValue(e.target.value)}
|
<div>
|
||||||
placeholder="Rechercher…"
|
<Label htmlFor={idFournisseur}>Fournisseur</Label>
|
||||||
className="min-w-[260px] rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<Input
|
||||||
/>
|
id={idFournisseur}
|
||||||
<datalist id={listId}>
|
list={idListe}
|
||||||
{fournisseurs.map((f) => (
|
value={saisie}
|
||||||
<option key={f.code} value={`${f.nom} — ${f.code}`} />
|
onChange={(e) => setSaisie(e.target.value)}
|
||||||
))}
|
placeholder="Tapez le nom ou le code…"
|
||||||
</datalist>
|
autoComplete="off"
|
||||||
|
aria-invalid={erreur !== null || undefined}
|
||||||
|
aria-describedby={erreur ? idErreur : undefined}
|
||||||
|
className="w-[300px] max-w-full"
|
||||||
|
/>
|
||||||
|
<datalist id={idListe}>
|
||||||
|
{fournisseurs.map((f) => (
|
||||||
|
<option key={f.code} value={`${f.nom} — ${f.code}`} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={idSemaines}>Toutes les … semaines</Label>
|
||||||
|
<Input
|
||||||
|
id={idSemaines}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={SEMAINES_MIN}
|
||||||
|
max={SEMAINES_MAX}
|
||||||
|
step={1}
|
||||||
|
value={semaines}
|
||||||
|
onChange={(e) => setSemaines(e.target.value)}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
<Plus /> Ajouter le rappel
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
{erreur && (
|
||||||
<label className="mb-1 text-xs font-medium text-gray-500">Toutes les (semaines)</label>
|
<p id={idErreur} role="alert" className="mt-2 text-[13px] font-medium text-[var(--accent-error)]">
|
||||||
<input
|
{erreur}
|
||||||
type="number"
|
</p>
|
||||||
min={1}
|
)}
|
||||||
max={104}
|
</form>
|
||||||
value={semaines}
|
|
||||||
onChange={(e) => setSemaines(parseInt(e.target.value, 10))}
|
|
||||||
className="w-28 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
disabled={pending}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" /> Activer l'alerte
|
|
||||||
</button>
|
|
||||||
{err && <span className="text-xs font-medium text-red-600">{err}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SiteSection({
|
const TH = "whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-left text-[13px] font-semibold text-[var(--text-secondary)]";
|
||||||
|
|
||||||
|
function SectionMagasin({
|
||||||
site,
|
site,
|
||||||
label,
|
|
||||||
rows,
|
rows,
|
||||||
fournisseurs,
|
fournisseurs,
|
||||||
}: {
|
}: {
|
||||||
site: string;
|
site: string;
|
||||||
label: string;
|
|
||||||
rows: CadenceView[];
|
rows: CadenceView[];
|
||||||
fournisseurs: { code: string; nom: string }[];
|
fournisseurs: Fournisseur[];
|
||||||
}) {
|
}) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
const nom = nomMagasin(site);
|
||||||
|
|
||||||
const sorted = useMemo(() => trier(rows), [rows]);
|
const triees = useMemo(() => trier(rows), [rows]);
|
||||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
|
const codesExistants = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
|
||||||
const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length;
|
const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length;
|
||||||
|
|
||||||
// Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
|
/**
|
||||||
// la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
|
* Exécute une action serveur et en rend compte : message de réussite, ou
|
||||||
function run(fn: () => Promise<unknown>) {
|
* message d'erreur (refus de l'action ou exception) — jamais d'échec muet.
|
||||||
startTransition(async () => {
|
* Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
|
||||||
await fn();
|
* la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
|
||||||
|
*/
|
||||||
|
function executer(action: () => Promise<Resultat>, messages: { succes: string; echec: string }): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const resultat = await action();
|
||||||
|
if (resultat.ok) toast.succes(messages.succes);
|
||||||
|
else toast.erreur(resultat.error ?? messages.echec);
|
||||||
|
resolve(resultat.ok);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[cadencier]", e);
|
||||||
|
toast.erreur(`${messages.echec} Vérifiez votre connexion puis réessayez.`);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = (input: { codefou: string; nomfou: string; semaines: number }) =>
|
const ajouter = (input: { codefou: string; nomfou: string; semaines: number }) =>
|
||||||
run(() =>
|
executer(
|
||||||
upsertCadence({
|
() =>
|
||||||
codefou: input.codefou,
|
upsertCadence({
|
||||||
nomfou: input.nomfou,
|
codefou: input.codefou,
|
||||||
site,
|
nomfou: input.nomfou,
|
||||||
intervalleSemaines: input.semaines,
|
site,
|
||||||
actif: true,
|
intervalleSemaines: input.semaines,
|
||||||
}),
|
actif: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
succes: `Rappel ajouté : ${input.nomfou}, toutes les ${pluriel(input.semaines, "semaine")}.`,
|
||||||
|
echec: "Le rappel n'a pas pu être ajouté.",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeSemaines = (r: CadenceView, semaines: number) =>
|
const changerSemaines = (r: CadenceView, semaines: number) =>
|
||||||
run(() =>
|
executer(
|
||||||
upsertCadence({
|
() =>
|
||||||
codefou: r.codefou,
|
upsertCadence({
|
||||||
nomfou: r.nomfou,
|
codefou: r.codefou,
|
||||||
site,
|
nomfou: r.nomfou,
|
||||||
intervalleSemaines: semaines,
|
site,
|
||||||
actif: r.actif,
|
intervalleSemaines: semaines,
|
||||||
}),
|
actif: r.actif,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
succes: `Rythme enregistré : ${r.nomfou}, toutes les ${pluriel(semaines, "semaine")}.`,
|
||||||
|
echec: "Le rythme n'a pas pu être enregistré.",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const basculer = (r: CadenceView) =>
|
||||||
|
executer(() => toggleCadence(r.codefou, r.site, !r.actif), {
|
||||||
|
succes: r.actif ? `Rappel désactivé : ${r.nomfou}.` : `Rappel réactivé : ${r.nomfou}.`,
|
||||||
|
echec: r.actif ? "Le rappel n'a pas pu être désactivé." : "Le rappel n'a pas pu être réactivé.",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function supprimer(r: CadenceView) {
|
||||||
|
const ok = await confirmer({
|
||||||
|
titre: "Supprimer ce rappel ?",
|
||||||
|
message: `Le rythme de commande de ${r.nomfou} pour ${nom} sera supprimé. Pour simplement suspendre les alertes, utilisez plutôt « Désactiver ».`,
|
||||||
|
libelleConfirmer: "Supprimer",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
await executer(() => removeCadence(r.codefou, r.site), {
|
||||||
|
succes: `Rappel supprimé : ${r.nomfou}.`,
|
||||||
|
echec: "Le rappel n'a pas pu être supprimé.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3" aria-label={`Rythme de commande — ${nom}`}>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
|
<h2 className="mr-1 text-lg font-semibold text-[var(--text-primary)]">{nom}</h2>
|
||||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
|
<Badge>
|
||||||
{rows.length} cadencé{rows.length !== 1 ? "s" : ""}
|
{pluriel(rows.length, "fournisseur")} suivi{rows.length > 1 ? "s" : ""}
|
||||||
</span>
|
</Badge>
|
||||||
{nbACommander > 0 && (
|
{nbACommander > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-semibold text-red-700">
|
<Badge ton="erreur">
|
||||||
<AlertTriangle className="h-3 w-3" /> {nbACommander} à commander
|
<AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {nbACommander} à commander
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddCadenceForm
|
<FormulaireAjout
|
||||||
site={site}
|
site={site}
|
||||||
fournisseurs={fournisseurs}
|
fournisseurs={fournisseurs}
|
||||||
existingCodes={existingCodes}
|
codesExistants={codesExistants}
|
||||||
onDone={add}
|
onAjouter={ajouter}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
{triees.length === 0 ? (
|
||||||
<table className="min-w-full text-sm">
|
<EmptyState
|
||||||
<thead className="border-b border-gray-200 bg-gray-50">
|
icon={CalendarClock}
|
||||||
<tr>
|
className="py-8"
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Code</th>
|
title="Aucun rappel pour ce magasin"
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Fournisseur</th>
|
description="Ajoutez un fournisseur avec le formulaire ci-dessus pour être prévenu quand il faut lui passer commande."
|
||||||
<th className="px-3 py-2.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-600">Toutes les</th>
|
/>
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Dernière réception</th>
|
) : (
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Prochaine commande</th>
|
<div
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Temps restant</th>
|
aria-busy={pending}
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Statut</th>
|
className="overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
|
||||||
<th className="px-3 py-2.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-600">Actions</th>
|
>
|
||||||
</tr>
|
<table className="min-w-full text-sm">
|
||||||
</thead>
|
<thead className="bg-[var(--bg-elevated)]">
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{sorted.length === 0 && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-3 py-8 text-center text-gray-400">
|
<th scope="col" className={TH}>Fournisseur</th>
|
||||||
Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus.
|
<th scope="col" className={TH}>Rythme</th>
|
||||||
</td>
|
<th scope="col" className={TH}>Dernière réception</th>
|
||||||
|
<th scope="col" className={TH}>
|
||||||
|
<InfoBulle explication="Date de la dernière réception du fournisseur dans ce magasin, plus le rythme choisi.">
|
||||||
|
Prochaine commande
|
||||||
|
</InfoBulle>
|
||||||
|
</th>
|
||||||
|
<th scope="col" className={TH}>Temps restant</th>
|
||||||
|
<th scope="col" className={TH}>Statut</th>
|
||||||
|
<th scope="col" className={cn(TH, "text-right")}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
</thead>
|
||||||
{sorted.map((r) => {
|
<tbody>
|
||||||
const urgent = r.actif && r.statut === "a_commander";
|
{triees.map((r) => {
|
||||||
return (
|
const urgent = r.actif && r.statut === "a_commander";
|
||||||
<tr
|
const texte = r.actif ? "text-[var(--text-primary)]" : "text-[var(--text-muted)]";
|
||||||
key={`${r.site}-${r.codefou}`}
|
return (
|
||||||
className={[
|
<tr
|
||||||
"transition-colors",
|
key={`${r.site}-${r.codefou}`}
|
||||||
!r.actif ? "opacity-50" : "",
|
className={cn(
|
||||||
urgent ? "bg-red-50/60 hover:bg-red-50" : "hover:bg-gray-50",
|
"border-b border-[var(--border)] last:border-b-0",
|
||||||
].join(" ")}
|
urgent ? "bg-[var(--accent-error-bg)]" : "hover:bg-[var(--bg-elevated)]",
|
||||||
>
|
)}
|
||||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{r.codefou}</td>
|
>
|
||||||
<td className="px-3 py-2.5 font-medium text-gray-900">{r.nomfou}</td>
|
<td className={cn("px-3 py-2", texte)}>
|
||||||
<td className="px-3 py-2.5 text-center">
|
<div className="font-medium">{r.nomfou}</div>
|
||||||
<span className="inline-flex items-center gap-1">
|
<div className="text-xs text-[var(--text-muted)]">Code {r.codefou}</div>
|
||||||
<input
|
</td>
|
||||||
type="number"
|
<td className={cn("whitespace-nowrap px-3 py-2", texte)}>
|
||||||
min={1}
|
<span className="inline-flex items-center gap-2">
|
||||||
max={104}
|
Toutes les
|
||||||
defaultValue={r.intervalleSemaines}
|
<Input
|
||||||
onBlur={(e) => {
|
// Remonté quand la valeur enregistrée change (après revalidation).
|
||||||
const v = parseInt(e.target.value, 10);
|
key={r.intervalleSemaines}
|
||||||
if (v && v !== r.intervalleSemaines && v >= 1 && v <= 104) changeSemaines(r, v);
|
type="number"
|
||||||
}}
|
inputMode="numeric"
|
||||||
className="w-16 rounded-md border border-gray-200 px-2 py-1 text-center text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
min={SEMAINES_MIN}
|
||||||
/>
|
max={SEMAINES_MAX}
|
||||||
<span className="text-xs text-gray-400">sem.</span>
|
step={1}
|
||||||
</span>
|
defaultValue={r.intervalleSemaines}
|
||||||
</td>
|
aria-label={`Rythme de commande de ${r.nomfou}, en semaines`}
|
||||||
<td className="px-3 py-2.5 text-gray-600">{fmtDate(r.derniereReception)}</td>
|
title="Modifiez le nombre puis quittez le champ (ou appuyez sur Entrée) pour l'enregistrer."
|
||||||
<td className="px-3 py-2.5 font-medium text-gray-800">{fmtDate(r.echeance)}</td>
|
onKeyDown={(e) => {
|
||||||
<td className={["px-3 py-2.5 font-semibold", urgent ? "text-red-700" : "text-gray-700"].join(" ")}>
|
if (e.key === "Enter") e.currentTarget.blur();
|
||||||
{tempsRestant(r.joursRestants)}
|
}}
|
||||||
</td>
|
onBlur={(e) => {
|
||||||
<td className="px-3 py-2.5">
|
const champ = e.currentTarget;
|
||||||
<StatutBadge statut={r.actif ? r.statut : "inconnu"} />
|
const brut = champ.value.trim();
|
||||||
</td>
|
const v = Number(brut);
|
||||||
<td className="px-3 py-2.5">
|
if (brut !== "" && v === r.intervalleSemaines) return;
|
||||||
<div className="flex items-center justify-end gap-1">
|
if (brut === "" || !Number.isInteger(v) || v < SEMAINES_MIN || v > SEMAINES_MAX) {
|
||||||
<button
|
champ.value = String(r.intervalleSemaines);
|
||||||
type="button"
|
toast.erreur(
|
||||||
title={r.actif ? "Désactiver" : "Réactiver"}
|
`Rythme non enregistré : indiquez un nombre entier de semaines, entre ${SEMAINES_MIN} et ${SEMAINES_MAX}.`,
|
||||||
onClick={() => run(() => toggleCadence(r.codefou, r.site, !r.actif))}
|
);
|
||||||
disabled={pending}
|
return;
|
||||||
className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
|
}
|
||||||
>
|
void changerSemaines(r, v).then((ok) => {
|
||||||
<Power className="h-4 w-4" />
|
if (!ok) champ.value = String(r.intervalleSemaines);
|
||||||
</button>
|
});
|
||||||
<button
|
}}
|
||||||
type="button"
|
className="h-8 w-20 px-2 text-center"
|
||||||
title="Supprimer"
|
/>
|
||||||
onClick={() => run(() => removeCadence(r.codefou, r.site))}
|
semaines
|
||||||
disabled={pending}
|
</span>
|
||||||
className="rounded-md p-1.5 text-gray-500 hover:bg-red-100 hover:text-red-700 disabled:opacity-50"
|
</td>
|
||||||
>
|
<td className={cn("whitespace-nowrap px-3 py-2 tabular-nums", r.actif ? "text-[var(--text-secondary)]" : texte)}>
|
||||||
<Trash2 className="h-4 w-4" />
|
{fmtDate(r.derniereReception)}
|
||||||
</button>
|
</td>
|
||||||
</div>
|
<td className={cn("whitespace-nowrap px-3 py-2 font-medium tabular-nums", texte)}>
|
||||||
</td>
|
{fmtDate(r.echeance)}
|
||||||
</tr>
|
</td>
|
||||||
);
|
<td
|
||||||
})}
|
className={cn(
|
||||||
</tbody>
|
"whitespace-nowrap px-3 py-2 font-semibold",
|
||||||
</table>
|
urgent ? "text-[var(--accent-error)]" : texte,
|
||||||
</div>
|
)}
|
||||||
|
>
|
||||||
|
{tempsRestant(r.joursRestants)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<StatutBadge cadence={r} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
r.actif
|
||||||
|
? "Mettre les alertes en pause, sans perdre le rythme choisi."
|
||||||
|
: "Reprendre les alertes pour ce fournisseur."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void basculer(r)}
|
||||||
|
disabled={pending}
|
||||||
|
aria-label={`${r.actif ? "Désactiver" : "Réactiver"} le rappel de ${r.nomfou}`}
|
||||||
|
>
|
||||||
|
<Power /> {r.actif ? "Désactiver" : "Réactiver"}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Supprimer définitivement ce rappel.">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void supprimer(r)}
|
||||||
|
disabled={pending}
|
||||||
|
aria-label={`Supprimer le rappel de ${r.nomfou}`}
|
||||||
|
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
|
||||||
|
>
|
||||||
|
<Trash2 /> Supprimer
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -315,9 +459,9 @@ export function CadencierClient({
|
|||||||
fournisseurs,
|
fournisseurs,
|
||||||
}: {
|
}: {
|
||||||
cadences: CadenceView[];
|
cadences: CadenceView[];
|
||||||
fournisseurs: { code: string; nom: string }[];
|
fournisseurs: Fournisseur[];
|
||||||
}) {
|
}) {
|
||||||
const bySite = useMemo(() => {
|
const parMagasin = useMemo(() => {
|
||||||
const m = new Map<string, CadenceView[]>();
|
const m = new Map<string, CadenceView[]>();
|
||||||
for (const c of cadences) {
|
for (const c of cadences) {
|
||||||
if (!m.has(c.site)) m.set(c.site, []);
|
if (!m.has(c.site)) m.set(c.site, []);
|
||||||
@@ -328,19 +472,17 @@ export function CadencierClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<p className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
<div className="flex items-start gap-3 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)] px-4 py-3 text-sm text-[var(--text-primary)]">
|
||||||
Activez un fournisseur en alerte de commande sur un magasin et définissez sa fréquence
|
<CalendarClock className="mt-0.5 h-5 w-5 shrink-0 text-[var(--accent)]" aria-hidden />
|
||||||
(toutes les X semaines). L'échéance est calculée à partir de la{" "}
|
<p>
|
||||||
<strong>dernière réception</strong> du fournisseur sur le magasin.
|
Le <Terme id="cadencier">cadencier</Terme> vous rappelle quand commander : pour chaque fournisseur suivi,
|
||||||
</p>
|
choisissez un rythme (toutes les X semaines). La prochaine commande est calculée à partir de la{" "}
|
||||||
{SITES.map(({ code, label }) => (
|
<strong>dernière réception</strong> du fournisseur dans le magasin. Le statut passe à « Bientôt » dans les
|
||||||
<SiteSection
|
7 jours qui précèdent, puis à « À commander » le jour venu.
|
||||||
key={code}
|
</p>
|
||||||
site={code}
|
</div>
|
||||||
label={label}
|
{MAGASINS.map((m) => (
|
||||||
rows={bySite.get(code) ?? []}
|
<SectionMagasin key={m.code} site={m.code} rows={parMagasin.get(m.code) ?? []} fournisseurs={fournisseurs} />
|
||||||
fournisseurs={fournisseurs}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,199 +1,187 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { ChevronUp, ChevronDown, ChevronsUpDown, CheckCircle2, AlertCircle, Minus } from "lucide-react";
|
import { ShoppingCart } from "lucide-react";
|
||||||
import type { PgCommandeAutoRow } from "./page";
|
import type { PgCommandeAutoRow } from "./page";
|
||||||
|
import { Badge, type Ton } from "@/components/ui/badge";
|
||||||
|
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
|
||||||
|
import { EmptyState } from "@/components/ui/states";
|
||||||
|
import { Terme } from "@/components/ui/tooltip";
|
||||||
|
import { fmtEntier, fmtEur2 } from "@/lib/format";
|
||||||
|
import { MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||||
|
|
||||||
type SortDir = "asc" | "desc";
|
type EtatFranco = "oui" | "non" | "inconnu";
|
||||||
|
|
||||||
const SITES: { code: string; label: string }[] = [
|
/**
|
||||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
* Franco atteint ? Sans montant de franco connu (0), on ne peut pas répondre,
|
||||||
{ code: "579", label: "579 — Houdemont" },
|
* quel que soit l'indicateur renvoyé par l'API.
|
||||||
|
*/
|
||||||
|
function etatFranco(r: PgCommandeAutoRow): EtatFranco {
|
||||||
|
if (!(Number(r.franco) > 0)) return "inconnu";
|
||||||
|
if (r.franco_atteint === true) return "oui";
|
||||||
|
if (r.franco_atteint === false) return "non";
|
||||||
|
return "inconnu";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ETATS_FRANCO: Record<EtatFranco, { libelle: string; ton: Ton }> = {
|
||||||
|
oui: { libelle: "Oui", ton: "succes" },
|
||||||
|
non: { libelle: "Non", ton: "alerte" },
|
||||||
|
inconnu: { libelle: "Inconnu", ton: "neutre" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function explicationFranco(r: PgCommandeAutoRow): string {
|
||||||
|
switch (etatFranco(r)) {
|
||||||
|
case "oui":
|
||||||
|
return "Le montant proposé atteint le franco : livraison sans frais de port.";
|
||||||
|
case "non":
|
||||||
|
return "Le montant proposé n'atteint pas le franco : des frais de port peuvent s'appliquer.";
|
||||||
|
default:
|
||||||
|
return Number(r.franco) > 0
|
||||||
|
? "Le franco est connu, mais FF n'indique pas s'il est atteint."
|
||||||
|
: "Aucun montant de franco connu pour ce fournisseur.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écart entre le montant proposé et le franco, toujours positif. */
|
||||||
|
function ecartFranco(r: PgCommandeAutoRow): number {
|
||||||
|
return Math.abs(Number(r.ecart_franco)) || Math.abs(Number(r.franco) - Number(r.montant_cde)) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EcartFranco({ r }: { r: PgCommandeAutoRow }) {
|
||||||
|
const etat = etatFranco(r);
|
||||||
|
const ecart = ecartFranco(r);
|
||||||
|
if (etat === "non") {
|
||||||
|
return <span className="font-semibold text-[var(--accent-warning)]">Il manque {fmtEur2(ecart)}</span>;
|
||||||
|
}
|
||||||
|
if (etat === "oui") {
|
||||||
|
return <span className="text-[var(--accent-success)]">{ecart > 0 ? `Dépassé de ${fmtEur2(ecart)}` : "Atteint"}</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-[var(--text-muted)]">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const somme = (rows: readonly PgCommandeAutoRow[], valeur: (r: PgCommandeAutoRow) => number) =>
|
||||||
|
rows.reduce((s, r) => s + (Number(valeur(r)) || 0), 0);
|
||||||
|
|
||||||
|
const COLONNES: DataColumn<PgCommandeAutoRow>[] = [
|
||||||
|
{
|
||||||
|
id: "fournisseur",
|
||||||
|
header: "Fournisseur",
|
||||||
|
sortValue: (r) => r.nomfou,
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="min-w-[200px]">
|
||||||
|
<div className="font-medium text-[var(--text-primary)]">{r.nomfou || "Fournisseur sans nom"}</div>
|
||||||
|
{r.codefou && <div className="text-xs text-[var(--text-muted)]">Code {r.codefou}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
footer: (rows) => `Total · ${fmtEntier(rows.length)} fournisseur${rows.length > 1 ? "s" : ""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "articles",
|
||||||
|
header: "Nb articles",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => Number(r.nb_articles),
|
||||||
|
cell: (r) => fmtEntier(Number(r.nb_articles)),
|
||||||
|
footer: (rows) => fmtEntier(somme(rows, (r) => r.nb_articles)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "montant",
|
||||||
|
header: "Montant proposé",
|
||||||
|
hint: "Montant hors taxes de la commande automatique proposée par FF.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => Number(r.montant_cde),
|
||||||
|
cell: (r) => <span className="font-semibold">{fmtEur2(Number(r.montant_cde))}</span>,
|
||||||
|
footer: (rows) => fmtEur2(somme(rows, (r) => r.montant_cde)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "franco",
|
||||||
|
header: <Terme id="franco" />,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => (Number(r.franco) > 0 ? Number(r.franco) : null),
|
||||||
|
cell: (r) =>
|
||||||
|
Number(r.franco) > 0 ? (
|
||||||
|
fmtEur2(Number(r.franco))
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--text-muted)]" title="Aucun montant de franco connu pour ce fournisseur.">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "atteint",
|
||||||
|
header: "Franco atteint",
|
||||||
|
align: "center",
|
||||||
|
// Oui avant Non, inconnus toujours en fin de liste.
|
||||||
|
sortValue: (r) => {
|
||||||
|
const etat = etatFranco(r);
|
||||||
|
return etat === "oui" ? 1 : etat === "non" ? 0 : null;
|
||||||
|
},
|
||||||
|
cell: (r) => {
|
||||||
|
const { libelle, ton } = ETATS_FRANCO[etatFranco(r)];
|
||||||
|
return <Badge ton={ton} title={explicationFranco(r)}>{libelle}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ecart",
|
||||||
|
header: "Écart au franco",
|
||||||
|
hint: "Ce qu'il manque pour atteindre le franco, ou de combien il est dépassé.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => {
|
||||||
|
const etat = etatFranco(r);
|
||||||
|
if (etat === "inconnu") return null;
|
||||||
|
return etat === "oui" ? ecartFranco(r) : -ecartFranco(r);
|
||||||
|
},
|
||||||
|
cell: (r) => <EcartFranco r={r} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function fmt(n: number) {
|
const FILTRES: DataFilter<PgCommandeAutoRow>[] = [
|
||||||
return n.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
{
|
||||||
}
|
id: "atteint",
|
||||||
|
label: "Franco atteint",
|
||||||
|
valueOf: (r) => ETATS_FRANCO[etatFranco(r)].libelle,
|
||||||
|
allLabel: "Tous",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function SortIcon({ col, sortKey, sortDir }: { col: string; sortKey: string; sortDir: SortDir }) {
|
function SectionMagasin({ nom, rows }: { nom: string; rows: PgCommandeAutoRow[] }) {
|
||||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
const nbAtteint = rows.filter((r) => etatFranco(r) === "oui").length;
|
||||||
return sortDir === "asc"
|
const totalMontant = somme(rows, (r) => r.montant_cde);
|
||||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
|
||||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SK = keyof PgCommandeAutoRow;
|
|
||||||
const NUMERIC: SK[] = ["franco", "nb_articles", "montant_cde"];
|
|
||||||
|
|
||||||
function FrancoCell({ atteint, ecart, franco }: { atteint: boolean | null; ecart: number; franco: number }) {
|
|
||||||
// franco === 0 : aucune donnée franco disponible
|
|
||||||
// atteint === null ET franco === 0 : pas de franco (N/A ou données manquantes)
|
|
||||||
// atteint === null ET franco > 0 : franco connu mais statut non déterminé → on affiche quand même le montant
|
|
||||||
if (franco === 0) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
|
|
||||||
<Minus className="w-3 h-3" /> Pas de franco
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (atteint === null) {
|
|
||||||
// Franco connu mais statut non calculé par l'API
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500">
|
|
||||||
<Minus className="w-3 h-3" /> N/A ({fmt(franco)})
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (atteint) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
|
||||||
<CheckCircle2 className="w-3 h-3" /> Atteint
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-semibold text-amber-700">
|
|
||||||
<AlertCircle className="w-3 h-3" /> −{fmt(Math.abs(ecart))} manquant
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StoreTable({ site, label, rows }: { site: string; label: string; rows: PgCommandeAutoRow[] }) {
|
|
||||||
const [sortKey, setSortKey] = useState<SK>("nomfou");
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
function handleSort(key: SK) {
|
|
||||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
|
||||||
else { setSortKey(key); setSortDir("asc"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
return rows.filter(r => !q || r.nomfou.toLowerCase().includes(q) || r.codefou.toLowerCase().includes(q));
|
|
||||||
}, [rows, search]);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => [...filtered].sort((a, b) => {
|
|
||||||
const av = a[sortKey];
|
|
||||||
const bv = b[sortKey];
|
|
||||||
if (NUMERIC.includes(sortKey)) {
|
|
||||||
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av);
|
|
||||||
}
|
|
||||||
return sortDir === "asc"
|
|
||||||
? String(av ?? "").localeCompare(String(bv ?? ""), "fr")
|
|
||||||
: String(bv ?? "").localeCompare(String(av ?? ""), "fr");
|
|
||||||
}), [filtered, sortKey, sortDir]);
|
|
||||||
|
|
||||||
const nbFrancoAtteint = rows.filter(r => r.franco_atteint === true).length;
|
|
||||||
const totalMontant = rows.reduce((s, r) => s + Number(r.montant_cde), 0);
|
|
||||||
|
|
||||||
const th = (key: SK, label: string, right = false) => (
|
|
||||||
<th
|
|
||||||
className={[
|
|
||||||
"cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900",
|
|
||||||
right ? "text-right" : "text-left",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => handleSort(key)}
|
|
||||||
>
|
|
||||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<section className="space-y-3" aria-label={`Propositions de commande — ${nom}`}>
|
||||||
{/* En-tête magasin */}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<h2 className="mr-1 text-lg font-semibold text-[var(--text-primary)]">{nom}</h2>
|
||||||
<div className="flex items-center gap-3">
|
<Badge>
|
||||||
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
|
{fmtEntier(rows.length)} fournisseur{rows.length > 1 ? "s" : ""}
|
||||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
|
</Badge>
|
||||||
{rows.length} fournisseur{rows.length !== 1 ? "s" : ""}
|
{nbAtteint > 0 && (
|
||||||
</span>
|
<Badge ton="succes">
|
||||||
{nbFrancoAtteint > 0 && (
|
{fmtEntier(nbAtteint)} franco{nbAtteint > 1 ? "s" : ""} atteint{nbAtteint > 1 ? "s" : ""}
|
||||||
<span className="rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
</Badge>
|
||||||
{nbFrancoAtteint} franco atteint
|
)}
|
||||||
</span>
|
<span className="text-[13px] text-[var(--text-secondary)]">
|
||||||
)}
|
Montant total proposé :{" "}
|
||||||
<span className="text-sm text-gray-500">
|
<span className="font-semibold tabular-nums text-[var(--text-primary)]">{fmtEur2(totalMontant)}</span>
|
||||||
Total : <span className="font-semibold text-gray-800">{fmt(totalMontant)}</span>
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Rechercher un fournisseur…"
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<DataTable
|
||||||
{/* Tableau */}
|
rows={rows}
|
||||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
columns={COLONNES}
|
||||||
<table className="min-w-full text-sm">
|
rowKey={(r) => `${r.site}-${r.codefou}`}
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
searchIn={(r) => [r.nomfou, r.codefou]}
|
||||||
<tr>
|
searchPlaceholder="Rechercher un fournisseur…"
|
||||||
{th("codefou", "Code")}
|
filters={FILTRES}
|
||||||
{th("nomfou", "Fournisseur")}
|
pageSize={0}
|
||||||
{th("nb_articles", "Articles", true)}
|
unite="fournisseurs"
|
||||||
{th("montant_cde", "Montant CDE auto", true)}
|
showFooter
|
||||||
{th("franco", "Franco", true)}
|
initialSort={{ id: "fournisseur", dir: "asc" }}
|
||||||
<th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">
|
emptyTitle="Aucune proposition pour ce magasin"
|
||||||
Statut franco
|
emptyDescription="FF ne propose aucune commande automatique pour ce magasin en ce moment."
|
||||||
</th>
|
/>
|
||||||
|
</section>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{sorted.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-3 py-8 text-center text-gray-400">
|
|
||||||
Aucun fournisseur en commande automatique
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{sorted.map(row => {
|
|
||||||
const francoAtteint = row.franco_atteint === true;
|
|
||||||
const hasFranco = row.franco > 0 && row.franco_atteint !== null;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={`${row.site}-${row.codefou}`}
|
|
||||||
className={[
|
|
||||||
"transition-colors",
|
|
||||||
francoAtteint
|
|
||||||
? "bg-emerald-50/50 hover:bg-emerald-50"
|
|
||||||
: "hover:bg-gray-50",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{row.codefou}</td>
|
|
||||||
<td className="px-3 py-2.5 font-medium text-gray-900">{row.nomfou}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700">{row.nb_articles}</td>
|
|
||||||
<td className={[
|
|
||||||
"px-3 py-2.5 text-right tabular-nums font-semibold",
|
|
||||||
francoAtteint ? "text-emerald-700" : "text-blue-700",
|
|
||||||
].join(" ")}>
|
|
||||||
{fmt(Number(row.montant_cde))}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-600">
|
|
||||||
{row.franco > 0 ? fmt(Number(row.franco)) : <span className="text-gray-400">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<FrancoCell
|
|
||||||
atteint={row.franco_atteint}
|
|
||||||
ecart={Number(row.ecart_franco)}
|
|
||||||
franco={Number(row.franco)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) {
|
export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) {
|
||||||
const bySite = useMemo(() => {
|
const parMagasin = useMemo(() => {
|
||||||
const map = new Map<string, PgCommandeAutoRow[]>();
|
const map = new Map<string, PgCommandeAutoRow[]>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!map.has(r.site)) map.set(r.site, []);
|
if (!map.has(r.site)) map.set(r.site, []);
|
||||||
@@ -204,20 +192,19 @@ export function CommandesAutoClient({ rows }: { rows: PgCommandeAutoRow[] }) {
|
|||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-12 text-center text-gray-400 shadow-sm">
|
<EmptyState
|
||||||
Aucune commande automatique trouvée.
|
icon={ShoppingCart}
|
||||||
</div>
|
title="Aucune proposition de commande"
|
||||||
|
description="FF ne propose aucune commande automatique pour le moment. Si vous en attendiez, le serveur FF est peut-être momentanément indisponible : rechargez la page dans quelques minutes."
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-10">
|
||||||
{SITES.map(({ code, label }) => {
|
{MAGASINS.map((m) => (
|
||||||
const siteRows = bySite.get(code) ?? [];
|
<SectionMagasin key={m.code} nom={nomMagasin(m.code)} rows={parMagasin.get(m.code) ?? []} />
|
||||||
return (
|
))}
|
||||||
<StoreTable key={code} site={code} label={label} rows={siteRows} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { ShoppingCart } from "lucide-react";
|
||||||
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
|
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
|
||||||
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
|
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
|
||||||
import { CommandesAutoTabs } from "./tabs";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { CommandesAutoTabs, type Onglet } from "./tabs";
|
||||||
import { cachedFF } from "@/lib/ff-cache";
|
import { cachedFF } from "@/lib/ff-cache";
|
||||||
|
|
||||||
export type { PgCommandeAutoRow };
|
export type { PgCommandeAutoRow };
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Commandes fournisseurs" };
|
||||||
|
|
||||||
// Données live (DB + API FF Nancy) : rendu à la requête, jamais prérendu au build.
|
// Données live (DB + API FF Nancy) : rendu à la requête, jamais prérendu au build.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function CommandesAutoPage() {
|
export default async function CommandesAutoPage(props: { searchParams: Promise<{ onglet?: string | string[] }> }) {
|
||||||
|
const { onglet } = await props.searchParams;
|
||||||
|
const ongletInitial: Onglet = onglet === "cadencier" ? "cadencier" : "propositions";
|
||||||
|
|
||||||
const [rows, cadences, fournisseurs] = await Promise.all([
|
const [rows, cadences, fournisseurs] = await Promise.all([
|
||||||
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
|
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
|
||||||
// gardées 2 minutes, pour que chaque modification du cadencier — qui
|
// gardées 2 minutes, pour que chaque modification du cadencier — qui
|
||||||
@@ -22,11 +30,13 @@ export default async function CommandesAutoPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
<div className="mx-auto max-w-screen-2xl">
|
<PageHeader
|
||||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Commandes automatiques</h1>
|
icon={ShoppingCart}
|
||||||
<CommandesAutoTabs rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
|
title="Commandes fournisseurs"
|
||||||
</div>
|
description="Propositions de commande automatique par magasin, et rappels selon le rythme de commande choisi pour chaque fournisseur."
|
||||||
|
/>
|
||||||
|
<CommandesAutoTabs initial={ongletInitial} rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,56 +1,51 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ShoppingCart, CalendarClock } from "lucide-react";
|
import { ShoppingCart, CalendarClock } from "lucide-react";
|
||||||
|
import { Tabs, useUrlTab } from "@/components/ui/tabs";
|
||||||
import { CommandesAutoClient } from "./client";
|
import { CommandesAutoClient } from "./client";
|
||||||
import { CadencierClient } from "./cadencier-client";
|
import { CadencierClient } from "./cadencier-client";
|
||||||
import type { PgCommandeAutoRow } from "./page";
|
import type { PgCommandeAutoRow } from "./page";
|
||||||
import type { CadenceView } from "@/features/commandes-auto/actions";
|
import type { CadenceView } from "@/features/commandes-auto/actions";
|
||||||
|
|
||||||
type TabKey = "propositions" | "cadencier";
|
export type Onglet = "propositions" | "cadencier";
|
||||||
|
|
||||||
export function CommandesAutoTabs({
|
export function CommandesAutoTabs({
|
||||||
|
initial,
|
||||||
rows,
|
rows,
|
||||||
cadences,
|
cadences,
|
||||||
fournisseurs,
|
fournisseurs,
|
||||||
}: {
|
}: {
|
||||||
|
initial: Onglet;
|
||||||
rows: PgCommandeAutoRow[];
|
rows: PgCommandeAutoRow[];
|
||||||
cadences: CadenceView[];
|
cadences: CadenceView[];
|
||||||
fournisseurs: { code: string; nom: string }[];
|
fournisseurs: { code: string; nom: string }[];
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<TabKey>("propositions");
|
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
|
||||||
|
|
||||||
const nbACommander = cadences.filter((c) => c.actif && c.statut === "a_commander").length;
|
const nbACommander = cadences.filter((c) => c.actif && c.statut === "a_commander").length;
|
||||||
|
|
||||||
const tabBtn = (key: TabKey, label: string, Icon: typeof ShoppingCart, badge?: number) => (
|
const onglets = [
|
||||||
<button
|
{ value: "propositions", label: "Propositions de commande", icon: ShoppingCart },
|
||||||
type="button"
|
{
|
||||||
onClick={() => setTab(key)}
|
value: "cadencier",
|
||||||
className={[
|
label: (
|
||||||
"inline-flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors",
|
<>
|
||||||
tab === key
|
Rythme de commande
|
||||||
? "border-blue-600 text-blue-700"
|
{nbACommander > 0 && <span className="sr-only"> (commandes à passer)</span>}
|
||||||
: "border-transparent text-gray-500 hover:text-gray-800",
|
</>
|
||||||
].join(" ")}
|
),
|
||||||
>
|
icon: CalendarClock,
|
||||||
<Icon className="h-4 w-4" />
|
// Pastille rouge : nombre de commandes à passer (masquée s'il n'y en a aucune).
|
||||||
{label}
|
count: nbACommander > 0 ? nbACommander : undefined,
|
||||||
{badge != null && badge > 0 && (
|
alert: true,
|
||||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-bold text-red-700">
|
},
|
||||||
{badge}
|
] as const;
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex gap-2 border-b border-gray-200">
|
<Tabs items={onglets} value={onglet} onChange={setOnglet} />
|
||||||
{tabBtn("propositions", "Propositions", ShoppingCart)}
|
|
||||||
{tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "propositions" ? (
|
{onglet === "propositions" ? (
|
||||||
<CommandesAutoClient rows={rows} />
|
<CommandesAutoClient rows={rows} />
|
||||||
) : (
|
) : (
|
||||||
<CadencierClient cadences={cadences} fournisseurs={fournisseurs} />
|
<CadencierClient cadences={cadences} fournisseurs={fournisseurs} />
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { getFournisseurs, getMagasins } from "@/features/grid/actions";
|
import { getFournisseurs, getMagasins } from "@/features/grid/actions";
|
||||||
import { GridClient } from "@/features/grid/components/grid-client";
|
import { GridClient } from "@/features/grid/components/grid-client";
|
||||||
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
|
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Révision d'assortiment" };
|
||||||
|
|
||||||
interface GridPageProps {
|
interface GridPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
fournisseur?: string;
|
fournisseur?: string;
|
||||||
@@ -23,11 +27,13 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
|||||||
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
|
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Fetch available suppliers & stores
|
// 1. Fetch available suppliers & stores (et les droits, lus une fois côté serveur)
|
||||||
const [fournisseurs, magasins] = await Promise.all([
|
const [fournisseurs, magasins, session] = await Promise.all([
|
||||||
getFournisseurs(),
|
getFournisseurs(),
|
||||||
getMagasins(),
|
getMagasins(),
|
||||||
|
auth(),
|
||||||
]);
|
]);
|
||||||
|
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
||||||
|
|
||||||
// 2. If no supplier selected, show compact selection UI
|
// 2. If no supplier selected, show compact selection UI
|
||||||
if (!codeFournisseur) {
|
if (!codeFournisseur) {
|
||||||
@@ -44,6 +50,7 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
|||||||
magasins={magasins}
|
magasins={magasins}
|
||||||
magasin={magasin}
|
magasin={magasin}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,381 +1,281 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useTransition, type ReactNode } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
|
||||||
|
import { Input, Label } from "@/components/ui/form-controls";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||||
|
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
|
||||||
|
import { couleurMarge } from "@/lib/marge";
|
||||||
|
import { telechargerExcel } from "@/lib/export-excel";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { HitParadePivotRow } from "./page";
|
import type { HitParadePivotRow } from "./page";
|
||||||
|
|
||||||
type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "stockTotal" | "stock292" | "stock579";
|
type Ligne = HitParadePivotRow;
|
||||||
type SortDir = "desc" | "asc";
|
|
||||||
|
|
||||||
const DEFAULT_SORT: SortKey = "caTotal";
|
/** Champs numériques de la ligne pivotée. */
|
||||||
const DEFAULT_DIR: SortDir = "desc";
|
type Champ = { [K in keyof Ligne]: Ligne[K] extends number ? K : never }[keyof Ligne];
|
||||||
|
|
||||||
function formatCA(v: number) {
|
interface Groupe {
|
||||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
id: string;
|
||||||
|
/** Nom affiché au-dessus des colonnes du groupe. */
|
||||||
|
titre: string;
|
||||||
|
/** Nom complet, pour les infobulles et l'export. */
|
||||||
|
nom: string;
|
||||||
|
qte: Champ;
|
||||||
|
ca: Champ;
|
||||||
|
marge: Champ;
|
||||||
|
stock: Champ;
|
||||||
}
|
}
|
||||||
function formatQte(v: number) {
|
|
||||||
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */
|
||||||
|
const CHAMPS_MAGASIN: Record<string, Pick<Groupe, "qte" | "ca" | "marge" | "stock">> = {
|
||||||
|
"292": { qte: "qte292", ca: "ca292", marge: "marge292", stock: "stock292" },
|
||||||
|
"579": { qte: "qte579", ca: "ca579", marge: "marge579", stock: "stock579" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUPES: Groupe[] = [
|
||||||
|
...MAGASINS.filter(m => CHAMPS_MAGASIN[m.code]).map(m => ({
|
||||||
|
id: m.code,
|
||||||
|
titre: nomMagasin(m.code),
|
||||||
|
nom: nomMagasin(m.code),
|
||||||
|
...CHAMPS_MAGASIN[m.code],
|
||||||
|
})),
|
||||||
|
{ id: "total", titre: "Total nos magasins", nom: "nos magasins", qte: "qteTotal", ca: "caTotal", marge: "margeTotal", stock: "stockTotal" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const somme = (rows: readonly Ligne[], champ: Champ) => rows.reduce((acc, r) => acc + r[champ], 0);
|
||||||
|
|
||||||
|
/** Taux de marge en %, `null` sans chiffre d'affaires. */
|
||||||
|
const tauxMarge = (ca: number, marge: number) => (ca > 0 ? (marge / ca) * 100 : null);
|
||||||
|
|
||||||
|
const famille = (r: Ligne) => (r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "Sans famille");
|
||||||
|
|
||||||
|
const vide = <span className="text-[var(--text-muted)]">—</span>;
|
||||||
|
|
||||||
|
function CelluleMarge({ taux }: { taux: number | null }) {
|
||||||
|
if (taux === null) return vide;
|
||||||
|
return <span className="font-medium" style={{ color: couleurMarge(taux) }}>{fmtDecimal1(taux)} %</span>;
|
||||||
}
|
}
|
||||||
function formatPct(ca: number, marge: number) {
|
|
||||||
if (ca === 0) return "—";
|
function CelluleStock({ v }: { v: number }) {
|
||||||
return ((marge / ca) * 100).toFixed(1) + " %";
|
return (
|
||||||
|
<span className={cn(v < 0 ? "font-semibold text-[var(--accent-error)]" : v === 0 ? "text-[var(--text-muted)]" : undefined)}>
|
||||||
|
{fmtEntier(v)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
|
||||||
|
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="flex flex-col items-end leading-tight">
|
||||||
|
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
|
||||||
|
<span>{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
|
||||||
|
const total = g.id === "total";
|
||||||
|
const bord = "border-l border-[var(--border)]";
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${g.id}-qte`,
|
||||||
|
header: <EnTete groupe={g.titre}>Qté</EnTete>,
|
||||||
|
hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`,
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => r[g.qte],
|
||||||
|
cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide),
|
||||||
|
footer: rows => fmtEntier(somme(rows, g.qte)),
|
||||||
|
className: cn(bord, total && "font-semibold"),
|
||||||
|
headerClassName: bord,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${g.id}-ca`,
|
||||||
|
header: <EnTete groupe={g.titre}>CA TTC</EnTete>,
|
||||||
|
hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`,
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => r[g.ca],
|
||||||
|
cell: r => (r[g.ca] !== 0 ? fmtEur0(r[g.ca]) : vide),
|
||||||
|
footer: rows => fmtEur0(somme(rows, g.ca)),
|
||||||
|
className: cn("whitespace-nowrap", total && "font-semibold"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${g.id}-marge`,
|
||||||
|
header: <EnTete groupe={g.titre}>% marge</EnTete>,
|
||||||
|
hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`,
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => tauxMarge(r[g.ca], r[g.marge]),
|
||||||
|
cell: r => <CelluleMarge taux={tauxMarge(r[g.ca], r[g.marge])} />,
|
||||||
|
footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
|
||||||
|
className: "whitespace-nowrap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${g.id}-stock`,
|
||||||
|
header: <EnTete groupe={g.titre}>Stock</EnTete>,
|
||||||
|
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => r[g.stock],
|
||||||
|
cell: r => <CelluleStock v={r[g.stock]} />,
|
||||||
|
footer: rows => fmtEntier(somme(rows, g.stock)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLONNES: DataColumn<Ligne>[] = [
|
||||||
|
{
|
||||||
|
id: "codein",
|
||||||
|
header: "Code",
|
||||||
|
sortValue: r => r.codein,
|
||||||
|
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
|
||||||
|
footer: () => "Total",
|
||||||
|
className: "whitespace-nowrap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "libelle",
|
||||||
|
header: "Désignation",
|
||||||
|
sortValue: r => r.libelle.trim(),
|
||||||
|
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
|
||||||
|
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
|
||||||
|
className: "min-w-[220px]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fournisseur",
|
||||||
|
header: "Fournisseur",
|
||||||
|
sortValue: r => r.fournisseur,
|
||||||
|
cell: r => <span className="text-[13px] text-[var(--text-secondary)]">{r.fournisseur}</span>,
|
||||||
|
className: "whitespace-nowrap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "famille",
|
||||||
|
header: "Famille",
|
||||||
|
hint: "Famille de produits (nomenclature FF)",
|
||||||
|
sortValue: famille,
|
||||||
|
cell: r =>
|
||||||
|
r.nomenclature_code ? (
|
||||||
|
<span className="text-[13px] text-[var(--text-secondary)]" title={famille(r)}>
|
||||||
|
<span className="font-mono text-[var(--text-muted)]">{r.nomenclature_code}</span> {r.nomenclature}
|
||||||
|
</span>
|
||||||
|
) : vide,
|
||||||
|
className: "min-w-[160px]",
|
||||||
|
},
|
||||||
|
...GROUPES.flatMap(colonnesGroupe),
|
||||||
|
];
|
||||||
|
|
||||||
|
const FILTRES: DataFilter<Ligne>[] = [
|
||||||
|
{ id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" },
|
||||||
|
{ id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const recherche = (r: Ligne) => [r.codein, r.libelle];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dateDebut: string;
|
dateDebut: string;
|
||||||
dateFin: string;
|
dateFin: string;
|
||||||
pivotted: HitParadePivotRow[];
|
pivotted: HitParadePivotRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColHeader({
|
|
||||||
label,
|
|
||||||
sortable,
|
|
||||||
group,
|
|
||||||
sortKey,
|
|
||||||
sortDir,
|
|
||||||
onSort,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
sortable?: SortKey;
|
|
||||||
group?: "292" | "579" | "total";
|
|
||||||
sortKey: SortKey;
|
|
||||||
sortDir: SortDir;
|
|
||||||
onSort: (key: SortKey) => void;
|
|
||||||
}) {
|
|
||||||
const isActive = sortable && sortKey === sortable;
|
|
||||||
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
|
|
||||||
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
onClick={sortable ? () => onSort(sortable) : undefined}
|
|
||||||
className={[
|
|
||||||
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
|
|
||||||
sortable ? "cursor-pointer" : "",
|
|
||||||
isActive ? activeBg : `${groupBg} text-gray-600`,
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{sortable && (
|
|
||||||
<span className="ml-1 inline-block w-3 text-center">
|
|
||||||
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
|
const [navigation, demarrerNavigation] = useTransition();
|
||||||
const [sortDir, setSortDir] = useState<SortDir>(DEFAULT_DIR);
|
|
||||||
const [filterFournisseur, setFilterFournisseur] = useState<string>("Tous");
|
|
||||||
const [filterNomenclature, setFilterNomenclature] = useState<string>("Tous");
|
|
||||||
|
|
||||||
// Dates locales pour les inputs (évite rechargement à chaque frappe)
|
// Dates locales pour les inputs (évite rechargement à chaque frappe)
|
||||||
const [localDebut, setLocalDebut] = useState(dateDebut);
|
const [localDebut, setLocalDebut] = useState(dateDebut);
|
||||||
const [localFin, setLocalFin] = useState(dateFin);
|
const [localFin, setLocalFin] = useState(dateFin);
|
||||||
|
|
||||||
const fournisseurs = useMemo(() => {
|
const periodeInvalide = Boolean(localDebut && localFin && localDebut > localFin);
|
||||||
const set = new Set(pivotted.map(r => r.fournisseur));
|
|
||||||
return ["Tous", ...Array.from(set).sort((a, b) => a.localeCompare(b, "fr"))];
|
|
||||||
}, [pivotted]);
|
|
||||||
|
|
||||||
const nomenclatures = useMemo(() => {
|
|
||||||
const map = new Map<string, string>(); // code → libelle
|
|
||||||
for (const r of pivotted) map.set(r.nomenclature_code, r.nomenclature);
|
|
||||||
const entries = Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0], "fr"));
|
|
||||||
return [{ code: "Tous", libelle: "Tous" }, ...entries.map(([code, libelle]) => ({ code, libelle }))];
|
|
||||||
}, [pivotted]);
|
|
||||||
|
|
||||||
function handleSort(key: SortKey) {
|
|
||||||
if (sortKey === key) {
|
|
||||||
setSortDir(d => d === "desc" ? "asc" : "desc");
|
|
||||||
} else {
|
|
||||||
setSortKey(key);
|
|
||||||
setSortDir("desc");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSort() {
|
|
||||||
setSortKey(DEFAULT_SORT);
|
|
||||||
setSortDir(DEFAULT_DIR);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDates() {
|
function applyDates() {
|
||||||
if (localDebut && localFin) {
|
if (localDebut && localFin && !periodeInvalide) {
|
||||||
router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`);
|
demarrerNavigation(() => {
|
||||||
|
router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
|
async function exportToExcel(rows: readonly Ligne[]) {
|
||||||
|
|
||||||
const filtered = useMemo(() =>
|
|
||||||
pivotted.filter(r =>
|
|
||||||
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
|
|
||||||
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
|
|
||||||
),
|
|
||||||
[pivotted, filterFournisseur, filterNomenclature]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sorted = useMemo(() =>
|
|
||||||
[...filtered].sort((a, b) => {
|
|
||||||
const diff = a[sortKey] - b[sortKey];
|
|
||||||
return sortDir === "desc" ? -diff : diff;
|
|
||||||
}),
|
|
||||||
[filtered, sortKey, sortDir]
|
|
||||||
);
|
|
||||||
|
|
||||||
const totals = useMemo(() => sorted.reduce(
|
|
||||||
(acc, r) => ({
|
|
||||||
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
|
|
||||||
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
|
|
||||||
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
|
|
||||||
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
|
|
||||||
}),
|
|
||||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
|
||||||
), [sorted]);
|
|
||||||
|
|
||||||
async function exportToExcel() {
|
|
||||||
// Chargée au clic seulement : la bibliothèque pèse plusieurs centaines de Ko.
|
|
||||||
const XLSX = await import("xlsx");
|
|
||||||
const headers = [
|
|
||||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
|
||||||
"Qté Nancy", "CA TTC Nancy", "% Marge Nancy", "Stock Nancy",
|
|
||||||
"Qté Houdemont", "CA TTC Houdemont", "% Marge Houdemont", "Stock Houdemont",
|
|
||||||
"Qté Total", "CA TTC Total", "% Marge Total", "Stock Total",
|
|
||||||
];
|
|
||||||
|
|
||||||
const pct = (ca: number, marge: number) => ca === 0 ? 0 : Math.round((marge / ca) * 1000) / 10;
|
const pct = (ca: number, marge: number) => ca === 0 ? 0 : Math.round((marge / ca) * 1000) / 10;
|
||||||
|
const nomExport = (g: Groupe) => (g.id === "total" ? "Total" : g.nom);
|
||||||
|
|
||||||
const dataRows = sorted.map(r => [
|
await telechargerExcel({
|
||||||
r.codein,
|
feuille: "Meilleures ventes",
|
||||||
r.libelle.trim(),
|
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
|
||||||
r.fournisseur,
|
entetes: [
|
||||||
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "",
|
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||||
r.qte292, r.ca292, pct(r.ca292, r.marge292), r.stock292,
|
...GROUPES.flatMap(g => [
|
||||||
r.qte579, r.ca579, pct(r.ca579, r.marge579), r.stock579,
|
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
|
||||||
r.qteTotal, r.caTotal, pct(r.caTotal, r.margeTotal), r.stockTotal,
|
]),
|
||||||
]);
|
],
|
||||||
|
largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
|
||||||
const totalRow = [
|
lignes: rows.map(r => [
|
||||||
`TOTAL — ${sorted.length} articles`, "", "", "",
|
r.codein,
|
||||||
totals.qte292, totals.ca292, pct(totals.ca292, totals.marge292), totals.stock292,
|
r.libelle.trim(),
|
||||||
totals.qte579, totals.ca579, pct(totals.ca579, totals.marge579), totals.stock579,
|
r.fournisseur,
|
||||||
totals.qteTotal, totals.caTotal, pct(totals.caTotal, totals.margeTotal), totals.stockTotal,
|
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "",
|
||||||
];
|
...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]),
|
||||||
|
]),
|
||||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...dataRows, totalRow]);
|
totaux: [
|
||||||
|
`TOTAL — ${rows.length} articles`, "", "", "",
|
||||||
// Largeurs de colonnes
|
...GROUPES.flatMap(g => {
|
||||||
ws["!cols"] = [
|
const ca = somme(rows, g.ca);
|
||||||
{ wch: 12 }, { wch: 40 }, { wch: 25 }, { wch: 30 },
|
return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)];
|
||||||
{ wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 },
|
}),
|
||||||
{ wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 },
|
],
|
||||||
{ wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 10 },
|
});
|
||||||
];
|
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Hit Parade");
|
|
||||||
XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4" aria-busy={navigation}>
|
||||||
{/* Barre de contrôles */}
|
{/* Période (relance le calcul côté serveur) */}
|
||||||
<div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
{/* Période */}
|
<div>
|
||||||
<div className="flex items-end gap-3">
|
<Label htmlFor="hit-parade-debut">Du</Label>
|
||||||
<div className="flex flex-col gap-1">
|
<Input
|
||||||
<label className="text-xs font-semibold text-gray-500">Du</label>
|
id="hit-parade-debut"
|
||||||
<input
|
type="date"
|
||||||
type="date"
|
value={localDebut}
|
||||||
value={localDebut}
|
onChange={e => setLocalDebut(e.target.value)}
|
||||||
onChange={e => setLocalDebut(e.target.value)}
|
/>
|
||||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-500">Au</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={localFin}
|
|
||||||
onChange={e => setLocalFin(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={applyDates}
|
|
||||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
|
||||||
>
|
|
||||||
Appliquer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
<Label htmlFor="hit-parade-fin">Au</Label>
|
||||||
|
<Input
|
||||||
{/* Filtre fournisseur */}
|
id="hit-parade-fin"
|
||||||
<div className="flex flex-col gap-1">
|
type="date"
|
||||||
<label className="text-xs font-semibold text-gray-500">Fournisseur</label>
|
value={localFin}
|
||||||
<select
|
onChange={e => setLocalFin(e.target.value)}
|
||||||
value={filterFournisseur}
|
/>
|
||||||
onChange={e => setFilterFournisseur(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400 min-w-[200px]"
|
|
||||||
>
|
|
||||||
{fournisseurs.map(f => (
|
|
||||||
<option key={f} value={f}>{f}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={applyDates} disabled={navigation || !localDebut || !localFin || periodeInvalide}>
|
||||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
{navigation && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{navigation ? "Chargement…" : "Appliquer"}
|
||||||
{/* Filtre nomenclature */}
|
</Button>
|
||||||
<div className="flex flex-col gap-1">
|
{periodeInvalide && (
|
||||||
<label className="text-xs font-semibold text-gray-500">Nomenclature</label>
|
<p role="alert" className="flex h-9 items-center text-[13px] text-[var(--accent-error)]">
|
||||||
<select
|
La date de début doit précéder la date de fin.
|
||||||
value={filterNomenclature}
|
</p>
|
||||||
onChange={e => setFilterNomenclature(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400 min-w-[200px]"
|
|
||||||
>
|
|
||||||
{nomenclatures.map(n => (
|
|
||||||
<option key={n.code} value={n.code}>
|
|
||||||
{n.code === "Tous" ? "Tous" : `${n.code} — ${n.libelle}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
|
||||||
|
|
||||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
|
||||||
{sorted.length} produits
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{isSorted && (
|
|
||||||
<button
|
|
||||||
onClick={resetSort}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-sm font-medium text-orange-700 hover:bg-orange-100 transition-colors self-center"
|
|
||||||
>
|
|
||||||
<span>✕</span> Annuler le tri
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={exportToExcel}
|
|
||||||
className="ml-auto flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 transition-colors self-center"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
Exporter Excel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tableau */}
|
<div className={cn("transition-opacity", navigation && "pointer-events-none opacity-50")}>
|
||||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
<DataTable<Ligne>
|
||||||
<table className="w-full border-collapse text-sm">
|
rows={pivotted}
|
||||||
<thead>
|
columns={COLONNES}
|
||||||
<tr>
|
rowKey={r => r.codein}
|
||||||
<th rowSpan={2} className="w-20 border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Code</th>
|
searchIn={recherche}
|
||||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Désignation</th>
|
searchPlaceholder="Code ou désignation…"
|
||||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Fournisseur</th>
|
filters={FILTRES}
|
||||||
<th rowSpan={2} className="border-b-2 border-gray-200 bg-gray-50 px-2 py-3 text-left text-xs font-semibold text-gray-600 align-bottom">Nom.</th>
|
pageSize={100}
|
||||||
<th colSpan={4} className="border-b border-l-2 border-blue-200 bg-blue-50 px-4 py-2 text-center text-xs font-bold text-blue-700 tracking-wide">
|
unite="produits"
|
||||||
Frouard / Nancy — 292
|
initialSort={{ id: "total-ca", dir: "desc" }}
|
||||||
</th>
|
showFooter
|
||||||
<th colSpan={4} className="border-b border-l-2 border-violet-200 bg-violet-50 px-4 py-2 text-center text-xs font-bold text-violet-700 tracking-wide">
|
emptyTitle="Aucune vente sur cette période"
|
||||||
Houdemont — 579
|
emptyDescription="Choisissez une autre période, puis cliquez sur « Appliquer »."
|
||||||
</th>
|
onExport={exportToExcel}
|
||||||
<th colSpan={4} className="border-b border-l-2 border-emerald-200 bg-emerald-50 px-4 py-2 text-center text-xs font-bold text-emerald-700 tracking-wide">
|
/>
|
||||||
Total Réseau
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b-2 border-gray-200">
|
|
||||||
<ColHeader label="Qté" sortable="qte292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<ColHeader label="CA TTC" sortable="ca292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
|
|
||||||
<ColHeader label="Stock" sortable="stock292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<ColHeader label="Qté" sortable="qte579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<ColHeader label="CA TTC" sortable="ca579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
|
|
||||||
<ColHeader label="Stock" sortable="stock579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<ColHeader label="Qté" sortable="qteTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<ColHeader label="CA TTC" sortable="caTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
|
|
||||||
<ColHeader label="Stock" sortable="stockTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sorted.map((row) => (
|
|
||||||
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
|
|
||||||
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
|
|
||||||
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
|
|
||||||
<td className="bg-white px-2 py-2.5 text-xs text-gray-500 whitespace-nowrap">{row.fournisseur}</td>
|
|
||||||
<td className="bg-white px-2 py-2.5 text-xs text-center whitespace-nowrap" title={row.nomenclature_code ? `${row.nomenclature_code} — ${row.nomenclature}` : ""}>
|
|
||||||
{row.nomenclature_code
|
|
||||||
? <span className="font-mono font-semibold text-gray-700">{row.nomenclature_code}</span>
|
|
||||||
: <span className="text-gray-300">—</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td className={`border-l-2 border-blue-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
|
|
||||||
{row.qte292 > 0 ? formatQte(row.qte292) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca292" ? "bg-blue-100 font-semibold" : "bg-blue-50/60"}`}>
|
|
||||||
{row.ca292 > 0 ? formatCA(row.ca292) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className="bg-blue-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-blue-500">
|
|
||||||
{row.ca292 > 0 ? formatPct(row.ca292, row.marge292) : ""}
|
|
||||||
</td>
|
|
||||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock292" ? "bg-blue-100 font-semibold text-blue-700" : "bg-blue-50/60 text-amber-600"}`}>
|
|
||||||
{row.stock292 > 0 ? formatQte(row.stock292) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className={`border-l-2 border-violet-200 px-2 py-2.5 text-center tabular-nums text-gray-700 ${sortKey === "qte579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
|
|
||||||
{row.qte579 > 0 ? formatQte(row.qte579) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className={`px-2 py-2.5 text-center tabular-nums text-gray-800 ${sortKey === "ca579" ? "bg-violet-100 font-semibold" : "bg-violet-50/60"}`}>
|
|
||||||
{row.ca579 > 0 ? formatCA(row.ca579) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className="bg-violet-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-violet-500">
|
|
||||||
{row.ca579 > 0 ? formatPct(row.ca579, row.marge579) : ""}
|
|
||||||
</td>
|
|
||||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs ${sortKey === "stock579" ? "bg-violet-100 font-semibold text-violet-700" : "bg-violet-50/60 text-amber-600"}`}>
|
|
||||||
{row.stock579 > 0 ? formatQte(row.stock579) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className={`border-l-2 border-emerald-200 px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "qteTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
|
|
||||||
{row.qteTotal > 0 ? formatQte(row.qteTotal) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className={`px-2 py-2.5 text-center tabular-nums font-semibold text-gray-900 ${sortKey === "caTotal" ? "bg-emerald-100" : "bg-emerald-50/60"}`}>
|
|
||||||
{row.caTotal > 0 ? formatCA(row.caTotal) : <span className="text-gray-300">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className="bg-emerald-50/60 px-2 py-2.5 text-center text-xs tabular-nums text-emerald-600">
|
|
||||||
{row.caTotal > 0 ? formatPct(row.caTotal, row.margeTotal) : ""}
|
|
||||||
</td>
|
|
||||||
<td className={`px-2 py-2.5 text-center tabular-nums text-xs font-semibold ${sortKey === "stockTotal" ? "bg-emerald-100 text-emerald-700" : row.stockTotal <= 0 ? "bg-emerald-50/60 text-red-400" : "bg-emerald-50/60 text-amber-600"}`}>
|
|
||||||
{row.stockTotal > 0 ? formatQte(row.stockTotal) : <span className="text-red-400 font-semibold">0</span>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="border-t-2 border-gray-300">
|
|
||||||
<td colSpan={4} className="bg-gray-100 px-4 py-3 text-sm font-bold text-gray-800">
|
|
||||||
TOTAL — {sorted.length} articles
|
|
||||||
</td>
|
|
||||||
<td className="border-l-2 border-blue-300 bg-blue-100 px-4 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte292)}</td>
|
|
||||||
<td className="bg-blue-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca292)}</td>
|
|
||||||
<td className="bg-blue-100 px-2 py-3 text-center text-xs tabular-nums text-blue-700 font-semibold">{formatPct(totals.ca292, totals.marge292)}</td>
|
|
||||||
<td className="bg-blue-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock292)}</td>
|
|
||||||
<td className="border-l-2 border-violet-300 bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qte579)}</td>
|
|
||||||
<td className="bg-violet-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.ca579)}</td>
|
|
||||||
<td className="bg-violet-100 px-2 py-3 text-center text-xs tabular-nums text-violet-700 font-semibold">{formatPct(totals.ca579, totals.marge579)}</td>
|
|
||||||
<td className="bg-violet-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stock579)}</td>
|
|
||||||
<td className="border-l-2 border-emerald-300 bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatQte(totals.qteTotal)}</td>
|
|
||||||
<td className="bg-emerald-100 px-2 py-3 text-center font-bold tabular-nums text-gray-900">{formatCA(totals.caTotal)}</td>
|
|
||||||
<td className="bg-emerald-100 px-2 py-3 text-center text-xs tabular-nums text-emerald-800 font-semibold">{formatPct(totals.caTotal, totals.margeTotal)}</td>
|
|
||||||
<td className="bg-emerald-100 px-2 py-3 text-center text-xs font-bold tabular-nums text-amber-700">{formatQte(totals.stockTotal)}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { RotateCcw, Trophy } from "lucide-react";
|
||||||
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
|
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
|
||||||
import { HitParadeClient } from "./client";
|
|
||||||
import { cachedFF } from "@/lib/ff-cache";
|
import { cachedFF } from "@/lib/ff-cache";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { ErrorState } from "@/components/ui/states";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HitParadeClient } from "./client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Meilleures ventes" };
|
||||||
|
|
||||||
export interface HitParadePivotRow {
|
export interface HitParadePivotRow {
|
||||||
codein: string;
|
codein: string;
|
||||||
@@ -81,19 +89,40 @@ export default async function HitParadePage(props: {
|
|||||||
const dateDebut = (searchParams.debut as string) || defaults.debut;
|
const dateDebut = (searchParams.debut as string) || defaults.debut;
|
||||||
const dateFin = (searchParams.fin as string) || defaults.fin;
|
const dateFin = (searchParams.fin as string) || defaults.fin;
|
||||||
|
|
||||||
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
|
let pivotted: HitParadePivotRow[] | null = null;
|
||||||
const pivotted = pivotHitParade(rows);
|
let erreur: string | null = null;
|
||||||
|
try {
|
||||||
|
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
|
||||||
|
pivotted = pivotHitParade(rows);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[hit-parade] chargement impossible :", e);
|
||||||
|
erreur = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
<div className="mx-auto max-w-screen-2xl">
|
<PageHeader
|
||||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Hit Parade</h1>
|
icon={Trophy}
|
||||||
|
title="Meilleures ventes"
|
||||||
|
description="Le classement des produits les plus vendus sur une période, magasin par magasin : quantités, chiffre d'affaires, taux de marge et stock actuel."
|
||||||
|
/>
|
||||||
|
{pivotted ? (
|
||||||
<HitParadeClient
|
<HitParadeClient
|
||||||
dateDebut={dateDebut}
|
dateDebut={dateDebut}
|
||||||
dateFin={dateFin}
|
dateFin={dateFin}
|
||||||
pivotted={pivotted}
|
pivotted={pivotted}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<ErrorState
|
||||||
|
title="Les meilleures ventes n'ont pas pu être chargées"
|
||||||
|
detail={erreur ?? undefined}
|
||||||
|
action={
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/hit-parade?debut=${dateDebut}&fin=${dateFin}`}><RotateCcw /> Réessayer</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,11 +2,82 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Loader2, TrendingUp } from "lucide-react";
|
import { Loader2, RefreshCw, TrendingUp } from "lucide-react";
|
||||||
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
|
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DataTable, type DataColumn } from "@/components/ui/data-table";
|
||||||
|
import { EmptyState, ErrorState } from "@/components/ui/states";
|
||||||
|
import { GLOSSAIRE } from "@/lib/glossaire";
|
||||||
|
import { fmtDecimal1, fmtEntier } from "@/lib/format";
|
||||||
|
|
||||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
const COLONNES: DataColumn<PgOpportuniteRow>[] = [
|
||||||
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
|
{
|
||||||
|
id: "produit",
|
||||||
|
header: "Produit",
|
||||||
|
sortValue: (r) => r.libelle1 || r.codein,
|
||||||
|
cell: (r) => (
|
||||||
|
<Link
|
||||||
|
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
|
||||||
|
className="font-medium text-[var(--accent)] hover:underline"
|
||||||
|
>
|
||||||
|
{r.libelle1 || r.codein}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fournisseur",
|
||||||
|
header: "Fournisseur",
|
||||||
|
sortValue: (r) => r.fournisseur,
|
||||||
|
cell: (r) => <span className="text-[var(--text-secondary)]">{r.fournisseur}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reseau",
|
||||||
|
header: "Réseau, par magasin",
|
||||||
|
hint: "Quantité vendue sur 12 mois par un magasin du réseau qui a ce produit.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.qte_reseau_par_magasin,
|
||||||
|
cell: (r) => fmtDecimal1(r.qte_reseau_par_magasin),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nous",
|
||||||
|
header: "Nous, par magasin",
|
||||||
|
hint: "Quantité vendue sur 12 mois par chacun de nos magasins, en moyenne.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.qte_locale_par_magasin,
|
||||||
|
cell: (r) => fmtDecimal1(r.qte_locale_par_magasin),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ecart",
|
||||||
|
header: "Écart",
|
||||||
|
hint: "Réseau par magasin moins nous par magasin. Positif : le réseau vend plus que nous.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.ecart_par_magasin,
|
||||||
|
cell: (r) => {
|
||||||
|
const opportunite = r.ecart_par_magasin > 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="font-semibold"
|
||||||
|
style={{ color: opportunite ? "var(--accent-warning)" : "var(--accent-success)" }}
|
||||||
|
title={opportunite
|
||||||
|
? "Le réseau vend plus que nous sur ce produit"
|
||||||
|
: "Nous vendons autant ou plus que le réseau"}
|
||||||
|
>
|
||||||
|
{opportunite ? "+" : ""}{fmtDecimal1(r.ecart_par_magasin)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "magasins",
|
||||||
|
header: "Magasins vendeurs",
|
||||||
|
hint: GLOSSAIRE.presenceReseau.definition,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.nb_magasins_reseau,
|
||||||
|
cell: (r) => <span className="text-[var(--text-secondary)]">{fmtEntier(r.nb_magasins_reseau)}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rechercherDans = (r: PgOpportuniteRow) => [r.libelle1, r.fournisseur, r.codein, r.code_centrale];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produits de la même famille que le produit affiché, classés par écart de
|
* Produits de la même famille que le produit affiché, classés par écart de
|
||||||
@@ -45,17 +116,31 @@ export function OpportunitesFamille({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rows === null) {
|
if (rows === null) {
|
||||||
|
if (error && !loading) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
className="py-8"
|
||||||
|
title="L'analyse de la famille n'a pas pu être faite"
|
||||||
|
description="Réessayez dans un instant. Si le problème persiste, prévenez un administrateur."
|
||||||
|
detail={error}
|
||||||
|
action={
|
||||||
|
<Button variant="outline" onClick={load}>
|
||||||
|
<RefreshCw /> Réessayer
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
|
Comparez ce produit aux autres produits{familleLabel ? <> de la famille « {familleLabel} »</> : " de la même famille"} :
|
||||||
lesquelles le réseau vend bien et que nous vendons peu ?
|
lesquels le réseau vend bien alors que nous les vendons peu ?
|
||||||
</p>
|
</p>
|
||||||
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
<Button variant="outline" onClick={load} disabled={loading}>
|
||||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
|
{loading ? <Loader2 className="animate-spin" /> : <TrendingUp />}
|
||||||
{loading ? "Analyse…" : "Analyser la famille"}
|
{loading ? "Analyse en cours…" : "Analyser la famille"}
|
||||||
</button>
|
</Button>
|
||||||
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,71 +150,31 @@ export function OpportunitesFamille({
|
|||||||
|
|
||||||
if (others.length === 0) {
|
if (others.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
<EmptyState
|
||||||
Aucune autre référence de cette famille n'a de données réseau en cache.
|
className="py-8"
|
||||||
Synchronisez d'autres fournisseurs pour enrichir la comparaison.
|
title="Aucun autre produit à comparer"
|
||||||
</p>
|
description="Aucun autre produit de cette famille n'a encore de données du réseau. Mettez à jour les données du réseau d'autres fournisseurs (révision d'assortiment) pour enrichir la comparaison."
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
<DataTable
|
||||||
<table className="w-full border-collapse text-[12px]">
|
rows={others}
|
||||||
<thead>
|
columns={COLONNES}
|
||||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
rowKey={(r) => r.codein}
|
||||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
|
searchIn={rechercherDans}
|
||||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
|
searchPlaceholder="Filtrer les produits…"
|
||||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
|
pageSize={25}
|
||||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
|
unite={others.length > 1 ? "produits" : "produit"}
|
||||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
|
maxHeight="60vh"
|
||||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
|
/>
|
||||||
</tr>
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
</thead>
|
Écart = quantité vendue par magasin dans le réseau − notre quantité vendue par magasin, sur 12 mois.
|
||||||
<tbody>
|
Un écart positif signale un produit sous-exploité chez nous. Seuls les produits <strong>de notre
|
||||||
{others.map((r) => {
|
catalogue</strong> qui ont déjà des données du réseau apparaissent ici : un produit absent de notre
|
||||||
const opportunite = r.ecart_par_magasin > 0;
|
catalogue ne peut pas y figurer.
|
||||||
return (
|
|
||||||
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Link
|
|
||||||
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
{r.libelle1 || r.codein}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{fmtDec(r.qte_reseau_par_magasin)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{fmtDec(r.qte_locale_par_magasin)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="px-3 py-2 text-right tabular-nums font-semibold"
|
|
||||||
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
|
|
||||||
title={opportunite
|
|
||||||
? "Le réseau vend plus que nous sur ce produit"
|
|
||||||
: "Nous vendons autant ou plus que le réseau"}
|
|
||||||
>
|
|
||||||
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{fmtQte(r.nb_magasins_reseau)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
|
|
||||||
Un écart positif signale une référence sous-exploitée chez nous.
|
|
||||||
Périmètre : les articles <strong>de notre catalogue</strong> ayant déjà des données réseau —
|
|
||||||
Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { PackageSearch, SearchX } from "lucide-react";
|
||||||
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
|
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { EmptyState } from "@/components/ui/states";
|
||||||
|
import { Terme } from "@/components/ui/tooltip";
|
||||||
import { ProduitSearchBar } from "./search-bar";
|
import { ProduitSearchBar } from "./search-bar";
|
||||||
import { ProduitResults } from "./results";
|
import { ProduitResults } from "./results";
|
||||||
import { ProduitFicheView } from "./fiche";
|
import { ProduitFicheView } from "./fiche";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Recherche produit" };
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,40 +37,44 @@ export default async function ProduitsPage(props: {
|
|||||||
const ficheDemandee = Boolean(codein || codeCentrale);
|
const ficheDemandee = Boolean(codein || codeCentrale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
<header className="space-y-1">
|
<PageHeader
|
||||||
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
|
icon={PackageSearch}
|
||||||
Recherche produit
|
title="Recherche produit"
|
||||||
</h1>
|
description={
|
||||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
<>
|
||||||
Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue —
|
Cherchez un produit par son nom ou son code, puis comparez ses ventes à celles des
|
||||||
performance réseau et ventes locales sur 12 mois glissants.
|
magasins du <Terme id="reseau">réseau.</Terme>
|
||||||
</p>
|
</>
|
||||||
</header>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
|
<div className="space-y-6">
|
||||||
<ProduitSearchBar key={q} initialQuery={q} />
|
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
|
||||||
|
<ProduitSearchBar key={q} initialQuery={q} />
|
||||||
|
|
||||||
{ficheDemandee && !fiche && (
|
{ficheDemandee && !fiche && (
|
||||||
<div
|
<EmptyState
|
||||||
className="rounded-xl p-6 text-center text-[13px]"
|
icon={SearchX}
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
title="Produit introuvable"
|
||||||
>
|
description={
|
||||||
Aucun produit trouvé pour{" "}
|
codein ? (
|
||||||
{codein
|
<>Aucun produit de notre catalogue ne porte le code article <span className="font-mono">{codein}</span>.</>
|
||||||
? <>le code article <span className="font-mono">{codein}</span></>
|
) : (
|
||||||
: <>le code centrale <span className="font-mono">{codeCentrale}</span></>}.
|
<>
|
||||||
{codeCentrale && !codein && (
|
Le code centrale <span className="font-mono">{codeCentrale}</span> n'est ni dans notre
|
||||||
<div className="mt-1">
|
catalogue, ni dans les données du réseau déjà chargées. Relancez une recherche par le nom
|
||||||
Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche.
|
ou le code du produit.
|
||||||
</div>
|
</>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
|
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
|
||||||
|
|
||||||
{!ficheDemandee && <ProduitResults query={q} />}
|
{!ficheDemandee && <ProduitResults query={q} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
|
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
|
||||||
import { TrendSparkline } from "@/features/grid/components/network-charts";
|
import { TrendSparkline } from "@/features/grid/components/network-charts";
|
||||||
import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
import { computeNetworkTrend, NB_MAGASINS_RESEAU, type NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||||
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
import { Badge } from "@/components/ui/badge";
|
||||||
const fmtDec = (v: number, d = 1) =>
|
import { DataTable, type DataColumn } from "@/components/ui/data-table";
|
||||||
new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
|
import { EmptyState, ErrorState } from "@/components/ui/states";
|
||||||
const fmtEur = (v: number) =>
|
import { Terme } from "@/components/ui/tooltip";
|
||||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
import { GLOSSAIRE } from "@/lib/glossaire";
|
||||||
const fmtEur2 = (v: number) =>
|
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
|
||||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
|
import { couleurMarge } from "@/lib/marge";
|
||||||
|
|
||||||
/** Intervalle d'interrogation de l'état du job de recherche. */
|
/** Intervalle d'interrogation de l'état du job de recherche. */
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
@@ -57,6 +57,38 @@ async function lireJson(res: Response): Promise<Record<string, unknown>> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les messages du serveur nomment l'outil source (Qlik) : l'utilisateur lit
|
||||||
|
* « données du réseau », le message d'origine reste dans le détail technique.
|
||||||
|
*/
|
||||||
|
const mentionneOutil = (texte: string | null | undefined) => /qlik/i.test(texte ?? "");
|
||||||
|
|
||||||
|
function libelleEtape(etape: string | null): string {
|
||||||
|
if (!etape || mentionneOutil(etape)) return "Recherche dans les données du réseau…";
|
||||||
|
return etape;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bandeau d'avertissement : explication en clair, détail technique replié. */
|
||||||
|
function Avertissement({ children, detail }: { children: ReactNode; detail?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex items-start gap-2.5 rounded-xl border border-[var(--accent-warning)]/40 bg-[var(--accent-warning-bg)] px-4 py-3 text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent-warning)]" aria-hidden />
|
||||||
|
<div className="min-w-0">
|
||||||
|
{children}
|
||||||
|
{detail && (
|
||||||
|
<details className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
<summary className="cursor-pointer">Détail technique</summary>
|
||||||
|
<p className="mt-1 break-words">{detail}</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résultats de recherche produit.
|
* Résultats de recherche produit.
|
||||||
*
|
*
|
||||||
@@ -144,36 +176,31 @@ export function ProduitResults({ query }: { query: string }) {
|
|||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return (
|
return (
|
||||||
<div
|
<EmptyState
|
||||||
className="rounded-xl p-10 text-center"
|
icon={PackageSearch}
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
title="Recherchez un produit"
|
||||||
>
|
description={
|
||||||
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
|
<>
|
||||||
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
Saisissez le nom d'un produit (ex. « poêle 28 ») ou son code centrale
|
||||||
Recherchez un produit dans le réseau
|
(ex. « 10000167303 »). Vous verrez ses ventes dans les {NB_MAGASINS_RESEAU} magasins
|
||||||
</p>
|
du réseau, puis chez nous s'il est dans notre catalogue.
|
||||||
<p className="text-[12px] mt-1 max-w-lg mx-auto" style={{ color: "var(--text-muted)" }}>
|
</>
|
||||||
La recherche interroge <strong>Qlik Sense</strong> (les ~{NB_MAGASINS_RESEAU} magasins du réseau),
|
}
|
||||||
puis rapproche chaque produit trouvé de notre catalogue.
|
/>
|
||||||
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-xl p-10 text-center"
|
aria-busy="true"
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
className="flex flex-col items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] px-6 py-12 text-center"
|
||||||
>
|
>
|
||||||
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
|
<Loader2 className="h-7 w-7 animate-spin text-[var(--accent)]" aria-hidden />
|
||||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
<p className="text-base font-semibold text-[var(--text-primary)]">{libelleEtape(etape)}</p>
|
||||||
{etape ?? "Interrogation de Qlik Sense…"}
|
<p className="max-w-lg text-sm text-[var(--text-secondary)]">
|
||||||
</p>
|
Recherche des produits puis de leurs ventes sur les 12 derniers mois.
|
||||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
|
Cela peut prendre plus d'une minute : vous pouvez laisser la page ouverte.
|
||||||
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants.
|
|
||||||
Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -181,21 +208,20 @@ export function ProduitResults({ query }: { query: string }) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl p-6" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
<ErrorState
|
||||||
<div className="flex items-start gap-3">
|
title="La recherche a échoué"
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
description={
|
||||||
<div className="min-w-0">
|
mentionneOutil(error)
|
||||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>
|
? "Les données du réseau n'ont pas répondu. Réessayez dans un instant ; si la recherche est très large, ajoutez un mot plus précis."
|
||||||
La recherche a échoué
|
: error
|
||||||
</p>
|
}
|
||||||
<p className="text-[12px] mt-0.5 break-words" style={{ color: "var(--text-muted)" }}>{error}</p>
|
detail={mentionneOutil(error) ? error : undefined}
|
||||||
<button onClick={relancer} className="btn-action btn-action-secondary mt-3 flex items-center gap-1.5">
|
action={
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<Button variant="outline" onClick={relancer}>
|
||||||
Réessayer
|
<RefreshCw /> Réessayer
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,196 +230,227 @@ export function ProduitResults({ query }: { query: string }) {
|
|||||||
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
|
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
<p className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
{source === "qlik" ? (
|
{source === "qlik"
|
||||||
<Globe className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} />
|
? <Globe className="h-4 w-4 text-[var(--accent)]" aria-hidden />
|
||||||
) : (
|
: <Database className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />}
|
||||||
<Database className="w-3.5 h-3.5" />
|
<span>
|
||||||
)}
|
<span className="font-semibold text-[var(--text-primary)]">{fmtEntier(rows.length)}</span>{" "}
|
||||||
{rows.length} produit{rows.length > 1 ? "s" : ""}
|
produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""}
|
||||||
{source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"}
|
{source === "qlik" ? " dans le réseau" : " dans notre catalogue"}
|
||||||
{tronque && " (liste tronquée — affinez la recherche)"}
|
{tronque && " (liste limitée : précisez la recherche pour tout voir)"}
|
||||||
{" · "}{(dureeMs / 1000).toFixed(1)} s
|
<span className="text-[var(--text-muted)]"> · recherche en {fmtDecimal1(dureeMs / 1000)} s</span>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<button onClick={relancer} className="btn-action btn-action-secondary flex items-center gap-1.5">
|
<Button
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
variant="outline"
|
||||||
Relancer
|
size="sm"
|
||||||
</button>
|
onClick={relancer}
|
||||||
|
title="Interroger à nouveau les données du réseau, sans réutiliser le dernier résultat"
|
||||||
|
>
|
||||||
|
<RefreshCw /> Relancer la recherche
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{source === "db" && (
|
{source === "db" && (
|
||||||
<div
|
<Avertissement detail={qlikError}>
|
||||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
Les données du réseau n'ont pas pu être interrogées : seuls les produits de notre catalogue
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
sont affichés, avec les chiffres du réseau de la dernière mise à jour.
|
||||||
>
|
</Avertissement>
|
||||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
|
||||||
<span>
|
|
||||||
Qlik n'a pas pu être interrogé, la recherche s'est repliée sur le catalogue local
|
|
||||||
(les chiffres réseau affichés viennent du dernier cache).
|
|
||||||
{qlikError && <span className="block mt-0.5" style={{ color: "var(--text-muted)" }}>{qlikError}</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{source === "qlik" && qlikError && (
|
{source === "qlik" && qlikError && (
|
||||||
<div
|
<Avertissement detail={qlikError}>
|
||||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
{rows.length === 0
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
? "La recherche n'a pas pu être appliquée aux données du réseau. Essayez un terme plus précis (deux mots, ou le code centrale)."
|
||||||
>
|
: "Produits trouvés, mais leurs ventes dans le réseau n'ont pas pu être chargées."}
|
||||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
</Avertissement>
|
||||||
<span>
|
|
||||||
{rows.length === 0
|
|
||||||
? qlikError
|
|
||||||
: `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div
|
<EmptyState
|
||||||
className="rounded-xl p-8 text-center text-[13px]"
|
title={<>Aucun produit ne correspond à « {query} »</>}
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
description="Vérifiez l'orthographe, essayez un mot plus court ou le code centrale du produit."
|
||||||
>
|
/>
|
||||||
Aucun article du réseau ne correspond à « {query} ».
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ResultTable rows={rows} query={query} />
|
<>
|
||||||
|
<ResultTable rows={rows} query={query} />
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Chiffres des magasins du <Terme id="reseau">réseau</Terme> sur les 12 derniers mois
|
||||||
|
(mois en cours exclu). Cliquez sur un produit pour ouvrir sa fiche.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{locauxHorsReseau.length > 0 && (
|
{locauxHorsReseau.length > 0 && (
|
||||||
<details className="rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
<details className="rounded-xl border border-[var(--border)] bg-[var(--bg-surface)]">
|
||||||
<summary className="cursor-pointer px-3 py-2.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
<summary className="cursor-pointer px-4 py-3 text-sm text-[var(--text-secondary)]">
|
||||||
{locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
|
{fmtEntier(locauxHorsReseau.length)} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
|
||||||
sans correspondance réseau (pas de code centrale, ou non vendu par le réseau)
|
sans ventes connues dans le réseau (pas de code centrale, ou pas vendu par le réseau)
|
||||||
</summary>
|
</summary>
|
||||||
<div className="px-3 pb-3">
|
<ul className="space-y-1.5 px-4 pb-4">
|
||||||
<ul className="space-y-1">
|
{locauxHorsReseau.slice(0, 30).map((l) => (
|
||||||
{locauxHorsReseau.slice(0, 30).map((l) => (
|
<li key={l.codein} className="text-sm">
|
||||||
<li key={l.codein} className="text-[12px]">
|
<Link
|
||||||
<Link
|
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
|
||||||
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
|
className="font-medium text-[var(--accent)] hover:underline"
|
||||||
className="hover:underline"
|
>
|
||||||
style={{ color: "var(--accent)" }}
|
{l.libelle1 || l.codein}
|
||||||
>
|
</Link>
|
||||||
{l.libelle1 || l.codein}
|
<span className="text-[var(--text-muted)]">
|
||||||
</Link>
|
{" · "}{l.fournisseur}{" · "}code article <span className="font-mono">{l.codein}</span>
|
||||||
<span style={{ color: "var(--text-muted)" }}>
|
</span>
|
||||||
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
|
</li>
|
||||||
</span>
|
))}
|
||||||
</li>
|
</ul>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tableau des résultats ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Ligne = ProduitRechercheRow & { trend: NetworkTrend };
|
||||||
|
|
||||||
|
const rechercherDans = (r: Ligne) => [r.libelle, r.fournisseur, r.codeCentrale, r.codein, r.nomenclature];
|
||||||
|
|
||||||
function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) {
|
function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) {
|
||||||
return (
|
// Tendance calculée une fois par ligne (et non à chaque tri ou rendu).
|
||||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
const lignes = useMemo<Ligne[]>(
|
||||||
<table className="w-full text-[13px] border-collapse">
|
() => rows.map((r) => ({ ...r, trend: computeNetworkTrend(r.qteByMonth, r.nbMagByMonth) })),
|
||||||
<thead>
|
[rows],
|
||||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
|
||||||
<Th>Libellé</Th>
|
|
||||||
<Th>Fournisseur</Th>
|
|
||||||
<Th>Code centrale</Th>
|
|
||||||
<Th align="right">Magasins</Th>
|
|
||||||
<Th align="right">Qté réseau</Th>
|
|
||||||
<Th align="right">Qté / mag.</Th>
|
|
||||||
<Th align="right">Prix moyen</Th>
|
|
||||||
<Th align="right">CA / mag.</Th>
|
|
||||||
<Th align="right">Marge %</Th>
|
|
||||||
<Th align="center">Tendance</Th>
|
|
||||||
<Th align="right">Nous</Th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => {
|
|
||||||
const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth);
|
|
||||||
const href = r.codein
|
|
||||||
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
|
|
||||||
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={r.codeCentrale}
|
|
||||||
className="transition-colors hover:bg-[var(--bg-elevated)]"
|
|
||||||
style={{ borderTop: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 max-w-[280px]">
|
|
||||||
<Link href={href} className="font-medium hover:underline" style={{ color: "var(--accent)" }}>
|
|
||||||
{r.libelle || "—"}
|
|
||||||
</Link>
|
|
||||||
{r.nomenclature && (
|
|
||||||
<div className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>{r.nomenclature}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{r.fournisseur || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{r.codeCentrale}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{fmtQte(r.nbMagasinsReseau)}
|
|
||||||
<span className="text-[11px] ml-1" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{fmtDec(r.tauxPresence * 100, 0)} %
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{fmtQte(r.qteReseau)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{fmtDec(r.qteParMagasinReseau)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-center">
|
|
||||||
{trend.hasData ? <TrendSparkline trend={trend} /> : <span style={{ color: "var(--text-muted)" }}>—</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right text-[12px]">
|
|
||||||
{r.codein ? (
|
|
||||||
<span style={{ color: "var(--text-secondary)" }} title={`Code article ${r.codein}`}>
|
|
||||||
stock {fmtQte(r.stockLocal ?? 0)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="rounded-md px-1.5 py-0.5 text-[11px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
|
|
||||||
title="Le réseau travaille ce produit, notre catalogue ne le référence pas"
|
|
||||||
>
|
|
||||||
non référencé
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
const colonnes = useMemo<DataColumn<Ligne>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "produit",
|
||||||
|
header: "Produit",
|
||||||
|
sortValue: (r) => r.libelle,
|
||||||
|
className: "max-w-[320px]",
|
||||||
|
cell: (r) => {
|
||||||
|
const href = r.codein
|
||||||
|
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
|
||||||
|
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link href={href} className="font-medium text-[var(--accent)] hover:underline">
|
||||||
|
{r.libelle || "—"}
|
||||||
|
</Link>
|
||||||
|
{r.nomenclature && (
|
||||||
|
<div className="truncate text-xs text-[var(--text-muted)]" title={r.nomenclature}>{r.nomenclature}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fournisseur",
|
||||||
|
header: "Fournisseur",
|
||||||
|
sortValue: (r) => r.fournisseur,
|
||||||
|
cell: (r) => <span className="text-[var(--text-secondary)]">{r.fournisseur || "—"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "codeCentrale",
|
||||||
|
header: "Code centrale",
|
||||||
|
hint: GLOSSAIRE.codeCentrale.definition,
|
||||||
|
cell: (r) => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codeCentrale}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "magasins",
|
||||||
|
header: "Magasins vendeurs",
|
||||||
|
hint: GLOSSAIRE.presenceReseau.definition,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.nbMagasinsReseau,
|
||||||
|
cell: (r) => (
|
||||||
|
<>
|
||||||
|
{fmtEntier(r.nbMagasinsReseau)}
|
||||||
|
<span className="ml-1 text-xs text-[var(--text-muted)]">({fmtEntier(r.tauxPresence * 100)} %)</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "qteReseau",
|
||||||
|
header: "Quantité vendue",
|
||||||
|
hint: "Quantité vendue par l'ensemble des magasins du réseau sur les 12 derniers mois.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.qteReseau,
|
||||||
|
cell: (r) => <span className="font-semibold">{fmtEntier(r.qteReseau)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "qteParMagasin",
|
||||||
|
header: "Quantité par magasin",
|
||||||
|
hint: "Quantité moyenne vendue sur 12 mois par un magasin du réseau qui a ce produit.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.qteParMagasinReseau,
|
||||||
|
cell: (r) => fmtDecimal1(r.qteParMagasinReseau),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prixMoyen",
|
||||||
|
header: "Prix moyen",
|
||||||
|
hint: "Prix de vente moyen constaté dans le réseau (chiffre d'affaires divisé par la quantité).",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.prixMoyenReseau,
|
||||||
|
cell: (r) => (r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "caParMagasin",
|
||||||
|
header: "CA par magasin",
|
||||||
|
hint: GLOSSAIRE.caParMagasin.definition,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => (r.caParMagasinReseau > 0 ? r.caParMagasinReseau : null),
|
||||||
|
cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur0(r.caParMagasinReseau) : "—"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "marge",
|
||||||
|
header: "Taux de marge",
|
||||||
|
hint: GLOSSAIRE.marge.definition,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => r.margePctReseau,
|
||||||
|
cell: (r) =>
|
||||||
|
r.margePctReseau != null ? (
|
||||||
|
<span className="font-medium" style={{ color: couleurMarge(r.margePctReseau) }}>
|
||||||
|
{fmtDecimal1(r.margePctReseau)} %
|
||||||
|
</span>
|
||||||
|
) : "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tendance",
|
||||||
|
header: "Tendance",
|
||||||
|
hint: "Ventes par magasin des 4 derniers mois comparées aux 4 premiers mois de la période de 12 mois.",
|
||||||
|
align: "center",
|
||||||
|
sortValue: (r) => (r.trend.hasData ? r.trend.pct : null),
|
||||||
|
cell: (r) => (r.trend.hasData ? <TrendSparkline trend={r.trend} /> : <span className="text-[var(--text-muted)]">—</span>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nous",
|
||||||
|
header: "Chez nous",
|
||||||
|
hint: "Notre stock (tous nos magasins) si le produit est dans notre catalogue.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (r) => (r.codein ? (r.stockLocal ?? 0) : null),
|
||||||
|
cell: (r) =>
|
||||||
|
r.codein ? (
|
||||||
|
<span className="text-[var(--text-secondary)]" title={`Code article ${r.codein}`}>
|
||||||
|
Stock {fmtEntier(r.stockLocal ?? 0)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Badge title="Le réseau vend ce produit, mais il n'est pas dans notre catalogue">Non référencé</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [query]);
|
||||||
|
|
||||||
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) {
|
|
||||||
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
|
|
||||||
const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
|
|
||||||
return (
|
return (
|
||||||
<th
|
<DataTable
|
||||||
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
|
rows={lignes}
|
||||||
style={{ color: "var(--text-muted)" }}
|
columns={colonnes}
|
||||||
>
|
rowKey={(r) => r.codeCentrale}
|
||||||
{children}
|
searchIn={rechercherDans}
|
||||||
</th>
|
searchPlaceholder="Filtrer les résultats…"
|
||||||
|
pageSize={50}
|
||||||
|
unite={lignes.length > 1 ? "produits" : "produit"}
|
||||||
|
emptyTitle="Aucun produit"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/form-controls";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Champ de recherche produit.
|
* Champ de recherche produit.
|
||||||
@@ -35,43 +37,42 @@ export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<label htmlFor="recherche-produit" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||||
<div className="relative flex-1 max-w-xl">
|
Nom du produit ou code centrale
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative w-full max-w-xl flex-1">
|
||||||
<Search
|
<Search
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]"
|
||||||
style={{ color: "var(--text-muted)" }}
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
|
id="recherche-produit"
|
||||||
type="text"
|
type="text"
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
|
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
|
||||||
placeholder="Libellé produit ou code centrale…"
|
placeholder="Ex. « poêle 28 » ou « 10000167303 »"
|
||||||
aria-label="Rechercher un produit par libellé ou code centrale"
|
aria-describedby={tooShort ? "recherche-produit-aide" : undefined}
|
||||||
className="apple-input h-10 text-[14px]"
|
className="h-10 w-full pl-9 pr-9"
|
||||||
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
|
|
||||||
/>
|
/>
|
||||||
{term && (
|
{term && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
aria-label="Effacer la recherche"
|
aria-label="Effacer la recherche"
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button onClick={submit} disabled={term.trim().length < 3} className="h-10">
|
||||||
onClick={submit}
|
<Search /> Rechercher
|
||||||
disabled={term.trim().length < 3}
|
</Button>
|
||||||
className="btn-action btn-action-primary disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Rechercher
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{tooShort && (
|
{tooShort && (
|
||||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
<p id="recherche-produit-aide" className="text-[13px] text-[var(--text-secondary)]">
|
||||||
Saisissez au moins 3 caractères.
|
Saisissez au moins 3 caractères.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,363 +1,424 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useMemo } from "react";
|
import { History, Info, Megaphone, RotateCcw } from "lucide-react";
|
||||||
import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import type { Publicite, PubliciteHistorique } from "./page";
|
import type { Publicite, PubliciteHistorique } from "./page";
|
||||||
|
import { Badge, StoreBadge, type Ton } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
|
||||||
|
import { Select, type SelectOption } from "@/components/ui/form-controls";
|
||||||
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
|
import { ErrorState } from "@/components/ui/states";
|
||||||
|
import { Tabs, useUrlTab } from "@/components/ui/tabs";
|
||||||
|
import { Terme, InfoBulle } from "@/components/ui/tooltip";
|
||||||
|
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
|
||||||
|
import { nomMagasin } from "@/lib/magasins";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
type Vue = "publicites" | "historique";
|
||||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
||||||
}
|
|
||||||
function fmtCA(v: number | string) {
|
|
||||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(Number(v));
|
|
||||||
}
|
|
||||||
function fmtPct(v: number | string) {
|
|
||||||
return Number(v).toFixed(2).replace(".", ",") + " %";
|
|
||||||
}
|
|
||||||
|
|
||||||
type SortKey = "intitule" | "site" | "ca_pub_periode_pub" | "pourc_capub_catotal" | "qte_vendue_pub" | "taux_sortie" | "statut";
|
const STATUTS: Record<string, { libelle: string; ton: Ton }> = {
|
||||||
type SortDir = "asc" | "desc";
|
en_cours: { libelle: "En cours", ton: "succes" },
|
||||||
|
passee: { libelle: "Passée", ton: "neutre" },
|
||||||
function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
|
||||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
|
||||||
return sortDir === "asc" ? <ChevronUp className="inline w-3 h-3 ml-1" /> : <ChevronDown className="inline w-3 h-3 ml-1" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUT_LABELS: Record<string, { label: string; cls: string }> = {
|
|
||||||
en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" },
|
|
||||||
passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
|
||||||
// Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes.
|
// Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes.
|
||||||
passees: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
passees: { libelle: "Passée", ton: "neutre" },
|
||||||
a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" },
|
a_venir: { libelle: "À venir", ton: "accent" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPTIONS_STATUT: SelectOption[] = [
|
||||||
|
{ value: "toutes", label: "Toutes" },
|
||||||
|
{ value: "en_cours", label: "En cours" },
|
||||||
|
{ value: "passees", label: "Passées" },
|
||||||
|
{ value: "a_venir", label: "À venir" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Pourquoi la liste est vide, selon le statut choisi. */
|
||||||
|
const VIDE_PAR_STATUT: Record<string, string> = {
|
||||||
|
toutes: "Aucune opération publicitaire n'est enregistrée pour le moment.",
|
||||||
|
en_cours: "Aucune opération publicitaire n'est en cours en ce moment.",
|
||||||
|
passees: "Aucune opération publicitaire passée n'a été trouvée.",
|
||||||
|
a_venir: "Aucune opération publicitaire à venir n'est encore enregistrée.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPLICATION_CA_PUB =
|
||||||
|
"Chiffre d'affaires réalisé sur les produits de l'opération pendant sa durée (du premier au dernier jour de la publicité).";
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les montants de l'API arrivent parfois en texte : valeur absente ou illisible = 0. */
|
||||||
|
function nombre(v: number | string | null | undefined): number {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function somme<T>(rows: readonly T[], valeur: (r: T) => number | string | null | undefined): number {
|
||||||
|
return rows.reduce((s, r) => s + nombre(valeur(r)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const libelleStatut = (statut?: string) => STATUTS[statut ?? ""]?.libelle ?? statut ?? "";
|
||||||
|
/** `passee` et `passees` désignent le même statut. */
|
||||||
|
const cleStatut = (statut?: string) => (statut === "passees" ? "passee" : statut || null);
|
||||||
|
|
||||||
|
/** Libellé suivi d'un « i » : explication libre au survol (hors glossaire). */
|
||||||
|
|
||||||
function StatutBadge({ statut }: { statut?: string }) {
|
function StatutBadge({ statut }: { statut?: string }) {
|
||||||
const s = statut ?? "";
|
const s = STATUTS[statut ?? ""];
|
||||||
const cfg = STATUT_LABELS[s] ?? { label: s || "—", cls: "bg-gray-100 text-gray-500" };
|
if (s) return <Badge ton={s.ton}>{s.libelle}</Badge>;
|
||||||
|
return statut ? <Badge>{statut}</Badge> : <span className="text-[var(--text-muted)]">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Intitulé d'abord, code de l'opération en petit dessous. */
|
||||||
|
function Operation({ intitule, code }: { intitule: string; code: string }) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold whitespace-nowrap ${cfg.cls}`}>
|
<div className="min-w-[220px]">
|
||||||
{cfg.label}
|
<div className="font-medium text-[var(--text-primary)]">{intitule || "Opération sans intitulé"}</div>
|
||||||
|
{code && <div className="text-xs text-[var(--text-muted)]">Code {code}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Part du CA total : mise en avant à partir de 10 %, puis de 20 %. */
|
||||||
|
function PartCa({ pct }: { pct: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
pct >= 20 ? "font-semibold text-[var(--accent)]" : pct >= 10 ? "font-semibold" : "text-[var(--text-secondary)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmtDecimal1(pct)} %
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pied de la colonne « Part du CA total » : somme du CA pub / somme du CA total. */
|
||||||
|
function PartCaTotal({ rows }: { rows: readonly Publicite[] }) {
|
||||||
|
const caPub = somme(rows, (r) => r.ca_pub_periode_pub);
|
||||||
|
const caTotal = somme(rows, (r) => r.ca_total_periode_pub);
|
||||||
|
if (caTotal <= 0) {
|
||||||
|
return <span className="text-[var(--text-muted)]" title="Chiffre d'affaires total inconnu : la part ne peut pas être calculée.">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<InfoBulle explication="Somme du CA pub période des lignes affichées, divisée par la somme de leur CA total sur les mêmes périodes.">
|
||||||
|
{fmtDecimal1((caPub / caTotal) * 100)} %
|
||||||
|
</InfoBulle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vue: string;
|
vueInitiale: Vue;
|
||||||
statut: string;
|
statut: string;
|
||||||
page: number;
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
publicites: Publicite[];
|
publicites: Publicite[];
|
||||||
total: number;
|
total: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
|
erreurPublicites?: string;
|
||||||
historique: PubliciteHistorique[];
|
historique: PubliciteHistorique[];
|
||||||
|
erreurHistorique?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicitesClient({ vue, statut, page, publicites, total, pages, historique }: Props) {
|
export function PublicitesClient({
|
||||||
|
vueInitiale,
|
||||||
|
statut,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
publicites,
|
||||||
|
total,
|
||||||
|
pages,
|
||||||
|
erreurPublicites,
|
||||||
|
historique,
|
||||||
|
erreurHistorique,
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [chargement, startTransition] = useTransition();
|
||||||
|
const [vue, setVue] = useUrlTab<Vue>("vue", vueInitiale);
|
||||||
|
|
||||||
const [filterSite, setFilterSite] = useState<string>("all");
|
function naviguer(changements: { statut?: string; page?: number }) {
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("intitule");
|
const sp = new URLSearchParams({ vue, statut, page: String(page) });
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
if (changements.statut !== undefined) sp.set("statut", changements.statut);
|
||||||
|
if (changements.page !== undefined) sp.set("page", String(changements.page));
|
||||||
function nav(params: Record<string, string | number>) {
|
startTransition(() => router.push(`/publicites?${sp}`));
|
||||||
const sp = new URLSearchParams({
|
|
||||||
vue, statut, page: String(page), ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
|
||||||
});
|
|
||||||
router.push(`/publicites?${sp}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPublicites = useMemo(() => publicites.filter(p => p.site !== "000"), [publicites]);
|
const reessayer = (
|
||||||
|
<Button variant="outline" onClick={() => startTransition(() => router.refresh())} disabled={chargement}>
|
||||||
|
<RotateCcw /> Réessayer
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
const sites = useMemo(() => {
|
const validPublicites = useMemo(() => publicites.filter((p) => p.site !== "000"), [publicites]);
|
||||||
const s = new Set(validPublicites.map(p => p.site).filter(Boolean));
|
const plusieursSites = useMemo(() => new Set(validPublicites.map((p) => p.site).filter(Boolean)).size > 1, [validPublicites]);
|
||||||
return Array.from(s).sort();
|
const paginee = pages > 1;
|
||||||
}, [validPublicites]);
|
|
||||||
|
|
||||||
const SITE_COLORS: Record<string, string> = useMemo(() => {
|
const filtresPublicites = useMemo<DataFilter<Publicite>[]>(
|
||||||
const palette = [
|
() =>
|
||||||
"bg-blue-50 text-blue-700", "bg-violet-50 text-violet-700",
|
plusieursSites
|
||||||
"bg-emerald-50 text-emerald-700", "bg-rose-50 text-rose-700",
|
? [{ id: "site", label: "Magasin", valueOf: (p) => p.site || null, optionLabel: (code) => nomMagasin(code), allLabel: "Tous les magasins" }]
|
||||||
"bg-amber-50 text-amber-700", "bg-cyan-50 text-cyan-700",
|
: [],
|
||||||
];
|
[plusieursSites],
|
||||||
return Object.fromEntries(sites.map((s, i) => [s, palette[i % palette.length]]));
|
);
|
||||||
}, [sites]);
|
|
||||||
|
|
||||||
function handleSort(key: SortKey) {
|
const colonnesPublicites = useMemo<DataColumn<Publicite>[]>(
|
||||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
() => [
|
||||||
else { setSortKey(key); setSortDir("asc"); }
|
{
|
||||||
}
|
id: "operation",
|
||||||
|
header: "Opération",
|
||||||
|
sortValue: (p) => p.intitule,
|
||||||
|
cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />,
|
||||||
|
footer: (rows) =>
|
||||||
|
`Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statut",
|
||||||
|
header: "Statut",
|
||||||
|
sortValue: (p) => libelleStatut(p.statut),
|
||||||
|
cell: (p) => <StatutBadge statut={p.statut} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site",
|
||||||
|
header: "Magasin",
|
||||||
|
sortValue: (p) => (p.site ? nomMagasin(p.site) : null),
|
||||||
|
cell: (p) => (p.site ? <StoreBadge code={p.site} /> : <span className="text-[var(--text-muted)]">—</span>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "debut",
|
||||||
|
header: "Début",
|
||||||
|
sortValue: (p) => p.date_debut,
|
||||||
|
cell: (p) => fmtDate(p.date_debut),
|
||||||
|
className: "whitespace-nowrap tabular-nums",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fin",
|
||||||
|
header: "Fin",
|
||||||
|
sortValue: (p) => p.date_fin,
|
||||||
|
cell: (p) => fmtDate(p.date_fin),
|
||||||
|
className: "whitespace-nowrap tabular-nums",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "caPub",
|
||||||
|
header: <InfoBulle explication={EXPLICATION_CA_PUB}>CA pub période</InfoBulle>,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (p) => nombre(p.ca_pub_periode_pub),
|
||||||
|
cell: (p) => <span className="font-semibold">{fmtEur0(nombre(p.ca_pub_periode_pub))}</span>,
|
||||||
|
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_pub_periode_pub)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "partCa",
|
||||||
|
header: <Terme id="partCaTotal" />,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (p) => nombre(p.pourc_capub_catotal),
|
||||||
|
cell: (p) => <PartCa pct={nombre(p.pourc_capub_catotal)} />,
|
||||||
|
footer: (rows) => <PartCaTotal rows={rows} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quantite",
|
||||||
|
header: "Quantité vendue",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (p) => nombre(p.qte_vendue_pub),
|
||||||
|
cell: (p) => fmtEntier(nombre(p.qte_vendue_pub)),
|
||||||
|
footer: (rows) => fmtEntier(somme(rows, (r) => r.qte_vendue_pub)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clients",
|
||||||
|
header: "Clients",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (p) => nombre(p.client_pub_periode),
|
||||||
|
cell: (p) => fmtEntier(nombre(p.client_pub_periode)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tauxSortie",
|
||||||
|
header: <Terme id="tauxSortie" />,
|
||||||
|
align: "right",
|
||||||
|
sortValue: (p) => nombre(p.taux_sortie),
|
||||||
|
cell: (p) => `${fmtDecimal1(nombre(p.taux_sortie))} %`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "articles",
|
||||||
|
header: "Nb articles",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (p) => nombre(p.nb_articles),
|
||||||
|
cell: (p) => fmtEntier(nombre(p.nb_articles)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[paginee],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtresHistorique = useMemo<DataFilter<PubliciteHistorique>[]>(
|
||||||
let rows = filterSite === "all" ? validPublicites : validPublicites.filter(p => p.site === filterSite);
|
() => [{ id: "statut", label: "Statut", valueOf: (h) => cleStatut(h.statut), optionLabel: (s) => libelleStatut(s), allLabel: "Tous" }],
|
||||||
return [...rows].sort((a, b) => {
|
[],
|
||||||
const va = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut")
|
);
|
||||||
? String(a[sortKey] ?? "").toLowerCase()
|
|
||||||
: Number(a[sortKey as keyof Publicite]);
|
|
||||||
const vb = (sortKey === "intitule" || sortKey === "site" || sortKey === "statut")
|
|
||||||
? String(b[sortKey] ?? "").toLowerCase()
|
|
||||||
: Number(b[sortKey as keyof Publicite]);
|
|
||||||
if (va < vb) return sortDir === "asc" ? -1 : 1;
|
|
||||||
if (va > vb) return sortDir === "asc" ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}, [validPublicites, filterSite, sortKey, sortDir]);
|
|
||||||
|
|
||||||
function thProps(key: SortKey, cls = "") {
|
const colonnesHistorique = useMemo<DataColumn<PubliciteHistorique>[]>(
|
||||||
return { className: `${cls} cursor-pointer select-none hover:bg-gray-100 transition-colors`, onClick: () => handleSort(key) };
|
() => [
|
||||||
}
|
{
|
||||||
|
id: "operation",
|
||||||
|
header: "Opération",
|
||||||
|
sortValue: (h) => h.intitule,
|
||||||
|
cell: (h) => <Operation intitule={h.intitule} code={h.tcr_code} />,
|
||||||
|
footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "statut",
|
||||||
|
header: "Statut",
|
||||||
|
sortValue: (h) => libelleStatut(h.statut),
|
||||||
|
cell: (h) => <StatutBadge statut={h.statut} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "debut",
|
||||||
|
header: "Début",
|
||||||
|
sortValue: (h) => h.date_debut,
|
||||||
|
cell: (h) => fmtDate(h.date_debut),
|
||||||
|
className: "whitespace-nowrap tabular-nums",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fin",
|
||||||
|
header: "Fin",
|
||||||
|
sortValue: (h) => h.date_fin,
|
||||||
|
cell: (h) => fmtDate(h.date_fin),
|
||||||
|
className: "whitespace-nowrap tabular-nums",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "magasins",
|
||||||
|
header: "Magasins",
|
||||||
|
hint: "Nombre de magasins concernés par l'opération.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (h) => nombre(h.nb_sites),
|
||||||
|
cell: (h) => fmtEntier(nombre(h.nb_sites)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ca",
|
||||||
|
header: "CA total",
|
||||||
|
hint: "Chiffre d'affaires de l'opération, tous magasins confondus.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (h) => nombre(h.ca_total),
|
||||||
|
cell: (h) => <span className="font-semibold">{fmtEur0(nombre(h.ca_total))}</span>,
|
||||||
|
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_total)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quantite",
|
||||||
|
header: "Quantité vendue",
|
||||||
|
align: "right",
|
||||||
|
sortValue: (h) => nombre(h.qte_totale),
|
||||||
|
cell: (h) => fmtEntier(nombre(h.qte_totale)),
|
||||||
|
footer: (rows) => fmtEntier(somme(rows, (r) => r.qte_totale)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const isHistorique = vue === "historique";
|
const onglets = [
|
||||||
|
{ value: "publicites", label: "Publicités", icon: Megaphone, count: erreurPublicites ? undefined : total },
|
||||||
|
{ value: "historique", label: "Historique", icon: History, count: erreurHistorique ? undefined : historique.length },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const selectStatut = (
|
||||||
|
<Select
|
||||||
|
id="publicites-statut"
|
||||||
|
label="Statut"
|
||||||
|
value={statut}
|
||||||
|
options={OPTIONS_STATUT}
|
||||||
|
onChange={(v) => naviguer({ statut: v, page: 1 })}
|
||||||
|
disabled={chargement}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptionVide =
|
||||||
|
page > 1 && total > 0 ? (
|
||||||
|
<>
|
||||||
|
Cette page ne contient aucune publicité.{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => naviguer({ page: 1 })}
|
||||||
|
className="font-medium text-[var(--accent)] underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Revenir à la première page
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{VIDE_PAR_STATUT[statut] ?? VIDE_PAR_STATUT.toutes}
|
||||||
|
{statut !== "toutes" && " Choisissez « Toutes » dans le filtre Statut pour voir les autres opérations."}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{/* ── Onglets ── */}
|
<Tabs items={onglets} value={vue} onChange={setVue} />
|
||||||
<div className="flex gap-2 border-b border-gray-200">
|
|
||||||
{[
|
|
||||||
{ key: "publicites", label: `Publicités${total ? ` (${total})` : ""}` },
|
|
||||||
{ key: "historique", label: `Historique${historique.length ? ` (${historique.length})` : ""}` },
|
|
||||||
].map(t => (
|
|
||||||
<button
|
|
||||||
key={t.key}
|
|
||||||
onClick={() => nav({ vue: t.key, page: 1 })}
|
|
||||||
className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px transition-colors ${
|
|
||||||
vue === t.key
|
|
||||||
? "border-orange-500 text-orange-600"
|
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Barre de contrôles ── */}
|
<div aria-busy={chargement} className={cn("transition-opacity", chargement && "pointer-events-none opacity-60")}>
|
||||||
<div className="flex flex-wrap items-end gap-4 rounded-xl bg-white px-5 py-4 shadow-sm border border-gray-100">
|
{vue === "publicites" ? (
|
||||||
{!isHistorique && (
|
erreurPublicites ? (
|
||||||
<>
|
<ErrorState
|
||||||
<div className="flex flex-col gap-1">
|
title="Les publicités n'ont pas pu être chargées"
|
||||||
<label className="text-xs font-semibold text-gray-500">Statut</label>
|
description="Le serveur des publicités n'a pas répondu correctement. Réessayez dans un instant ; si le problème persiste, prévenez un administrateur."
|
||||||
<select
|
detail={erreurPublicites}
|
||||||
value={statut}
|
action={reessayer}
|
||||||
onChange={e => nav({ vue, statut: e.target.value, page: 1 })}
|
/>
|
||||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-400 min-w-[160px]"
|
|
||||||
>
|
|
||||||
<option value="toutes">Toutes</option>
|
|
||||||
<option value="en_cours">En cours</option>
|
|
||||||
<option value="passees">Passées</option>
|
|
||||||
<option value="a_venir">À venir</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sites.length > 1 && (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-500">Magasin</label>
|
|
||||||
<select
|
|
||||||
value={filterSite}
|
|
||||||
onChange={e => setFilterSite(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-400 min-w-[160px]"
|
|
||||||
>
|
|
||||||
<option value="all">Tous les magasins</option>
|
|
||||||
{sites.map(s => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="h-8 w-px bg-gray-200 self-center" />
|
|
||||||
|
|
||||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
|
||||||
{total} publicité{total !== 1 ? "s" : ""} au total
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isHistorique && (
|
|
||||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 self-center">
|
|
||||||
{historique.length} opération{historique.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Vue Publicités ── */}
|
|
||||||
{!isHistorique && (
|
|
||||||
<>
|
|
||||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="py-16 text-center text-gray-400 text-sm">Aucune publicité trouvée.</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
|
||||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 w-20">N°</th>
|
|
||||||
<th {...thProps("intitule", "px-3 py-3 text-left text-xs font-semibold text-gray-500")}>
|
|
||||||
Opération <SortIcon col="intitule" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th {...thProps("statut", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
|
||||||
Statut <SortIcon col="statut" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th {...thProps("site", "px-3 py-3 text-left text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
|
||||||
Magasin <SortIcon col="site" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date début</th>
|
|
||||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date fin</th>
|
|
||||||
<th {...thProps("ca_pub_periode_pub", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
|
||||||
CA pub période <SortIcon col="ca_pub_periode_pub" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th {...thProps("pourc_capub_catotal", "px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50")}>
|
|
||||||
% CA total <SortIcon col="pourc_capub_catotal" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th {...thProps("qte_vendue_pub", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
|
||||||
Qté vendue <SortIcon col="qte_vendue_pub" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Clients</th>
|
|
||||||
<th {...thProps("taux_sortie", "px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap")}>
|
|
||||||
Taux sortie <SortIcon col="taux_sortie" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Nb articles</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map((pub, idx) => (
|
|
||||||
<tr key={`${pub.tcr_code}-${pub.site}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
|
||||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{pub.tcr_code}</td>
|
|
||||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{pub.intitule}</td>
|
|
||||||
<td className="px-3 py-2.5"><StatutBadge statut={pub.statut} /></td>
|
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-600 whitespace-nowrap">
|
|
||||||
<span className={`inline-block rounded-md px-2 py-0.5 font-medium ${SITE_COLORS[pub.site] ?? "bg-gray-100 text-gray-600"}`}>
|
|
||||||
{pub.site || "—"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(pub.date_debut)}</td>
|
|
||||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(pub.date_fin)}</td>
|
|
||||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">{fmtCA(pub.ca_pub_periode_pub)}</td>
|
|
||||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right tabular-nums">
|
|
||||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
|
||||||
Number(pub.pourc_capub_catotal) >= 20 ? "bg-orange-100 text-orange-700"
|
|
||||||
: Number(pub.pourc_capub_catotal) >= 10 ? "bg-amber-100 text-amber-700"
|
|
||||||
: "bg-gray-100 text-gray-600"
|
|
||||||
}`}>
|
|
||||||
{fmtPct(pub.pourc_capub_catotal)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.qte_vendue_pub).toLocaleString("fr-FR")}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.client_pub_periode).toLocaleString("fr-FR")}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(pub.taux_sortie).toFixed(1).replace(".", ",")} %</td>
|
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-500 text-xs">{pub.nb_articles}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
|
||||||
<td colSpan={6} className="px-4 py-3 text-sm font-bold text-gray-800">
|
|
||||||
TOTAL — {filtered.length} publicité{filtered.length !== 1 ? "s" : ""} (page {page}/{pages})
|
|
||||||
</td>
|
|
||||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
|
||||||
{fmtCA(filtered.reduce((s, p) => s + Number(p.ca_pub_periode_pub), 0))}
|
|
||||||
</td>
|
|
||||||
<td className="bg-orange-100 px-3 py-3 text-right text-xs font-semibold text-orange-700">—</td>
|
|
||||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
|
||||||
{filtered.reduce((s, p) => s + Number(p.qte_vendue_pub), 0).toLocaleString("fr-FR")}
|
|
||||||
</td>
|
|
||||||
<td colSpan={3} />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Pagination ── */}
|
|
||||||
{pages > 1 && (
|
|
||||||
<div className="flex items-center justify-between rounded-xl bg-white px-5 py-3 shadow-sm border border-gray-100">
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
Page <strong>{page}</strong> sur <strong>{pages}</strong> — {total} publicités
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => nav({ vue, statut, page: page - 1 })}
|
|
||||||
className="flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" /> Préc.
|
|
||||||
</button>
|
|
||||||
{/* Pages around current */}
|
|
||||||
{Array.from({ length: Math.min(pages, 7) }, (_, i) => {
|
|
||||||
const p = pages <= 7 ? i + 1 : Math.max(1, page - 3) + i;
|
|
||||||
if (p > pages) return null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
onClick={() => nav({ vue, statut, page: p })}
|
|
||||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium ${
|
|
||||||
p === page
|
|
||||||
? "bg-orange-500 text-white"
|
|
||||||
: "border border-gray-300 text-gray-600 hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
disabled={page >= pages}
|
|
||||||
onClick={() => nav({ vue, statut, page: page + 1 })}
|
|
||||||
className="flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Suiv. <ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Vue Historique ── */}
|
|
||||||
{isHistorique && (
|
|
||||||
<div className="overflow-x-auto rounded-xl bg-white shadow-sm border border-gray-100">
|
|
||||||
{historique.length === 0 ? (
|
|
||||||
<div className="py-16 text-center text-gray-400 text-sm">Aucun historique disponible.</div>
|
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full border-collapse text-sm">
|
<div className="space-y-3">
|
||||||
<thead>
|
{paginee && (
|
||||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
<p className="flex items-start gap-2 text-[13px] text-[var(--text-secondary)]">
|
||||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 w-20">N°</th>
|
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--text-muted)]" aria-hidden />
|
||||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500">Opération</th>
|
<span>
|
||||||
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500">Statut</th>
|
<span className="font-semibold text-[var(--text-primary)]">
|
||||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date début</th>
|
Tri et filtre magasin appliqués à cette page.
|
||||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Date fin</th>
|
</span>{" "}
|
||||||
<th className="px-3 py-3 text-center text-xs font-semibold text-gray-500 whitespace-nowrap">Sites</th>
|
Les publicités sont chargées par pages de {fmtEntier(pageSize)} (page {fmtEntier(page)} sur{" "}
|
||||||
<th className="px-3 py-3 text-right text-xs font-semibold text-orange-600 whitespace-nowrap bg-orange-50">CA total</th>
|
{fmtEntier(pages)}) : la ligne Total ne porte, elle aussi, que sur la page affichée.
|
||||||
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 whitespace-nowrap">Qté totale</th>
|
</span>
|
||||||
</tr>
|
</p>
|
||||||
</thead>
|
)}
|
||||||
<tbody>
|
<DataTable
|
||||||
{historique.map((h, idx) => (
|
rows={validPublicites}
|
||||||
<tr key={`${h.tcr_code}-${idx}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
columns={colonnesPublicites}
|
||||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-400">{h.tcr_code}</td>
|
rowKey={(p, i) => `${p.tcr_code}-${p.site}-${i}`}
|
||||||
<td className="px-3 py-2.5 text-gray-900 font-medium">{h.intitule}</td>
|
filters={filtresPublicites}
|
||||||
<td className="px-3 py-2.5"><StatutBadge statut={h.statut} /></td>
|
toolbar={selectStatut}
|
||||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(h.date_debut)}</td>
|
pageSize={0}
|
||||||
<td className="px-3 py-2.5 text-center text-xs text-gray-600 whitespace-nowrap tabular-nums">{fmtDate(h.date_fin)}</td>
|
unite="publicités"
|
||||||
<td className="px-3 py-2.5 text-center text-xs text-gray-500">{h.nb_sites}</td>
|
showFooter
|
||||||
<td className="bg-orange-50/60 px-3 py-2.5 text-right font-semibold tabular-nums text-gray-900">{fmtCA(h.ca_total)}</td>
|
initialSort={{ id: "operation", dir: "asc" }}
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums text-gray-700 text-xs">{Number(h.qte_totale).toLocaleString("fr-FR")}</td>
|
emptyTitle="Aucune publicité à afficher"
|
||||||
</tr>
|
emptyDescription={descriptionVide}
|
||||||
))}
|
/>
|
||||||
</tbody>
|
<Pagination
|
||||||
<tfoot>
|
page={page}
|
||||||
<tr className="border-t-2 border-gray-300 bg-gray-50">
|
totalPages={pages}
|
||||||
<td colSpan={6} className="px-4 py-3 text-sm font-bold text-gray-800">
|
total={total}
|
||||||
TOTAL — {historique.length} opérations
|
pageSize={pageSize}
|
||||||
</td>
|
onChange={(p) => naviguer({ page: p })}
|
||||||
<td className="bg-orange-100 px-3 py-3 text-right font-bold tabular-nums text-gray-900">
|
unite="publicités"
|
||||||
{fmtCA(historique.reduce((s, h) => s + Number(h.ca_total), 0))}
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-3 py-3 text-right font-bold tabular-nums text-gray-700 text-xs">
|
)
|
||||||
{historique.reduce((s, h) => s + Number(h.qte_totale), 0).toLocaleString("fr-FR")}
|
) : erreurHistorique ? (
|
||||||
</td>
|
<ErrorState
|
||||||
</tr>
|
title="L'historique n'a pas pu être chargé"
|
||||||
</tfoot>
|
description="Le serveur des publicités n'a pas répondu correctement. Réessayez dans un instant ; si le problème persiste, prévenez un administrateur."
|
||||||
</table>
|
detail={erreurHistorique}
|
||||||
)}
|
action={reessayer}
|
||||||
</div>
|
/>
|
||||||
)}
|
) : (
|
||||||
|
<DataTable
|
||||||
|
rows={historique}
|
||||||
|
columns={colonnesHistorique}
|
||||||
|
rowKey={(h, i) => `${h.tcr_code}-${i}`}
|
||||||
|
searchIn={(h) => [h.intitule, h.tcr_code]}
|
||||||
|
searchPlaceholder="Rechercher une opération ou un code…"
|
||||||
|
filters={filtresHistorique}
|
||||||
|
unite="opérations"
|
||||||
|
showFooter
|
||||||
|
emptyTitle="Aucune opération dans l'historique"
|
||||||
|
emptyDescription="L'historique des opérations publicitaires est vide pour le moment."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Megaphone } from "lucide-react";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { PublicitesClient } from "./client";
|
import { PublicitesClient } from "./client";
|
||||||
import { cachedFF } from "@/lib/ff-cache";
|
import { cachedFF } from "@/lib/ff-cache";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Publicités" };
|
||||||
|
|
||||||
export interface Publicite {
|
export interface Publicite {
|
||||||
tcr_code: string;
|
tcr_code: string;
|
||||||
intitule: string;
|
intitule: string;
|
||||||
@@ -114,27 +119,25 @@ export default async function PublicitesPage(props: {
|
|||||||
fetchHistorique(),
|
fetchHistorique(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const error = pubResult.error ?? histoResult.error;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
<div className="mx-auto max-w-screen-xl">
|
<PageHeader
|
||||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Publicités</h1>
|
icon={Megaphone}
|
||||||
{error && (
|
title="Publicités"
|
||||||
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">
|
description="Résultats des opérations publicitaires (en cours, passées, à venir) et leur poids dans les ventes de vos magasins."
|
||||||
{error}
|
/>
|
||||||
</div>
|
<PublicitesClient
|
||||||
)}
|
vueInitiale={vue === "historique" ? "historique" : "publicites"}
|
||||||
<PublicitesClient
|
statut={statut}
|
||||||
vue={vue}
|
page={page}
|
||||||
statut={statut}
|
pageSize={PAGE_SIZE}
|
||||||
page={page}
|
publicites={pubResult.data}
|
||||||
publicites={pubResult.data}
|
total={pubResult.total}
|
||||||
total={pubResult.total}
|
pages={pubResult.pages}
|
||||||
pages={pubResult.pages}
|
erreurPublicites={pubResult.error}
|
||||||
historique={histoResult.data}
|
historique={histoResult.data}
|
||||||
/>
|
erreurHistorique={histoResult.error}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileText,
|
||||||
|
KeyRound,
|
||||||
|
Loader2,
|
||||||
|
PlugZap,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { Tabs, useUrlTab, type TabItem } from "@/components/ui/tabs";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input, Label } from "@/components/ui/form-controls";
|
||||||
|
import { Terme } from "@/components/ui/tooltip";
|
||||||
|
import { toast } from "@/components/ui/feedback";
|
||||||
|
import { fmtEntier } from "@/lib/format";
|
||||||
|
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||||
|
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
||||||
|
import {
|
||||||
|
testDatabaseConnection,
|
||||||
|
saveDatabaseSettings,
|
||||||
|
getSavedDatabaseConfig,
|
||||||
|
saveQlikSettings,
|
||||||
|
testQlikConnection,
|
||||||
|
saveFfApiSettings,
|
||||||
|
testFfApiConnection,
|
||||||
|
} from "@/features/settings/actions";
|
||||||
|
import { UserManagement } from "@/features/admin/components/user-management";
|
||||||
|
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
|
||||||
|
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
|
||||||
|
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||||
|
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
|
||||||
|
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
|
||||||
|
* serveur s'exécutent les unes après les autres.
|
||||||
|
*/
|
||||||
|
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
|
||||||
|
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
|
||||||
|
if (force || !savedConfigPromise) {
|
||||||
|
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
|
||||||
|
savedConfigPromise = null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return savedConfigPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message lisible d'une erreur de server action (onglet périmé compris). */
|
||||||
|
function messageErreur(e: unknown): string {
|
||||||
|
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateHeure(valeur: string | null | undefined): string {
|
||||||
|
if (!valeur) return "—";
|
||||||
|
const d = new Date(valeur);
|
||||||
|
return isNaN(d.getTime()) ? valeur : d.toLocaleString("fr-FR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Onglet = "connexions" | "utilisateurs" | "api" | "journal";
|
||||||
|
|
||||||
|
const ONGLETS: readonly TabItem<Onglet>[] = [
|
||||||
|
{ value: "connexions", label: "Connexions", icon: PlugZap },
|
||||||
|
{ value: "utilisateurs", label: "Utilisateurs", icon: Users },
|
||||||
|
{ value: "api", label: "Accès API", icon: KeyRound },
|
||||||
|
{ value: "journal", label: "Journal serveur", icon: FileText },
|
||||||
|
];
|
||||||
|
|
||||||
|
function estOnglet(v: string | undefined): v is Onglet {
|
||||||
|
return ONGLETS.some((o) => o.value === v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu d'un onglet : monté à la première visite puis seulement masqué, pour
|
||||||
|
* qu'une saisie non enregistrée survive au changement d'onglet et que rien ne
|
||||||
|
* soit rechargé en revenant.
|
||||||
|
*/
|
||||||
|
function Panneau({ actif, visite, children }: { actif: boolean; visite: boolean; children: ReactNode }) {
|
||||||
|
if (!visite) return null;
|
||||||
|
return (
|
||||||
|
<div role="tabpanel" hidden={!actif} className="space-y-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Éléments de formulaire ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Champ({ id, label, aide, children, className }: {
|
||||||
|
id: string;
|
||||||
|
label: ReactNode;
|
||||||
|
aide?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{aide && <p className="mt-1 text-xs text-[var(--text-muted)]">{aide}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EtatTest = { etat: "idle" } | { etat: "en-cours" } | { etat: "ok" } | { etat: "erreur"; message: string };
|
||||||
|
|
||||||
|
/** Résultat d'un test de connexion, affiché à côté des boutons. */
|
||||||
|
function ResultatTest({ test }: { test: EtatTest }) {
|
||||||
|
if (test.etat === "ok") {
|
||||||
|
return (
|
||||||
|
<Badge ton="succes">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Connexion réussie
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (test.etat === "erreur") {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="flex items-start gap-1.5 text-[13px] text-[var(--accent-error)]">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">Connexion impossible.</span>{" "}
|
||||||
|
<span className="text-[var(--text-secondary)]">{test.message}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||||
|
const {
|
||||||
|
host, setHost,
|
||||||
|
port, setPort,
|
||||||
|
database, setDatabase,
|
||||||
|
user, setUser,
|
||||||
|
password, setPassword,
|
||||||
|
ssl, setSsl,
|
||||||
|
getDatabaseUrl,
|
||||||
|
} = useDbSettingsStore();
|
||||||
|
|
||||||
|
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||||
|
const [enregistrement, setEnregistrement] = useState(false);
|
||||||
|
const [rechargement, setRechargement] = useState(false);
|
||||||
|
|
||||||
|
const tester = async () => {
|
||||||
|
setTest({ etat: "en-cours" });
|
||||||
|
try {
|
||||||
|
const res = await testDatabaseConnection(getDatabaseUrl());
|
||||||
|
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." });
|
||||||
|
} catch (e) {
|
||||||
|
setTest({ etat: "erreur", message: messageErreur(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enregistrer = async () => {
|
||||||
|
setEnregistrement(true);
|
||||||
|
try {
|
||||||
|
const res = await saveDatabaseSettings(getDatabaseUrl());
|
||||||
|
if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée.");
|
||||||
|
else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||||
|
} finally {
|
||||||
|
setEnregistrement(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rechargerConfig = async () => {
|
||||||
|
setRechargement(true);
|
||||||
|
try {
|
||||||
|
await recharger();
|
||||||
|
toast.info("Les valeurs enregistrées sur le serveur ont été rechargées.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.erreur(`La configuration n'a pas pu être relue : ${messageErreur(e)}`);
|
||||||
|
} finally {
|
||||||
|
setRechargement(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Base de données de l'application (PostgreSQL)"
|
||||||
|
description="Contient les comptes utilisateurs, l'historique des sessions et les réglages. Les produits et les ventes, eux, viennent de l'API FF Nancy."
|
||||||
|
/>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Champ
|
||||||
|
id="pg-hote"
|
||||||
|
label="Serveur"
|
||||||
|
aide="Adresse du serveur : localhost, une adresse IP (192.168.1.10) ou le nom du service Docker (postgres, db)."
|
||||||
|
className="sm:col-span-2"
|
||||||
|
>
|
||||||
|
<Input id="pg-hote" className="w-full font-mono" placeholder="localhost" value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)} />
|
||||||
|
</Champ>
|
||||||
|
<Champ id="pg-port" label="Port" aide="5432 dans la plupart des installations.">
|
||||||
|
<Input id="pg-port" className="w-full font-mono" placeholder="5432" value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)} />
|
||||||
|
</Champ>
|
||||||
|
<Champ id="pg-base" label="Nom de la base">
|
||||||
|
<Input id="pg-base" className="w-full font-mono" placeholder="collectflow" value={database}
|
||||||
|
onChange={(e) => setDatabase(e.target.value)} />
|
||||||
|
</Champ>
|
||||||
|
<Champ id="pg-utilisateur" label="Utilisateur">
|
||||||
|
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
|
||||||
|
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
|
||||||
|
</Champ>
|
||||||
|
<Champ id="pg-mdp" label="Mot de passe">
|
||||||
|
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
|
||||||
|
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||||||
|
</Champ>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pg-ssl" className="inline-flex cursor-pointer items-center gap-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="pg-ssl"
|
||||||
|
checked={ssl}
|
||||||
|
onChange={(e) => setSsl(e.target.checked)}
|
||||||
|
className="h-4 w-4 cursor-pointer accent-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
Connexion chiffrée (SSL)
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
|
À cocher si le serveur de base de données l'exige, en général quand il est hébergé à distance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||||
|
<Button onClick={enregistrer} disabled={enregistrement}>
|
||||||
|
{enregistrement ? <Loader2 className="animate-spin" /> : <Save />}
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={tester} disabled={test.etat === "en-cours"}>
|
||||||
|
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
|
Tester la connexion
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={rechargerConfig} disabled={rechargement}>
|
||||||
|
{rechargement ? <Loader2 className="animate-spin" /> : <RotateCcw />}
|
||||||
|
Recharger les valeurs enregistrées
|
||||||
|
</Button>
|
||||||
|
<ResultatTest test={test} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API FF Nancy ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
|
||||||
|
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
|
||||||
|
|
||||||
|
function SectionFfApi() {
|
||||||
|
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [savedUrl, setSavedUrl] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testedUrl, setTestedUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
||||||
|
useEffect(() => {
|
||||||
|
loadSavedConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||||
|
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||||
|
})
|
||||||
|
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runTest = useCallback((candidate?: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
testFfApiConnection(candidate)
|
||||||
|
.then((res) => {
|
||||||
|
setTestedUrl(res.url);
|
||||||
|
if (res.success) setStatus(res.status as FfSyncStatus);
|
||||||
|
else setError(res.error ?? "Échec inconnu.");
|
||||||
|
})
|
||||||
|
.catch((e) => setError(messageErreur(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { runTest(); }, [runTest]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await saveFfApiSettings(url);
|
||||||
|
if (!res.success) {
|
||||||
|
toast.erreur(res.error ?? "Enregistrement impossible.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavedUrl(url.trim() || null);
|
||||||
|
toast.succes(url.trim() ? "Adresse de l'API FF enregistrée." : "L'adresse par défaut de l'API FF sera utilisée.");
|
||||||
|
runTest(url);
|
||||||
|
} catch (e) {
|
||||||
|
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const test: EtatTest = loading ? { etat: "en-cours" } : status ? { etat: "ok" } : error ? { etat: "erreur", message: error } : { etat: "idle" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="API FF Nancy"
|
||||||
|
description="Source des produits et des ventes de nos magasins. Ses données sont mises à jour chaque nuit depuis la base FF : elles s'arrêtent donc à la veille."
|
||||||
|
/>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Champ
|
||||||
|
id="ff-url"
|
||||||
|
label="Adresse de l'API"
|
||||||
|
aide="Laissez vide pour utiliser l'adresse par défaut (https://api.ffnancy.fr)."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="ff-url"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://api.ffnancy.fr"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
|
||||||
|
className="w-full font-mono"
|
||||||
|
/>
|
||||||
|
</Champ>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||||
|
<Button onClick={save} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => runTest(url)} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
|
Tester la connexion
|
||||||
|
</Button>
|
||||||
|
<ResultatTest test={test} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
|
||||||
|
alors que le réglage enregistré ou la variable d'environnement prime. */}
|
||||||
|
{testedUrl && (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
Adresse testée : <span className="font-mono">{testedUrl}</span>
|
||||||
|
{!savedUrl && <> (adresse par défaut, aucun réglage enregistré)</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Dernière mise à jour complète des données :{" "}
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">{fmtDateHeure(status.lastSync)}</span>
|
||||||
|
</p>
|
||||||
|
{status.tables?.length > 0 && (
|
||||||
|
<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">Table de données</th>
|
||||||
|
<th scope="col" className="px-3 py-2 text-right">Mise à jour</th>
|
||||||
|
<th scope="col" className="px-3 py-2 text-right">Lignes</th>
|
||||||
|
<th scope="col" className="px-3 py-2 text-right">État</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{status.tables.map((t) => (
|
||||||
|
<tr key={t.nom} className="border-t border-[var(--border)]">
|
||||||
|
<td className="px-3 py-2 text-sm font-mono text-[var(--text-primary)]">{t.nom}</td>
|
||||||
|
<td className="px-3 py-2 text-sm text-right text-[var(--text-secondary)]">{fmtDateHeure(t.derniereSync)}</td>
|
||||||
|
<td className="px-3 py-2 text-sm text-right tabular-nums text-[var(--text-secondary)]">
|
||||||
|
{t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"}
|
||||||
|
</td>
|
||||||
|
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
|
||||||
|
<td className="px-3 py-2 text-sm text-right">
|
||||||
|
{t.statut === "ok"
|
||||||
|
? <Badge ton="succes">OK</Badge>
|
||||||
|
: <Badge ton="erreur" title={t.erreur ?? undefined}>{t.statut ?? "—"}</Badge>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Qlik Sense ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionQlik() {
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [user, setUser] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPwd, setShowPwd] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSavedConfig()
|
||||||
|
.then((c) => {
|
||||||
|
if (!c) return;
|
||||||
|
setHost(c.qlikHost ?? "");
|
||||||
|
setUser(c.qlikUser ?? "");
|
||||||
|
setPassword(c.qlikPassword ?? "");
|
||||||
|
})
|
||||||
|
.catch(() => { /* l'échec de lecture est signalé par la page */ });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await saveQlikSettings(host.trim(), user.trim(), password);
|
||||||
|
if (res.success) toast.succes("Identifiants Qlik enregistrés.");
|
||||||
|
else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTest({ etat: "en-cours" });
|
||||||
|
try {
|
||||||
|
const res = await testQlikConnection(host.trim(), user.trim(), password);
|
||||||
|
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." });
|
||||||
|
} catch (e) {
|
||||||
|
setTest({ etat: "erreur", message: messageErreur(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testImpossible = !user || !password;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Qlik Sense (données du réseau)"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Identifiants utilisés pour récupérer les ventes des magasins du{" "}
|
||||||
|
<Terme id="reseau">réseau</Terme> dans l'outil de reporting Qlik Sense de La Foir'Fouille.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Champ
|
||||||
|
id="qlik-hote"
|
||||||
|
label="Serveur Qlik"
|
||||||
|
aide="Laissez vide pour utiliser le serveur habituel : reporting-magasins.lafoirfouille.fr."
|
||||||
|
>
|
||||||
|
<Input id="qlik-hote" type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)} className="w-full font-mono" />
|
||||||
|
</Champ>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Champ
|
||||||
|
id="qlik-utilisateur"
|
||||||
|
label="Identifiant"
|
||||||
|
aide="Identifiant Windows sans le domaine, par exemple FFSCH (connexion par authentification Windows, dite NTLM)."
|
||||||
|
>
|
||||||
|
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
|
||||||
|
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
|
||||||
|
</Champ>
|
||||||
|
<Champ id="qlik-mdp" label="Mot de passe">
|
||||||
|
<div className="relative">
|
||||||
|
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setShowPwd((v) => !v)}
|
||||||
|
aria-label={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
||||||
|
title={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
||||||
|
className="absolute right-0.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
|
||||||
|
>
|
||||||
|
{showPwd ? <EyeOff /> : <Eye />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Champ>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={test.etat === "en-cours" || testImpossible}
|
||||||
|
title={testImpossible ? "Renseignez l'identifiant et le mot de passe pour tester" : undefined}
|
||||||
|
>
|
||||||
|
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
|
Tester la connexion
|
||||||
|
</Button>
|
||||||
|
<ResultatTest test={test} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) {
|
||||||
|
const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions";
|
||||||
|
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
|
||||||
|
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => new Set([initial]));
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
const setHost = useDbSettingsStore((s) => s.setHost);
|
||||||
|
const setPort = useDbSettingsStore((s) => s.setPort);
|
||||||
|
const setDatabase = useDbSettingsStore((s) => s.setDatabase);
|
||||||
|
const setUser = useDbSettingsStore((s) => s.setUser);
|
||||||
|
const setPassword = useDbSettingsStore((s) => s.setPassword);
|
||||||
|
const setSsl = useDbSettingsStore((s) => s.setSsl);
|
||||||
|
|
||||||
|
const reloadFromServer = useCallback(async (force = false) => {
|
||||||
|
const config = await loadSavedConfig(force);
|
||||||
|
if (config?.url) {
|
||||||
|
// Parser l'URL pour remettre dans le store
|
||||||
|
try {
|
||||||
|
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
|
||||||
|
setHost(url.hostname);
|
||||||
|
setPort(url.port || "5432");
|
||||||
|
setDatabase(url.pathname.slice(1).split("?")[0]);
|
||||||
|
setUser(url.username);
|
||||||
|
setPassword(decodeURIComponent(url.password));
|
||||||
|
setSsl(config.url.includes("sslmode=require"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse saved URL", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL
|
||||||
|
// lus dans le localStorage) : il doit donc être posé au montage, ce que
|
||||||
|
// la règle déconseille en général mais qui est ici le but.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setIsMounted(true);
|
||||||
|
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
|
||||||
|
// juste après (cf. `isMounted`), réutilisent cette même requête.
|
||||||
|
reloadFromServer(true).catch((e) => {
|
||||||
|
toast.erreur(`La configuration enregistrée n'a pas pu être lue : ${messageErreur(e)}`);
|
||||||
|
});
|
||||||
|
}, [reloadFromServer]);
|
||||||
|
|
||||||
|
const changerOnglet = (suivant: Onglet) => {
|
||||||
|
setOnglet(suivant);
|
||||||
|
setVisites((prev) => (prev.has(suivant) ? prev : new Set([...prev, suivant])));
|
||||||
|
};
|
||||||
|
|
||||||
|
const recharger = useCallback(() => reloadFromServer(true), [reloadFromServer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl">
|
||||||
|
<PageHeader
|
||||||
|
icon={Settings}
|
||||||
|
title="Paramètres"
|
||||||
|
description="Connexions aux sources de données, comptes des utilisateurs et clés d'accès à l'API (réservé aux administrateurs)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs items={ONGLETS} value={onglet} onChange={changerOnglet} className="mb-6" />
|
||||||
|
|
||||||
|
{isMounted && (
|
||||||
|
<>
|
||||||
|
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
|
||||||
|
<SectionPostgres recharger={recharger} />
|
||||||
|
<SectionFfApi />
|
||||||
|
<SectionQlik />
|
||||||
|
</Panneau>
|
||||||
|
|
||||||
|
<Panneau actif={onglet === "utilisateurs"} visite={visites.has("utilisateurs")}>
|
||||||
|
<UserManagement />
|
||||||
|
</Panneau>
|
||||||
|
|
||||||
|
<Panneau actif={onglet === "api"} visite={visites.has("api")}>
|
||||||
|
<ApiConnectionInfo />
|
||||||
|
<ApiKeyManagement />
|
||||||
|
<GridWarmup />
|
||||||
|
</Panneau>
|
||||||
|
|
||||||
|
<Panneau actif={onglet === "journal"} visite={visites.has("journal")}>
|
||||||
|
<ServerLogs />
|
||||||
|
</Panneau>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,544 +1,16 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
import { ParametresClient } from "./client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
export const metadata: Metadata = { title: "Paramètres" };
|
||||||
import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, RotateCcw } from "lucide-react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
|
||||||
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
|
||||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { UserManagement } from "@/features/admin/components/user-management";
|
|
||||||
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
|
|
||||||
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
|
|
||||||
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
|
||||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
|
* Paramètres (administrateurs) : connexions aux sources de données, comptes
|
||||||
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
|
* utilisateurs, accès à l'API et journal serveur, rangés en onglets.
|
||||||
* serveur s'exécutent les unes après les autres.
|
*
|
||||||
|
* Composant serveur minimal : il lit l'onglet demandé (`?onglet=`) pour que le
|
||||||
|
* lien se partage et survive au rechargement, puis rend la page cliente.
|
||||||
*/
|
*/
|
||||||
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
|
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
|
||||||
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
|
const { onglet } = await props.searchParams;
|
||||||
if (force || !savedConfigPromise) {
|
return <ParametresClient onglet={onglet} />;
|
||||||
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
|
|
||||||
savedConfigPromise = null;
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return savedConfigPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<section className="apple-card">
|
|
||||||
<div className="apple-card-header">
|
|
||||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h2>
|
|
||||||
{subtitle && <p className="text-[12px] mt-0.5 text-[var(--text-secondary)]">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="apple-card-content space-y-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[12px] font-medium text-[var(--text-secondary)]">{label}</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-[11px] text-[var(--text-muted)]">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
|
|
||||||
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
|
|
||||||
|
|
||||||
function FfApiStatusSection() {
|
|
||||||
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [url, setUrl] = useState("");
|
|
||||||
const [savedUrl, setSavedUrl] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [testedUrl, setTestedUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
|
||||||
useEffect(() => {
|
|
||||||
loadSavedConfig()
|
|
||||||
.then((cfg) => {
|
|
||||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
|
||||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
|
||||||
})
|
|
||||||
.catch(() => { /* réglage optionnel : on laisse le champ vide */ });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const runTest = useCallback((candidate?: string) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setStatus(null);
|
|
||||||
testFfApiConnection(candidate)
|
|
||||||
.then((res) => {
|
|
||||||
setTestedUrl(res.url);
|
|
||||||
if (res.success) setStatus(res.status as FfSyncStatus);
|
|
||||||
else setError(res.error ?? "Échec inconnu");
|
|
||||||
})
|
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : "Erreur inconnue"))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { runTest(); }, [runTest]);
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await saveFfApiSettings(url);
|
|
||||||
if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; }
|
|
||||||
setSavedUrl(url.trim() || null);
|
|
||||||
runTest(url);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
|
|
||||||
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="https://api.ffnancy.fr"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
|
|
||||||
className="apple-input w-full"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<button onClick={save} disabled={saving} className="apple-btn h-9 px-4">
|
|
||||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
<button onClick={() => runTest(url)} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
|
||||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
|
||||||
Tester
|
|
||||||
</button>
|
|
||||||
{status && <span className="text-[12px] text-emerald-400 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5" /> Connexion OK</span>}
|
|
||||||
{error && <span className="text-[12px] text-red-400 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> {error}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
|
|
||||||
alors que le réglage enregistré ou la variable d'environnement prime. */}
|
|
||||||
{testedUrl && (
|
|
||||||
<p className="text-[11px] text-[var(--text-muted)]">
|
|
||||||
Adresse testée : <span className="font-mono">{testedUrl}</span>
|
|
||||||
{!savedUrl && <> — valeur par défaut, aucun réglage enregistré</>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status && (
|
|
||||||
<div className="space-y-2 mt-2">
|
|
||||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
|
||||||
Dernière sync globale : <span className="font-mono font-medium">{(() => { const d = new Date(status.lastSync); return isNaN(d.getTime()) ? (status.lastSync ?? "—") : d.toLocaleString("fr-FR"); })()}</span>
|
|
||||||
</p>
|
|
||||||
{status.tables?.length > 0 && (
|
|
||||||
<div className="rounded-lg overflow-hidden border border-[var(--border-subtle)]">
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[var(--surface-tertiary)]">
|
|
||||||
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
|
|
||||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
|
|
||||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
|
|
||||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{status.tables.map((t) => (
|
|
||||||
<tr key={t.nom} className="border-t border-[var(--border-subtle)]">
|
|
||||||
<td className="px-3 py-2 font-mono text-[var(--text-primary)]">{t.nom}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-[var(--text-secondary)]">
|
|
||||||
{(() => { const d = new Date(t.derniereSync); return isNaN(d.getTime()) ? (t.derniereSync ?? "—") : d.toLocaleString("fr-FR"); })()}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
|
|
||||||
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
|
|
||||||
</td>
|
|
||||||
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
{t.statut === "ok"
|
|
||||||
? <span className="text-emerald-400">ok</span>
|
|
||||||
: <span className="text-red-400" title={t.erreur ?? undefined}>{t.statut ?? "—"}</span>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function QlikSettingsSection() {
|
|
||||||
const [host, setHost] = useState("");
|
|
||||||
const [user, setUser] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [showPwd, setShowPwd] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSavedConfig().then((c) => {
|
|
||||||
if (!c) return;
|
|
||||||
setHost(c.qlikHost ?? "");
|
|
||||||
setUser(c.qlikUser ?? "");
|
|
||||||
setPassword(c.qlikPassword ?? "");
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setStatus(null);
|
|
||||||
const res = await saveQlikSettings(host.trim(), user.trim(), password);
|
|
||||||
setSaving(false);
|
|
||||||
setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTest = async () => {
|
|
||||||
setTesting(true);
|
|
||||||
setStatus(null);
|
|
||||||
const res = await testQlikConnection(host.trim(), user.trim(), password);
|
|
||||||
setTesting(false);
|
|
||||||
setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
|
|
||||||
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
|
|
||||||
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
|
|
||||||
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
|
|
||||||
<input type="text" placeholder="FFSCH" value={user}
|
|
||||||
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Mot de passe">
|
|
||||||
<div className="relative">
|
|
||||||
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
|
|
||||||
<button type="button" onClick={() => setShowPwd((v) => !v)}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
|
|
||||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
{status && (
|
|
||||||
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
|
|
||||||
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
|
||||||
{status.msg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={handleSave} disabled={saving}
|
|
||||||
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
|
|
||||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
<button onClick={handleTest} disabled={testing || !user || !password}
|
|
||||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
|
||||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
|
||||||
Tester
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
|
||||||
const isDark = resolvedTheme === "dark";
|
|
||||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
|
||||||
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
|
|
||||||
|
|
||||||
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
|
|
||||||
const [dbError, setDbError] = useState<string | null>(null);
|
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
host, setHost,
|
|
||||||
port, setPort,
|
|
||||||
database, setDatabase,
|
|
||||||
user, setUser,
|
|
||||||
password, setPassword,
|
|
||||||
ssl, setSsl,
|
|
||||||
getDatabaseUrl
|
|
||||||
} = useDbSettingsStore();
|
|
||||||
|
|
||||||
const reloadFromServer = useCallback(async (force = false) => {
|
|
||||||
const config = await loadSavedConfig(force);
|
|
||||||
if (config) {
|
|
||||||
if (config.url) {
|
|
||||||
// Parser l'URL pour remettre dans le store
|
|
||||||
try {
|
|
||||||
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
|
|
||||||
setHost(url.hostname);
|
|
||||||
setPort(url.port || "5432");
|
|
||||||
setDatabase(url.pathname.slice(1).split("?")[0]);
|
|
||||||
setUser(url.username);
|
|
||||||
setPassword(decodeURIComponent(url.password));
|
|
||||||
setSsl(config.url.includes("sslmode=require"));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse saved URL", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// `isMounted` sert à éviter un écart d'hydratation : il doit donc être posé
|
|
||||||
// au montage, ce que la règle déconseille en général mais qui est ici le but.
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setIsMounted(true);
|
|
||||||
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
|
|
||||||
// juste après (cf. `isMounted`), réutilisent cette même requête.
|
|
||||||
reloadFromServer(true);
|
|
||||||
}, [reloadFromServer]);
|
|
||||||
|
|
||||||
const testDb = async () => {
|
|
||||||
setDbStatus("testing");
|
|
||||||
setDbError(null);
|
|
||||||
const url = getDatabaseUrl();
|
|
||||||
const res = await testDatabaseConnection(url);
|
|
||||||
if (res.success) {
|
|
||||||
setDbStatus("ok");
|
|
||||||
} else {
|
|
||||||
setDbStatus("error");
|
|
||||||
setDbError(res.error || "Erreur de connexion inconnue");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaveStatus("saving");
|
|
||||||
const url = getDatabaseUrl();
|
|
||||||
const res = await saveDatabaseSettings(url);
|
|
||||||
if (res.success) {
|
|
||||||
setSaveStatus("saved");
|
|
||||||
setTimeout(() => setSaveStatus("idle"), 2500);
|
|
||||||
} else {
|
|
||||||
setSaveStatus("idle");
|
|
||||||
alert("Erreur lors de la sauvegarde : " + res.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-xl">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-[22px] font-bold tracking-[-0.4px] text-[var(--text-primary)]">Paramètres</h1>
|
|
||||||
<p className="text-[13px] mt-1 text-[var(--text-secondary)]">Configuration de la connexion et des services.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PostgreSQL */}
|
|
||||||
<Section title="PostgreSQL — Auth & Historique" subtitle="Connexion pour les comptes utilisateurs, snapshots et contexte IA. Les données produits proviennent de l'API FF Nancy.">
|
|
||||||
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="localhost"
|
|
||||||
value={host}
|
|
||||||
onChange={(e) => setHost(e.target.value)}
|
|
||||||
className="apple-input font-mono"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Field label="Port">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="5432"
|
|
||||||
value={port}
|
|
||||||
onChange={(e) => setPort(e.target.value)}
|
|
||||||
className="apple-input font-mono"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Base de données">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="collectflow"
|
|
||||||
value={database}
|
|
||||||
onChange={(e) => setDatabase(e.target.value)}
|
|
||||||
className="apple-input font-mono"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Field label="Utilisateur">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="postgres"
|
|
||||||
value={user}
|
|
||||||
onChange={(e) => setUser(e.target.value)}
|
|
||||||
className="apple-input font-mono"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Mot de passe">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password || ""}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="apple-input font-mono"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="ssl"
|
|
||||||
checked={ssl}
|
|
||||||
onChange={(e) => setSsl(e.target.checked)}
|
|
||||||
className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<label htmlFor="ssl" className="text-[12px] cursor-pointer text-[var(--text-secondary)]">Connexion SSL</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button onClick={testDb} disabled={dbStatus === "testing"} className="apple-btn-secondary h-9 px-4">
|
|
||||||
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
|
||||||
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
|
|
||||||
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
|
|
||||||
Tester la connexion
|
|
||||||
</button>
|
|
||||||
{dbStatus === "error" && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[12px] text-red-400 font-medium">Connexion refusée</span>
|
|
||||||
{dbError && <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{dbError}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{dbStatus === "ok" && (
|
|
||||||
<span className="text-[12px] text-teal-500 font-medium">Connexion réussie !</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="pt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => reloadFromServer(true)}
|
|
||||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3 h-3" />
|
|
||||||
Recharger la configuration enregistrée sur le serveur
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* API FF Nancy */}
|
|
||||||
<FfApiStatusSection />
|
|
||||||
|
|
||||||
<QlikSettingsSection />
|
|
||||||
|
|
||||||
{/* Apparence */}
|
|
||||||
<Section title="Apparence">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-[13px] font-medium text-[var(--text-primary)]">Thème</p>
|
|
||||||
<p className="text-[12px] text-[var(--text-secondary)]">Interface claire ou sombre</p>
|
|
||||||
</div>
|
|
||||||
{/* Segment control */}
|
|
||||||
<div className="segment-control">
|
|
||||||
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
|
|
||||||
<Sun className="w-3.5 h-3.5" /> Clair
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
|
|
||||||
<Moon className="w-3.5 h-3.5" /> Sombre
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-[13px] font-medium text-[var(--text-primary)]">Densité de la grille</p>
|
|
||||||
<p className="text-[12px] text-[var(--text-secondary)]">Hauteur de ligne par défaut</p>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
className="apple-input w-auto"
|
|
||||||
value={displayDensity}
|
|
||||||
onChange={(e) => setDisplayDensity(e.target.value as "compact" | "normal" | "comfortable")}
|
|
||||||
>
|
|
||||||
<option value="compact">Compact (32px)</option>
|
|
||||||
<option value="normal">Normal (40px)</option>
|
|
||||||
<option value="comfortable">Spacieux (48px)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Journaux serveur — diagnostic des extractions Qlik */}
|
|
||||||
<Section
|
|
||||||
title="Journaux serveur"
|
|
||||||
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
|
|
||||||
>
|
|
||||||
<ServerLogs />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Gestion des Utilisateurs */}
|
|
||||||
<Section
|
|
||||||
title="Gestion des Utilisateurs"
|
|
||||||
subtitle="Administration des accès et des rôles"
|
|
||||||
>
|
|
||||||
<UserManagement />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* API publique /api/v1 — connexion puis gestion des clés */}
|
|
||||||
<Section
|
|
||||||
title="API CollectFlow — Connexion"
|
|
||||||
subtitle="Tout ce qu'il faut pour appeler l'API depuis un script ou un outil externe"
|
|
||||||
>
|
|
||||||
<ApiConnectionInfo />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
title="Clés d'API"
|
|
||||||
subtitle="Créer et révoquer les clés d'accès à /api/v1"
|
|
||||||
>
|
|
||||||
<ApiKeyManagement />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
title="Données exposées à l'API"
|
|
||||||
subtitle="Calculer l'instantané de tous les fournisseurs pour qu'une IA externe y ait accès"
|
|
||||||
>
|
|
||||||
<GridWarmup />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Save Button — Floating/Sticky style at bottom */}
|
|
||||||
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saveStatus === "saving"}
|
|
||||||
className={`apple-btn-primary min-w-[240px] shadow-lg ${saveStatus === "saving" ? "animate-pulse" : saveStatus === "saved" ? "animate-apple-save" : ""}`}
|
|
||||||
style={{ background: saveStatus === "saved" ? "var(--accent-success)" : "var(--brand-solid)" }}
|
|
||||||
>
|
|
||||||
{saveStatus === "saving" ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : saveStatus === "saved" ? (
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-1">
|
|
||||||
{saveStatus === "saving"
|
|
||||||
? "Enregistrement en cours..."
|
|
||||||
: saveStatus === "saved"
|
|
||||||
? "Configuration enregistrée !"
|
|
||||||
: "Sauvegarder la configuration"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -2,28 +2,20 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
|
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
|
||||||
import {
|
import { Loader2 } from "lucide-react";
|
||||||
ChevronUp,
|
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
|
||||||
ChevronDown,
|
import { Tabs, type TabItem } from "@/components/ui/tabs";
|
||||||
ChevronsUpDown,
|
import { Select } from "@/components/ui/form-controls";
|
||||||
ChevronLeft,
|
import { StoreBadge } from "@/components/ui/badge";
|
||||||
ChevronRight,
|
import { Terme } from "@/components/ui/tooltip";
|
||||||
ChevronsLeft,
|
import { MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||||
ChevronsRight,
|
import { fmtDecimal1, fmtEntier } from "@/lib/format";
|
||||||
Download,
|
import { cn } from "@/lib/utils";
|
||||||
Search,
|
|
||||||
} from "lucide-react";
|
|
||||||
import LoadingModal from "@/components/shared/loading-modal";
|
|
||||||
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page";
|
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page";
|
||||||
|
|
||||||
type SortDir = "asc" | "desc";
|
|
||||||
|
|
||||||
/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */
|
/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
/** Un seul collateur réutilisé : `localeCompare` en crée un par appel (très coûteux sur 17 000 lignes). */
|
|
||||||
const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
|
|
||||||
|
|
||||||
function fmtDate(raw: string | null) {
|
function fmtDate(raw: string | null) {
|
||||||
if (!raw) return "—";
|
if (!raw) return "—";
|
||||||
const d = new Date(raw);
|
const d = new Date(raw);
|
||||||
@@ -40,7 +32,7 @@ function getYear(raw: string | null): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
|
* Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
|
||||||
* Évite le clignotement du modal sur les transitions instantanées.
|
* Évite le clignotement de l'indicateur sur les transitions instantanées.
|
||||||
*/
|
*/
|
||||||
function useDelayedFlag(active: boolean, delay = 120) {
|
function useDelayedFlag(active: boolean, delay = 120) {
|
||||||
const [elapsed, setElapsed] = useState(false);
|
const [elapsed, setElapsed] = useState(false);
|
||||||
@@ -63,17 +55,15 @@ function useDelayedFlag(active: boolean, delay = 120) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export)
|
// Export Excel — format d'import de régularisation dans FF.
|
||||||
|
//
|
||||||
|
// En-têtes (CODEIN / GENCODE / QTE / CODMV), codes mouvement (503 / 412),
|
||||||
|
// colonnes, valeurs et nom de fichier : à ne pas modifier, le fichier est
|
||||||
|
// importé tel quel dans FF. (`telechargerExcel` n'est pas utilisé : il fige la
|
||||||
|
// ligne d'en-tête et renseigne les propriétés du classeur, le fichier ne
|
||||||
|
// serait donc plus strictement identique.)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface Column<T> {
|
|
||||||
key: Extract<keyof T, string>;
|
|
||||||
label: string;
|
|
||||||
/** Tri numérique au lieu du tri alphabétique. */
|
|
||||||
numeric?: boolean;
|
|
||||||
render?: (row: T) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExcelSpec<T> {
|
interface ExcelSpec<T> {
|
||||||
sheet: string;
|
sheet: string;
|
||||||
filePrefix: string;
|
filePrefix: string;
|
||||||
@@ -82,432 +72,243 @@ interface ExcelSpec<T> {
|
|||||||
row: (r: T) => (string | number)[];
|
row: (r: T) => (string | number)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StockTableProps<T> {
|
async function exporterPourFF<T>(excel: ExcelSpec<T>, rows: readonly T[], magasin: string) {
|
||||||
rows: T[];
|
const ExcelJS = (await import("exceljs")).default;
|
||||||
magasin: string;
|
const wb = new ExcelJS.Workbook();
|
||||||
columns: Column<T>[];
|
const ws = wb.addWorksheet(excel.sheet);
|
||||||
fournisseurOf: (r: T) => string;
|
ws.addRow(excel.header);
|
||||||
/** Source du filtre « Année » — omis, le filtre n'est pas affiché. */
|
ws.getRow(1).eachCell(cell => {
|
||||||
anneeOf?: (r: T) => string;
|
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||||
anneeLabel?: string;
|
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||||
searchIn: (r: T) => (string | null | undefined)[];
|
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||||
emptyLabel: string;
|
});
|
||||||
excel: ExcelSpec<T>;
|
ws.getRow(1).height = 22;
|
||||||
|
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
|
||||||
|
for (const r of rows) ws.addRow(excel.row(r));
|
||||||
|
ws.columns = excel.widths.map(width => ({ width }));
|
||||||
|
const buffer = await wb.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 = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
|
const EXCEL_NEGATIF: ExcelSpec<PgStockNegatifRow> = {
|
||||||
if (!active) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
sheet: "Stock Négatif",
|
||||||
return dir === "asc"
|
filePrefix: "Stock_Negatif",
|
||||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
|
||||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
widths: [16, 16, 10, 10, 16, 22],
|
||||||
}
|
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
|
||||||
|
};
|
||||||
|
|
||||||
function FilterSelect({ label, value, onChange, options }: {
|
const EXCEL_SANS_VENTE: ExcelSpec<PgStockSansVenteRow> = {
|
||||||
label: string;
|
sheet: "Entrées sans vente",
|
||||||
value: string;
|
filePrefix: "Entrees_Sans_Vente",
|
||||||
onChange: (v: string) => void;
|
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
|
||||||
options: string[];
|
widths: [16, 16, 10, 10, 22],
|
||||||
}) {
|
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
|
||||||
return (
|
};
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-sm font-medium text-gray-600 whitespace-nowrap">{label}</label>
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[150px]"
|
|
||||||
>
|
|
||||||
<option value="">Tous</option>
|
|
||||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pagination({ page, totalPages, total, from, to, onChange }: {
|
const EXCEL_SANS_VENTE_6MOIS: ExcelSpec<PgSansVente6MoisRow> = {
|
||||||
page: number;
|
sheet: "Sans vente 6 mois",
|
||||||
totalPages: number;
|
filePrefix: "Sans_Vente_6Mois",
|
||||||
total: number;
|
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
|
||||||
from: number;
|
widths: [16, 16, 10, 10, 22],
|
||||||
to: number;
|
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
|
||||||
onChange: (p: number) => void;
|
};
|
||||||
}) {
|
|
||||||
if (total === 0) return null;
|
|
||||||
|
|
||||||
const btn = "rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-1">
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
Lignes <span className="font-medium text-gray-700">{from.toLocaleString("fr-FR")}</span>
|
|
||||||
{" à "}
|
|
||||||
<span className="font-medium text-gray-700">{to.toLocaleString("fr-FR")}</span>
|
|
||||||
{" sur "}
|
|
||||||
<span className="font-medium text-gray-700">{total.toLocaleString("fr-FR")}</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button className={btn} onClick={() => onChange(1)} disabled={page <= 1} aria-label="Première page">
|
|
||||||
<ChevronsLeft className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button className={btn} onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Page précédente">
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<span className="px-2 text-xs text-gray-600 tabular-nums">
|
|
||||||
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
|
|
||||||
</span>
|
|
||||||
<button className={btn} onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button className={btn} onClick={() => onChange(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
|
|
||||||
<ChevronsRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StockTable<T extends object>({
|
|
||||||
rows,
|
|
||||||
magasin,
|
|
||||||
columns,
|
|
||||||
fournisseurOf,
|
|
||||||
anneeOf,
|
|
||||||
anneeLabel = "Année entrée",
|
|
||||||
searchIn,
|
|
||||||
emptyLabel,
|
|
||||||
excel,
|
|
||||||
}: StockTableProps<T>) {
|
|
||||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
|
||||||
const [filterAnnee, setFilterAnnee] = useState("");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
// `null` = ordre renvoyé par la base (le plus pertinent : stock le plus négatif,
|
|
||||||
// entrée la plus récente…). Aucun tri client tant que l'utilisateur n'en demande pas.
|
|
||||||
const [sortKey, setSortKey] = useState<Extract<keyof T, string> | null>(null);
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
|
|
||||||
const fournisseurs = useMemo(
|
|
||||||
() => [...new Set(rows.map(fournisseurOf).filter(Boolean))].sort((a, b) => collator.compare(a, b)),
|
|
||||||
[rows, fournisseurOf]
|
|
||||||
);
|
|
||||||
const annees = useMemo(
|
|
||||||
() => anneeOf ? [...new Set(rows.map(anneeOf).filter(Boolean))].sort((a, b) => b.localeCompare(a)) : [],
|
|
||||||
[rows, anneeOf]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!filterFournisseur && !filterAnnee && !q) return rows;
|
|
||||||
return rows.filter(r =>
|
|
||||||
(!filterFournisseur || fournisseurOf(r) === filterFournisseur) &&
|
|
||||||
(!filterAnnee || (anneeOf ? anneeOf(r) === filterAnnee : true)) &&
|
|
||||||
(!q || searchIn(r).some(v => v?.toLowerCase().includes(q)))
|
|
||||||
);
|
|
||||||
}, [rows, filterFournisseur, filterAnnee, search, fournisseurOf, anneeOf, searchIn]);
|
|
||||||
|
|
||||||
const numericKeys = useMemo(
|
|
||||||
() => new Set(columns.filter(c => c.numeric).map(c => c.key as string)),
|
|
||||||
[columns]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
if (!sortKey) return filtered;
|
|
||||||
const numeric = numericKeys.has(sortKey);
|
|
||||||
const dir = sortDir === "asc" ? 1 : -1;
|
|
||||||
return [...filtered].sort((a, b) => {
|
|
||||||
const av = (a as Record<string, unknown>)[sortKey];
|
|
||||||
const bv = (b as Record<string, unknown>)[sortKey];
|
|
||||||
if (numeric) {
|
|
||||||
const an = Number(av);
|
|
||||||
const bn = Number(bv);
|
|
||||||
if (Number.isNaN(an) && Number.isNaN(bn)) return 0;
|
|
||||||
if (Number.isNaN(an)) return 1;
|
|
||||||
if (Number.isNaN(bn)) return -1;
|
|
||||||
return (an - bn) * dir;
|
|
||||||
}
|
|
||||||
return collator.compare(String(av ?? ""), String(bv ?? "")) * dir;
|
|
||||||
});
|
|
||||||
}, [filtered, sortKey, sortDir, numericKeys]);
|
|
||||||
|
|
||||||
// Toute modification des filtres / du tri ramène à la première page.
|
|
||||||
// Ajustement pendant le rendu : pas d'effet, donc pas de rendu en cascade.
|
|
||||||
const [pagedSet, setPagedSet] = useState(sorted);
|
|
||||||
if (pagedSet !== sorted) {
|
|
||||||
setPagedSet(sorted);
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = sorted.length;
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
||||||
const currentPage = Math.min(page, totalPages);
|
|
||||||
const pageRows = useMemo(
|
|
||||||
() => sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
|
|
||||||
[sorted, currentPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleSort(key: Extract<keyof T, string>) {
|
|
||||||
if (sortKey === key) setSortDir(d => (d === "asc" ? "desc" : "asc"));
|
|
||||||
else { setSortKey(key); setSortDir("asc"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasFilters = Boolean(filterFournisseur || filterAnnee || search);
|
|
||||||
|
|
||||||
async function handleExport() {
|
|
||||||
if (exporting) return;
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const ExcelJS = (await import("exceljs")).default;
|
|
||||||
const wb = new ExcelJS.Workbook();
|
|
||||||
const ws = wb.addWorksheet(excel.sheet);
|
|
||||||
ws.addRow(excel.header);
|
|
||||||
ws.getRow(1).eachCell(cell => {
|
|
||||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
|
||||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
|
||||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
|
||||||
});
|
|
||||||
ws.getRow(1).height = 22;
|
|
||||||
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
|
|
||||||
for (const r of sorted) ws.addRow(excel.row(r));
|
|
||||||
ws.columns = excel.widths.map(width => ({ width }));
|
|
||||||
const buffer = await wb.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 = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{exporting && (
|
|
||||||
<LoadingModal
|
|
||||||
message="Génération du fichier Excel"
|
|
||||||
subMessage={`${total.toLocaleString("fr-FR")} ligne${total !== 1 ? "s" : ""} en cours d'écriture…`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Code article ou libellé…"
|
|
||||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
|
||||||
{anneeOf && (
|
|
||||||
<FilterSelect label={anneeLabel} value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
|
||||||
)}
|
|
||||||
{hasFilters && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
|
||||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
|
||||||
>
|
|
||||||
✕ Réinitialiser
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""}
|
|
||||||
{hasFilters && total !== rows.length && (
|
|
||||||
<span className="text-gray-400"> / {rows.length.toLocaleString("fr-FR")}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={exporting || total === 0}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />Exporter Excel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
|
||||||
<tr>
|
|
||||||
{columns.map(c => (
|
|
||||||
<th
|
|
||||||
key={c.key}
|
|
||||||
onClick={() => handleSort(c.key)}
|
|
||||||
className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
|
||||||
>
|
|
||||||
{c.label}
|
|
||||||
<SortIcon active={sortKey === c.key} dir={sortDir} />
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{pageRows.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-400">{emptyLabel}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{pageRows.map((row, i) => (
|
|
||||||
<tr key={`${String((row as Record<string, unknown>).codein)}-${String((row as Record<string, unknown>).site)}-${i}`}
|
|
||||||
className="hover:bg-gray-50 transition-colors">
|
|
||||||
{columns.map(c => (
|
|
||||||
<td key={c.key} className="px-3 py-2">
|
|
||||||
{c.render
|
|
||||||
? c.render(row)
|
|
||||||
: String((row as Record<string, unknown>)[c.key] ?? "")}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
page={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
total={total}
|
|
||||||
from={total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
|
|
||||||
to={Math.min(currentPage * PAGE_SIZE, total)}
|
|
||||||
onChange={p => setPage(Math.min(Math.max(1, p), totalPages))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cellules partagées
|
// Colonnes et filtres des 3 onglets
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const cellCode = (v: string) => <span className="font-mono text-xs text-gray-700">{v}</span>;
|
|
||||||
const cellSite = (v: string) => (
|
|
||||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{v}</span>
|
|
||||||
);
|
|
||||||
const cellDate = (v: string | null) => <span className="text-gray-600 text-xs">{fmtDate(v)}</span>;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Configuration des 3 onglets
|
|
||||||
//
|
//
|
||||||
// Définies au niveau module : leur identité doit rester stable d'un rendu à
|
// Définis au niveau module : leur identité doit rester stable d'un rendu à
|
||||||
// l'autre, sinon les `useMemo` de StockTable se recalculent en boucle.
|
// l'autre, sinon les `useMemo` du tableau se recalculent en boucle.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type TabConfig<T> = Omit<StockTableProps<T>, "rows" | "magasin">;
|
interface LigneStock {
|
||||||
|
codein: string;
|
||||||
|
libelle1: string;
|
||||||
|
fournisseur: string;
|
||||||
|
site: string;
|
||||||
|
}
|
||||||
|
|
||||||
const NEGATIF_CONFIG: TabConfig<PgStockNegatifRow> = {
|
/** Colonnes communes aux 3 onglets : code, libellé, fournisseur, magasin. */
|
||||||
fournisseurOf: r => r.fournisseur,
|
function colonnesIdentite<T extends LigneStock>(): DataColumn<T>[] {
|
||||||
anneeOf: r => getYear(r.derniereentree),
|
return [
|
||||||
searchIn: r => [r.codein, r.libelle1],
|
|
||||||
emptyLabel: "Aucun article en stock négatif",
|
|
||||||
columns: [
|
|
||||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
|
||||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
|
||||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
|
||||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
|
||||||
{ key: "stockdispo", label: "Stock", numeric: true, render: r => <span className="font-semibold text-red-600">{r.stockdispo}</span> },
|
|
||||||
{ key: "dernierevente", label: "Dernière vente", render: r => cellDate(r.dernierevente) },
|
|
||||||
{ key: "derniereentree", label: "Dernière entrée", render: r => cellDate(r.derniereentree) },
|
|
||||||
],
|
|
||||||
excel: {
|
|
||||||
sheet: "Stock Négatif",
|
|
||||||
filePrefix: "Stock_Negatif",
|
|
||||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
|
|
||||||
widths: [16, 16, 10, 10, 16, 22],
|
|
||||||
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SANS_VENTE_CONFIG: TabConfig<PgStockSansVenteRow> = {
|
|
||||||
fournisseurOf: r => r.fournisseur,
|
|
||||||
anneeOf: r => getYear(r.derniere_entree),
|
|
||||||
searchIn: r => [r.codein, r.libelle1],
|
|
||||||
emptyLabel: "Aucun article trouvé",
|
|
||||||
columns: [
|
|
||||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
|
||||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
|
||||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
|
||||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
|
||||||
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
|
|
||||||
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
|
|
||||||
],
|
|
||||||
excel: {
|
|
||||||
sheet: "Entrées sans vente",
|
|
||||||
filePrefix: "Entrees_Sans_Vente",
|
|
||||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
|
|
||||||
widths: [16, 16, 10, 10, 22],
|
|
||||||
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SANS_VENTE_6MOIS_CONFIG: TabConfig<PgSansVente6MoisRow> = {
|
|
||||||
fournisseurOf: r => r.fournisseur,
|
|
||||||
searchIn: r => [r.codein, r.libelle1],
|
|
||||||
emptyLabel: "Aucun article trouvé",
|
|
||||||
columns: [
|
|
||||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
|
||||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
|
||||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
|
||||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
|
||||||
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
|
|
||||||
{ key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) },
|
|
||||||
{
|
{
|
||||||
key: "jours_sans_vente",
|
id: "codein",
|
||||||
label: "Jours sans vente",
|
header: "Code article",
|
||||||
numeric: true,
|
sortValue: r => r.codein,
|
||||||
render: r => (
|
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
|
||||||
<span
|
className: "whitespace-nowrap",
|
||||||
className="tabular-nums font-medium"
|
|
||||||
style={{ color: r.jours_sans_vente && r.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}
|
|
||||||
>
|
|
||||||
{r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
|
{
|
||||||
],
|
id: "libelle1",
|
||||||
excel: {
|
header: "Libellé",
|
||||||
sheet: "Sans vente 6 mois",
|
sortValue: r => r.libelle1,
|
||||||
filePrefix: "Sans_Vente_6Mois",
|
cell: r => r.libelle1,
|
||||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
|
className: "min-w-[220px]",
|
||||||
widths: [16, 16, 10, 10, 22],
|
},
|
||||||
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
|
{
|
||||||
|
id: "fournisseur",
|
||||||
|
header: "Fournisseur",
|
||||||
|
sortValue: r => r.fournisseur,
|
||||||
|
cell: r => <span className="text-[var(--text-secondary)]">{r.fournisseur}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site",
|
||||||
|
header: "Magasin",
|
||||||
|
sortValue: r => nomMagasin(r.site),
|
||||||
|
cell: r => <StoreBadge code={r.site} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quantité en stock : rouge si négative, en clair sinon. */
|
||||||
|
function celluleStock(v: number) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("font-semibold", v < 0 ? "text-[var(--accent-error)]" : "text-[var(--text-primary)]")}
|
||||||
|
>
|
||||||
|
{fmtDecimal1(v)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const celluleDate = (v: string | null) => (
|
||||||
|
<span className="whitespace-nowrap text-[13px] text-[var(--text-secondary)]">{fmtDate(v)}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rechercheStock = (r: LigneStock) => [r.codein, r.libelle1];
|
||||||
|
|
||||||
|
function filtreFournisseur<T extends LigneStock>(): DataFilter<T> {
|
||||||
|
return { id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtreAnneeEntree<T>(anneeOf: (r: T) => string): DataFilter<T> {
|
||||||
|
return { id: "annee", label: "Année d'entrée", valueOf: anneeOf, allLabel: "Toutes les années", order: "desc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLONNES_NEGATIF: DataColumn<PgStockNegatifRow>[] = [
|
||||||
|
...colonnesIdentite<PgStockNegatifRow>(),
|
||||||
|
{
|
||||||
|
id: "stockdispo",
|
||||||
|
header: "Stock",
|
||||||
|
hint: "Stock calculé dans FF : il est négatif quand des ventes ont été enregistrées sans l'entrée de marchandise correspondante.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => r.stockdispo,
|
||||||
|
cell: r => celluleStock(r.stockdispo),
|
||||||
},
|
},
|
||||||
};
|
{ id: "dernierevente", header: "Dernière vente", sortValue: r => r.dernierevente, cell: r => celluleDate(r.dernierevente) },
|
||||||
|
{ id: "derniereentree", header: "Dernière entrée", sortValue: r => r.derniereentree, cell: r => celluleDate(r.derniereentree) },
|
||||||
|
];
|
||||||
|
const FILTRES_NEGATIF: DataFilter<PgStockNegatifRow>[] = [
|
||||||
|
filtreFournisseur<PgStockNegatifRow>(),
|
||||||
|
filtreAnneeEntree<PgStockNegatifRow>(r => getYear(r.derniereentree)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLONNES_SANS_VENTE: DataColumn<PgStockSansVenteRow>[] = [
|
||||||
|
...colonnesIdentite<PgStockSansVenteRow>(),
|
||||||
|
{
|
||||||
|
id: "stock_actuel",
|
||||||
|
header: "Stock actuel",
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => r.stock_actuel,
|
||||||
|
cell: r => celluleStock(r.stock_actuel),
|
||||||
|
},
|
||||||
|
{ id: "derniere_entree", header: "Dernière entrée", sortValue: r => r.derniere_entree, cell: r => celluleDate(r.derniere_entree) },
|
||||||
|
];
|
||||||
|
const FILTRES_SANS_VENTE: DataFilter<PgStockSansVenteRow>[] = [
|
||||||
|
filtreFournisseur<PgStockSansVenteRow>(),
|
||||||
|
filtreAnneeEntree<PgStockSansVenteRow>(r => getYear(r.derniere_entree)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLONNES_SANS_VENTE_6MOIS: DataColumn<PgSansVente6MoisRow>[] = [
|
||||||
|
...colonnesIdentite<PgSansVente6MoisRow>(),
|
||||||
|
{
|
||||||
|
id: "stock_actuel",
|
||||||
|
header: "Stock actuel",
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => r.stock_actuel,
|
||||||
|
cell: r => celluleStock(r.stock_actuel),
|
||||||
|
},
|
||||||
|
{ id: "derniere_vente", header: "Dernière vente", sortValue: r => r.derniere_vente, cell: r => celluleDate(r.derniere_vente) },
|
||||||
|
{
|
||||||
|
id: "jours_sans_vente",
|
||||||
|
header: "Jours sans vente",
|
||||||
|
hint: "Nombre de jours depuis la dernière vente. En rouge : plus d'un an.",
|
||||||
|
align: "right",
|
||||||
|
sortValue: r => r.jours_sans_vente,
|
||||||
|
cell: r =>
|
||||||
|
r.jours_sans_vente != null ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
r.jours_sans_vente > 365 ? "text-[var(--accent-error)]" : "text-[var(--text-secondary)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmtEntier(r.jours_sans_vente)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-[var(--text-muted)]">Jamais vendu</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ id: "derniere_entree", header: "Dernière entrée", sortValue: r => r.derniere_entree, cell: r => celluleDate(r.derniere_entree) },
|
||||||
|
];
|
||||||
|
const FILTRES_SANS_VENTE_6MOIS: DataFilter<PgSansVente6MoisRow>[] = [filtreFournisseur<PgSansVente6MoisRow>()];
|
||||||
|
|
||||||
|
const cleLigne = (r: LigneStock, i: number) => `${r.codein}-${r.site}-${i}`;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Composant principal avec onglets
|
// Composant principal avec onglets
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const TABS = [
|
const TAB_KEYS = ["negatif", "sans-vente", "sans-vente-6mois"] as const;
|
||||||
{ key: "negatif", label: "Stock négatif" },
|
|
||||||
{ key: "sans-vente", label: "Entrées sans vente" },
|
|
||||||
{ key: "sans-vente-6mois", label: "Sans vente 6 mois" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type TabKey = typeof TABS[number]["key"];
|
type TabKey = typeof TAB_KEYS[number];
|
||||||
|
|
||||||
function isTabKey(v: string): v is TabKey {
|
function isTabKey(v: string): v is TabKey {
|
||||||
return TABS.some(t => t.key === v);
|
return (TAB_KEYS as readonly string[]).includes(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ce que liste chaque onglet, et quoi en faire. */
|
||||||
|
const EXPLICATIONS: Record<TabKey, ReactNode> = {
|
||||||
|
"negatif": (
|
||||||
|
<>
|
||||||
|
Produits dont le <Terme id="stockNegatif">stock est négatif</Terme> : des ventes ont été enregistrées sans
|
||||||
|
l'entrée de marchandise correspondante. Vérifiez s'il manque une réception, puis exportez la
|
||||||
|
liste : le fichier Excel est prêt à être importé dans FF pour régulariser le stock.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"sans-vente": (
|
||||||
|
<>
|
||||||
|
Produits reçus avant ce mois-ci, encore en stock, mais qui n'ont jamais été vendus dans ce magasin.
|
||||||
|
Vérifiez qu'ils sont bien en rayon et étiquetés ; si le stock est erroné, exportez la liste : le
|
||||||
|
fichier Excel est prêt à être importé dans FF pour le régulariser.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"sans-vente-6mois": (
|
||||||
|
<>
|
||||||
|
Produits en stock qui ne se sont pas vendus depuis plus de 6 mois (ou jamais) et qui n'ont pas été
|
||||||
|
réapprovisionnés depuis. Mettez-les en avant ou en promotion ; si le stock est erroné, le fichier Excel
|
||||||
|
exporté est prêt à être importé dans FF pour le régulariser.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS_MAGASINS = MAGASINS.map(m => ({ value: m.code, label: nomMagasin(m.code) }));
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rowsNegatif: PgStockNegatifRow[];
|
rowsNegatif: PgStockNegatifRow[];
|
||||||
rowsSansVente: PgStockSansVenteRow[];
|
rowsSansVente: PgStockSansVenteRow[];
|
||||||
rowsSansVente6Mois: PgSansVente6MoisRow[];
|
rowsSansVente6Mois: PgSansVente6MoisRow[];
|
||||||
magasin: string;
|
magasin: string;
|
||||||
tab: string;
|
tab: string;
|
||||||
sites: { code: string; label: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) {
|
export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
|
const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
|
||||||
|
|
||||||
@@ -531,8 +332,7 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
|
|||||||
return `/stock-negatif?${params.toString()}`;
|
return `/stock-negatif?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
function handleMagasinChange(val: string) {
|
||||||
const val = e.target.value;
|
|
||||||
startNavigation(() => {
|
startNavigation(() => {
|
||||||
router.push(buildUrl(activeTab, val));
|
router.push(buildUrl(activeTab, val));
|
||||||
});
|
});
|
||||||
@@ -545,83 +345,87 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
|
|||||||
startSwitch(() => setActiveTab(key));
|
startSwitch(() => setActiveTab(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
const counts: Record<TabKey, number> = useMemo(() => ({
|
const tabs: TabItem<TabKey>[] = useMemo(() => [
|
||||||
"negatif": rowsNegatif.length,
|
{ value: "negatif", label: "Stock négatif", count: rowsNegatif.length, alert: true },
|
||||||
"sans-vente": rowsSansVente.length,
|
{ value: "sans-vente", label: "Entrées sans vente", count: rowsSansVente.length },
|
||||||
"sans-vente-6mois": rowsSansVente6Mois.length,
|
{ value: "sans-vente-6mois", label: "Sans vente depuis 6 mois", count: rowsSansVente6Mois.length },
|
||||||
}), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
|
], [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
|
||||||
|
|
||||||
const busy = switching || navigating;
|
const busy = switching || navigating;
|
||||||
const showLoader = useDelayedFlag(busy);
|
const showLoader = useDelayedFlag(busy);
|
||||||
const loaderMessage = navigating ? "Chargement des données" : "Préparation de l'onglet";
|
|
||||||
const loaderSubMessage = navigating
|
|
||||||
? "Interrogation du stock pour le magasin sélectionné…"
|
|
||||||
: "Application des filtres et du tri…";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4" aria-busy={busy}>
|
<div className="space-y-4" aria-busy={busy}>
|
||||||
{showLoader && <LoadingModal message={loaderMessage} subMessage={loaderSubMessage} />}
|
{/* Choix du magasin */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
{/* Toolbar */}
|
<Select
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
id="stock-magasin"
|
||||||
<div className="flex items-center gap-2">
|
label="Magasin"
|
||||||
<label className="text-sm font-medium text-gray-700">Magasin</label>
|
value={magasin}
|
||||||
<select
|
onChange={handleMagasinChange}
|
||||||
value={magasin}
|
disabled={navigating}
|
||||||
onChange={handleMagasinChange}
|
placeholder={LIBELLE_TOUS_MAGASINS}
|
||||||
disabled={navigating}
|
options={OPTIONS_MAGASINS}
|
||||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-wait disabled:opacity-60"
|
/>
|
||||||
>
|
{showLoader && (
|
||||||
<option value="">Tous les magasins</option>
|
<span role="status" className="inline-flex h-9 items-center gap-2 text-[13px] text-[var(--text-secondary)]">
|
||||||
{sites.map(s => (
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
<option key={s.code} value={s.code}>{s.label}</option>
|
Chargement…
|
||||||
))}
|
</span>
|
||||||
</select>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
<Tabs items={tabs} value={activeTab} onChange={handleTabChange} disabled={busy} />
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="-mb-px flex gap-1">
|
|
||||||
{TABS.map(t => {
|
|
||||||
const isActive = activeTab === t.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={t.key}
|
|
||||||
onClick={() => handleTabChange(t.key)}
|
|
||||||
disabled={busy}
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={[
|
|
||||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
|
||||||
isActive
|
|
||||||
? "border-blue-600 text-blue-600"
|
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
|
||||||
busy ? "cursor-wait" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
<span className={[
|
|
||||||
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
|
|
||||||
isActive ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
|
|
||||||
].join(" ")}>
|
|
||||||
{counts[t.key].toLocaleString("fr-FR")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
<p className="max-w-4xl text-sm text-[var(--text-secondary)]">{EXPLICATIONS[activeTab]}</p>
|
||||||
{activeTab === "negatif" && (
|
|
||||||
<StockTable<PgStockNegatifRow> rows={rowsNegatif} magasin={magasin} {...NEGATIF_CONFIG} />
|
<div className={cn("transition-opacity", showLoader && "pointer-events-none opacity-50")}>
|
||||||
)}
|
{activeTab === "negatif" && (
|
||||||
{activeTab === "sans-vente" && (
|
<DataTable<PgStockNegatifRow>
|
||||||
<StockTable<PgStockSansVenteRow> rows={rowsSansVente} magasin={magasin} {...SANS_VENTE_CONFIG} />
|
rows={rowsNegatif}
|
||||||
)}
|
columns={COLONNES_NEGATIF}
|
||||||
{activeTab === "sans-vente-6mois" && (
|
rowKey={cleLigne}
|
||||||
<StockTable<PgSansVente6MoisRow> rows={rowsSansVente6Mois} magasin={magasin} {...SANS_VENTE_6MOIS_CONFIG} />
|
searchIn={rechercheStock}
|
||||||
)}
|
searchPlaceholder="Code article ou libellé…"
|
||||||
|
filters={FILTRES_NEGATIF}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
unite="articles"
|
||||||
|
emptyTitle="Aucun article en stock négatif"
|
||||||
|
emptyDescription="Aucun stock n'est inférieur à zéro pour ce choix de magasin : rien à régulariser."
|
||||||
|
onExport={rows => exporterPourFF(EXCEL_NEGATIF, rows, magasin)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "sans-vente" && (
|
||||||
|
<DataTable<PgStockSansVenteRow>
|
||||||
|
rows={rowsSansVente}
|
||||||
|
columns={COLONNES_SANS_VENTE}
|
||||||
|
rowKey={cleLigne}
|
||||||
|
searchIn={rechercheStock}
|
||||||
|
searchPlaceholder="Code article ou libellé…"
|
||||||
|
filters={FILTRES_SANS_VENTE}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
unite="articles"
|
||||||
|
emptyTitle="Aucun article reçu sans vente"
|
||||||
|
emptyDescription="Tous les produits reçus et encore en stock ont été vendus au moins une fois."
|
||||||
|
onExport={rows => exporterPourFF(EXCEL_SANS_VENTE, rows, magasin)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "sans-vente-6mois" && (
|
||||||
|
<DataTable<PgSansVente6MoisRow>
|
||||||
|
rows={rowsSansVente6Mois}
|
||||||
|
columns={COLONNES_SANS_VENTE_6MOIS}
|
||||||
|
rowKey={cleLigne}
|
||||||
|
searchIn={rechercheStock}
|
||||||
|
searchPlaceholder="Code article ou libellé…"
|
||||||
|
filters={FILTRES_SANS_VENTE_6MOIS}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
unite="articles"
|
||||||
|
emptyTitle="Aucun article sans vente depuis 6 mois"
|
||||||
|
emptyDescription="Tous les produits en stock se sont vendus ou ont été réapprovisionnés ces 6 derniers mois."
|
||||||
|
onExport={rows => exporterPourFF(EXCEL_SANS_VENTE_6MOIS, rows, magasin)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PackageMinus, RotateCcw } from "lucide-react";
|
||||||
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client";
|
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client";
|
||||||
import { GestionStockClient } from "./client";
|
|
||||||
import { cachedFF } from "@/lib/ff-cache";
|
import { cachedFF } from "@/lib/ff-cache";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { ErrorState } from "@/components/ui/states";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GestionStockClient } from "./client";
|
||||||
|
|
||||||
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
|
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
|
||||||
|
|
||||||
const SITES = [
|
export const metadata: Metadata = { title: "Stocks à surveiller" };
|
||||||
{ code: "292", label: "292 — Frouard / Nancy" },
|
|
||||||
{ code: "579", label: "579 — Houdemont" },
|
interface DonneesStock {
|
||||||
];
|
rowsNegatif: PgStockNegatifRow[];
|
||||||
|
rowsSansVente: PgStockSansVenteRow[];
|
||||||
|
rowsSansVente6Mois: PgSansVente6MoisRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export default async function GestionStockPage(props: {
|
export default async function GestionStockPage(props: {
|
||||||
searchParams: Promise<Record<string, string | string[]>>;
|
searchParams: Promise<Record<string, string | string[]>>;
|
||||||
@@ -18,25 +27,50 @@ export default async function GestionStockPage(props: {
|
|||||||
|
|
||||||
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
|
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
|
||||||
const site = magasin || undefined;
|
const site = magasin || undefined;
|
||||||
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
|
let donnees: DonneesStock | null = null;
|
||||||
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
|
let erreur: string | null = null;
|
||||||
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
|
try {
|
||||||
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
|
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
|
||||||
]);
|
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
|
||||||
|
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
|
||||||
|
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
|
||||||
|
]);
|
||||||
|
donnees = { rowsNegatif, rowsSansVente, rowsSansVente6Mois };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[stock-negatif] chargement impossible :", e);
|
||||||
|
erreur = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (magasin) params.set("magasin", magasin);
|
||||||
|
params.set("tab", tab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
<div className="mx-auto max-w-screen-2xl">
|
<PageHeader
|
||||||
<h1 className="mb-6 text-3xl font-bold text-gray-900">Gestion de Stock</h1>
|
icon={PackageMinus}
|
||||||
|
title="Stocks à surveiller"
|
||||||
|
description="Les produits dont le stock pose question : stock négatif, reçus mais jamais vendus, ou sans vente depuis 6 mois. Vérifiez-les en magasin, puis régularisez le stock dans FF grâce à l'export Excel."
|
||||||
|
/>
|
||||||
|
{donnees ? (
|
||||||
<GestionStockClient
|
<GestionStockClient
|
||||||
rowsNegatif={rowsNegatif}
|
rowsNegatif={donnees.rowsNegatif}
|
||||||
rowsSansVente={rowsSansVente}
|
rowsSansVente={donnees.rowsSansVente}
|
||||||
rowsSansVente6Mois={rowsSansVente6Mois}
|
rowsSansVente6Mois={donnees.rowsSansVente6Mois}
|
||||||
magasin={magasin}
|
magasin={magasin}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
sites={SITES}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<ErrorState
|
||||||
|
title="Les stocks n'ont pas pu être chargés"
|
||||||
|
detail={erreur ?? undefined}
|
||||||
|
action={
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/stock-negatif?${params.toString()}`}><RotateCcw /> Réessayer</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { X, KeyRound, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
|
|
||||||
|
|
||||||
interface ChangePasswordModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (newPassword: string) => Promise<void>;
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChangePasswordModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
username,
|
|
||||||
}: ChangePasswordModalProps) {
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [confirm, setConfirm] = useState("");
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setIsVisible(true);
|
|
||||||
setPassword("");
|
|
||||||
setConfirm("");
|
|
||||||
setError(null);
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 50);
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsVisible(false);
|
|
||||||
setTimeout(onClose, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (password.length < 4) {
|
|
||||||
setError("Le mot de passe doit faire au moins 4 caractères.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password !== confirm) {
|
|
||||||
setError("Les mots de passe ne correspondent pas.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await onConfirm(password);
|
|
||||||
handleClose();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message ?? "Une erreur est survenue.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen && !isVisible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0"
|
|
||||||
style={{ background: "rgba(0,0,0,0.55)" }}
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<div
|
|
||||||
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
|
|
||||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
{/* Accent bar */}
|
|
||||||
<div className="absolute top-0 left-0 w-full h-0.5" style={{ background: "linear-gradient(to right, var(--accent), var(--accent-success))" }} />
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="p-7">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4 mb-7">
|
|
||||||
<div className="w-11 h-11 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center shrink-0">
|
|
||||||
<KeyRound className="w-5 h-5 text-[var(--accent)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-[var(--text-primary)]">Changer le mot de passe</h3>
|
|
||||||
<p className="text-xs text-[var(--text-muted)]">Compte : <span className="font-semibold text-[var(--text-secondary)]">{username}</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{/* New password */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)]">
|
|
||||||
Nouveau mot de passe
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Min. 4 caractères"
|
|
||||||
className="apple-input pr-10"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(v => !v)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)]">
|
|
||||||
Confirmer le mot de passe
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showConfirm ? "text" : "password"}
|
|
||||||
value={confirm}
|
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
|
||||||
placeholder="Répétez le mot de passe"
|
|
||||||
className="apple-input pr-10"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowConfirm(v => !v)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
|
||||||
>
|
|
||||||
{showConfirm ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password match indicator */}
|
|
||||||
{confirm.length > 0 && (
|
|
||||||
<div className={`flex items-center gap-2 text-xs font-semibold ${password === confirm ? "text-[var(--accent-success)]" : "text-[var(--accent-error)]"}`}>
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
|
||||||
{password === confirm ? "Les mots de passe correspondent" : "Les mots de passe ne correspondent pas"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="px-3 py-2.5 rounded-lg bg-[var(--accent-error-bg)] border border-[var(--accent-error)] text-[var(--accent-error)] text-xs font-semibold">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="apple-btn-secondary flex-1 justify-center"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || password !== confirm || password.length < 4}
|
|
||||||
className="apple-btn-primary flex-1 justify-center disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <KeyRound className="w-4 h-4" />}
|
|
||||||
{isLoading ? "Mise à jour..." : "Valider"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { X, AlertTriangle } from "lucide-react";
|
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title?: string;
|
|
||||||
message: string;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
variant?: "danger" | "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
title = "Confirmation",
|
|
||||||
message,
|
|
||||||
confirmLabel = "OK",
|
|
||||||
cancelLabel = "Annuler",
|
|
||||||
variant = "default",
|
|
||||||
}: ConfirmModalProps) {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const confirmBtnRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setIsVisible(true);
|
|
||||||
setTimeout(() => confirmBtnRef.current?.focus(), 50);
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsVisible(false);
|
|
||||||
setTimeout(onClose, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
setIsVisible(false);
|
|
||||||
setTimeout(onConfirm, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") handleClose();
|
|
||||||
if (e.key === "Enter") handleConfirm();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen && !isVisible) return null;
|
|
||||||
|
|
||||||
const isDanger = variant === "danger";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`fixed inset-0 z-[200] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0"
|
|
||||||
style={{ background: "rgba(0,0,0,0.55)" }}
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<div
|
|
||||||
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
|
|
||||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
{/* Accent bar */}
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 w-full h-0.5"
|
|
||||||
style={{
|
|
||||||
background: isDanger
|
|
||||||
? "linear-gradient(to right, var(--accent-error), #fb7185)"
|
|
||||||
: "linear-gradient(to right, var(--accent), var(--accent-success))"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="p-7">
|
|
||||||
{/* Icon + Title */}
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${isDanger ? "bg-[var(--accent-error-bg)] border border-[var(--accent-error)]" : "bg-[var(--accent-bg)] border border-[var(--accent-border)]"}`}>
|
|
||||||
<AlertTriangle className={`w-5 h-5 ${isDanger ? "text-[var(--accent-error)]" : "text-[var(--accent)]"}`} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-base font-bold text-[var(--text-primary)]">{title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message */}
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-7 leading-relaxed pl-[52px]">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="apple-btn-secondary"
|
|
||||||
>
|
|
||||||
{cancelLabel}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
ref={confirmBtnRef}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
className={isDanger ? "apple-btn-danger" : "apple-btn-primary"}
|
|
||||||
>
|
|
||||||
{confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
interface LoadingModalProps {
|
|
||||||
message?: string;
|
|
||||||
subMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoadingModal({
|
|
||||||
message = "Chargement des données",
|
|
||||||
subMessage = "Veuillez patienter...",
|
|
||||||
}: LoadingModalProps) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center gap-5 px-10 py-8 rounded-2xl shadow-2xl border"
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-elevated)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative w-12 h-12">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 rounded-full border-[3px] animate-spin"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
borderTopColor: "var(--accent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="text-sm font-semibold tracking-wide"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{subMessage}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,7 @@ export function Sidebar() {
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-[width] duration-200 relative border-r border-[var(--border)]",
|
"flex-shrink-0 flex flex-col h-screen z-30 glass transition-[width] duration-200 relative border-r border-[var(--border)]",
|
||||||
isExpanded ? "w-[272px]" : "w-16",
|
isExpanded ? "w-[280px]" : "w-16",
|
||||||
)}
|
)}
|
||||||
aria-label="Menu principal"
|
aria-label="Menu principal"
|
||||||
>
|
>
|
||||||
@@ -78,7 +78,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className={cn("flex-1 overflow-y-auto py-3", isExpanded ? "px-3" : "px-2")}>
|
<nav className={cn("flex-1 overflow-y-auto py-3 px-2")}>
|
||||||
{groupes.map((groupe, gi) => (
|
{groupes.map((groupe, gi) => (
|
||||||
<div key={groupe.title} className={cn(gi > 0 && "mt-4")}>
|
<div key={groupe.title} className={cn(gi > 0 && "mt-4")}>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
|
||||||
|
|
||||||
interface SuccessModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
|
||||||
/**
|
|
||||||
* `error` change l'icône, l'accent et **désactive la fermeture automatique** :
|
|
||||||
* un message d'échec doit rester à l'écran le temps d'être lu, et il ne doit
|
|
||||||
* pas s'afficher sous une coche verte de succès.
|
|
||||||
*/
|
|
||||||
variant?: "success" | "error";
|
|
||||||
/** Action secondaire optionnelle (ex. « Recharger la page »). */
|
|
||||||
action?: { label: string; onClick: () => void };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Durée de l'animation de sortie, alignée sur les classes `animate-out`. */
|
|
||||||
const EXIT_MS = 200;
|
|
||||||
|
|
||||||
export function SuccessModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
duration = 3000,
|
|
||||||
variant = "success",
|
|
||||||
action,
|
|
||||||
}: SuccessModalProps) {
|
|
||||||
// Seul l'état de SORTIE est porté par un state : l'entrée est jouée par CSS
|
|
||||||
// au montage (`animate-in`), ce qui évite un setState synchrone dans l'effet.
|
|
||||||
const [closing, setClosing] = useState(false);
|
|
||||||
const isError = variant === "error";
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setClosing(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setClosing(false);
|
|
||||||
onClose();
|
|
||||||
}, EXIT_MS);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Une erreur ne disparaît pas toute seule : l'utilisateur la ferme.
|
|
||||||
if (!isOpen || isError) return;
|
|
||||||
const timer = setTimeout(() => handleClose(), duration);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [isOpen, duration, isError, handleClose]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const state = closing ? "closed" : "open";
|
|
||||||
const Icon = isError ? AlertCircle : CheckCircle2;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-state={state}
|
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-sm duration-200 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out"
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0"
|
|
||||||
style={{ background: "rgba(0,0,0,0.45)" }}
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<div
|
|
||||||
data-state={state}
|
|
||||||
className="relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl duration-200 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95"
|
|
||||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
{/* Accent bar */}
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 w-full h-0.5"
|
|
||||||
style={{
|
|
||||||
background: isError
|
|
||||||
? "var(--accent-error)"
|
|
||||||
: "linear-gradient(to right, var(--accent), var(--accent-success))",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="p-8 flex flex-col items-center text-center">
|
|
||||||
{/* Icon */}
|
|
||||||
<div
|
|
||||||
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
|
|
||||||
style={
|
|
||||||
isError
|
|
||||||
? { background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)" }
|
|
||||||
: { background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="w-8 h-8" style={{ color: isError ? "var(--accent-error)" : "var(--accent)" }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2 tracking-tight">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)] font-medium leading-relaxed">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{action && (
|
|
||||||
<button onClick={action.onClick} className="apple-btn-primary mt-7 w-full justify-center">
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className={`${action ? "apple-btn-secondary mt-2" : "apple-btn-primary mt-7"} w-full justify-center`}
|
|
||||||
>
|
|
||||||
{action ? "Plus tard" : "Continuer"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -74,3 +74,15 @@ export function Terme({ id, children, className }: TermeProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Texte suivi d'un « i » : explication libre au survol (hors glossaire). */
|
||||||
|
export function InfoBulle({ children, explication, className }: { children: ReactNode; explication: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip content={explication}>
|
||||||
|
<span tabIndex={0} className={cn("inline-flex items-center gap-1 cursor-help outline-none", className)}>
|
||||||
|
{children}
|
||||||
|
<Info className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,31 +8,42 @@
|
|||||||
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
|
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Copy, Check, ExternalLink, Bot } from "lucide-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 }) {
|
function CopyButton({ text }: { text: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
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 (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
|
variant="outline"
|
||||||
className="shrink-0 rounded-lg px-2 py-1.5"
|
size="icon-sm"
|
||||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
onClick={copier}
|
||||||
|
aria-label="Copier"
|
||||||
title="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" />}
|
{copied ? <Check className="text-[var(--accent-success)]" /> : <Copy />}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeLine({ children, copy }: { children: string; copy?: string }) {
|
function CodeLine({ children, copy }: { children: string; copy?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<code
|
<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)]">
|
||||||
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)" }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
<CopyButton text={copy ?? children} />
|
<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 }> = [
|
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: "/fournisseurs", desc: "Fournisseurs, avec la date de calcul de leur grille" },
|
||||||
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
|
{ 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 nb d'articles et CA" },
|
{ 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 transversale, tous fournisseurs" },
|
{ 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: "/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: "/network/{codeCentrale}", desc: "Ventes du réseau (Qlik) et courbe sur 12 mois" },
|
||||||
{ method: "GET", path: "/openapi.json", desc: "Spécification lisible par machine" },
|
{ method: "GET", path: "/openapi.json", desc: "Description de l'API lisible par un programme (format OpenAPI)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PARAMS: Array<{ name: string; desc: string }> = [
|
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: "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: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc." },
|
||||||
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
|
{ 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: "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: "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: "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: "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 vite" },
|
{ 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() {
|
export function ApiConnectionInfo() {
|
||||||
@@ -70,128 +103,150 @@ export function ApiConnectionInfo() {
|
|||||||
const base = `${origin || "https://votre-domaine"}/api/v1`;
|
const base = `${origin || "https://votre-domaine"}/api/v1`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<Card>
|
||||||
{/* Connexion */}
|
<CardHeader
|
||||||
<div className="space-y-2">
|
title="Se connecter à l'API"
|
||||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>URL de base</p>
|
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."
|
||||||
<CodeLine>{base}</CodeLine>
|
/>
|
||||||
<p className="text-[12px] font-semibold pt-1" style={{ color: "var(--text-primary)" }}>Authentification</p>
|
<CardContent className="space-y-6">
|
||||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
<Bloc titre="Adresse de base">
|
||||||
Une clé créée ci-dessous, dans l'un ou l'autre de ces en-têtes. Depuis un navigateur
|
<CodeLine>{base}</CodeLine>
|
||||||
connecté, la session suffit — aucune clé n'est nécessaire.
|
</Bloc>
|
||||||
</p>
|
|
||||||
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
|
|
||||||
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Endpoints */}
|
<Bloc titre="Identification">
|
||||||
<div className="space-y-2">
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Endpoints</p>
|
Chaque appel doit porter une clé (à créer dans le cadre « Clés d'accès à l'API » ci-dessous),
|
||||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
dans l'un ou l'autre de ces en-têtes. Depuis un navigateur déjà connecté à CollectFlow,
|
||||||
<table className="w-full text-[11px]">
|
aucune clé n'est nécessaire.
|
||||||
<tbody>
|
</p>
|
||||||
{ENDPOINTS.map((e, i) => (
|
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
|
||||||
<tr key={e.path} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
|
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
|
||||||
<td className="px-2.5 py-1.5 font-mono font-bold align-top" style={{ color: "var(--accent)", width: 40 }}>{e.method}</td>
|
</Bloc>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Paramètres */}
|
{/* Branchement d'une IA externe (ChatGPT) */}
|
||||||
<div className="space-y-2">
|
<Bloc
|
||||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Paramètres communs</p>
|
titre={
|
||||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
<span className="inline-flex items-center gap-2">
|
||||||
<table className="w-full text-[11px]">
|
<Bot className="h-4 w-4 text-[var(--accent)]" aria-hidden />
|
||||||
<tbody>
|
Connecter une IA externe (ChatGPT)
|
||||||
{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'omettre <code className="font-mono">limit</code>,
|
|
||||||
et <code className="font-mono">meta.complet</code> confirme qu'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'articles) : lister d'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'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'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'importer.
|
|
||||||
Votre application doit être joignable depuis Internet.
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
}
|
||||||
<li>
|
>
|
||||||
Dans <strong>Authentification</strong>, choisissez <strong>Clé d'API</strong>, type{" "}
|
<ol className="list-decimal space-y-2 pl-5 text-[13px] text-[var(--text-secondary)]">
|
||||||
<strong>Personnalisé</strong>, nom d'en-tête <code className="font-mono">X-API-Key</code>,
|
<li>Créez une clé dans le cadre « Clés d'accès à l'API » ci-dessous et copiez-la.</li>
|
||||||
et collez votre clé.
|
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
|
||||||
</li>
|
<li>
|
||||||
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
|
Cliquez sur <strong>Importer depuis une URL</strong> et collez l'adresse de la description de l'API :
|
||||||
</ol>
|
<div className="mt-1.5"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
|
||||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
<span className="mt-1 block text-[var(--text-muted)]">
|
||||||
Si votre domaine public diffère de celui affiché ici, renseignez la variable
|
Cette description est publique (elle ne contient aucune donnée) pour que ChatGPT puisse la lire.
|
||||||
d'environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l'URL
|
L'application doit être joignable depuis Internet.
|
||||||
déclarée dans le schéma.
|
</span>
|
||||||
</p>
|
</li>
|
||||||
</div>
|
<li>
|
||||||
|
Dans <strong>Authentification</strong>, choisissez <strong>Clé d'API</strong>, type{" "}
|
||||||
|
<strong>Personnalisé</strong>, nom d'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'adresse publique de l'application diffère de celle affichée ici, renseignez la variable
|
||||||
|
d'environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l'adresse
|
||||||
|
annoncée dans la description.
|
||||||
|
</p>
|
||||||
|
</Bloc>
|
||||||
|
|
||||||
{/* Comportement à connaître */}
|
<div className="space-y-2">
|
||||||
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
|
<Repli titre="Adresses disponibles (endpoints)">
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||||
<p><strong>Aucun recalcul.</strong> L'API lit l'instantané de la grille, écrit quand la Grille
|
<table className="w-full">
|
||||||
est ouverte dans l'application. Un fournisseur jamais consulté renvoie <code className="font-mono">202</code>{" "}
|
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||||
<code className="font-mono">not_ready</code> plutôt que de faire attendre.</p>
|
<tr>
|
||||||
<p><strong>Métriques Qlik</strong> (<code className="font-mono">network</code>) et{" "}
|
<th scope="col" className="px-3 py-2 text-left">Méthode</th>
|
||||||
<strong>gamme serveur non modifiée</strong> (<code className="font-mono">codeGammeServeur</code>) sont
|
<th scope="col" className="px-3 py-2 text-left">Adresse</th>
|
||||||
relues à chaque appel. <code className="font-mono">network</code> vaut{" "}
|
<th scope="col" className="px-3 py-2 text-left">Contenu</th>
|
||||||
<code className="font-mono">null</code> quand le produit n'a pas de données réseau.</p>
|
</tr>
|
||||||
<p>Erreurs : <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec{" "}
|
</thead>
|
||||||
<code className="font-mono">401</code> (clé absente/invalide/révoquée),{" "}
|
<tbody>
|
||||||
<code className="font-mono">400</code>, <code className="font-mono">404</code>.</p>
|
{ENDPOINTS.map((e) => (
|
||||||
</div>
|
<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
|
<Repli titre="Paramètres communs">
|
||||||
href="/api/v1/openapi.json"
|
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||||
target="_blank"
|
<table className="w-full">
|
||||||
rel="noreferrer"
|
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] font-medium"
|
<tr>
|
||||||
style={{ color: "var(--accent)" }}
|
<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>
|
||||||
Ouvrir la spécification OpenAPI <ExternalLink className="w-3.5 h-3.5" />
|
</tr>
|
||||||
</a>
|
</thead>
|
||||||
</div>
|
<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'omettre <code className="font-mono">limit</code>,
|
||||||
|
et <code className="font-mono">meta.complet</code> confirme qu'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'articles) : lister d'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'API lit des grilles déjà calculées</strong>, enregistrées quand la Grille est ouverte dans
|
||||||
|
l'application ou par le préchauffage ci-dessous. Un fournisseur jamais calculé l'est au premier
|
||||||
|
appel, qui est alors plus lent ; avec <code className="font-mono">compute=0</code>, l'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'a pas de données réseau.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
En cas d'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'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).
|
* 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 { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
|
||||||
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
|
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 {
|
function fmtDate(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
|
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() {
|
export function ApiKeyManagement() {
|
||||||
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [erreurChargement, setErreurChargement] = useState<string | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [name, setName] = useState("");
|
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 [freshKey, setFreshKey] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
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
|
// 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.
|
// statement est un await. Cela évite une cascade de rendus au montage.
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setKeys(await getApiKeys());
|
setKeys(await getApiKeys());
|
||||||
setError(null);
|
setErreurChargement(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Erreur de chargement");
|
setErreurChargement(messageErreur(e));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -41,177 +63,233 @@ export function ApiKeyManagement() {
|
|||||||
|
|
||||||
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
|
// 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.
|
// 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(() => { void refresh(); }, [refresh]);
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const reessayer = () => {
|
||||||
|
setLoading(true);
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim() || creating) return;
|
const nom = name.trim();
|
||||||
|
if (!nom || creating) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setError(null);
|
try {
|
||||||
const res = await createApiKey(name.trim(), role);
|
const res = await createApiKey(nom, role);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setFreshKey(res.key);
|
setFreshKey(res.key);
|
||||||
setName("");
|
setName("");
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
await refresh();
|
toast.succes(`Clé « ${nom} » créée. Copiez-la maintenant : elle ne sera plus affichée.`);
|
||||||
} else {
|
await refresh();
|
||||||
setError(res.error);
|
} 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) => {
|
const copier = async () => {
|
||||||
await revokeApiKey(id);
|
if (!freshKey) return;
|
||||||
await refresh();
|
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) => {
|
const handleRevoke = async (k: ApiKeyRow) => {
|
||||||
await deleteApiKey(id);
|
const ok = await confirmer({
|
||||||
await refresh();
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<Card>
|
||||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
<CardHeader
|
||||||
Les clés donnent accès à l'API de lecture <code className="font-mono">/api/v1</code> (grille,
|
title="Clés d'accès à l'API"
|
||||||
recherche, métriques réseau) depuis un script ou un outil externe, via l'en-tête{" "}
|
description={
|
||||||
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
|
<>
|
||||||
</p>
|
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'est nécessaire.
|
||||||
{/* 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)" }}>
|
<CardContent className="space-y-5">
|
||||||
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
{/* Clé fraîchement créée — visible une seule fois */}
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
{freshKey && (
|
||||||
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
|
<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'ai copié la clé, la masquer
|
||||||
|
</Button>
|
||||||
</div>
|
</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)" }}>
|
{/* Création */}
|
||||||
{freshKey}
|
<form onSubmit={handleCreate} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end">
|
||||||
</code>
|
<div>
|
||||||
<button
|
<Label htmlFor="cle-nom">Nom de la clé</Label>
|
||||||
type="button"
|
<Input
|
||||||
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
|
id="cle-nom"
|
||||||
className="rounded-lg px-2.5 py-2 shrink-0"
|
value={name}
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
onChange={(e) => setName(e.target.value)}
|
||||||
title="Copier"
|
placeholder="ex. Export comptabilité"
|
||||||
>
|
className="w-full"
|
||||||
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
|
autoComplete="off"
|
||||||
</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Select
|
||||||
type="button"
|
id="cle-role"
|
||||||
onClick={() => setFreshKey(null)}
|
label="Droits"
|
||||||
className="text-[11px] underline"
|
value={role}
|
||||||
style={{ color: "var(--text-muted)" }}
|
onChange={(v) => setRole(v as Role)}
|
||||||
>
|
options={ROLES}
|
||||||
J'ai copié la clé, masquer
|
/>
|
||||||
</button>
|
<Button type="submit" disabled={creating || !name.trim()}>
|
||||||
</div>
|
{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'outil n'est plus utilisé.
|
||||||
|
La clé s'envoie dans l'en-tête <code className="font-mono">X-API-Key</code> de chaque appel.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Création */}
|
{/* Liste */}
|
||||||
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
|
{loading ? (
|
||||||
<input
|
<div className="space-y-2" aria-busy="true" aria-label="Chargement des clés">
|
||||||
value={name}
|
{Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
onChange={(e) => setName(e.target.value)}
|
</div>
|
||||||
placeholder="Nom de la clé (ex. Export comptabilité)"
|
) : erreurChargement ? (
|
||||||
className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none"
|
<ErrorState
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
title="La liste des clés n'a pas pu être chargée"
|
||||||
/>
|
detail={erreurChargement}
|
||||||
<select
|
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
|
||||||
value={role}
|
/>
|
||||||
onChange={(e) => setRole(e.target.value as "admin" | "user")}
|
) : keys.length === 0 ? (
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none"
|
<EmptyState
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
icon={KeyRound}
|
||||||
>
|
title="Aucune clé pour le moment"
|
||||||
<option value="user">Rôle : utilisateur</option>
|
description="Créez une clé ci-dessus pour permettre à un outil externe d'interroger l'API."
|
||||||
<option value="admin">Rôle : administrateur</option>
|
/>
|
||||||
</select>
|
) : (
|
||||||
<button
|
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||||
type="submit"
|
<table className="w-full">
|
||||||
disabled={creating || !name.trim()}
|
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
|
<tr>
|
||||||
style={{ background: "var(--accent)" }}
|
<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>
|
||||||
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
<th scope="col" className="px-3 py-2 text-left">Droits</th>
|
||||||
Créer
|
<th scope="col" className="px-3 py-2 text-left">Créée le</th>
|
||||||
</button>
|
<th scope="col" className="px-3 py-2 text-left">Dernière utilisation</th>
|
||||||
</form>
|
<th scope="col" className="px-3 py-2 text-right"><span className="sr-only">Actions</span></th>
|
||||||
|
|
||||||
{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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{keys.map((k) => (
|
||||||
</div>
|
<tr key={k.id} className="border-t border-[var(--border)]">
|
||||||
)}
|
<td className="px-3 py-2 text-sm font-medium text-[var(--text-primary)]">
|
||||||
</div>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 {
|
interface WarmupState {
|
||||||
status: "idle" | "running" | "success" | "error";
|
status: "idle" | "running" | "success" | "error";
|
||||||
@@ -26,7 +30,7 @@ interface WarmupState {
|
|||||||
|
|
||||||
export function GridWarmup() {
|
export function GridWarmup() {
|
||||||
const [state, setState] = useState<WarmupState | null>(null);
|
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 pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const running = state?.status === "running";
|
const running = state?.status === "running";
|
||||||
@@ -57,92 +61,102 @@ export function GridWarmup() {
|
|||||||
}, [running, fetchState]);
|
}, [running, fetchState]);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
setError(null);
|
setDemarrage(true);
|
||||||
|
let echec: string | null = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
|
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
|
if (res.ok) setState(data);
|
||||||
setState(data);
|
else echec = data.error ?? `erreur ${res.status}`;
|
||||||
} catch (e) {
|
} 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 traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
|
||||||
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
|
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<Card>
|
||||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
<CardHeader
|
||||||
L'API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
|
title="Préchauffage des données de l'API"
|
||||||
l'application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
|
description="Préchauffage : calcule à l'avance les grilles de tous les fournisseurs pour que l'API réponde immédiatement."
|
||||||
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.
|
<CardContent className="space-y-4">
|
||||||
</p>
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
L'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">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<Button onClick={start} disabled={running || demarrage}>
|
||||||
onClick={start}
|
{running || demarrage ? <Loader2 className="animate-spin" /> : <Play />}
|
||||||
disabled={running}
|
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
|
||||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
|
</Button>
|
||||||
style={{ background: "var(--accent)" }}
|
{state && state.total > 0 && (
|
||||||
>
|
<span className="text-[13px] tabular-nums text-[var(--text-secondary)]">
|
||||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
{fmtEntier(traites)} fournisseurs sur {fmtEntier(state.total)} ({pct} %)
|
||||||
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
|
</span>
|
||||||
</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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{state?.status === "success" && (
|
{running && state && (
|
||||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
<div className="space-y-1.5">
|
||||||
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
|
<div
|
||||||
<span>
|
className="h-2 overflow-hidden rounded-full bg-[var(--bg-elevated)]"
|
||||||
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
|
role="progressbar"
|
||||||
<strong>{state.skipped}</strong> déjà à jour
|
aria-valuemin={0}
|
||||||
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
|
aria-valuemax={100}
|
||||||
</span>
|
aria-valuenow={pct}
|
||||||
</div>
|
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 && (
|
{state?.status === "success" && (
|
||||||
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
<p className="flex items-start gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent-success)]" aria-hidden />
|
||||||
<ul className="mt-1 pl-4 list-disc space-y-0.5">
|
<span>
|
||||||
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
|
Terminé : <strong className="text-[var(--text-primary)]">{fmtEntier(state.done)}</strong> fournisseur(s) calculé(s),{" "}
|
||||||
</ul>
|
<strong className="text-[var(--text-primary)]">{fmtEntier(state.skipped)}</strong> déjà à jour
|
||||||
</details>
|
{state.failed > 0 && <>, <strong className="text-[var(--accent-error)]">{fmtEntier(state.failed)} en échec</strong></>}.
|
||||||
)}
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{(error || state?.error) && (
|
{state && state.failed > 0 && state.lastErrors.length > 0 && (
|
||||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
|
<details className="text-[13px] text-[var(--text-secondary)]">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
<summary className="cursor-pointer font-medium">Voir les échecs ({state.lastErrors.length} premiers)</summary>
|
||||||
<span>{error ?? state?.error}</span>
|
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-xs">
|
||||||
</div>
|
{state.lastErrors.map((e, i) => <li key={i} className="break-all font-mono">{e}</li>)}
|
||||||
)}
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
{state?.status === "idle" && (
|
{state?.error && (
|
||||||
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
<p role="alert" className="flex items-start gap-2 text-sm text-[var(--accent-error)]">
|
||||||
<Database className="w-3.5 h-3.5" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||||
Aucun préchauffage lancé depuis le démarrage de l'application.
|
<span>{state.error}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{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'application.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,209 +1,334 @@
|
|||||||
"use client";
|
"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 { 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 {
|
import {
|
||||||
UserPlus,
|
Dialog,
|
||||||
Trash2,
|
DialogContent,
|
||||||
Shield,
|
DialogDescription,
|
||||||
User,
|
DialogFooter,
|
||||||
Loader2,
|
DialogHeader,
|
||||||
Check,
|
DialogTitle,
|
||||||
Key,
|
} from "@/components/ui/dialog";
|
||||||
} from "lucide-react";
|
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||||
import { SuccessModal } from "@/components/shared/success-modal";
|
|
||||||
import { ChangePasswordModal } from "@/components/shared/change-password-modal";
|
|
||||||
|
|
||||||
export function UserManagement() {
|
type Utilisateur = Awaited<ReturnType<typeof getUsers>>[number];
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
type Role = "admin" | "user";
|
||||||
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: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const ROLES = [
|
||||||
setLoading(true);
|
{ value: "user", label: "Utilisateur" },
|
||||||
try {
|
{ value: "admin", label: "Administrateur" },
|
||||||
const data = await getUsers();
|
] as const;
|
||||||
setUsers(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
function messageErreur(e: unknown): string {
|
||||||
fetchUsers();
|
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();
|
e.preventDefault();
|
||||||
setIsCreating(true);
|
setErreur(null);
|
||||||
const res = await createUser(newUsername, newPassword, newRole);
|
if (motDePasse.length < 4) {
|
||||||
if (res.success) {
|
setErreur("Le mot de passe doit faire au moins 4 caractères.");
|
||||||
setModal({
|
return;
|
||||||
isOpen: true,
|
|
||||||
title: "Utilisateur Créé",
|
|
||||||
message: `L'utilisateur ${newUsername} a été ajouté avec succès.`
|
|
||||||
});
|
|
||||||
setNewUsername("");
|
|
||||||
setNewPassword("");
|
|
||||||
fetchUsers();
|
|
||||||
} else {
|
|
||||||
alert(res.error);
|
|
||||||
}
|
}
|
||||||
setIsCreating(false);
|
if (motDePasse !== confirmation) {
|
||||||
};
|
setErreur("Les deux mots de passe ne sont pas identiques.");
|
||||||
|
return;
|
||||||
const handleUpdatePassword = (id: number, username: string) => {
|
}
|
||||||
setPwdModal({ isOpen: true, userId: id, username });
|
setEnvoi(true);
|
||||||
};
|
try {
|
||||||
|
const res = await updatePassword(cible.id, motDePasse);
|
||||||
const handleConfirmPassword = async (newPassword: string) => {
|
if (!res.success) {
|
||||||
const res = await updatePassword(pwdModal.userId, newPassword);
|
setErreur(res.error ?? "Erreur lors de la mise à jour.");
|
||||||
if (res.success) {
|
return;
|
||||||
setModal({
|
}
|
||||||
isOpen: true,
|
toast.succes(`Le mot de passe de « ${cible.username} » a été modifié.`);
|
||||||
title: "Mot de passe mis à jour",
|
onFermer();
|
||||||
message: `Le mot de passe de ${pwdModal.username} a été modifié avec succès.`
|
} catch (err) {
|
||||||
});
|
setErreur(messageErreur(err));
|
||||||
} else {
|
} finally {
|
||||||
throw new Error(res.error ?? "Erreur lors de la mise à jour.");
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<Dialog open onOpenChange={(open) => { if (!open && !envoi) onFermer(); }}>
|
||||||
{/* Formulaire de création */}
|
<DialogContent className="sm:max-w-md">
|
||||||
<div className="bg-[var(--bg-elevated)] border border-[var(--border)] rounded-2xl p-6">
|
<form onSubmit={valider} className="grid gap-4">
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<DialogHeader>
|
||||||
<div className="w-12 h-12 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
<DialogTitle>Changer le mot de passe</DialogTitle>
|
||||||
<UserPlus className="w-6 h-6 text-[var(--accent)]" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-[var(--text-primary)]">Nouvel Utilisateur</h2>
|
<Label htmlFor="mdp-confirmation">Confirmer le mot de passe</Label>
|
||||||
<p className="text-[var(--text-secondary)] text-xs">Ajoutez un collaborateur à la plateforme.</p>
|
<Input id="mdp-confirmation" type="password" className="w-full" autoComplete="new-password"
|
||||||
|
value={confirmation} onChange={(e) => setConfirmation(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{erreur && (
|
||||||
|
<p role="alert" className="text-[13px] text-[var(--accent-error)]">{erreur}</p>
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<DialogFooter>
|
||||||
<div className="space-y-2">
|
<Button type="button" variant="outline" onClick={onFermer} disabled={envoi}>Annuler</Button>
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Utilisateur</label>
|
<Button type="submit" disabled={envoi}>
|
||||||
<input
|
{envoi && <Loader2 className="animate-spin" />}
|
||||||
required
|
Enregistrer le mot de passe
|
||||||
value={newUsername}
|
</Button>
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
</DialogFooter>
|
||||||
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>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
{/* 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) => (
|
export function UserManagement() {
|
||||||
<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">
|
const [users, setUsers] = useState<Utilisateur[]>([]);
|
||||||
<div className="w-10 h-10 rounded-xl bg-[var(--bg-elevated)] flex items-center justify-center shrink-0 border border-[var(--border)]">
|
const [chargement, setChargement] = useState(true);
|
||||||
{u.role === "admin" ? <Shield className="w-5 h-5 text-amber-500" /> : <User className="w-5 h-5 text-[var(--text-muted)]" />}
|
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>
|
||||||
<div className="flex-1">
|
<Button type="submit" disabled={creation}>
|
||||||
<h4 className="text-sm font-bold text-[var(--text-primary)]">{u.username}</h4>
|
{creation ? <Loader2 className="animate-spin" /> : <UserPlus />}
|
||||||
<span className="text-[10px] uppercase font-black text-[var(--text-muted)] tracking-wider">
|
Créer le compte
|
||||||
{u.role === "admin" ? "Administrateur" : "Utilisateur Standard"}
|
</Button>
|
||||||
</span>
|
</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>
|
||||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
) : erreurChargement ? (
|
||||||
<button
|
<ErrorState
|
||||||
onClick={() => handleUpdatePassword(u.id, u.username)}
|
title="La liste des comptes n'a pas pu être chargée"
|
||||||
className="p-2.5 rounded-lg hover:bg-[var(--accent-bg)] text-[var(--text-muted)] hover:text-[var(--accent)] transition-colors"
|
detail={erreurChargement}
|
||||||
title="Changer le mot de passe"
|
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
|
||||||
>
|
/>
|
||||||
<Key className="w-4 h-4" />
|
) : users.length === 0 ? (
|
||||||
</button>
|
<EmptyState
|
||||||
<button
|
icon={User}
|
||||||
onClick={() => handleDelete(u.id, u.username)}
|
title="Aucun compte"
|
||||||
className="p-2.5 rounded-lg hover:bg-[var(--accent-error-bg)] text-[var(--text-muted)] hover:text-[var(--accent-error)] transition-colors"
|
description="Créez le premier compte avec le formulaire ci-dessus."
|
||||||
title="Supprimer"
|
/>
|
||||||
>
|
) : (
|
||||||
<Trash2 className="w-4 h-4" />
|
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||||
</button>
|
<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>
|
)}
|
||||||
))}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<SuccessModal
|
{cibleMotDePasse && (
|
||||||
isOpen={modal.isOpen}
|
<DialogueMotDePasse
|
||||||
onClose={() => setModal({ ...modal, isOpen: false })}
|
key={cibleMotDePasse.id}
|
||||||
title={modal.title}
|
cible={cibleMotDePasse}
|
||||||
message={modal.message}
|
onFermer={() => setCibleMotDePasse(null)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<ChangePasswordModal
|
</>
|
||||||
isOpen={pwdModal.isOpen}
|
|
||||||
onClose={() => setPwdModal({ ...pwdModal, isOpen: false })}
|
|
||||||
onConfirm={handleConfirmPassword}
|
|
||||||
username={pwdModal.username}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,88 +2,87 @@
|
|||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { loginAction } from "../api/login-action";
|
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() {
|
export function LoginForm() {
|
||||||
const [error, action, isPending] = useActionState(loginAction, undefined);
|
const [error, action, isPending] = useActionState(loginAction, undefined);
|
||||||
|
|
||||||
return (
|
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="w-full max-w-sm rounded-2xl border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--shadow-md)]">
|
||||||
<div className="flex flex-col items-center mb-10 text-center">
|
<div className="mb-8 flex flex-col items-center 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">
|
<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="w-8 h-8 text-[var(--accent)]" />
|
<ShieldCheck className="h-6 w-6 text-[var(--accent)]" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-black text-[var(--text-primary)] tracking-tight mb-2">
|
<h1 className="text-2xl font-bold tracking-tight text-[var(--text-primary)]">CollectFlow</h1>
|
||||||
CollectFlow
|
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
</h1>
|
Connectez-vous avec votre identifiant et votre mot de passe.
|
||||||
<p className="text-[var(--text-secondary)] text-sm font-medium">
|
|
||||||
Connectez-vous pour accéder à votre espace d'arbitrage.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={action} className="space-y-6">
|
<form action={action} className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">
|
<label htmlFor="username" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||||
Utilisateur
|
Identifiant
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative">
|
||||||
<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" />
|
<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
|
<Input
|
||||||
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="admin"
|
autoComplete="username"
|
||||||
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"
|
placeholder="Votre identifiant"
|
||||||
|
className="h-11 w-full pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">
|
<label htmlFor="password" className="block text-sm font-medium text-[var(--text-primary)]">
|
||||||
Mot de passe
|
Mot de passe
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative">
|
||||||
<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" />
|
<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
|
<Input
|
||||||
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
placeholder="••••••••"
|
autoComplete="current-password"
|
||||||
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"
|
placeholder="Votre mot de passe"
|
||||||
|
className="h-11 w-full pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--accent-error)] shrink-0" />
|
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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button type="submit" size="lg" disabled={isPending} className="h-11 w-full">
|
||||||
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)" }}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
Connexion...
|
Connexion…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Se connecter"
|
"Se connecter"
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-8 text-center border-t border-[var(--border)] pt-8">
|
<p className="mt-6 border-t border-[var(--border)] pt-5 text-center text-[13px] text-[var(--text-muted)]">
|
||||||
<p className="text-[10px] text-[var(--text-muted)] uppercase font-black tracking-widest leading-relaxed">
|
Révision d'assortiment et suivi des ventes des magasins
|
||||||
Plateforme d'Arbitrage Magasin<br />
|
</p>
|
||||||
Propulsée par l'Intelligence Artificielle
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -307,7 +307,7 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
|
|||||||
{/* Réglages : quel magasin, et jusqu'où dans le classement */}
|
{/* 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 flex-wrap items-center gap-x-5 gap-y-2">
|
||||||
<div className="flex items-center gap-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)" }}>
|
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
{sites.map((s) => (
|
{sites.map((s) => (
|
||||||
<button
|
<button
|
||||||
@@ -327,7 +327,7 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)" }}>
|
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
{PROFONDEURS.map((p) => (
|
{PROFONDEURS.map((p) => (
|
||||||
<button
|
<button
|
||||||
@@ -345,7 +345,7 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{classement.length < profondeur && (
|
{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)
|
({classement.length} lignes affichées seulement)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -420,13 +420,13 @@ export function AssortmentGapsModal({ classement, critereId, mois, magasinInitia
|
|||||||
<tr key={row.codein} style={{ borderTop: "1px solid var(--border)" }}>
|
<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 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 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 || "—"}
|
{row.reference || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }} title={row.libelle1}>
|
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }} title={row.libelle1}>
|
||||||
<span className="font-semibold">{row.libelle1}</span>
|
<span className="font-semibold">{row.libelle1}</span>
|
||||||
{row.libelle3 && (
|
{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>
|
||||||
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
<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>
|
</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}.
|
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'apparaît pas ici : le magasin le détient, c'est un invendu, pas un trou d'assortiment.
|
Un produit sans vente mais en stock n'apparaît pas ici : le magasin le détient, c'est un invendu, pas un trou d'assortiment.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,14 +3,10 @@
|
|||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { GammeCode } from "@/types/grid";
|
import { GammeCode } from "@/types/grid";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface BulkActionToolbarProps {
|
||||||
selectedCodeins: string[];
|
selectedCodeins: string[];
|
||||||
@@ -32,30 +28,28 @@ export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkAct
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div
|
||||||
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
|
role="region"
|
||||||
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
|
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>
|
</span>
|
||||||
<div className="h-4 w-px bg-slate-700/50" />
|
<span className="text-[13px] text-[var(--text-secondary)]">— leur donner la gamme :</span>
|
||||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-tight">Appliquer en masse :</span>
|
{GAMMES.map((g) => (
|
||||||
{GAMME_OPTIONS.map(({ label, code, color }) => (
|
|
||||||
<button
|
<button
|
||||||
key={code}
|
key={g.code}
|
||||||
onClick={() => applyBulk(code)}
|
onClick={() => applyBulk(g.code as GammeCode)}
|
||||||
className={cn(
|
title={g.description}
|
||||||
"px-3 py-1 text-xs font-bold border rounded transition-colors",
|
className={cn("rounded-md border px-3 py-1 text-[13px] font-semibold transition-opacity hover:opacity-80", g.classes)}
|
||||||
color
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{label}
|
{g.code} — {g.nom}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={onClearSelection} className="ml-auto">
|
||||||
onClick={onClearSelection}
|
Annuler la sélection
|
||||||
className="ml-auto text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
</Button>
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
</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 })} €
|
|
||||||
</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 })} €
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,55 @@
|
|||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
"use client";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
|
|
||||||
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { SyncQlikButton } from "./sync-qlik-button";
|
import { SyncQlikButton } from "./sync-qlik-button";
|
||||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
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 { useMemo, useState, useTransition } from "react";
|
||||||
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
|
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 { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 }) {
|
function Stat({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: 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";
|
|
||||||
return (
|
return (
|
||||||
// Barre maintenant dans le flux : chaque pixel de hauteur est pris au
|
<div className="flex flex-col leading-tight">
|
||||||
// tableau. Ces chiffres sont déjà repris en gros dans l'en-tête de page,
|
<span className="text-xs text-[var(--text-muted)]">{label}</span>
|
||||||
// ils n'ont pas besoin d'y être une seconde fois en 20 px.
|
<span className="text-base font-bold tabular-nums text-[var(--text-primary)]">
|
||||||
<div className="flex flex-col items-center leading-tight">
|
{value}
|
||||||
<span className="text-slate-500 text-[10px] uppercase font-black tracking-tighter">{label}</span>
|
{sub && <span className="ml-1.5 text-[13px] font-semibold" style={{ color: subColor }}>{sub}</span>}
|
||||||
<span className={`font-mono-nums font-black text-base ${valColor}`}>{value}</span>
|
</span>
|
||||||
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloatingSummaryBar() {
|
function messageErreur(err: unknown): string {
|
||||||
const { data: session } = useSession();
|
if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE;
|
||||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
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 summary = useGridStore((s) => s.summary);
|
||||||
const resetDrafts = useGridStore((s) => s.resetDrafts);
|
const resetDrafts = useGridStore((s) => s.resetDrafts);
|
||||||
const rows = useGridStore((s) => s.rows);
|
const rows = useGridStore((s) => s.rows);
|
||||||
@@ -40,21 +57,8 @@ export function FloatingSummaryBar() {
|
|||||||
// Magasin affiché (bascule de la Grille). `filters.magasin` n'est jamais
|
// Magasin affiché (bascule de la Grille). `filters.magasin` n'est jamais
|
||||||
// renseigné : les sessions et validations étaient toutes notées « TOTAL ».
|
// renseigné : les sessions et validations étaient toutes notées « TOTAL ».
|
||||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||||
const draftChanges = useGridStore((s) => s.draftChanges);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
const [enCours, setEnCours] = useState<string | null>(null);
|
||||||
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 visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||||
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
|
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
|
||||||
@@ -70,42 +74,52 @@ export function FloatingSummaryBar() {
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await save();
|
try {
|
||||||
setSaveStatus(result.success ? "success" : "error");
|
const result = await save();
|
||||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
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 = () => {
|
const handleReset = async () => {
|
||||||
if (window.confirm("Es-tu sûr de vouloir annuler tous les changements non enregistrés (gammes) ?")) {
|
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();
|
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") => {
|
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot") => {
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return null;
|
||||||
setIsSavingSnapshot(true);
|
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 {
|
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({
|
const res = await saveSnapshot({
|
||||||
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
|
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
|
||||||
nomFournisseur: rows[0].nomFournisseur,
|
nomFournisseur: rows[0].nomFournisseur,
|
||||||
magasin: activeMagasin || "TOTAL",
|
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,
|
changes,
|
||||||
type,
|
type,
|
||||||
summary: {
|
summary: {
|
||||||
@@ -113,263 +127,148 @@ export function FloatingSummaryBar() {
|
|||||||
totalCa: summary.totalCa,
|
totalCa: summary.totalCa,
|
||||||
totalMarge: summary.totalMarge,
|
totalMarge: summary.totalMarge,
|
||||||
tauxMargeGlobal: summary.tauxMargeGlobal,
|
tauxMargeGlobal: summary.tauxMargeGlobal,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
if (!res.success) throw new Error(res.error);
|
||||||
if (res.success) {
|
if (!labelOverride) toast.succes("Session enregistrée dans l'Historique.");
|
||||||
if (!labelOverride) {
|
return res.snapshotId;
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
if (!labelOverride) toast.erreur(`La session n'a pas pu être enregistrée : ${messageErreur(err)}`);
|
||||||
if (!labelOverride) {
|
return null;
|
||||||
// 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)) {
|
/** Lance un export avec indicateur et messages d'erreur communs. */
|
||||||
setModal({
|
const lancer = async (cle: string, action: () => Promise<boolean>, vide: string) => {
|
||||||
isOpen: true,
|
if (enCours) return;
|
||||||
title: "Page à recharger",
|
setEnCours(cle);
|
||||||
message: STALE_ACTION_MESSAGE,
|
try {
|
||||||
variant: "error",
|
const ok = await action();
|
||||||
action: { label: "Recharger la page", onClick: () => window.location.reload() },
|
if (!ok) toast.info(vide);
|
||||||
});
|
} catch (err) {
|
||||||
} else {
|
toast.erreur(`L'export a échoué : ${messageErreur(err)}`);
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingSnapshot(false);
|
setEnCours(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
/*
|
/*
|
||||||
* Dans le flux, plus en `fixed`.
|
* 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.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
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"
|
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={{
|
style={{ borderColor: hasDrafts ? "var(--accent-warning)" : "var(--border-strong)" }}
|
||||||
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "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">
|
<div className="flex flex-wrap items-center gap-x-8 gap-y-1">
|
||||||
<button
|
<Stat label="Produits actifs" value={fmtEntier(summary.totalRows)} sub={summary.totalProducts !== summary.totalRows ? `sur ${fmtEntier(summary.totalProducts)}` : undefined} subColor="var(--text-muted)" />
|
||||||
onClick={handleReset}
|
<Stat label="Quantités vendues (12 mois)" value={fmtEntier(summary.totalQuantite)} />
|
||||||
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"
|
<Stat label="Chiffre d'affaires" value={fmtEur0(summary.totalCa)} />
|
||||||
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">
|
|
||||||
<Stat
|
<Stat
|
||||||
label="Nombre de Produits"
|
label="Marge"
|
||||||
value={summary.totalProducts.toLocaleString("fr-FR")}
|
value={fmtEur0(summary.totalMarge)}
|
||||||
/>
|
sub={`${fmtDecimal1(summary.tauxMargeGlobal)} %`}
|
||||||
<Stat
|
subColor={couleurMarge(summary.tauxMargeGlobal)}
|
||||||
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 })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-3 items-center">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{isAdmin && <SyncQlikButton codeFournisseur={supplierCode} lastUpdate={lastQlikUpdate} />}
|
{isAdmin && <SyncQlikButton codeFournisseur={supplierCode} lastUpdate={lastQlikUpdate} />}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="btn-action btn-action-secondary flex items-center gap-1.5">
|
<Button variant="outline" disabled={rows.length === 0 || enCours !== null}>
|
||||||
Export Excel
|
{enCours ? <Loader2 className="animate-spin" /> : <Download />}
|
||||||
<ChevronDown className="w-3.5 h-3.5" />
|
Exporter
|
||||||
</button>
|
<ChevronDown className="opacity-60" />
|
||||||
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</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'import des gammes</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
<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"
|
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">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">Seulement les changements</span>
|
<span className="font-medium">Gammes modifiées seulement</span>
|
||||||
<span className="text-xs text-muted-foreground">Exporter uniquement les gammes modifiées</span>
|
<span className="text-xs text-[var(--text-muted)]">Les produits dont la gamme a changé</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<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"
|
className="cursor-pointer"
|
||||||
|
onSelect={() => lancer("complet", () => exporterFichierGammes(false), "Aucun produit à exporter.")}
|
||||||
>
|
>
|
||||||
|
<FileSpreadsheet />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">Export complet</span>
|
<span className="font-medium">Toutes les gammes</span>
|
||||||
<span className="text-xs text-muted-foreground">Exporter toutes les lignes du fournisseur</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'Historique</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">Pour reprendre cette session plus tard</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<button
|
{hasDrafts && (
|
||||||
onClick={handleSave}
|
<Button variant="ghost" onClick={handleReset} disabled={isPending}>
|
||||||
disabled={!hasDrafts || isPending}
|
<RotateCcw /> Annuler
|
||||||
className="btn-action btn-action-primary"
|
</Button>
|
||||||
>
|
)}
|
||||||
{isPending ? (
|
<Button onClick={handleSave} disabled={!hasDrafts || isPending}>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
|
||||||
) : saveStatus === "success" ? (
|
{isPending
|
||||||
<CheckCircle className="w-4 h-4" />
|
? "Enregistrement…"
|
||||||
) : saveStatus === "error" ? (
|
: hasDrafts
|
||||||
<AlertCircle className="w-4 h-4" />
|
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
|
||||||
) : null}
|
: "Aucune modification"}
|
||||||
{isPending ? "Validation..." : "Valider"}
|
</Button>
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { GammeCode } from "@/types/grid";
|
import { GammeCode } from "@/types/grid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CLASSES_SANS_GAMME, GAMMES, trouverGamme } from "@/lib/gammes";
|
||||||
|
|
||||||
interface GammeSelectProps {
|
interface GammeSelectProps {
|
||||||
value: GammeCode | null;
|
value: GammeCode | null;
|
||||||
@@ -9,37 +10,38 @@ interface GammeSelectProps {
|
|||||||
onChange: (gamme: GammeCode) => void;
|
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",
|
* Choix de la gamme dans une ligne de la Grille.
|
||||||
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",
|
* La case affiche la lettre (la colonne est étroite) mais la liste déroulante
|
||||||
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",
|
* donne le sens de chaque gamme (« A — Cœur », « Z — Sortie »…) : une liste
|
||||||
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",
|
* native transparente est posée sur la pastille.
|
||||||
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",
|
*/
|
||||||
};
|
|
||||||
|
|
||||||
export function GammeSelect({ value, isDraft, onChange }: GammeSelectProps) {
|
export function GammeSelect({ value, isDraft, onChange }: GammeSelectProps) {
|
||||||
const displayValue = (!value || value.trim() === "") ? "Aucune" : value;
|
const gamme = trouverGamme(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<div
|
||||||
value={displayValue}
|
|
||||||
onChange={(e) => onChange(e.target.value as GammeCode)}
|
|
||||||
className={cn(
|
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",
|
"relative w-full rounded-lg border py-1.5 px-2 text-center text-xs font-bold shadow-sm",
|
||||||
"focus:ring-2 focus:ring-brand-500/20",
|
gamme ? gamme.classes : CLASSES_SANS_GAMME,
|
||||||
GAMME_STYLES[displayValue] || "border-[1px] border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-500",
|
isDraft && "ring-2 ring-[var(--accent-warning)]/60",
|
||||||
isDraft && "shadow-[0_0_8px_rgba(251,191,36,0.3)] border-amber-400/50"
|
|
||||||
)}
|
)}
|
||||||
|
title={gamme ? `${gamme.code} — ${gamme.nom} : ${gamme.description}` : "Aucune gamme"}
|
||||||
>
|
>
|
||||||
<option value="Aucune" disabled={displayValue !== "Aucune" && displayValue !== ""}>
|
<span aria-hidden>{gamme ? gamme.code : "—"}</span>
|
||||||
{displayValue === "Aucune" || displayValue === "" ? "— (Aucune)" : "—"}
|
<select
|
||||||
</option>
|
aria-label="Gamme du produit"
|
||||||
<option value="A">A</option>
|
value={gamme ? gamme.code : ""}
|
||||||
<option value="B">B</option>
|
onChange={(e) => onChange(e.target.value as GammeCode)}
|
||||||
<option value="C">C</option>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<option value="Y">Y</option>
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
<option value="Z">Z</option>
|
>
|
||||||
</select>
|
<option value="" disabled>— Aucune gamme</option>
|
||||||
|
{GAMMES.map((g) => (
|
||||||
|
<option key={g.code} value={g.code}>{g.code} — {g.nom}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
"use client";
|
"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 { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
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 { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
|
||||||
import type { ProductRow } from "@/types/grid";
|
import type { ProductRow } from "@/types/grid";
|
||||||
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { nomMagasin } from "@/lib/magasins";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { GridFilters } from "@/types/grid";
|
import type { GridFilters } from "@/types/grid";
|
||||||
@@ -22,6 +21,8 @@ interface GridClientProps {
|
|||||||
magasins: { code: string; nom: string }[];
|
magasins: { code: string; nom: string }[];
|
||||||
magasin: string;
|
magasin: string;
|
||||||
filters: Pick<GridFilters, "code1" | "code2" | "code3">;
|
filters: Pick<GridFilters, "code1" | "code2" | "code3">;
|
||||||
|
/** Calculé côté serveur : les droits ne changent plus d'un rendu à l'autre. */
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GridRowsStreamMessage =
|
type GridRowsStreamMessage =
|
||||||
@@ -34,14 +35,14 @@ type GridRowsStreamMessage =
|
|||||||
function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
|
function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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)" }}
|
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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<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)" }}
|
style={{ color: accent ? "var(--accent)" : "var(--text-primary)" }}
|
||||||
title={value}
|
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) {
|
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, isAdmin }: GridClientProps) {
|
||||||
const { data: session } = useSession();
|
|
||||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
|
||||||
const setRows = useGridStore((s) => s.setRows);
|
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 setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
||||||
const setFilter = useGridStore((s) => s.setFilter);
|
const setFilter = useGridStore((s) => s.setFilter);
|
||||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||||
@@ -63,16 +64,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
|
||||||
const [isLoadingRows, setIsLoadingRows] = useState(false);
|
const [isLoadingRows, setIsLoadingRows] = useState(false);
|
||||||
const [rowsLoaded, setRowsLoaded] = useState(0);
|
const [rowsLoaded, setRowsLoaded] = useState(0);
|
||||||
const [totalRows, setTotalRows] = useState<number | null>(null);
|
const [totalRows, setTotalRows] = useState<number | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | 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);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
@@ -126,7 +121,6 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
setTotalRows(null);
|
setTotalRows(null);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
setIsLoadingRows(true);
|
setIsLoadingRows(true);
|
||||||
setShowStartOverlay(true);
|
|
||||||
|
|
||||||
// Chaque mise à jour du store reconstruit tout le tableau (index, tri,
|
// Chaque mise à jour du store reconstruit tout le tableau (index, tri,
|
||||||
// filtres, compteurs) : la faire à chaque paquet de 150 lignes rendait le
|
// 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");
|
setActiveMagasin(magasin || "TOTAL");
|
||||||
}, [magasin, setActiveMagasin, isMounted]);
|
}, [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
|
// 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).
|
// 10 min (`refresh=1` → forceRefresh) et remonte l'état serveur à jour (INIT).
|
||||||
const handleForceRefresh = () => {
|
const handleForceRefresh = () => {
|
||||||
@@ -229,92 +215,73 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!isMounted) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full space-y-3 min-h-0">
|
<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 flex-wrap items-center justify-between gap-3 pb-3 border-b border-[var(--border)]">
|
||||||
<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-4 min-w-0">
|
||||||
<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))" }}
|
|
||||||
/>
|
|
||||||
<div className="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)" }}>
|
<p className="text-[13px] text-[var(--text-secondary)]">Révision d'assortiment</p>
|
||||||
Révision d'assortiment
|
<h1 className="text-xl font-bold tracking-tight truncate text-[var(--text-primary)]" title={nomFournisseur}>
|
||||||
</div>
|
|
||||||
<h1 className="text-[19px] font-bold tracking-tight truncate leading-none" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{nomFournisseur}
|
{nomFournisseur}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center gap-2 ml-1 pl-3 shrink-0" style={{ borderLeft: "1px solid var(--border)" }}>
|
<div className="hidden md:flex items-center gap-2 pl-4 shrink-0 border-l border-[var(--border)]">
|
||||||
<MetricPill label="Magasin" value={activeStoreNom} />
|
<MetricPill label="Magasin" value={nomMagasin(magasin)} />
|
||||||
<MetricPill label="Références" value={(totalRows ?? rows.length).toLocaleString("fr-FR")} accent />
|
<MetricPill label="Produits" value={nbReferences.toLocaleString("fr-FR")} accent />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{(isLoadingRows || loadError) && (
|
{loadError && (
|
||||||
<div
|
<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)]">
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tabular-nums"
|
<AlertCircle className="h-4 w-4" /> {loadError}
|
||||||
style={{
|
</span>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={handleForceRefresh}
|
onClick={handleForceRefresh}
|
||||||
disabled={isLoadingRows}
|
disabled={isLoadingRows}
|
||||||
title="Forcer le rafraîchissement — recharge les données depuis le serveur en ignorant le cache (met à jour la colonne INIT)"
|
title="Recharger les données depuis le serveur (gammes FF à jour comprises)"
|
||||||
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)" }}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${isLoadingRows ? "animate-spin" : ""}`} />
|
<RefreshCw className={isLoadingRows ? "animate-spin" : undefined} />
|
||||||
<span className="hidden sm:inline">Rafraîchir</span>
|
Actualiser
|
||||||
</button>
|
</Button>
|
||||||
<div id="grid-toolbar-actions"></div>
|
<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>
|
</div>
|
||||||
</header>
|
</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 */}
|
{/* Filters */}
|
||||||
<div className="shrink-0 print:hidden">
|
<div className="shrink-0 print:hidden">
|
||||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||||
@@ -339,77 +306,8 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
|
|
||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<div className="print:hidden">
|
<div className="print:hidden">
|
||||||
<FloatingSummaryBar />
|
<FloatingSummaryBar isAdmin={isAdmin} nomFournisseur={nomFournisseur} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
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 { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { trouverGamme } from "@/lib/gammes";
|
||||||
|
|
||||||
import { SupplierCombobox } from "./supplier-combobox";
|
import { SupplierCombobox } from "./supplier-combobox";
|
||||||
import { StoreCombobox } from "./store-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 draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
|
||||||
const [searchValue, setSearchValue] = React.useState(filters.search);
|
const [searchValue, setSearchValue] = React.useState(filters.search);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -104,30 +104,9 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
|||||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<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 */}
|
{/* Supplier Selector */}
|
||||||
<SupplierCombobox
|
<SupplierCombobox
|
||||||
fournisseurs={fournisseurs}
|
fournisseurs={fournisseurs}
|
||||||
@@ -141,7 +120,6 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
|||||||
magasins={magasins}
|
magasins={magasins}
|
||||||
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
|
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
|
||||||
onSelect={handleStoreSelect}
|
onSelect={handleStoreSelect}
|
||||||
className="w-[170px] xl:w-[220px]"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -152,7 +130,8 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Rechercher (code, désignation...)"
|
placeholder="Rechercher un produit (code, nom…)"
|
||||||
|
aria-label="Rechercher un produit dans la grille"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
// La croix native de `type="search"` (WebKit/Blink) est masquée :
|
// 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 count = value === null ? rows.length : gammeCounts[value] || 0;
|
||||||
const isActive = filters.codeGamme === value;
|
const isActive = filters.codeGamme === value;
|
||||||
|
|
||||||
// Semantic colors for the badge based on Gamme
|
const gamme = value ? trouverGamme(value) : undefined;
|
||||||
const getBadgeStyles = () => {
|
const titre = value === null
|
||||||
if (!isActive) return { background: "rgba(0,0,0,0.05)", color: "var(--text-muted)" };
|
? "Toutes les gammes"
|
||||||
switch (value) {
|
: gamme
|
||||||
case "A": return { background: "rgba(16, 185, 129, 0.15)", color: "rgb(5, 150, 105)" }; // Emerald
|
? `${gamme.code} — ${gamme.nom} : ${gamme.description}`
|
||||||
case "B": return { background: "rgba(59, 130, 246, 0.15)", color: "rgb(37, 99, 235)" }; // Blue
|
: "Produits sans gamme";
|
||||||
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();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
onClick={() => setFilter("codeGamme", value)}
|
onClick={() => setFilter("codeGamme", value)}
|
||||||
|
title={titre}
|
||||||
|
aria-pressed={isActive}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "shadow-[0_1px_2px_rgba(0,0,0,0.05)]"
|
? "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={{
|
style={{
|
||||||
background: isActive ? "var(--bg-surface)" : "transparent",
|
background: isActive ? "var(--bg-surface)" : "transparent",
|
||||||
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
{gamme ? (
|
||||||
<span
|
<span className={cn("rounded border px-1 text-xs font-bold", gamme.classes)}>{gamme.code}</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"
|
) : null}
|
||||||
style={badgeStyles}
|
<span>{value === null ? "Toutes" : gamme ? gamme.nom : "Sans gamme"}</span>
|
||||||
>
|
<span className="text-xs font-medium tabular-nums text-[var(--text-muted)]">
|
||||||
{count}
|
{count.toLocaleString("fr-FR")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ import {
|
|||||||
} from "@/features/grid/lib/network-trend";
|
} from "@/features/grid/lib/network-trend";
|
||||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||||
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
|
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 {
|
interface HeatmapGridProps {
|
||||||
onSelectionChange?: (codeins: string[]) => void;
|
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;
|
const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme;
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
|
const gamme = trouverGamme(displayValue);
|
||||||
return (
|
return (
|
||||||
<div className={[
|
<div
|
||||||
"w-full text-xs font-bold rounded-lg py-1.5 px-2 text-center",
|
className={cn("w-full rounded-lg border py-1.5 px-2 text-center text-xs font-bold", gamme ? gamme.classes : CLASSES_SANS_GAMME)}
|
||||||
displayValue === "A" ? "bg-emerald-500/10 text-emerald-700" :
|
title={gamme ? `${gamme.code} — ${gamme.nom} : ${gamme.description}` : "Aucune gamme"}
|
||||||
displayValue === "B" ? "bg-blue-500/10 text-blue-700" :
|
>
|
||||||
displayValue === "C" ? "bg-amber-500/10 text-amber-700" :
|
{gamme ? gamme.code : "—"}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,6 +242,38 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
|
|||||||
});
|
});
|
||||||
GammeCell.displayName = "GammeCell";
|
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 { Cell, Column, Row } from "@tanstack/react-table";
|
||||||
import { VirtualItem } from "@tanstack/react-virtual";
|
import { VirtualItem } from "@tanstack/react-virtual";
|
||||||
|
|
||||||
@@ -317,7 +348,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
|||||||
className={cn(
|
className={cn(
|
||||||
// Pas de `transition-all` : la ligne est positionnée par `transform`
|
// Pas de `transition-all` : la ligne est positionnée par `transform`
|
||||||
// et chaque défilement l'aurait animée pendant 200 ms.
|
// 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"
|
effectiveGamme === "Z" && "opacity-40 grayscale-[0.5] hover:grayscale-0 hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -345,7 +376,10 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
|||||||
// pas par le voile `bg-white/5` des cellules ordinaires.
|
// pas par le voile `bg-white/5` des cellules ordinaires.
|
||||||
estFige
|
estFige
|
||||||
? (isSelected
|
? (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)]")
|
: "bg-[var(--bg-surface)] group-hover/row:bg-[var(--bg-elevated)]")
|
||||||
: cn("group-hover/row:bg-white/5", isSelected && "bg-transparent"),
|
: 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)" }}>
|
<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") : "—"}
|
{d.qty != null ? Math.round(d.qty).toLocaleString("fr-FR") : "—"}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Entrées */}
|
{/* 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)" }}>
|
<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")}` : "—"}
|
{hasReceptions ? `+${Math.round(d.receptions!).toLocaleString("fr-FR")}` : "—"}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Stock fin de mois */}
|
{/* 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)" }}>
|
<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") : "—"}
|
{d.stock != null ? Math.round(d.stock).toLocaleString("fr-FR") : "—"}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -532,7 +566,7 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
<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>
|
<thead>
|
||||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
<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>
|
<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",
|
accessorKey: "codein",
|
||||||
header: "Code interne",
|
header: "Code article",
|
||||||
size: 90,
|
size: 90,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<CopiableCell
|
<CopiableCell
|
||||||
@@ -722,7 +756,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
<div
|
<div
|
||||||
key={magasin}
|
key={magasin}
|
||||||
title={tooltipTitle}
|
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={{
|
style={{
|
||||||
background: config.bg,
|
background: config.bg,
|
||||||
borderColor: config.border,
|
borderColor: config.border,
|
||||||
@@ -754,7 +788,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "gtin",
|
accessorKey: "gtin",
|
||||||
header: "EAN / GTIN",
|
header: "Code-barres",
|
||||||
size: 120,
|
size: 120,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<CopiableCell
|
<CopiableCell
|
||||||
@@ -784,7 +818,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
id: "caReseau",
|
id: "caReseau",
|
||||||
accessorFn: (row) => valeurTriable(row.caReseau),
|
accessorFn: (row) => valeurTriable(row.caReseau),
|
||||||
sortUndefined: "last" as const,
|
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,
|
size: 90,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.caReseau;
|
const val = row.original.caReseau;
|
||||||
@@ -799,7 +833,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
id: "qteReseau",
|
id: "qteReseau",
|
||||||
accessorFn: (row) => valeurTriable(row.qteReseau),
|
accessorFn: (row) => valeurTriable(row.qteReseau),
|
||||||
sortUndefined: "last" as const,
|
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,
|
size: 80,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.qteReseau;
|
const val = row.original.qteReseau;
|
||||||
@@ -814,7 +848,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
id: "nbMagasinsReseau",
|
id: "nbMagasinsReseau",
|
||||||
accessorFn: (row) => valeurTriable(row.nbMagasinsReseau),
|
accessorFn: (row) => valeurTriable(row.nbMagasinsReseau),
|
||||||
sortUndefined: "last" as const,
|
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,
|
size: 80,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.nbMagasinsReseau;
|
const val = row.original.nbMagasinsReseau;
|
||||||
@@ -836,7 +870,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
if (t.nouveau) return Number.POSITIVE_INFINITY;
|
if (t.nouveau) return Number.POSITIVE_INFINITY;
|
||||||
return t.pct ?? Number.NEGATIVE_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,
|
size: 78,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
|
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
|
// 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
|
// un libellé trop long chevauche la colonne voisine. Le détail
|
||||||
// (« CA réseau ÷ quantité ») est dans l'infobulle des cellules.
|
// (« 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,
|
size: 88,
|
||||||
cell: ({ row }: { row: { original: ProductRow } }) => {
|
cell: ({ row }: { row: { original: ProductRow } }) => {
|
||||||
const val = prixMoyenReseau(row.original);
|
const val = prixMoyenReseau(row.original);
|
||||||
@@ -888,7 +922,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
id: "prixVente",
|
id: "prixVente",
|
||||||
accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur),
|
accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur),
|
||||||
sortUndefined: "last" as const,
|
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,
|
size: 88,
|
||||||
cell: ({ row }: { row: { original: ProductRow } }) => {
|
cell: ({ row }: { row: { original: ProductRow } }) => {
|
||||||
const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin);
|
const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin);
|
||||||
@@ -903,7 +937,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
{valeur != null ? fmtEuro2(valeur) : "-"}
|
{valeur != null ? fmtEuro2(valeur) : "-"}
|
||||||
{/* Prix différents d'un magasin à l'autre : le signaler, la
|
{/* Prix différents d'un magasin à l'autre : le signaler, la
|
||||||
cellule ne peut en afficher qu'un. */}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -913,7 +947,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
id: "caParMagasinReseau",
|
id: "caParMagasinReseau",
|
||||||
accessorFn: (row) => valeurTriable(row.caParMagasinReseau),
|
accessorFn: (row) => valeurTriable(row.caParMagasinReseau),
|
||||||
sortUndefined: "last" as const,
|
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,
|
size: 85,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.caParMagasinReseau;
|
const val = row.original.caParMagasinReseau;
|
||||||
@@ -928,7 +962,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
id: "margePctReseau",
|
id: "margePctReseau",
|
||||||
accessorFn: (row) => valeurTriable(row.margePctReseau),
|
accessorFn: (row) => valeurTriable(row.margePctReseau),
|
||||||
sortUndefined: "last" as const,
|
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,
|
size: 75,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.margePctReseau;
|
const val = row.original.margePctReseau;
|
||||||
@@ -977,7 +1011,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
{
|
{
|
||||||
id: "totalQuantite",
|
id: "totalQuantite",
|
||||||
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0),
|
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,
|
size: 90,
|
||||||
cell: ({ row }: { row: { original: ProductRow } }) => {
|
cell: ({ row }: { row: { original: ProductRow } }) => {
|
||||||
const qty = activeMagasin === "TOTAL"
|
const qty = activeMagasin === "TOTAL"
|
||||||
@@ -1008,7 +1042,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
{
|
{
|
||||||
id: "totalCa",
|
id: "totalCa",
|
||||||
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalCa : (row.caByStore?.[activeMagasin] ?? 0),
|
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,
|
size: 90,
|
||||||
cell: ({ row }: { row: { original: ProductRow } }) => {
|
cell: ({ row }: { row: { original: ProductRow } }) => {
|
||||||
const ca = activeMagasin === "TOTAL"
|
const ca = activeMagasin === "TOTAL"
|
||||||
@@ -1024,7 +1058,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
{
|
{
|
||||||
id: "totalMarge",
|
id: "totalMarge",
|
||||||
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalMarge : (row.margeByStore?.[activeMagasin] ?? 0),
|
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,
|
size: 110,
|
||||||
cell: ({ row }: { row: { original: ProductRow } }) => {
|
cell: ({ row }: { row: { original: ProductRow } }) => {
|
||||||
const marge = activeMagasin === "TOTAL"
|
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)" }}>
|
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
|
||||||
{fmtEntier(marge)} €
|
{fmtEntier(marge)} €
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums text-[10px] font-bold opacity-70" style={{
|
<span className="tabular-nums text-xs font-semibold" style={{ color: couleurMarge(taux) }}>
|
||||||
color: taux >= 40 ? "var(--accent-success)" : taux >= 25 ? "var(--accent-warning)" : "var(--accent-error)"
|
|
||||||
}}>
|
|
||||||
{taux.toFixed(1)}%
|
{taux.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1050,7 +1082,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gammeInitial",
|
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,
|
size: 56,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-center font-bold text-[12px] print:hidden" style={{ color: "var(--text-secondary)" }}>
|
<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",
|
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,
|
size: 110,
|
||||||
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
||||||
},
|
},
|
||||||
@@ -1173,7 +1205,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
border: "1px solid var(--border)",
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1182,41 +1214,38 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
<>
|
<>
|
||||||
<div className="h-full w-full relative">
|
<div className="h-full w-full relative">
|
||||||
{portalContainer && createPortal(
|
{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. */}
|
{/* Trous d'assortiment — lit le classement affiché, tri compris. */}
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => setGapsOpen(true)}
|
onClick={() => setGapsOpen(true)}
|
||||||
disabled={tableRows.length === 0}
|
disabled={tableRows.length === 0}
|
||||||
title="Voir les produits qu'un magasin ne travaille pas — ni vente ni stock — en haut du classement affiché"
|
title="Les produits du haut du classement affiché qu'un de nos magasins ne vend pas et n'a pas en stock"
|
||||||
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)" }}
|
|
||||||
>
|
>
|
||||||
<PackageX className="w-4 h-4" />
|
<PackageX />
|
||||||
<span className="hidden lg:inline">Non travaillés</span>
|
<span className="hidden lg:inline">Non travaillés</span>
|
||||||
</button>
|
</Button>
|
||||||
{/*
|
{/*
|
||||||
* « Vues » replie des BLOCS de colonnes d'un geste, là où le menu
|
* « Blocs de colonnes » replie des BLOCS de colonnes d'un geste, là
|
||||||
* « Colonnes » voisin agit colonne par colonne. Douze cellules
|
* où le menu « Colonnes » voisin agit colonne par colonne. Douze
|
||||||
* mensuelles ou deux prix ne se masquent pas une par une.
|
* cellules mensuelles ou deux prix ne se masquent pas une par une.
|
||||||
*/}
|
*/}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<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"
|
variant="outline"
|
||||||
style={nbVues > 0
|
size="sm"
|
||||||
? { background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }
|
title="Afficher ou masquer les ventes mois par mois et les prix"
|
||||||
: { 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"
|
|
||||||
aria-label="Afficher ou masquer les blocs de colonnes"
|
aria-label="Afficher ou masquer les blocs de colonnes"
|
||||||
>
|
>
|
||||||
{nbVues > 0 ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
{nbVues > 0 ? <Eye /> : <EyeOff />}
|
||||||
<span className="hidden lg:inline">Vues</span>
|
<span className="hidden lg:inline">Affichage</span>
|
||||||
<span className="text-[11px] font-bold tabular-nums opacity-70">{nbVues}/2</span>
|
</Button>
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-[280px] z-50">
|
<DropdownMenuContent align="end" className="w-[300px] z-50">
|
||||||
<DropdownMenuLabel className="text-[11px] uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
<DropdownMenuLabel className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||||
Blocs de colonnes
|
Blocs de colonnes affichés
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
checked={showMonthlySales}
|
checked={showMonthlySales}
|
||||||
@@ -1225,11 +1254,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<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="flex flex-col">
|
||||||
<span className="text-xs font-semibold">Ventes mensuelles</span>
|
<span className="text-sm font-medium">Ventes mois par mois</span>
|
||||||
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
12 mois · détail au clic sur le total
|
Les 12 derniers mois
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1241,11 +1270,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<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="flex flex-col">
|
||||||
<span className="text-xs font-semibold">Prix</span>
|
<span className="text-sm font-medium">Prix</span>
|
||||||
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
PV moyen réseau · PV central
|
Prix moyen du réseau et prix de vente de nos magasins
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1254,32 +1283,28 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<Button variant="outline" size="sm" aria-label="Choisir les colonnes affichées">
|
||||||
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"
|
<SlidersHorizontal />
|
||||||
aria-label="Afficher/masquer les colonnes"
|
<span className="hidden lg:inline">Colonnes</span>
|
||||||
>
|
</Button>
|
||||||
<SlidersHorizontal className="w-4 h-4" />
|
|
||||||
Colonnes
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
</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
|
{table
|
||||||
.getAllLeafColumns()
|
.getAllLeafColumns()
|
||||||
.filter((column: Column<ProductRow, unknown>) => column.getCanHide())
|
.filter((column: Column<ProductRow, unknown>) => column.getCanHide() && column.id !== "select")
|
||||||
.map((column: Column<ProductRow, unknown>) => {
|
.map((column: Column<ProductRow, unknown>) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className="capitalize text-xs cursor-pointer font-medium"
|
className="text-sm cursor-pointer"
|
||||||
checked={column.getIsVisible()}
|
checked={column.getIsVisible()}
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
onSelect={(e: Event) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{typeof column.columnDef.header === 'string'
|
{libelleColonne(column.id, column.columnDef.header)}
|
||||||
? column.columnDef.header
|
|
||||||
: column.id.startsWith('month_')
|
|
||||||
? formatMonthLabel(column.id.replace('month_', ''))
|
|
||||||
: column.id}
|
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1324,7 +1349,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className={cn(
|
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",
|
!enteteFigee && "hover:bg-white/5",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1332,7 +1357,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
|
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
|
||||||
minWidth: size,
|
minWidth: size,
|
||||||
maxWidth: isFlexible ? "none" : size,
|
maxWidth: isFlexible ? "none" : size,
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-secondary)",
|
||||||
justifyContent: isCenter ? "center" : "flex-start",
|
justifyContent: isCenter ? "center" : "flex-start",
|
||||||
...(enteteFigee ? {
|
...(enteteFigee ? {
|
||||||
position: "sticky" as const,
|
position: "sticky" as const,
|
||||||
@@ -1393,16 +1418,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
|||||||
const isSelected = row.getIsSelected();
|
const isSelected = row.getIsSelected();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={row.id} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
|
// Pas de <div> autour de la ligne : un <div> dans un <tbody>
|
||||||
<GridRow
|
// est du HTML invalide (erreur d'hydratation signalée par React).
|
||||||
virtualRow={virtualRow}
|
<GridRow
|
||||||
row={row}
|
key={row.id}
|
||||||
rowHeight={rowHeight}
|
virtualRow={virtualRow}
|
||||||
isSelected={isSelected}
|
row={row}
|
||||||
columnsKey={columnsKey}
|
rowHeight={rowHeight}
|
||||||
columnSizing={columnSizing}
|
isSelected={isSelected}
|
||||||
/>
|
columnsKey={columnsKey}
|
||||||
</div>
|
columnSizing={columnSizing}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</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} />
|
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
{pct != null && (
|
{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)}%
|
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pct == null && nouveau && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -432,7 +432,7 @@ export function DualLineChart({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{labels.map((lab, i) => (
|
{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)}
|
{fmtMonthShort(lab)}
|
||||||
</text>
|
</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)" }}
|
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCode3Filter(null)}
|
onClick={() => setCode3Filter(null)}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLign
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
<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>
|
<thead>
|
||||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
|
<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>
|
<div className="font-semibold">{l.nom}</div>
|
||||||
{/* L'information secondaire (dernière entrée, mois avec
|
{/* L'information secondaire (dernière entrée, mois avec
|
||||||
vente) est écrite, pas réservée au survol. */}
|
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>
|
</td>
|
||||||
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
|
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
|
||||||
{cumul && (
|
{cumul && (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
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="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}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import { useMemo } from "react";
|
||||||
import { Check, ChevronsUpDown, Search, Building2 } from "lucide-react";
|
import { Segmented } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -16,98 +16,25 @@ interface StoreComboboxProps {
|
|||||||
className?: string;
|
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) {
|
export function StoreCombobox({ magasins, selectedCode, onSelect, className }: StoreComboboxProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const items = useMemo(
|
||||||
const [search, setSearch] = React.useState("");
|
() => [
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
{ value: CODE_TOUS_MAGASINS, label: LIBELLE_TOUS_MAGASINS },
|
||||||
|
...magasins.map((m) => ({ value: m.code, label: nomMagasin(m.code) })),
|
||||||
const options = React.useMemo(() => [
|
],
|
||||||
{ code: "TOTAL", nom: "Tous les magasins (Total)" },
|
[magasins],
|
||||||
...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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative w-[280px]", className)} ref={containerRef}>
|
<Segmented
|
||||||
<button
|
items={items}
|
||||||
type="button"
|
value={selectedCode || CODE_TOUS_MAGASINS}
|
||||||
onClick={() => setOpen(!open)}
|
onChange={onSelect}
|
||||||
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"
|
className={className}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,24 +14,30 @@ interface SupplierComboboxProps {
|
|||||||
selectedCode: string | null;
|
selectedCode: string | null;
|
||||||
onSelect: (code: string) => void;
|
onSelect: (code: string) => void;
|
||||||
className?: string;
|
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 [open, setOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const selectedSupplier = fournisseurs.find((f) => f.code === selectedCode);
|
const selectedSupplier = fournisseurs.find((f) => f.code === selectedCode);
|
||||||
|
|
||||||
const filtered = React.useMemo(() => {
|
const { affiches, total } = React.useMemo(() => {
|
||||||
if (!search) return fournisseurs;
|
const s = search.trim().toLowerCase();
|
||||||
const s = search.toLowerCase();
|
const trouves = s
|
||||||
return fournisseurs.filter(f =>
|
? fournisseurs.filter((f) => f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s))
|
||||||
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
|
: fournisseurs;
|
||||||
).slice(0, 50); // Limit display for performance
|
return { affiches: trouves.slice(0, AFFICHAGE_MAX), total: trouves.length };
|
||||||
}, [fournisseurs, search]);
|
}, [fournisseurs, search]);
|
||||||
|
|
||||||
// Handle click outside to close
|
// Fermeture au clic à l'extérieur
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
@@ -47,60 +53,62 @@ export function SupplierCombobox({ fournisseurs, selectedCode, onSelect, classNa
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
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">
|
<span className={cn("truncate", selectedSupplier ? "font-medium text-[var(--text-primary)]" : "text-[var(--text-muted)]")}>
|
||||||
{selectedSupplier ? selectedSupplier.nom : "Sélectionner un fournisseur..."}
|
{selectedSupplier ? selectedSupplier.nom : placeholder}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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="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="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
|
<div className="flex items-center gap-2 border-b border-[var(--border)] p-2">
|
||||||
<Search className="w-4 h-4 text-slate-400 shrink-0" />
|
<Search className="h-4 w-4 shrink-0 text-[var(--text-muted)]" />
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Rechercher..."
|
placeholder="Nom ou code du fournisseur…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
||||||
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
|
<div role="listbox" className="max-h-[320px] overflow-auto py-1">
|
||||||
{filtered.length === 0 ? (
|
{affiches.length === 0 ? (
|
||||||
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
|
<div className="px-4 py-3 text-center text-sm text-[var(--text-muted)]">
|
||||||
Aucun résultat
|
Aucun fournisseur ne correspond.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((f) => (
|
affiches.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.code}
|
key={f.code}
|
||||||
|
role="option"
|
||||||
|
aria-selected={f.code === selectedCode}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(f.code);
|
onSelect(f.code);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
f.code === selectedCode
|
||||||
? "bg-emerald-500/10 text-emerald-600 font-bold"
|
? "bg-[var(--accent-bg)] font-semibold text-[var(--text-primary)]"
|
||||||
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
|
: "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="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>
|
</div>
|
||||||
{f.code === selectedCode && (
|
{f.code === selectedCode && <Check className="h-4 w-4 shrink-0 text-[var(--accent)]" />}
|
||||||
<Check className="h-4 w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{fournisseurs.length > 50 && !search && (
|
{total > affiches.length && (
|
||||||
<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">
|
<div className="border-t border-[var(--border)] px-4 py-2 text-center text-xs text-[var(--text-muted)]">
|
||||||
Saisissez pour filtrer les {fournisseurs.length} entrées
|
{affiches.length} premiers sur {total.toLocaleString("fr-FR")} — précisez la recherche
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { SupplierCombobox } from "./supplier-combobox";
|
import { LayoutGrid } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { SupplierCombobox } from "./supplier-combobox";
|
||||||
|
import { GAMMES } from "@/lib/gammes";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Supplier {
|
interface Supplier {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -13,37 +16,48 @@ interface SupplierSelectionLandingProps {
|
|||||||
fournisseurs: Supplier[];
|
fournisseurs: Supplier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entrée de la Grille : une seule question, « quel fournisseur ? ». */
|
||||||
export function SupplierSelectionLanding({ fournisseurs }: SupplierSelectionLandingProps) {
|
export function SupplierSelectionLanding({ fournisseurs }: SupplierSelectionLandingProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSelect = (code: string) => {
|
const handleSelect = (code: string) => {
|
||||||
router.push(`/grid?fournisseur=${code}`);
|
router.push(`/grid?fournisseur=${encodeURIComponent(code)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||||
<div className="max-w-md space-y-6">
|
<div className="w-full max-w-xl space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center mx-auto mb-2 text-emerald-600">
|
<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)]">
|
||||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<LayoutGrid className="h-7 w-7" />
|
||||||
<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>
|
</div>
|
||||||
<h1 className="text-4xl font-black tracking-tight text-slate-900 dark:text-white">CollectFlow</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-[var(--text-primary)]">Révision d'assortiment</h1>
|
||||||
<p className="text-lg text-slate-500 dark:text-slate-400">
|
<p className="text-base text-[var(--text-secondary)]">
|
||||||
Optimisez votre assortiment en quelques clics. Sélectionnez un fournisseur pour commencer.
|
Choisissez un fournisseur : vous verrez tous ses produits avec leurs ventes, et vous pourrez
|
||||||
|
attribuer à chacun sa gamme.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center">
|
||||||
<SupplierCombobox
|
<SupplierCombobox
|
||||||
fournisseurs={fournisseurs}
|
fournisseurs={fournisseurs}
|
||||||
selectedCode={null}
|
selectedCode={null}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
className="w-[400px] !h-12 text-lg shadow-xl"
|
className="w-full max-w-md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-8 text-[11px] text-slate-400 uppercase tracking-[0.2em] font-bold">
|
<p className="text-[13px] text-[var(--text-muted)]">
|
||||||
Accès Rapide • {fournisseurs.length} Fournisseurs Disponibles
|
{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>
|
</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'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 "{search}"</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -38,21 +38,23 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<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)" }}
|
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
||||||
title={message || undefined}
|
title={message || undefined}
|
||||||
>
|
>
|
||||||
{status === "error"
|
{status === "error"
|
||||||
? message
|
? message
|
||||||
: status === "running"
|
: status === "running"
|
||||||
? (message || `Extraction Qlik…`)
|
? (message || "Récupération des données réseau…")
|
||||||
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
|
: lastUpdate
|
||||||
|
? `Réseau mis à jour le ${formatLastUpdate(lastUpdate)}`
|
||||||
|
: "Réseau jamais mis à jour"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={start}
|
onClick={start}
|
||||||
disabled={status === "running"}
|
disabled={status === "running"}
|
||||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
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" ? (
|
{status === "running" ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<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" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { GammeCode } from "@/types/grid";
|
|||||||
*/
|
*/
|
||||||
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
|
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
|
||||||
const draftChanges = useGridStore((s) => s.draftChanges);
|
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 resetDrafts = useGridStore((s) => s.resetDrafts);
|
||||||
const clearDrafts = useGridStore((s) => s.clearDrafts);
|
const clearDrafts = useGridStore((s) => s.clearDrafts);
|
||||||
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
|
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
|
||||||
@@ -31,8 +31,10 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
|
|||||||
const count = Object.keys(activeDrafts).length;
|
const count = Object.keys(activeDrafts).length;
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
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 changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => {
|
||||||
const row = rows.find((r) => r.codein === codein);
|
const row = rowsByCodein[codein];
|
||||||
return {
|
return {
|
||||||
codein,
|
codein,
|
||||||
codeGammeBefore: row?.codeGammeInit ?? row?.codeGamme ?? null,
|
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 };
|
if (changes.length === 0) return { success: true, saved: 0 };
|
||||||
|
|
||||||
// Derive supplier info from the first matching row
|
// 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 codeFournisseur = firstRow?.codeFournisseur ?? "";
|
||||||
const nomFournisseur = firstRow?.nomFournisseur ?? "";
|
const nomFournisseur = firstRow?.nomFournisseur ?? "";
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [activeDrafts, rows, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
|
}, [activeDrafts, rowsByCodein, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
|
||||||
|
|
||||||
return { save, hasDrafts: count > 0, count };
|
return { save, hasDrafts: count > 0, count };
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
|
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
|
||||||
* entre le rendu serveur et le rendu client.
|
* entre le rendu serveur et le rendu client.
|
||||||
*/
|
*/
|
||||||
|
import { MAGASINS } from "@/lib/magasins";
|
||||||
|
|
||||||
export function getLast12Months(): string[] {
|
export function getLast12Months(): string[] {
|
||||||
const months: string[] = [];
|
const months: string[] = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -87,11 +89,10 @@ export function qlikMonthToFf(key: string): string {
|
|||||||
return key.replace("-", "");
|
return key.replace("-", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Les deux magasins FF Nancy suivis par l'application. */
|
/** Les deux magasins FF Nancy suivis par l'application (source : lib/magasins). */
|
||||||
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
export const SITE_LABELS: Record<string, { label: string; nom: string }> = Object.fromEntries(
|
||||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
MAGASINS.map((m) => [m.code, { label: m.initiale, nom: m.nom }]),
|
||||||
"579": { label: "H", nom: "Houdemont" },
|
);
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
|
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 {
|
interface LogEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,7 +18,7 @@ interface LogEntry {
|
|||||||
function formatTaille(octets: number): string {
|
function formatTaille(octets: number): string {
|
||||||
if (octets < 1024) return `${octets} o`;
|
if (octets < 1024) return `${octets} o`;
|
||||||
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
|
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 {
|
function formatDate(iso: string): string {
|
||||||
@@ -80,69 +85,61 @@ export function ServerLogs() {
|
|||||||
}, [appliquer]);
|
}, [appliquer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<Card>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<CardHeader
|
||||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
title="Journal serveur"
|
||||||
Journal complet de chaque extraction Qlik (le plus récent en premier).
|
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."
|
||||||
Conservés le temps de la vie du serveur, 20 au maximum.
|
actions={
|
||||||
</p>
|
<Button variant="outline" size="sm" onClick={recharger} disabled={etat === "chargement"}>
|
||||||
<button
|
{etat === "chargement" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
onClick={recharger}
|
Actualiser
|
||||||
disabled={etat === "chargement"}
|
</Button>
|
||||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0 disabled:opacity-60"
|
}
|
||||||
>
|
/>
|
||||||
{etat === "chargement"
|
<CardContent>
|
||||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
{etat === "erreur" ? (
|
||||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
<ErrorState
|
||||||
Actualiser
|
title="Les journaux n'ont pas pu être chargés"
|
||||||
</button>
|
detail={erreur}
|
||||||
</div>
|
action={<Button variant="outline" onClick={recharger}>Réessayer</Button>}
|
||||||
|
/>
|
||||||
{etat === "erreur" && (
|
) : etat === "chargement" && logs.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 text-[12px] text-[var(--accent-error)]">
|
<div className="space-y-2" aria-busy="true" aria-label="Chargement des journaux">
|
||||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
{Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
<span>{erreur}</span>
|
</div>
|
||||||
</div>
|
) : logs.length === 0 ? (
|
||||||
)}
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
{etat !== "erreur" && logs.length === 0 && (
|
title="Aucun journal pour l'instant"
|
||||||
<p className="text-[12px] text-[var(--text-muted)]">
|
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."
|
||||||
Aucun journal pour l'instant — lancez une synchronisation Qlik depuis la Grille.
|
/>
|
||||||
</p>
|
) : (
|
||||||
)}
|
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)]">
|
||||||
|
{logs.map((log) => (
|
||||||
{logs.length > 0 && (
|
<li key={log.id} className="flex flex-wrap items-center gap-3 px-3 py-2.5">
|
||||||
<ul className="divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)]">
|
<FileText className="h-4 w-4 shrink-0 text-[var(--text-muted)]" aria-hidden />
|
||||||
{logs.map((log) => (
|
<div className="min-w-0 flex-1">
|
||||||
<li key={log.id} className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="w-4 h-4 shrink-0 text-[var(--text-muted)]" />
|
<span className="truncate font-mono text-sm font-medium text-[var(--text-primary)]">
|
||||||
<div className="min-w-0 flex-1">
|
{log.id}
|
||||||
<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
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span className="text-[11px] text-[var(--text-muted)]">
|
<Button asChild variant="outline" size="sm">
|
||||||
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
|
<a href={`/api/logs?id=${encodeURIComponent(log.id)}`} download>
|
||||||
</span>
|
<Download />
|
||||||
</div>
|
Télécharger
|
||||||
<a
|
</a>
|
||||||
href={`/api/logs?id=${encodeURIComponent(log.id)}`}
|
</Button>
|
||||||
download
|
</li>
|
||||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0"
|
))}
|
||||||
>
|
</ul>
|
||||||
<Download className="w-3.5 h-3.5" />
|
)}
|
||||||
Télécharger
|
</CardContent>
|
||||||
</a>
|
</Card>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,271 +1,267 @@
|
|||||||
"use client";
|
"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 { getSnapshots } from "../api/get-snapshots";
|
||||||
import { deleteSnapshot } from "../api/delete-snapshot";
|
import { deleteSnapshot } from "../api/delete-snapshot";
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { useRouter } from "next/navigation";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { StoreBadge } from "@/components/ui/badge";
|
||||||
Trash2,
|
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
|
||||||
UploadCloud,
|
import { confirmer, toast } from "@/components/ui/feedback";
|
||||||
Calendar,
|
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
|
||||||
Store,
|
import { couleurMarge } from "@/lib/marge";
|
||||||
History,
|
|
||||||
ChevronRight,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { SuccessModal } from "@/components/shared/success-modal";
|
|
||||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||||
import { ConfirmModal } from "@/components/shared/confirm-modal";
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
type Snapshot = Awaited<ReturnType<typeof getSnapshots>>[number];
|
||||||
return new Intl.DateTimeFormat("fr-FR", {
|
/** Modifications enregistrées : codein → gamme avant / après. */
|
||||||
day: "numeric",
|
type Changements = Record<string, { before?: string | null; after: string }>;
|
||||||
month: "long",
|
interface Resume { totalCa?: number; tauxMargeGlobal?: number }
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
const fmtDateLongue = new Intl.DateTimeFormat("fr-FR", {
|
||||||
minute: "2-digit"
|
day: "numeric",
|
||||||
}).format(date);
|
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 {
|
interface SnapshotListProps {
|
||||||
type?: "snapshot" | "export";
|
type?: "snapshot" | "export";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SnapshotList({ type }: SnapshotListProps) {
|
export function SnapshotList({ type }: SnapshotListProps) {
|
||||||
const [snapshots, setSnapshots] = useState<any[]>([]);
|
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [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: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
const restoreSnapshot = useGridStore((state) => state.restoreSnapshot);
|
||||||
* 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 router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const fetchSnapshots = async () => {
|
const estExport = type === "export";
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
// Aucun setState avant le premier await : appelable depuis l'effet de montage.
|
||||||
|
const fetchSnapshots = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getSnapshots(type);
|
const data = await getSnapshots(type);
|
||||||
setSnapshots(data);
|
setSnapshots(data);
|
||||||
|
setError(null);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSnapshots();
|
|
||||||
}, [type]);
|
}, [type]);
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
useEffect(() => { void fetchSnapshots(); }, [fetchSnapshots]);
|
||||||
setConfirmModal({
|
|
||||||
isOpen: true,
|
const reessayer = () => {
|
||||||
title: "Supprimer",
|
setLoading(true);
|
||||||
message: `Supprimer cet ${type === 'export' ? 'export' : 'snapshot'} définitivement ?`,
|
void fetchSnapshots();
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoad = (snapshot: any) => {
|
const handleDelete = async (s: Snapshot) => {
|
||||||
setConfirmModal({
|
const ok = await confirmer({
|
||||||
isOpen: true,
|
titre: estExport ? "Retirer cet export de l'historique ?" : "Supprimer cette session ?",
|
||||||
title: "Restaurer la session",
|
message: estExport
|
||||||
message: `Charger la session "${snapshot.label}" ? Cela remplacera vos brouillons actuels.`,
|
? `« ${titreDe(s)} » sera retiré définitivement de votre historique. Le fichier Excel déjà téléchargé n'est pas concerné.`
|
||||||
variant: "default",
|
: `« ${titreDe(s)} » sera supprimée définitivement : vous ne pourrez plus la reprendre dans la Grille.`,
|
||||||
onConfirm: () => {
|
libelleConfirmer: "Supprimer",
|
||||||
const changes: Record<string, string> = {};
|
danger: true,
|
||||||
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()}`);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center p-20 space-y-4">
|
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique">
|
||||||
<Loader2 className="w-8 h-8 text-[var(--accent)] animate-spin" />
|
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-24 w-full rounded-xl" />)}
|
||||||
<p className="text-[var(--text-muted)] text-sm">Récupération de l'historique...</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && snapshots.length === 0) {
|
if (error && snapshots.length === 0) {
|
||||||
return (
|
return error.perime ? (
|
||||||
<div className="border border-[var(--accent-error)] rounded-2xl p-10 text-center bg-[var(--accent-error-bg)]">
|
<ErrorState
|
||||||
<AlertCircle className="w-8 h-8 mx-auto mb-3 text-[var(--accent-error)]" />
|
title="Page à recharger"
|
||||||
<h3 className="text-[var(--text-primary)] font-bold mb-1">L'historique n'a pas pu être chargé</h3>
|
description={STALE_ACTION_MESSAGE}
|
||||||
<p className="text-[var(--text-secondary)] text-sm mb-4">{error}</p>
|
action={<Button onClick={() => window.location.reload()}>Recharger la page</Button>}
|
||||||
<button onClick={fetchSnapshots} className="btn-action btn-action-secondary">
|
/>
|
||||||
Réessayer
|
) : (
|
||||||
</button>
|
<ErrorState
|
||||||
</div>
|
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) {
|
if (snapshots.length === 0) {
|
||||||
return (
|
return estExport ? (
|
||||||
<div className="border border-dashed border-[var(--border-strong)] rounded-2xl p-16 text-center bg-[var(--bg-elevated)]/30">
|
<EmptyState
|
||||||
<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)]">
|
icon={FileDown}
|
||||||
<History className="w-8 h-8 text-[var(--text-muted)]" />
|
title="Aucun export"
|
||||||
</div>
|
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é."
|
||||||
<h3 className="text-[var(--text-primary)] font-bold mb-1">
|
/>
|
||||||
{type === "export" ? "Aucun export pour le moment" : "Aucun snapshot pour le moment"}
|
) : (
|
||||||
</h3>
|
<EmptyState
|
||||||
<p className="text-[var(--text-secondary)] text-sm max-w-xs mx-auto">
|
icon={History}
|
||||||
{type === "export"
|
title="Aucune session enregistrée"
|
||||||
? "Vos exports Excel apparaîtront ici pour historique."
|
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."
|
||||||
: "Capturez l'état de votre travail depuis la grille pour le retrouver ici ultérieurement."}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<ul className="grid gap-3">
|
||||||
{snapshots.map((s) => (
|
{snapshots.map((s) => {
|
||||||
<div
|
const nbModifs = Object.keys(changementsDe(s)).length;
|
||||||
key={s.id}
|
const resume = resumeDe(s);
|
||||||
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)]"
|
const taux = resume?.tauxMargeGlobal;
|
||||||
>
|
const IconeType = estExport ? FileDown : Save;
|
||||||
<div className="w-11 h-11 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center shrink-0">
|
return (
|
||||||
<Store className="w-5 h-5 text-[var(--accent)]" />
|
<li
|
||||||
</div>
|
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-1 min-w-0">
|
>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex min-w-0 flex-1 basis-72 items-start gap-4">
|
||||||
<h3 className="text-sm font-bold text-[var(--text-primary)] truncate">{s.label}</h3>
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)]">
|
||||||
<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">
|
<IconeType className="h-5 w-5 text-[var(--accent)]" aria-hidden />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-[9px] uppercase font-black text-[var(--text-muted)] tracking-wider">Marge</span>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-bold text-[var(--text-secondary)]">
|
<h3 className="truncate text-sm font-semibold text-[var(--text-primary)]">
|
||||||
{s.summaryJson.tauxMargeGlobal?.toFixed(1)}%
|
{titreDe(s)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{resume && (
|
||||||
<button
|
<dl className="flex items-center gap-6 md:border-l md:border-[var(--border)] md:pl-6">
|
||||||
onClick={() => handleLoad(s)}
|
<div>
|
||||||
className="apple-btn-primary flex items-center gap-2"
|
<dt className="text-xs font-medium text-[var(--text-muted)]">Chiffre d'affaires</dt>
|
||||||
>
|
<dd className="text-sm font-semibold tabular-nums text-[var(--text-primary)]">
|
||||||
Restaurer
|
{fmtEur0(resume.totalCa ?? 0)}
|
||||||
<ChevronRight className="w-4 h-4" />
|
</dd>
|
||||||
</button>
|
</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
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => handleDelete(s.id)}
|
<Button onClick={() => handleLoad(s)}>
|
||||||
disabled={isDeleting === s.id}
|
Reprendre dans la Grille
|
||||||
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"
|
<ChevronRight />
|
||||||
title="Supprimer"
|
</Button>
|
||||||
>
|
<Button
|
||||||
{isDeleting === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
variant="outline"
|
||||||
</button>
|
size="icon"
|
||||||
</div>
|
onClick={() => handleDelete(s)}
|
||||||
</div>
|
disabled={isDeleting === s.id}
|
||||||
))}
|
aria-label={estExport ? "Supprimer cet export" : "Supprimer cette session"}
|
||||||
|
title="Supprimer"
|
||||||
<SuccessModal
|
className="text-[var(--text-secondary)] hover:border-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
|
||||||
isOpen={modal.isOpen}
|
>
|
||||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
{isDeleting === s.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||||
title={modal.title}
|
</Button>
|
||||||
message={modal.message}
|
</div>
|
||||||
variant={modal.variant}
|
</li>
|
||||||
action={modal.action}
|
);
|
||||||
/>
|
})}
|
||||||
|
</ul>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id.toString(),
|
id: user.id.toString(),
|
||||||
|
// `name` est repris dans la session : sans lui, l'en-tête ne
|
||||||
|
// pouvait pas afficher qui est connecté.
|
||||||
|
name: user.username,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const GLOSSAIRE = {
|
|||||||
},
|
},
|
||||||
reseau: {
|
reseau: {
|
||||||
terme: "Réseau",
|
terme: "Réseau",
|
||||||
definition: `Les ${NB_MAGASINS_RESEAU} magasins du réseau La Foir'Fouille (données Qlik).`,
|
definition: `Les ${NB_MAGASINS_RESEAU} magasins du réseau La Foir'Fouille, toutes enseignes confondues.`,
|
||||||
},
|
},
|
||||||
nosMagasins: {
|
nosMagasins: {
|
||||||
terme: "Nos magasins",
|
terme: "Nos magasins",
|
||||||
|
|||||||
Reference in new issue
Block a user