mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement session snapshot creation and history viewing with new pages and components.
This commit is contained in:
1 parent
7765f97a1f
commit
c9cf83d4c7
7 files changed
+309
-22
No files matched your search
@@ -1,14 +1,24 @@
|
||||
import { SnapshotList } from "@/features/snapshots/components/snapshot-list";
|
||||
import { FileDown } from "lucide-react";
|
||||
|
||||
export default function ExportsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Exports</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">Génération des fichiers Excel différentiels pour l'ERP.</p>
|
||||
</div>
|
||||
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-500 text-sm">Sélectionnez un fournisseur dans la grille, puis cliquez sur <strong className="text-slate-400">« Exporter »</strong>.</p>
|
||||
<p className="text-slate-600 text-xs mt-1">L'export génère un fichier <code className="text-slate-500">.xlsx</code> avec le delta avant/après des gammes révisées.</p>
|
||||
<div className="space-y-8 max-w-6xl mx-auto px-4 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
|
||||
<FileDown className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-100 italic uppercase">Historique d'Arbitrage</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 max-w-xl">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SnapshotList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
import { SnapshotList } from "@/features/snapshots/components/snapshot-list";
|
||||
import { Camera } from "lucide-react";
|
||||
|
||||
export default function SnapshotsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Snapshots</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">Historique de vos sessions d'arbitrage sauvegardées.</p>
|
||||
</div>
|
||||
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-500 text-sm">Aucun snapshot enregistré.</p>
|
||||
<p className="text-slate-600 text-xs mt-1">Utilisez le bouton <strong className="text-slate-500">« Valider »</strong> dans la grille pour créer votre premier snapshot.</p>
|
||||
<div className="space-y-8 max-w-6xl mx-auto px-4 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
|
||||
<Camera className="w-5 h-5 text-indigo-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-100 italic uppercase">Snapshots</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 max-w-xl">
|
||||
Historique de vos sessions d'arbitrage. Restaurez une sauvegarde pour retrouver vos modifications en cours sur une gamme spécifique.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SnapshotList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
|
||||
@@ -51,7 +94,7 @@ export function FloatingSummaryBar() {
|
||||
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-bold px-4 py-2 rounded-full border flex items-center gap-2 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-500">
|
||||
<div className="text-xs font-bold px-4 py-2 rounded-full border flex items-center bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-500">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-1.5 hover:bg-rose-50 dark:hover:bg-rose-900/20 text-slate-400 hover:text-rose-500 transition-colors rounded-lg border border-transparent hover:border-rose-200"
|
||||
@@ -59,7 +102,16 @@ export function FloatingSummaryBar() {
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-800 mx-1" />
|
||||
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-800 mx-1.5" />
|
||||
<button
|
||||
onClick={() => handleSnapshot()}
|
||||
disabled={isSavingSnapshot}
|
||||
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-slate-400 hover:text-indigo-500 transition-colors rounded-lg border border-transparent hover:border-indigo-200 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>
|
||||
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-800 mx-1.5" />
|
||||
{hasDrafts && (
|
||||
<span className="text-[10px] font-black uppercase text-amber-600 dark:text-amber-500 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded border border-amber-200 dark:border-amber-800">
|
||||
{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",
|
||||
|
||||
@@ -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<string, GammeCode>) => void;
|
||||
}
|
||||
|
||||
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
|
||||
@@ -101,6 +102,9 @@ export const useGridStore = create<GridState>()(
|
||||
},
|
||||
setDisplayDensity: (density) => set({ displayDensity: density }),
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
|
||||
restoreSnapshot: (changes) => {
|
||||
set({ draftChanges: changes, summary: computeSummary(get().rows, changes) });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "collectflow-grid-storage",
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<number | null>(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<string, GammeCode>
|
||||
const changes: Record<string, string> = {};
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center p-20 space-y-4">
|
||||
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
|
||||
<p className="text-slate-400 text-sm">Récupération de vos sessions...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="border border-dashed border-slate-700 rounded-2xl p-16 text-center bg-slate-800/20">
|
||||
<div className="w-16 h-16 rounded-full bg-slate-800 flex items-center justify-center mx-auto mb-4 border border-slate-700">
|
||||
<History className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<h3 className="text-slate-200 font-bold mb-1">Aucun snapshot pour le moment</h3>
|
||||
<p className="text-slate-500 text-sm max-w-xs mx-auto">
|
||||
Capturez l'état de votre travail depuis la grille pour le retrouver ici ultérieurement.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{snapshots.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="group relative flex items-center gap-6 p-5 rounded-2xl border transition-all hover:shadow-xl bg-slate-800/40 border-slate-700/50 hover:border-indigo-500/50 hover:bg-slate-800/60"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center shrink-0">
|
||||
<Store className="w-6 h-6 text-indigo-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-sm font-bold text-slate-100 truncate">{s.label}</h3>
|
||||
<span className="px-2 py-0.5 rounded-full bg-indigo-500/10 border border-indigo-500/20 text-[10px] font-black uppercase text-indigo-400 tracking-wider">
|
||||
{s.magasin || "Magasin Inconnu"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-[11px] text-slate-400 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-slate-700/50 pl-8 mr-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] uppercase font-black text-slate-500 tracking-wider">CA</span>
|
||||
<span className="text-xs font-bold text-emerald-400">
|
||||
{(s.summaryJson.totalCa || 0).toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] uppercase font-black text-slate-500 tracking-wider">Marge</span>
|
||||
<span className="text-xs font-bold text-slate-200">
|
||||
{s.summaryJson.tauxMargeGlobal?.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleLoad(s)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-[12px] font-black transition-all active:scale-95 shadow-lg shadow-indigo-900/20"
|
||||
>
|
||||
Restaurer
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(s.id)}
|
||||
disabled={isDeleting === s.id}
|
||||
className="p-2.5 rounded-xl border border-slate-700 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/30 transition-all active:scale-95"
|
||||
title="Supprimer"
|
||||
>
|
||||
{isDeleting === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user