feat: manual Save All button in TopBar with pending change tracking

Changes are now queued locally (yellow highlight) and only persisted to
Supabase when the user clicks the Save button in the top-right corner.
The button shows the count of pending changes and a spinner while saving.
EditPanel closes immediately after queuing — no Supabase call per edit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-04-09 08:45:22 +02:00
co-authored by Claude Sonnet 4.6
parent c544b9b709
commit acc1d06269
3 changed files with 49 additions and 43 deletions
+27 -16
View File
@@ -39,6 +39,8 @@ 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 [isSavingAll, setIsSavingAll] = useState(false);
useEffect(() => { useEffect(() => {
const loadDefaultData = async () => { const loadDefaultData = async () => {
@@ -238,31 +240,37 @@ export default function App() {
setAppState(prev => ({ ...prev, hasUnsavedChanges: false })); setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
}; };
const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow): Promise<boolean> => { const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => {
// 1. Optimistically update UI // Update local UI state only — no Supabase call here.
// Changes are queued in pendingRows and saved manually via handleSaveAll.
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]); const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
setPendingRows(prev => ({ ...prev, [articleNo]: updatedRow }));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' })); setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setEditingRowIndex(null);
};
// 2. Persist to Supabase const handleSaveAll = async () => {
const success = await saveRowToSupabase(articleNo, updatedRow); const entries = Object.entries(pendingRows) as [string, ExcelRow][];
if (entries.length === 0) return;
if (success) { setIsSavingAll(true);
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' })); let allSuccess = true;
setAppState(prev => ({ ...prev, hasUnsavedChanges: false })); for (const [articleNo, rowData] of entries) {
setEditingRowIndex(null); // Close panel only after confirmed save const success = await saveRowToSupabase(articleNo, rowData);
} else { if (success) {
console.error(`Failed to save ${articleNo} to Supabase`); setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' })); setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
// Panel stays open so user can retry } else {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
allSuccess = false;
}
} }
if (allSuccess) setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
return success; setIsSavingAll(false);
}; };
const captureState = (message: string) => { const captureState = (message: string) => {
@@ -334,6 +342,9 @@ export default function App() {
onUndo={handleUndo} onUndo={handleUndo}
undoMessage={undoHistory[0]?.message} undoMessage={undoHistory[0]?.message}
undoSteps={undoHistory.length} undoSteps={undoHistory.length}
pendingCount={Object.keys(pendingRows).length}
onSaveAll={handleSaveAll}
isSavingAll={isSavingAll}
/> />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} /> <Sidebar activeModule={activeModule} setActiveModule={setActiveModule} />
+7 -25
View File
@@ -8,7 +8,7 @@ import { ConfirmModal } from './ConfirmModal';
interface EditPanelProps { interface EditPanelProps {
row: ExcelRow; row: ExcelRow;
rowIndex: number; rowIndex: number;
onSave: (rowIndex: number, updatedRow: ExcelRow) => Promise<boolean>; onSave: (rowIndex: number, updatedRow: ExcelRow) => void;
onClose: () => void; onClose: () => void;
onCaptureState: (message: string) => void; onCaptureState: (message: string) => void;
} }
@@ -33,8 +33,6 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const [loadingField, setLoadingField] = useState<string | null>(null); const [loadingField, setLoadingField] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null); const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set()); const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
@@ -136,11 +134,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
} }
}; };
const handleSave = async () => { const handleSave = () => {
setIsConfirmOpen(false); setIsConfirmOpen(false);
setSaveError(false);
setIsSaving(true);
const hasModifications = Object.keys(formData).some(k => isModified(k as keyof typeof formData)); const hasModifications = Object.keys(formData).some(k => isModified(k as keyof typeof formData));
if (hasModifications) { if (hasModifications) {
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`); onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
@@ -160,13 +155,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
newRow[COLUMNS.MOQ] = formData.moq; newRow[COLUMNS.MOQ] = formData.moq;
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe; newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn; newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
onSave(rowIndex, newRow);
const success = await onSave(rowIndex, newRow);
// If save failed, panel stays open — handleSaveRow in App.tsx won't call setEditingRowIndex(null)
if (!success) {
setSaveError(true);
}
setIsSaving(false);
}; };
const DimensionInput = ({ label, field, placeholder }: { label: string, field: keyof typeof formData, placeholder?: string }) => ( const DimensionInput = ({ label, field, placeholder }: { label: string, field: keyof typeof formData, placeholder?: string }) => (
@@ -315,25 +304,18 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
</div> </div>
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3"> <div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
{saveError && (
<span className="flex items-center text-sm text-red-400 mr-auto">
Error saving check your connection and retry.
</span>
)}
<button <button
onClick={onClose} onClick={onClose}
disabled={isSaving} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={() => setIsConfirmOpen(true)} onClick={() => setIsConfirmOpen(true)}
disabled={isSaving} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
> >
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} <Save className="w-4 h-4" />
{isSaving ? 'Saving...' : saveError ? 'Retry Save' : 'Save to Memory'} Queue Changes
</button> </button>
</div> </div>
<ConfirmModal <ConfirmModal
+15 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Download, Database, LogOut, Undo2 } from 'lucide-react'; import { Download, LogOut, Undo2, CloudUpload, Loader2 } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface TopBarProps { interface TopBarProps {
@@ -13,9 +13,12 @@ interface TopBarProps {
onUndo: () => void; onUndo: () => void;
undoMessage?: string; undoMessage?: string;
undoSteps: number; undoSteps: number;
pendingCount: number;
onSaveAll: () => Promise<void>;
isSavingAll: boolean;
} }
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps }: TopBarProps) { export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, onSaveAll, isSavingAll }: TopBarProps) {
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">
@@ -56,6 +59,16 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{pendingCount > 0 && (
<button
onClick={onSaveAll}
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"
>
{isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
{isSavingAll ? 'Saving...' : `Save ${pendingCount} change${pendingCount > 1 ? 's' : ''}`}
</button>
)}
{hasData && ( {hasData && (
<button <button
onClick={onExport} onClick={onExport}