From 2944e6958c826b527264770b78b3de52e0089814 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Fri, 15 May 2026 10:51:01 +0200 Subject: [PATCH] fix(history): paginate all records, number oldest=1 newest=N, display newest first --- src/components/HistoryView.tsx | 346 ++++++++++++++++++++++++++++++--- src/lib/supabase.ts | 94 ++++++--- 2 files changed, 384 insertions(+), 56 deletions(-) diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx index 6416bea..203a79c 100644 --- a/src/components/HistoryView.tsx +++ b/src/components/HistoryView.tsx @@ -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; + +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([]); @@ -20,6 +45,9 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp const [expandedId, setExpandedId] = useState(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(() => 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 = {}; + 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) => { + 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 (
@@ -181,6 +386,68 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp
+
+
+ {selectedEntries.length} selected + ยท + {statusCounts.bc_pending} pending + {statusCounts.previewed} previewed + {statusCounts.synced} synced + {statusCounts.failed} failed +
+
+ + + + + +
+
+
{filteredHistory.length === 0 ? (
@@ -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 (
setExpandedId(isExpanded ? null : entry.id)} > + e.stopPropagation()} + onChange={() => updateEntry(entry, { selected: !selected })} + className="accent-blue-500" + /> {isExpanded ? : }
@@ -234,33 +510,41 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp {formatDate(entry.changed_at)}
-
- - {changes.length} {changes.length === 1 ? 'change' : 'changes'} - - - +
+
+ + + {changes.length} {changes.length === 1 ? 'change' : 'changes'} + + + +
+ {status === 'failed' && bcHistorySync[getEntryKey(entry)]?.error && ( +
+ BC error: {bcHistorySync[getEntryKey(entry)].error} +
+ )}
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index c1fa47b..03252c4 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -116,6 +116,28 @@ export interface HistoryEntry { changed_at: string; } +function normalizeHistoryValue(value: any): any { + if (value === undefined || value === null || value === '') return null; + return value; +} + +function valuesEqual(a: any, b: any): boolean { + return normalizeHistoryValue(a) === normalizeHistoryValue(b); +} + +function getChangedIndices(oldData: ExcelRow = [], newData: ExcelRow = []): number[] { + const maxLen = Math.max(oldData.length, newData.length); + const changed: number[] = []; + + for (let i = 0; i < maxLen; i++) { + if (!valuesEqual(oldData[i], newData[i])) { + changed.push(i); + } + } + + return changed; +} + export async function saveHistoryEntry( productId: string, articleName: string, @@ -160,25 +182,34 @@ export async function saveHistoryEntry( export async function getHistory(): Promise { try { - const response = await safeFetch( - `${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=500`, - { cache: 'no-store' } - ); + const PAGE_SIZE = 1000; + const allRows: HistoryEntry[] = []; + for (let page = 0; page < 20; page++) { + const offset = page * PAGE_SIZE; + const response = await safeFetch( + `${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`, + { cache: 'no-store' } + ); - if (!response.ok) { - const errText = await response.text(); - console.error('[getHistory] Error:', response.status, errText.substring(0, 200)); - return [{ - id: 'ERROR', - product_id: 'ERROR', - article_name: `Failed: ${response.status} ${errText.substring(0, 200)}`, - old_data: [], - new_data: [], - changed_at: new Date().toISOString(), - changed_by: 'system' - }]; + if (!response.ok) { + const errText = await response.text(); + console.error('[getHistory] Error:', response.status, errText.substring(0, 200)); + return [{ + id: 'ERROR', + product_id: 'ERROR', + article_name: `Failed: ${response.status} ${errText.substring(0, 200)}`, + old_data: [], + new_data: [], + changed_at: new Date().toISOString(), + changed_by: 'system' + }]; + } + const batch: HistoryEntry[] = await response.json(); + allRows.push(...batch); + if (batch.length < PAGE_SIZE) break; } - return await response.json(); + // Return oldest-first so index+1 = natural chronological number + return allRows; } catch (error: any) { console.error('[getHistory] Exception:', error.message); return [{ @@ -196,7 +227,7 @@ export async function getHistory(): Promise { export async function getHistoryDataForMerge(): Promise> { try { const response = await safeFetch( - `${SUPABASE_URL}/rest/v1/products_history?select=product_id,new_data&order=changed_at.desc`, + `${SUPABASE_URL}/rest/v1/products_history?select=product_id,old_data,new_data,changed_at,id`, { cache: 'no-store' } ); @@ -204,17 +235,30 @@ export async function getHistoryDataForMerge(): Promise console.error('[getHistoryDataForMerge] Error:', response.status); return {}; } - const entries: Array<{ product_id: string; new_data: ExcelRow }> = await response.json(); - + const entries: HistoryEntry[] = await response.json(); + entries.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 result: Record = {}; - const seen = new Set(); + for (const entry of entries) { - if (seen.has(entry.product_id)) continue; - seen.add(entry.product_id); - if (entry.new_data && entry.new_data.length > 0) { - result[entry.product_id] = entry.new_data; + if (!entry.product_id) continue; + + if (!result[entry.product_id]) { + result[entry.product_id] = []; + } + + const target = result[entry.product_id]; + const changedIndices = getChangedIndices(entry.old_data || [], entry.new_data || []); + + for (const idx of changedIndices) { + target[idx] = entry.new_data?.[idx]; } } + return result; } catch (error: any) { console.error('[getHistoryDataForMerge] Exception:', error.message);