Delete history entry after revert

This commit is contained in:
Christian Vidal Wolf
2026-04-10 09:47:35 +02:00
parent 0cf4f59937
commit 23f4b508c8
3 changed files with 135 additions and 131 deletions
+6 -2
View File
@@ -6,7 +6,7 @@ import { TopBar } from './components/TopBar';
import { ProductDescriptions } from './components/ProductDescriptions';
import { MatrixView } from './components/MatrixView';
import { EditPanel } from './components/EditPanel';
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry } from './lib/supabase';
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry, deleteHistoryEntry } from './lib/supabase';
import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView';
@@ -474,7 +474,7 @@ export default function App() {
<HistoryView
headers={appState.headers}
data={appState.data}
onRevert={(articleNo, revertedData) => {
onRevert={async (articleNo, revertedData, historyId) => {
// Find the row in appState.data and update it
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
if (rowIndex !== -1) {
@@ -494,6 +494,10 @@ export default function App() {
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
}
}));
// Delete the history entry after revert
if (historyId) {
await deleteHistoryEntry(String(historyId));
}
}
}}
/>
+15 -13
View File
@@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag } from 'lucide-react';
import { getHistory, HistoryEntry } from '../lib/supabase';
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
import { ExcelRow, COLUMNS } from '../types';
import { cn } from '../lib/utils';
interface HistoryViewProps {
headers: string[];
data: ExcelRow[];
onRevert: (articleNo: string, oldData: ExcelRow) => void;
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void;
}
export function HistoryView({ headers, data, onRevert }: HistoryViewProps) {
@@ -27,6 +27,18 @@ export function HistoryView({ headers, data, onRevert }: HistoryViewProps) {
setLoading(false);
};
const handleRevert = (entry: HistoryEntry) => {
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
const currentRow = data.find(r => String(r[0]) === entry.product_id);
if (currentRow && JSON.stringify(currentRow) === JSON.stringify(entry.old_data)) {
if (!window.confirm("Reverting will restore original data. No actual changes will be made. Continue?")) {
return;
}
}
onRevert(entry.product_id, entry.old_data, entry.id);
}
};
const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
const changes: { header: string; old: any; new: any; index: number }[] = [];
const maxLen = Math.max(oldData.length, newData.length);
@@ -157,17 +169,7 @@ export function HistoryView({ headers, data, onRevert }: HistoryViewProps) {
<button
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
// Check if old_data equals current data in appState
const currentRow = data.find(r => String(r[0]) === entry.product_id);
if (currentRow && JSON.stringify(currentRow) === JSON.stringify(entry.old_data)) {
if (window.confirm("Reverting will restore original data. No actual changes will be made. Continue?")) {
onRevert(entry.product_id, entry.old_data);
}
} else {
onRevert(entry.product_id, entry.old_data);
}
}
handleRevert(entry);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
>
+114 -116
View File
@@ -1,149 +1,104 @@
import { ExcelRow } from '../types';
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
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;
export interface ExcelRow extends Array<any> {}
export interface SyncedRow {
data: ExcelRow;
status?: 'pending' | 'synced';
}
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=updated_at.desc`,
{
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
if (!response.ok) return {};
const rows = await response.json();
const result: Record<string, SyncedRow> = {};
for (const row of rows) {
result[row.product_id] = { data: row.data, status: row.status };
}
await new Promise(r => setTimeout(r, delayMs));
return result;
} catch (error) {
console.error('Error fetching synced rows from Supabase:', error);
return {};
}
throw lastError ?? new Error('fetch failed');
}
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<boolean> {
try {
// 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',
'Prefer': 'resolution=merge-duplicates'
},
body: JSON.stringify({
id: articleNo,
data: rowData,
status_check: 'pending',
updated_at: new Date().toISOString()
})
});
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products?product_id=eq.${encodeURIComponent(articleNo)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
},
body: JSON.stringify({
product_id: articleNo,
data: rowData,
status: 'synced',
updated_at: new Date().toISOString()
})
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Supabase upsert failed:', response.status, errorData);
return false;
}
return true;
return response.ok;
} catch (error) {
console.error('Error saving to Supabase:', error);
return false;
}
}
export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRow, status: string }>> {
try {
// 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'
};
});
return result;
} catch (error) {
console.error('Error fetching from Supabase:', error);
return {};
}
}
export async function resetAllPendingRows(): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
method: 'PATCH',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
status_check: 'original',
updated_at: new Date().toISOString()
})
});
return response.ok;
} catch (error) {
console.error('Error resetting statuses in Supabase:', error);
return false;
}
}
export interface HistoryEntry {
id: string;
id?: number;
product_id: string;
article_name: string;
old_data: ExcelRow;
new_data: ExcelRow;
changed_at: string;
changed_by: string;
changed_at: string;
}
export async function saveHistoryEntry(
articleNo: string,
productId: string,
articleName: string,
oldData: ExcelRow,
newData: ExcelRow,
userEmail: string
changedBy: string
): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_history`, {
method: 'POST',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
product_id: articleNo,
article_name: articleName,
old_data: oldData,
new_data: newData,
changed_at: new Date().toISOString(),
changed_by: userEmail
})
});
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
},
body: JSON.stringify({
product_id: productId,
article_name: articleName,
old_data: oldData,
new_data: newData,
changed_by: changedBy,
changed_at: new Date().toISOString()
})
}
);
return response.ok;
} catch (error) {
@@ -171,3 +126,46 @@ export async function getHistory(): Promise<HistoryEntry[]> {
return [];
}
}
export async function deleteHistoryEntry(id: string): Promise<boolean> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(id)}`,
{
method: 'DELETE',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
return response.ok;
} catch (error) {
console.error('Error deleting history from Supabase:', error);
return false;
}
}
export async function resetAllPendingRows(): Promise<boolean> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
},
body: JSON.stringify({ status: 'synced' })
}
);
return response.ok;
} catch (error) {
console.error('Error resetting pending rows in Supabase:', error);
return false;
}
}