fix(history): paginate all records, number oldest=1 newest=N, display newest first

This commit is contained in:
Christian Vidal Wolf
2026-05-15 10:51:01 +02:00
parent f66295f07b
commit 2944e6958c
2 changed files with 384 additions and 56 deletions
+315 -31
View File
@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2, Maximize2 } from 'lucide-react';
import React, { useState, useEffect, useMemo } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2, Maximize2, Check, Loader2, CloudUpload } from 'lucide-react';
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { cn } from '../lib/utils';
import { usePersistentState } from '../contexts/FilterContext';
import { previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
import { SyncStatusPill } from './SyncStatusPill';
interface HistoryViewProps {
headers: string[];
@@ -13,6 +15,29 @@ interface HistoryViewProps {
onEdit?: (rowIndex: number) => void;
}
type HistorySyncStatus = 'bc_pending' | 'previewed' | 'syncing' | 'synced' | 'failed';
interface HistorySyncRecord {
selected: boolean;
status: HistorySyncStatus;
previewToken?: string;
error?: string;
}
type HistorySyncMap = Record<string, HistorySyncRecord>;
function readStoredHistorySyncMap(): HistorySyncMap {
try {
const raw = localStorage.getItem('history-bcSync');
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as HistorySyncMap;
} catch {
return {};
}
}
export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProps) {
const COLUMNS = useColumns();
const [history, setHistory] = useState<HistoryEntry[]>([]);
@@ -20,6 +45,9 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
const [expandedId, setExpandedId] = useState<string | null>(null);
const [search, setSearch] = usePersistentState('history-search', '');
const [userFilter, setUserFilter] = usePersistentState('history-userFilter', '');
const [statusFilter, setStatusFilter] = usePersistentState<'all' | 'bc_pending' | 'previewed' | 'synced' | 'failed'>('history-statusFilter', 'all');
const [bcHistorySync, setBcHistorySync] = useState<HistorySyncMap>(() => readStoredHistorySyncMap());
const [bcSyncBusy, setBcSyncBusy] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
@@ -32,6 +60,14 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
}
}, [data]);
useEffect(() => {
try {
localStorage.setItem('history-bcSync', JSON.stringify(bcHistorySync));
} catch {
// Ignore storage quota or serialization errors.
}
}, [bcHistorySync]);
const loadHistory = async () => {
setLoading(true);
const historyData = await getHistory();
@@ -39,6 +75,23 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
setLoading(false);
};
useEffect(() => {
if (history.length === 0) return;
const validKeys = new Set(history.map(entry => String(entry.id || `${entry.product_id}-${entry.changed_at}`)));
setBcHistorySync(prev => {
let changed = false;
const next: Record<string, HistorySyncRecord> = {};
for (const [key, value] of Object.entries(prev)) {
if (!validKeys.has(key)) {
changed = true;
continue;
}
next[key] = value as HistorySyncRecord;
}
return changed ? next : prev;
});
}, [history]);
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);
@@ -82,14 +135,166 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
const uniqueUsers = Array.from(new Set(history.map(e => e.changed_by))).sort();
const filteredHistory = history.filter(entry => {
const getEntryKey = (entry: HistoryEntry) => String(entry.id || `${entry.product_id}-${entry.changed_at}`);
const getEntryStatus = (entry: HistoryEntry): HistorySyncStatus => {
return bcHistorySync[getEntryKey(entry)]?.status || 'bc_pending';
};
const getEntrySelected = (entry: HistoryEntry): boolean => {
return bcHistorySync[getEntryKey(entry)]?.selected || false;
};
// history is asc (oldest first) so index+1 = chronological #
// display newest first by reversing for render only
const filteredHistory = [...history].reverse().filter(entry => {
if (userFilter && entry.changed_by !== userFilter) return false;
const status = getEntryStatus(entry);
if (statusFilter !== 'all' && status !== statusFilter) return false;
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return true;
const searchableText = `${entry.article_name} ${entry.product_id} ${entry.changed_by}`.toLowerCase();
return terms.every(term => searchableText.includes(term));
});
const selectedEntries = useMemo(
() => history.filter(entry => getEntrySelected(entry)),
[history, bcHistorySync]
);
const statusCounts = useMemo(() => {
const counts = { bc_pending: 0, previewed: 0, synced: 0, failed: 0 };
history.forEach(entry => {
const status = getEntryStatus(entry);
if (status in counts) counts[status as keyof typeof counts] += 1;
});
return counts;
}, [history, bcHistorySync]);
const updateEntry = (entry: HistoryEntry, patch: Partial<HistorySyncRecord>) => {
const key = getEntryKey(entry);
setBcHistorySync(prev => ({
...prev,
[key]: {
selected: prev[key]?.selected ?? false,
status: prev[key]?.status ?? 'bc_pending',
previewToken: prev[key]?.previewToken,
error: prev[key]?.error,
...prev[key],
...patch,
},
}));
};
const selectAllVisible = (selected: boolean) => {
setBcHistorySync(prev => {
const next = { ...prev };
filteredHistory.forEach(entry => {
const key = getEntryKey(entry);
next[key] = {
selected,
status: next[key]?.status ?? 'bc_pending',
previewToken: next[key]?.previewToken,
error: next[key]?.error,
};
});
return next;
});
};
const previewSelected = async () => {
if (selectedEntries.length === 0) return;
setBcSyncBusy(true);
try {
for (const entry of [...selectedEntries].sort((a, b) => {
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
if (timeDelta !== 0) return timeDelta;
return String(a.id || '').localeCompare(String(b.id || ''));
})) {
const preview = await previewBusinessCentralSync(headers, entry.new_data);
if (!preview.success) {
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', selected: true });
continue;
}
updateEntry(entry, {
status: 'previewed',
previewToken: preview.previewToken,
error: undefined,
selected: true,
});
}
} finally {
setBcSyncBusy(false);
}
};
const syncSelected = async () => {
if (selectedEntries.length === 0) return;
setBcSyncBusy(true);
try {
const orderedEntries = [...selectedEntries].sort((a, b) => {
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
if (timeDelta !== 0) return timeDelta;
return String(a.id || '').localeCompare(String(b.id || ''));
});
for (const entry of orderedEntries) {
updateEntry(entry, { status: 'syncing', error: undefined, selected: true });
const key = getEntryKey(entry);
const currentRecord = bcHistorySync[key];
let previewToken = currentRecord?.previewToken;
if (!previewToken) {
const preview = await previewBusinessCentralSync(headers, entry.new_data);
if (!preview.success) {
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', selected: true });
continue;
}
previewToken = preview.previewToken;
updateEntry(entry, { status: 'previewed', previewToken, error: undefined, selected: true });
}
const apply = await applyBusinessCentralSync(headers, entry.new_data, previewToken);
if (!apply.success) {
if (isPreviewTokenMismatchError(apply.error)) {
const refreshedPreview = await previewBusinessCentralSync(headers, entry.new_data);
if (refreshedPreview.success) {
updateEntry(entry, {
status: 'previewed',
previewToken: refreshedPreview.previewToken,
error: undefined,
selected: true,
});
const retryApply = await applyBusinessCentralSync(headers, entry.new_data, refreshedPreview.previewToken);
if (retryApply.success) {
updateEntry(entry, {
status: 'synced',
error: undefined,
selected: false,
previewToken: retryApply.previewToken || refreshedPreview.previewToken,
});
continue;
}
updateEntry(entry, { status: 'failed', error: retryApply.error || 'BC sync failed', selected: true });
continue;
}
}
updateEntry(entry, { status: 'failed', error: apply.error || 'BC sync failed', selected: true });
continue;
}
updateEntry(entry, {
status: 'synced',
error: undefined,
selected: false,
previewToken: apply.previewToken || previewToken,
});
}
} finally {
setBcSyncBusy(false);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
@@ -181,6 +386,68 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
</div>
</div>
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="font-semibold text-slate-200">{selectedEntries.length}</span> selected
<span className="text-slate-600">·</span>
<span>{statusCounts.bc_pending} pending</span>
<span>{statusCounts.previewed} previewed</span>
<span>{statusCounts.synced} synced</span>
<span>{statusCounts.failed} failed</span>
</div>
<div className="ml-auto flex items-center gap-2 flex-wrap">
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value as any)}
className="bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
>
<option value="all">All statuses</option>
<option value="bc_pending">Pending BC</option>
<option value="previewed">Previewed</option>
<option value="synced">Synced BC</option>
<option value="failed">Failed</option>
</select>
<button
onClick={() => selectAllVisible(true)}
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
>
Select all visible
</button>
<button
onClick={() => selectAllVisible(false)}
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
>
Clear selection
</button>
<button
onClick={previewSelected}
disabled={bcSyncBusy || selectedEntries.length === 0}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
bcSyncBusy || selectedEntries.length === 0
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-500/30"
)}
>
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
Preview selected
</button>
<button
onClick={syncSelected}
disabled={bcSyncBusy || selectedEntries.length === 0}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
bcSyncBusy || selectedEntries.length === 0
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
: "bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500/30"
)}
>
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CloudUpload className="w-3.5 h-3.5" />}
Sync selected to BC
</button>
</div>
</div>
<div className="bg-[#0a1628] border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
{filteredHistory.length === 0 ? (
<div className="p-12 text-center">
@@ -195,7 +462,9 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
{filteredHistory.map((entry, filteredIdx) => {
const isExpanded = expandedId === entry.id;
const changes = getChangedFields(entry.old_data, entry.new_data);
const globalNumber = history.length - history.indexOf(entry);
const globalNumber = history.indexOf(entry) + 1;
const status = getEntryStatus(entry);
const selected = getEntrySelected(entry);
return (
<div key={entry.id} className={cn(
@@ -207,6 +476,13 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
className="p-4 flex items-center gap-4 cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
>
<input
type="checkbox"
checked={selected}
onClick={e => e.stopPropagation()}
onChange={() => updateEntry(entry, { selected: !selected })}
className="accent-blue-500"
/>
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
<div className="w-8 text-right shrink-0">
@@ -234,33 +510,41 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
<span className="text-sm">{formatDate(entry.changed_at)}</span>
</div>
<div className="flex items-center justify-end gap-3 text-sm">
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
if (idx !== -1) onEdit(idx);
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
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"
>
<RotateCcw className="w-4 h-4" />
Revert
</button>
<div className="flex flex-col items-end gap-2 text-sm">
<div className="flex items-center justify-end gap-3 flex-wrap">
<SyncStatusPill status={status} />
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
if (idx !== -1) onEdit(idx);
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
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"
>
<RotateCcw className="w-4 h-4" />
Revert
</button>
</div>
{status === 'failed' && bcHistorySync[getEntryKey(entry)]?.error && (
<div className="max-w-[40rem] rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
<span className="font-semibold">BC error:</span> {bcHistorySync[getEntryKey(entry)].error}
</div>
)}
</div>
</div>
</div>