mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:35:23 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
da4673f7c4
commit
c544b9b709
+10
-14
@@ -238,35 +238,31 @@ export default function App() {
|
|||||||
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow) => {
|
const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow): Promise<boolean> => {
|
||||||
// 1. Update UI state
|
// 1. Optimistically update UI
|
||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
const newData = [...prev.data];
|
const newData = [...prev.data];
|
||||||
newData[rowIndex] = updatedRow;
|
newData[rowIndex] = updatedRow;
|
||||||
return {
|
return { ...prev, data: newData, hasUnsavedChanges: true };
|
||||||
...prev,
|
|
||||||
data: newData,
|
|
||||||
hasUnsavedChanges: true
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
setEditingRowIndex(null);
|
|
||||||
|
|
||||||
// 2. Persist to Supabase
|
|
||||||
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
|
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
|
||||||
console.log(`Saving article ${articleNo} to Supabase...`);
|
|
||||||
|
|
||||||
// Update local status to pending
|
|
||||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
|
||||||
|
|
||||||
|
// 2. Persist to Supabase
|
||||||
const success = await saveRowToSupabase(articleNo, updatedRow);
|
const success = await saveRowToSupabase(articleNo, updatedRow);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log(`Successfully saved ${articleNo}`);
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
||||||
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
||||||
|
setEditingRowIndex(null); // Close panel only after confirmed save
|
||||||
} else {
|
} else {
|
||||||
console.error(`Failed to save ${articleNo} to Supabase`);
|
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) => {
|
const captureState = (message: string) => {
|
||||||
|
|||||||
@@ -279,13 +279,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/30">
|
<tbody className="divide-y divide-slate-700/30">
|
||||||
{paginatedData.map(({ row, index }) => {
|
{paginatedData.map(({ row, index }) => {
|
||||||
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-slate-700/20 transition-colors",
|
"hover:bg-slate-700/20 transition-colors",
|
||||||
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
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" : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||||
|
|||||||
@@ -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) => void;
|
onSave: (rowIndex: number, updatedRow: ExcelRow) => Promise<boolean>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,8 @@ 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());
|
||||||
@@ -134,8 +136,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
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]}`);
|
||||||
@@ -155,7 +160,13 @@ 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 }) => (
|
||||||
@@ -304,18 +315,25 @@ 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}
|
||||||
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
|
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 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsConfirmOpen(true)}
|
onClick={() => setIsConfirmOpen(true)}
|
||||||
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"
|
disabled={isSaving}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
Save to Memory
|
{isSaving ? 'Saving...' : saveError ? 'Retry Save' : 'Save to Memory'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
|
|||||||
@@ -455,19 +455,21 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
<tbody>
|
<tbody>
|
||||||
{filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => {
|
{filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => {
|
||||||
const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0;
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={dataIndex}
|
key={dataIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b border-slate-700/50 transition-colors hover:bg-slate-700/20',
|
'border-b border-slate-700/50 transition-colors hover:bg-slate-700/20',
|
||||||
isPending
|
saveStatus === 'error'
|
||||||
? 'bg-yellow-400/20 border-l-4 border-l-yellow-400'
|
? 'bg-red-400/20 border-l-4 border-l-red-500'
|
||||||
: isCritical
|
: saveStatus === 'pending'
|
||||||
? 'bg-red-950/20'
|
? 'bg-yellow-400/20 border-l-4 border-l-yellow-400'
|
||||||
: pricingErrors.length > 0
|
: isCritical
|
||||||
? 'bg-amber-950/10'
|
? 'bg-red-950/20'
|
||||||
: ''
|
: pricingErrors.length > 0
|
||||||
|
? 'bg-amber-950/10'
|
||||||
|
: ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Article No */}
|
{/* Article No */}
|
||||||
|
|||||||
@@ -352,14 +352,15 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/50">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{paginatedData.map(({ row, index }) => {
|
{paginatedData.map(({ row, index }) => {
|
||||||
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
getRowColor(row),
|
getRowColor(row),
|
||||||
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
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" : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||||
|
|||||||
+44
-36
@@ -3,14 +3,40 @@ import { ExcelRow } from '../types';
|
|||||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
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 {
|
try {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?id=eq.${encodeURIComponent(articleNo)}`, {
|
// Single upsert: POST with Prefer=resolution=merge-duplicates
|
||||||
method: 'PATCH',
|
// 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: {
|
headers: {
|
||||||
'apikey': SUPABASE_KEY,
|
'apikey': SUPABASE_KEY,
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'resolution=merge-duplicates'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: articleNo,
|
id: articleNo,
|
||||||
@@ -21,32 +47,9 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
console.error('Initial PATCH failed:', response.status, errorData);
|
console.error('Supabase upsert failed:', response.status, errorData);
|
||||||
|
return false;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -57,12 +60,17 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) {
|
|||||||
|
|
||||||
export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRow, status: string }>> {
|
export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRow, status: string }>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
|
// Explicit limit to avoid Supabase's default 1000-row cap
|
||||||
headers: {
|
const response = await fetch(
|
||||||
'apikey': SUPABASE_KEY,
|
`${SUPABASE_URL}/rest/v1/products_sync?select=id,data,status_check&limit=10000`,
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
{
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||||
|
'Range': '0-9999'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!response.ok) return {};
|
if (!response.ok) return {};
|
||||||
|
|
||||||
@@ -81,7 +89,7 @@ export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetAllPendingRows() {
|
export async function resetAllPendingRows(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
|
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
Reference in New Issue
Block a user