feat: persistent multi-step undo system in TopBar

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<boolean> 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 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-04-09 08:22:58 +02:00
co-authored by Claude Sonnet 4.6
parent da4673f7c4
commit c544b9b709
6 changed files with 100 additions and 74 deletions
+47 -39
View File
@@ -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<Response> {
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<boolean> {
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<Record<string, { data: ExcelRow, status: string }>> {
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<string, { data: ExcelRow, status: string }> = {};
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<Record<string, { data: ExcelRo
}
}
export async function resetAllPendingRows() {
export async function resetAllPendingRows(): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
method: 'PATCH',