feat: revert individual pending changes from TopBar dropdown

- pendingRows now stores originalData + newData per article so changes
  can be individually reverted to their pre-edit state
- TopBar Save button becomes a split button: left saves all, right
  opens a dropdown listing every pending change with article no + name
- Each row in the dropdown has a Revert button that restores the
  original data locally and removes it from the pending queue
- Dropdown closes automatically when all changes are saved/reverted
  or when clicking outside

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-04-09 08:58:31 +02:00
co-authored by Claude Sonnet 4.6
parent acc1d06269
commit a7d8dad36e
2 changed files with 115 additions and 25 deletions
+31 -8
View File
@@ -39,7 +39,7 @@ export default function App() {
const [isLoadingDefault, setIsLoadingDefault] = useState(true); const [isLoadingDefault, setIsLoadingDefault] = useState(true);
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null); const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({}); const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
const [pendingRows, setPendingRows] = useState<Record<string, ExcelRow>>({}); const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
const [isSavingAll, setIsSavingAll] = useState(false); const [isSavingAll, setIsSavingAll] = useState(false);
useEffect(() => { useEffect(() => {
@@ -241,26 +241,47 @@ export default function App() {
}; };
const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => { const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => {
// Update local UI state only — no Supabase call here. const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
// Changes are queued in pendingRows and saved manually via handleSaveAll. const originalData = appState.data[rowIndex]; // Capture before update
setAppState(prev => { setAppState(prev => {
const newData = [...prev.data]; const newData = [...prev.data];
newData[rowIndex] = updatedRow; newData[rowIndex] = updatedRow;
return { ...prev, data: newData, hasUnsavedChanges: true }; return { ...prev, data: newData, hasUnsavedChanges: true };
}); });
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]); setPendingRows(prev => ({
setPendingRows(prev => ({ ...prev, [articleNo]: updatedRow })); ...prev,
[articleNo]: {
rowIndex,
// Keep the very first originalData if already pending (re-edit case)
originalData: prev[articleNo]?.originalData ?? originalData,
newData: updatedRow,
articleName: String(updatedRow[COLUMNS.ARTICLE_NAME] || articleNo),
}
}));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' })); setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setEditingRowIndex(null); setEditingRowIndex(null);
}; };
const handleRevertRow = (articleNo: string) => {
const pending = pendingRows[articleNo];
if (!pending) return;
setAppState(prev => {
const newData = [...prev.data];
newData[pending.rowIndex] = pending.originalData;
const stillPending = Object.keys(pendingRows).length > 1;
return { ...prev, data: newData, hasUnsavedChanges: stillPending };
});
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
setRowStatuses(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
};
const handleSaveAll = async () => { const handleSaveAll = async () => {
const entries = Object.entries(pendingRows) as [string, ExcelRow][]; const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
if (entries.length === 0) return; if (entries.length === 0) return;
setIsSavingAll(true); setIsSavingAll(true);
let allSuccess = true; let allSuccess = true;
for (const [articleNo, rowData] of entries) { for (const [articleNo, { newData }] of entries) {
const success = await saveRowToSupabase(articleNo, rowData); const success = await saveRowToSupabase(articleNo, newData);
if (success) { if (success) {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' })); setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; }); setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
@@ -343,7 +364,9 @@ export default function App() {
undoMessage={undoHistory[0]?.message} undoMessage={undoHistory[0]?.message}
undoSteps={undoHistory.length} undoSteps={undoHistory.length}
pendingCount={Object.keys(pendingRows).length} pendingCount={Object.keys(pendingRows).length}
pendingChanges={Object.fromEntries(Object.entries(pendingRows).map(([k, v]) => [k, { articleName: (v as any).articleName }]))}
onSaveAll={handleSaveAll} onSaveAll={handleSaveAll}
onRevertRow={handleRevertRow}
isSavingAll={isSavingAll} isSavingAll={isSavingAll}
/> />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
+71 -4
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Download, LogOut, Undo2, CloudUpload, Loader2 } from 'lucide-react'; import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface TopBarProps { interface TopBarProps {
@@ -14,11 +14,32 @@ interface TopBarProps {
undoMessage?: string; undoMessage?: string;
undoSteps: number; undoSteps: number;
pendingCount: number; pendingCount: number;
pendingChanges: Record<string, { articleName: string }>;
onSaveAll: () => Promise<void>; onSaveAll: () => Promise<void>;
onRevertRow: (articleNo: string) => void;
isSavingAll: boolean; isSavingAll: boolean;
} }
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, onSaveAll, isSavingAll }: TopBarProps) { export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll }: TopBarProps) {
const [showPending, setShowPending] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showPending) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowPending(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPending]);
// Close dropdown when all changes are saved/reverted
useEffect(() => {
if (pendingCount === 0) setShowPending(false);
}, [pendingCount]);
return ( return (
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl"> <header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
<div className="flex items-center -ml-4"> <div className="flex items-center -ml-4">
@@ -60,15 +81,61 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{pendingCount > 0 && ( {pendingCount > 0 && (
<div className="relative" ref={dropdownRef}>
{/* Split button: Save All + dropdown toggle */}
<div className="flex items-center rounded-md overflow-hidden shadow-lg shadow-green-900/30">
<button <button
onClick={onSaveAll} onClick={onSaveAll}
disabled={isSavingAll} disabled={isSavingAll}
className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all bg-green-600 hover:bg-green-500 disabled:opacity-60 text-white shadow-lg shadow-green-900/30" className="flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all bg-green-600 hover:bg-green-500 disabled:opacity-60 text-white"
> >
{isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />} {isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
{isSavingAll ? 'Saving...' : `Save ${pendingCount} change${pendingCount > 1 ? 's' : ''}`} {isSavingAll ? 'Saving...' : `Save ${pendingCount} change${pendingCount > 1 ? 's' : ''}`}
</button> </button>
<button
onClick={() => setShowPending(v => !v)}
disabled={isSavingAll}
className="flex items-center px-2 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-60 text-white border-l border-green-500/40 transition-all"
title="View pending changes"
>
<ChevronDown className={cn("w-4 h-4 transition-transform", showPending && "rotate-180")} />
</button>
</div>
{/* Dropdown: list of pending changes */}
{showPending && (
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
</div>
<div className="max-h-64 overflow-y-auto">
{Object.entries(pendingChanges).map(([articleNo, { articleName }]) => (
<div
key={articleNo}
className="flex items-center gap-2 px-3 py-2.5 hover:bg-slate-700/50 border-b border-slate-700/50 last:border-0"
>
<div className="w-2 h-2 rounded-full bg-yellow-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-mono text-slate-400">{articleNo}</p>
<p className="text-sm text-white truncate">{articleName}</p>
</div>
<button
onClick={() => onRevertRow(articleNo)}
title="Discard this change"
className="shrink-0 flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-white hover:bg-red-600 rounded transition-colors"
>
<RotateCcw className="w-3 h-3" />
Revert
</button>
</div>
))}
</div>
</div>
)} )}
</div>
)}
{hasData && ( {hasData && (
<button <button
onClick={onExport} onClick={onExport}