diff --git a/src/app/(dashboard)/exports/page.tsx b/src/app/(dashboard)/exports/page.tsx index a62ce29..3017ba1 100644 --- a/src/app/(dashboard)/exports/page.tsx +++ b/src/app/(dashboard)/exports/page.tsx @@ -1,14 +1,24 @@ +import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; +import { FileDown } from "lucide-react"; + export default function ExportsPage() { return ( -
-
-

Exports

-

Génération des fichiers Excel différentiels pour l'ERP.

-
-
-

Sélectionnez un fournisseur dans la grille, puis cliquez sur « Exporter ».

-

L'export génère un fichier .xlsx avec le delta avant/après des gammes révisées.

+
+
+
+
+
+ +
+

Historique d'Arbitrage

+
+

+ Retrouvez ici toutes vos sessions sauvegardées et exports réalisés. Vous pouvez restaurer une session pour reprendre votre travail ou supprimer les anciens enregistrements. +

+
+ +
); } diff --git a/src/app/(dashboard)/snapshots/page.tsx b/src/app/(dashboard)/snapshots/page.tsx index ec96772..880768d 100644 --- a/src/app/(dashboard)/snapshots/page.tsx +++ b/src/app/(dashboard)/snapshots/page.tsx @@ -1,14 +1,24 @@ +import { SnapshotList } from "@/features/snapshots/components/snapshot-list"; +import { Camera } from "lucide-react"; + export default function SnapshotsPage() { return ( -
-
-

Snapshots

-

Historique de vos sessions d'arbitrage sauvegardées.

-
-
-

Aucun snapshot enregistré.

-

Utilisez le bouton « Valider » dans la grille pour créer votre premier snapshot.

+
+
+
+
+
+ +
+

Snapshots

+
+

+ Historique de vos sessions d'arbitrage. Restaurez une sauvegarde pour retrouver vos modifications en cours sur une gamme spécifique. +

+
+ +
); } diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 9bcf919..58b9469 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -1,12 +1,11 @@ -"use client"; - import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store"; import { BulkAiAnalyzer } from "./bulk-ai-analyzer"; import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; -import { Loader2, CheckCircle, AlertCircle, RotateCcw } from "lucide-react"; +import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera } from "lucide-react"; import { useState, useTransition } from "react"; +import { saveSnapshot } from "@/features/snapshots/api/save-snapshot"; function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { // Determine color based on label to match the prototype @@ -21,9 +20,10 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin } export function FloatingSummaryBar() { - const { summary, resetDrafts, rows, filters } = useGridStore(); + const { summary, resetDrafts, rows, filters, draftChanges } = useGridStore(); const { resetInsights } = useAiCopilotStore(); const [isPending, startTransition] = useTransition(); + const [isSavingSnapshot, setIsSavingSnapshot] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); const visibleCodeins = rows.map(r => r.codein); @@ -44,6 +44,49 @@ export function FloatingSummaryBar() { } }; + const handleSnapshot = async (labelOverride?: string) => { + if (rows.length === 0) return; + setIsSavingSnapshot(true); + try { + const modifiedRows = rows.filter(r => draftChanges[r.codein]); + const changes = Object.fromEntries( + modifiedRows.map(r => [ + r.codein, + { + before: r.codeGamme, + after: draftChanges[r.codein] as string + } + ]) + ); + + const res = await saveSnapshot({ + codeFournisseur: filters.codeFournisseur || rows[0].codeFournisseur, + nomFournisseur: rows[0].nomFournisseur, + magasin: filters.magasin || "TOTAL", + label: labelOverride || `Session ${rows[0].nomFournisseur} — ${new Date().toLocaleTimeString()}`, + changes, + summary: { + totalRows: summary.totalRows, + totalCa: summary.totalCa, + totalMarge: summary.totalMarge, + tauxMargeGlobal: summary.tauxMargeGlobal, + } + }); + + if (res.success) { + if (!labelOverride) alert("Snapshot enregistré avec succès ! Retrouvez-le dans la page Snapshots."); + return res.snapshotId; + } else { + throw new Error(res.error); + } + } catch (err) { + console.error(err); + if (!labelOverride) alert("Erreur lors de la création du snapshot."); + } finally { + setIsSavingSnapshot(false); + } + }; + return (
-
+
-
+
+ +
{hasDrafts && ( {count} modif. @@ -111,6 +163,9 @@ export function FloatingSummaryBar() { const nomFournisseur = modifiedRows[0].nomFournisseur; + // Auto-save snapshot for history + await handleSnapshot(`Export ${nomFournisseur} — ${new Date().toLocaleTimeString()}`); + try { const res = await fetch("/api/export/modified-gammes", { method: "POST", diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 625699c..4b7ef49 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -24,6 +24,7 @@ interface GridState { setFilter: (key: keyof GridFilters, value: string | null) => void; setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void; setActiveGridQuery: (query: string) => void; + restoreSnapshot: (changes: Record) => void; } function computeSummary(rows: ProductRow[], drafts: Record): GridSummary { @@ -101,6 +102,9 @@ export const useGridStore = create()( }, setDisplayDensity: (density) => set({ displayDensity: density }), setActiveGridQuery: (query) => set({ activeGridQuery: query }), + restoreSnapshot: (changes) => { + set({ draftChanges: changes, summary: computeSummary(get().rows, changes) }); + }, }), { name: "collectflow-grid-storage", diff --git a/src/features/snapshots/api/delete-snapshot.ts b/src/features/snapshots/api/delete-snapshot.ts new file mode 100644 index 0000000..de84817 --- /dev/null +++ b/src/features/snapshots/api/delete-snapshot.ts @@ -0,0 +1,15 @@ +"use server"; + +import { db } from "@/db"; +import { sessionSnapshots } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export async function deleteSnapshot(id: number) { + try { + await db.delete(sessionSnapshots).where(eq(sessionSnapshots.id, id)); + return { success: true }; + } catch (err) { + console.error(err); + return { success: false, error: "Delete failed" }; + } +} diff --git a/src/features/snapshots/api/get-snapshots.ts b/src/features/snapshots/api/get-snapshots.ts new file mode 100644 index 0000000..fda2126 --- /dev/null +++ b/src/features/snapshots/api/get-snapshots.ts @@ -0,0 +1,9 @@ +"use server"; + +import { db } from "@/db"; +import { sessionSnapshots } from "@/db/schema"; +import { desc } from "drizzle-orm"; + +export async function getSnapshots() { + return db.select().from(sessionSnapshots).orderBy(desc(sessionSnapshots.createdAt)); +} diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx new file mode 100644 index 0000000..24c8e3b --- /dev/null +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { getSnapshots } from "../api/get-snapshots"; +import { deleteSnapshot } from "../api/delete-snapshot"; +import { useGridStore } from "@/features/grid/store/use-grid-store"; +import { useRouter } from "next/navigation"; +import { + Trash2, + UploadCloud, + Calendar, + Store, + History, + ChevronRight, + Loader2, + AlertCircle +} from "lucide-react"; + +const formatDate = (date: Date) => { + return new Intl.DateTimeFormat("fr-FR", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }).format(date); +}; + +export function SnapshotList() { + const [snapshots, setSnapshots] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(null); + + const restoreSnapshot = useGridStore(state => state.restoreSnapshot); + const router = useRouter(); + + const fetchSnapshots = async () => { + setLoading(true); + try { + const data = await getSnapshots(); + setSnapshots(data); + } catch (err) { + setError("Impossible de charger les snapshots."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSnapshots(); + }, []); + + const handleDelete = async (id: number) => { + if (!window.confirm("Supprimer ce snapshot définitivement ?")) return; + setIsDeleting(id); + try { + const res = await deleteSnapshot(id); + if (res.success) { + setSnapshots(prev => prev.filter(s => s.id !== id)); + } else { + alert("Erreur lors de la suppression."); + } + } catch (err) { + alert("Erreur technique lors de la suppression."); + } finally { + setIsDeleting(null); + } + }; + + const handleLoad = (snapshot: any) => { + if (!window.confirm(`Charger la session "${snapshot.label}" ? Cela remplacera vos brouillons actuels.`)) return; + + // Les changements sont stockés sous forme d'objet { codein: { before, after } } + // On doit re-transformer en Record + const changes: Record = {}; + Object.entries(snapshot.changes as any).forEach(([codein, delta]: [string, any]) => { + changes[codein] = delta.after; + }); + + restoreSnapshot(changes); + + // Redirection vers la grille avec les filtres du snapshot + 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 (loading) { + return ( +
+ +

Récupération de vos sessions...

+
+ ); + } + + if (snapshots.length === 0) { + return ( +
+
+ +
+

Aucun snapshot pour le moment

+

+ Capturez l'état de votre travail depuis la grille pour le retrouver ici ultérieurement. +

+
+ ); + } + + return ( +
+ {snapshots.map((s) => ( +
+
+ +
+ +
+
+

{s.label}

+ + {s.magasin || "Magasin Inconnu"} + +
+ +
+ + + {formatDate(new Date(s.createdAt))} + + + + {Object.keys(s.changes as any).length} modifications enregistrées + +
+
+ + {s.summaryJson && ( +
+
+ CA + + {(s.summaryJson.totalCa || 0).toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })} + +
+
+ Marge + + {s.summaryJson.tauxMargeGlobal?.toFixed(1)}% + +
+
+ )} + +
+ + + +
+
+ ))} +
+ ); +}