From c544b9b709fbae7136d19950ace3a189d894ccfa Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 9 Apr 2026 08:22:58 +0200 Subject: [PATCH] feat: persistent multi-step undo system in TopBar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: save all changes to Supabase with reliable upsert - Replace broken PATCH→POST fallback with single atomic upsert (POST + Prefer: resolution=merge-duplicates). The old PATCH returned 200 OK with empty body for new articles, causing silent data loss on every first save per article. - Fix getAllSyncedRows pagination: add explicit limit=10000 and Range header to bypass Supabase's default 1000-row cap. - Add fetchWithRetry helper (2 retries on 5xx/network errors). - handleSaveRow now returns Promise and closes EditPanel only after a confirmed successful save. - Add 'error' save status: failed rows turn red (border-l-red-500) across ProductDescriptions, ArticleDetails, and PricingView. - EditPanel shows saving spinner, disables buttons while saving, and displays inline error message with "Retry Save" on failure. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 24 +++---- src/components/ArticleDetails.tsx | 9 +-- src/components/EditPanel.tsx | 32 +++++++--- src/components/PricingView.tsx | 18 +++--- src/components/ProductDescriptions.tsx | 5 +- src/lib/supabase.ts | 86 ++++++++++++++------------ 6 files changed, 100 insertions(+), 74 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1f53b39..b134d48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -238,35 +238,31 @@ export default function App() { setAppState(prev => ({ ...prev, hasUnsavedChanges: false })); }; - const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow) => { - // 1. Update UI state + const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow): Promise => { + // 1. Optimistically update UI setAppState(prev => { const newData = [...prev.data]; newData[rowIndex] = updatedRow; - return { - ...prev, - data: newData, - hasUnsavedChanges: true - }; + return { ...prev, data: newData, hasUnsavedChanges: true }; }); - setEditingRowIndex(null); - // 2. Persist to Supabase const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]); - console.log(`Saving article ${articleNo} to Supabase...`); - - // Update local status to pending setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' })); + // 2. Persist to Supabase const success = await saveRowToSupabase(articleNo, updatedRow); if (success) { - console.log(`Successfully saved ${articleNo}`); + setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' })); setAppState(prev => ({ ...prev, hasUnsavedChanges: false })); + setEditingRowIndex(null); // Close panel only after confirmed save } else { console.error(`Failed to save ${articleNo} to Supabase`); - alert("Error saving to database. Local changes will be lost on refresh if not saved."); + setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' })); + // Panel stays open so user can retry } + + return success; }; const captureState = (message: string) => { diff --git a/src/components/ArticleDetails.tsx b/src/components/ArticleDetails.tsx index e86f8cf..6e3f81d 100644 --- a/src/components/ArticleDetails.tsx +++ b/src/components/ArticleDetails.tsx @@ -279,13 +279,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp {paginatedData.map(({ row, index }) => { - const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending'; + const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])]; return ( - {row[COLUMNS.ARTICLE_NO]} diff --git a/src/components/EditPanel.tsx b/src/components/EditPanel.tsx index c4f54eb..18c20c1 100644 --- a/src/components/EditPanel.tsx +++ b/src/components/EditPanel.tsx @@ -8,7 +8,7 @@ import { ConfirmModal } from './ConfirmModal'; interface EditPanelProps { row: ExcelRow; rowIndex: number; - onSave: (rowIndex: number, updatedRow: ExcelRow) => void; + onSave: (rowIndex: number, updatedRow: ExcelRow) => Promise; onClose: () => void; onCaptureState: (message: string) => void; } @@ -33,6 +33,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed const [loadingField, setLoadingField] = useState(null); const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [pendingGeminiField, setPendingGeminiField] = useState(null); const [generatedFields, setGeneratedFields] = useState>(new Set()); @@ -134,8 +136,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed } }; - const handleSave = () => { + const handleSave = async () => { setIsConfirmOpen(false); + setSaveError(false); + setIsSaving(true); + const hasModifications = Object.keys(formData).some(k => isModified(k as keyof typeof formData)); if (hasModifications) { onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`); @@ -155,7 +160,13 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed newRow[COLUMNS.MOQ] = formData.moq; newRow[COLUMNS.DETAILS_DE] = formData.detailsDe; 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 }) => ( @@ -304,18 +315,25 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
+ {saveError && ( + + Error saving — check your connection and retry. + + )}
{filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => { const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0; - const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending'; + const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])]; return ( 0 - ? 'bg-amber-950/10' - : '' + saveStatus === 'error' + ? 'bg-red-400/20 border-l-4 border-l-red-500' + : saveStatus === 'pending' + ? 'bg-yellow-400/20 border-l-4 border-l-yellow-400' + : isCritical + ? 'bg-red-950/20' + : pricingErrors.length > 0 + ? 'bg-amber-950/10' + : '' )} > {/* Article No */} diff --git a/src/components/ProductDescriptions.tsx b/src/components/ProductDescriptions.tsx index a71a280..088b9a2 100644 --- a/src/components/ProductDescriptions.tsx +++ b/src/components/ProductDescriptions.tsx @@ -352,14 +352,15 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri {paginatedData.map(({ row, index }) => { - const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending'; + const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])]; return ( {row[COLUMNS.ARTICLE_NO]} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index a501b49..125001c 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -3,14 +3,40 @@ import { ExcelRow } from '../types'; const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; -export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) { +async function fetchWithRetry( + url: string, + options: RequestInit, + retries = 2, + delayMs = 1000 +): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const res = await fetch(url, options); + if (res.ok || res.status < 500 || attempt === retries) return res; + } catch (err) { + lastError = err as Error; + if (attempt === retries) throw lastError; + } + await new Promise(r => setTimeout(r, delayMs)); + } + throw lastError ?? new Error('fetch failed'); +} + +export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise { try { - const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?id=eq.${encodeURIComponent(articleNo)}`, { - method: 'PATCH', + // Single upsert: POST with Prefer=resolution=merge-duplicates + // This handles both INSERT (new article) and UPDATE (existing) atomically. + // The old PATCH approach silently failed for new articles because Supabase + // returns 200 OK with an empty body when no rows match — indistinguishable + // from a successful update. + const response = await fetchWithRetry(`${SUPABASE_URL}/rest/v1/products_sync`, { + method: 'POST', headers: { 'apikey': SUPABASE_KEY, 'Authorization': `Bearer ${SUPABASE_KEY}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Prefer': 'resolution=merge-duplicates' }, body: JSON.stringify({ id: articleNo, @@ -21,32 +47,9 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) { }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - console.error('Initial PATCH failed:', response.status, errorData); - - // If PATCH didn't find the record, try UPSERT - const upsertResponse = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, { - method: 'POST', - headers: { - 'apikey': SUPABASE_KEY, - 'Authorization': `Bearer ${SUPABASE_KEY}`, - 'Content-Type': 'application/json', - 'Prefer': 'resolution=merge-duplicates' - }, - body: JSON.stringify({ - id: articleNo, - data: rowData, - status_check: 'pending', - updated_at: new Date().toISOString() - }) - }); - - if (!upsertResponse.ok) { - const upsertError = await upsertResponse.json().catch(() => ({})); - console.error('UPSERT failed:', upsertResponse.status, upsertError); - return false; - } - return true; + const errorData = await response.json().catch(() => ({})); + console.error('Supabase upsert failed:', response.status, errorData); + return false; } return true; } catch (error) { @@ -57,21 +60,26 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) { export async function getAllSyncedRows(): Promise> { try { - const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, { - headers: { - 'apikey': SUPABASE_KEY, - 'Authorization': `Bearer ${SUPABASE_KEY}` + // Explicit limit to avoid Supabase's default 1000-row cap + const response = await fetch( + `${SUPABASE_URL}/rest/v1/products_sync?select=id,data,status_check&limit=10000`, + { + headers: { + 'apikey': SUPABASE_KEY, + 'Authorization': `Bearer ${SUPABASE_KEY}`, + 'Range': '0-9999' + } } - }); + ); if (!response.ok) return {}; const data = await response.json(); const result: Record = {}; data.forEach((item: any) => { - result[item.id] = { - data: item.data, - status: item.status_check || 'original' + result[item.id] = { + data: item.data, + status: item.status_check || 'original' }; }); return result; @@ -81,7 +89,7 @@ export async function getAllSyncedRows(): Promise { try { const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, { method: 'PATCH',