mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +02:00
- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms (immédiatement à la fermeture), tendance réseau et texte de recherche calculés une fois par ligne, courbe de tendance mémorisée, barre du bas abonnée au seul nombre de modifications. - Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux fournisseurs du cadencier (résultats identiques sur la base de test). - Dépendances inutilisées retirées (react-query, @google/genai, ai, react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ; pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README). - Code mort et journaux de build retirés. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
282 lines
14 KiB
TypeScript
282 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
|
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
|
import { SyncQlikButton } from "./sync-qlik-button";
|
|
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
|
import { ChevronDown, Download, FileSpreadsheet, FileText, History, Loader2, RotateCcw, Save, Table2 } from "lucide-react";
|
|
import { useMemo, useState, useTransition } from "react";
|
|
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
|
|
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Button } from "@/components/ui/button";
|
|
import { confirmer, toast } from "@/components/ui/feedback";
|
|
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
|
|
import { couleurMarge } from "@/lib/marge";
|
|
import {
|
|
exporterFichierGammes,
|
|
exporterGammeA,
|
|
exporterTousProduits,
|
|
imprimerPdf,
|
|
lignesModifiees,
|
|
} from "@/features/grid/lib/exports";
|
|
|
|
function Stat({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) {
|
|
return (
|
|
<div className="flex flex-col leading-tight">
|
|
<span className="text-xs text-[var(--text-muted)]">{label}</span>
|
|
<span className="text-base font-bold tabular-nums text-[var(--text-primary)]">
|
|
{value}
|
|
{sub && <span className="ml-1.5 text-[13px] font-semibold" style={{ color: subColor }}>{sub}</span>}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function messageErreur(err: unknown): string {
|
|
if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE;
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
/**
|
|
* Barre du bas de la Grille : totaux du périmètre affiché et TOUTES les actions
|
|
* sur les gammes (annuler, enregistrer, exporter). Il y avait des doublons dans
|
|
* l'en-tête, avec des droits différents.
|
|
*/
|
|
function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean; nomFournisseur: string }) {
|
|
const summary = useGridStore((s) => s.summary);
|
|
const resetDrafts = useGridStore((s) => s.resetDrafts);
|
|
const rows = useGridStore((s) => s.rows);
|
|
const filterFournisseur = useGridStore((s) => s.filters.codeFournisseur);
|
|
// Magasin affiché (bascule de la Grille). `filters.magasin` n'est jamais
|
|
// renseigné : les sessions et validations étaient toutes notées « TOTAL ».
|
|
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
|
const [isPending, startTransition] = useTransition();
|
|
const [enCours, setEnCours] = useState<string | null>(null);
|
|
|
|
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
|
|
|
|
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
|
|
const lastQlikUpdate = useMemo(() => {
|
|
let max: string | null = null;
|
|
for (const r of rows) {
|
|
if (r.networkFetchedAt && (!max || r.networkFetchedAt > max)) max = r.networkFetchedAt;
|
|
}
|
|
return max;
|
|
}, [rows]);
|
|
|
|
const handleSave = () => {
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await save();
|
|
if (result.success) {
|
|
toast.succes(`${result.saved} gamme${result.saved > 1 ? "s" : ""} enregistrée${result.saved > 1 ? "s" : ""}.`);
|
|
} else {
|
|
toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`);
|
|
}
|
|
} catch (err) {
|
|
toast.erreur(`Enregistrement impossible : ${messageErreur(err)}`);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleReset = async () => {
|
|
const ok = await confirmer({
|
|
titre: "Annuler les modifications ?",
|
|
message: `Les ${count} gamme${count > 1 ? "s" : ""} modifiée${count > 1 ? "s" : ""} et non enregistrée${count > 1 ? "s" : ""} reviendront à leur valeur précédente.`,
|
|
libelleConfirmer: "Annuler les modifications",
|
|
danger: true,
|
|
});
|
|
if (ok) {
|
|
resetDrafts();
|
|
toast.info("Modifications annulées.");
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Enregistre une copie de la session dans l'Historique.
|
|
* Renvoie l'identifiant créé, ou `null` en cas d'échec (déjà signalé).
|
|
*/
|
|
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot") => {
|
|
if (rows.length === 0) return null;
|
|
const { draftChanges } = useGridStore.getState();
|
|
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
|
|
const changes = Object.fromEntries(
|
|
lignesModifiees().map(r => [
|
|
r.codein,
|
|
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
|
|
])
|
|
);
|
|
try {
|
|
const res = await saveSnapshot({
|
|
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
|
|
nomFournisseur: rows[0].nomFournisseur,
|
|
magasin: activeMagasin || "TOTAL",
|
|
label: labelOverride || `Session ${rows[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
|
|
changes,
|
|
type,
|
|
summary: {
|
|
totalRows: summary.totalRows,
|
|
totalCa: summary.totalCa,
|
|
totalMarge: summary.totalMarge,
|
|
tauxMargeGlobal: summary.tauxMargeGlobal,
|
|
},
|
|
});
|
|
if (!res.success) throw new Error(res.error);
|
|
if (!labelOverride) toast.succes("Session enregistrée dans l'Historique.");
|
|
return res.snapshotId;
|
|
} catch (err) {
|
|
if (!labelOverride) toast.erreur(`La session n'a pas pu être enregistrée : ${messageErreur(err)}`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/** Lance un export avec indicateur et messages d'erreur communs. */
|
|
const lancer = async (cle: string, action: () => Promise<boolean>, vide: string) => {
|
|
if (enCours) return;
|
|
setEnCours(cle);
|
|
try {
|
|
const ok = await action();
|
|
if (!ok) toast.info(vide);
|
|
} catch (err) {
|
|
toast.erreur(`L'export a échoué : ${messageErreur(err)}`);
|
|
} finally {
|
|
setEnCours(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
/*
|
|
* Dans le flux, plus en `fixed` : la grille cède exactement la hauteur
|
|
* nécessaire, sans recouvrir la dernière ligne ni l'ascenseur horizontal.
|
|
*/
|
|
className="shrink-0 px-4 py-2.5 flex flex-wrap justify-between items-center gap-x-6 gap-y-2 rounded-xl border bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
|
|
style={{ borderColor: hasDrafts ? "var(--accent-warning)" : "var(--border-strong)" }}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-x-8 gap-y-1">
|
|
<Stat label="Produits actifs" value={fmtEntier(summary.totalRows)} sub={summary.totalProducts !== summary.totalRows ? `sur ${fmtEntier(summary.totalProducts)}` : undefined} subColor="var(--text-muted)" />
|
|
<Stat label="Quantités vendues (12 mois)" value={fmtEntier(summary.totalQuantite)} />
|
|
<Stat label="Chiffre d'affaires" value={fmtEur0(summary.totalCa)} />
|
|
<Stat
|
|
label="Marge"
|
|
value={fmtEur0(summary.totalMarge)}
|
|
sub={`${fmtDecimal1(summary.tauxMargeGlobal)} %`}
|
|
subColor={couleurMarge(summary.tauxMargeGlobal)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{isAdmin && <SyncQlikButton codeFournisseur={supplierCode} lastUpdate={lastQlikUpdate} />}
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" disabled={rows.length === 0 || enCours !== null}>
|
|
{enCours ? <Loader2 className="animate-spin" /> : <Download />}
|
|
Exporter
|
|
<ChevronDown className="opacity-60" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80">
|
|
<DropdownMenuLabel className="text-xs font-medium text-[var(--text-secondary)]">Fichier d'import des gammes</DropdownMenuLabel>
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
onSelect={() => lancer("modifs", async () => {
|
|
if (lignesModifiees().length === 0) return false;
|
|
const nom = lignesModifiees()[0].nomFournisseur;
|
|
// Copie dans l'Historique, onglet « Exports »
|
|
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export");
|
|
return exporterFichierGammes(true);
|
|
}, "Aucune gamme n'a été modifiée par rapport à FF.")}
|
|
>
|
|
<FileSpreadsheet />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">Gammes modifiées seulement</span>
|
|
<span className="text-xs text-[var(--text-muted)]">Les produits dont la gamme a changé</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
onSelect={() => lancer("complet", () => exporterFichierGammes(false), "Aucun produit à exporter.")}
|
|
>
|
|
<FileSpreadsheet />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">Toutes les gammes</span>
|
|
<span className="text-xs text-[var(--text-muted)]">Tous les produits du fournisseur</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuLabel className="text-xs font-medium text-[var(--text-secondary)]">Documents de travail</DropdownMenuLabel>
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
onSelect={() => lancer("tous", () => exporterTousProduits(nomFournisseur), "Aucun produit à exporter.")}
|
|
>
|
|
<Table2 />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">Tableau complet (Excel)</span>
|
|
<span className="text-xs text-[var(--text-muted)]">Gamme avant / après et chiffres sur 12 mois</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
onSelect={() => lancer("gammeA", exporterGammeA, "Aucun produit en gamme A.")}
|
|
>
|
|
<FileSpreadsheet />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">Liste des produits en gamme A (Excel)</span>
|
|
<span className="text-xs text-[var(--text-muted)]">Code-barres, référence, libellé, magasins</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
onSelect={() => lancer("pdf", () => imprimerPdf(nomFournisseur), "Aucun produit à imprimer.")}
|
|
>
|
|
<FileText />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">Tableau imprimable (PDF)</span>
|
|
<span className="text-xs text-[var(--text-muted)]">Mise en page paysage</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="cursor-pointer" onSelect={() => { void handleSnapshot(); }}>
|
|
<History />
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">Garder une copie dans l'Historique</span>
|
|
<span className="text-xs text-[var(--text-muted)]">Pour reprendre cette session plus tard</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{hasDrafts && (
|
|
<Button variant="ghost" onClick={handleReset} disabled={isPending}>
|
|
<RotateCcw /> Annuler
|
|
</Button>
|
|
)}
|
|
<Button onClick={handleSave} disabled={!hasDrafts || isPending}>
|
|
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
|
|
{isPending
|
|
? "Enregistrement…"
|
|
: hasDrafts
|
|
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
|
|
: "Aucune modification"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
|
|
* parent change d'état (sélection, progression du chargement…).
|
|
*/
|
|
export const FloatingSummaryBar = React.memo(FloatingSummaryBarInner);
|