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[]; data: ExcelRow[]; onRevert: (articleNo: string, oldData: ExcelRow, historyId?: string) => void; onEdit?: (rowIndex: number) => void; } type HistorySyncStatus = 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed'; interface HistorySyncRecord { selected: boolean; status: HistorySyncStatus; previewToken?: string; error?: string; warning?: 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([]); const [loading, setLoading] = useState(true); 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' | 'preview_only' | 'synced' | 'failed'>('history-statusFilter', 'all'); const [bcHistorySync, setBcHistorySync] = useState(() => readStoredHistorySyncMap()); const [bcSyncBusy, setBcSyncBusy] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { loadHistory(); }, []); useEffect(() => { if (data.length > 0) { loadHistory(); } }, [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(); setHistory(historyData); 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); 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); for (let i = 0; i < maxLen; i++) { if (oldData[i] !== newData[i]) { changes.push({ header: headers[i] || `Col ${i}`, old: oldData[i], new: newData[i], index: i }); } } return changes; }; const formatDate = (dateStr: string) => { const date = new Date(dateStr); return new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(date); }; const uniqueUsers = Array.from(new Set(history.map(e => e.changed_by))).sort(); 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, preview_only: 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, warning: next[key]?.warning, }; }); return next; }); }; const discardSelected = () => { if (selectedEntries.length === 0) return; if (!window.confirm(`Discard ${selectedEntries.length} pending sync(s)? They will be reset to "Pending BC" and will not be synced to Business Central.`)) return; setBcHistorySync(prev => { const next = { ...prev }; selectedEntries.forEach(entry => { const key = getEntryKey(entry); next[key] = { selected: false, status: 'bc_pending' }; }); 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', warning: undefined, selected: true }); continue; } updateEntry(entry, { status: 'previewed', previewToken: preview.previewToken, error: undefined, warning: 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', warning: undefined, selected: true }); continue; } previewToken = preview.previewToken; updateEntry(entry, { status: 'previewed', previewToken, error: undefined, warning: 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, warning: undefined, selected: true, }); const retryApply = await applyBusinessCentralSync(headers, entry.new_data, refreshedPreview.previewToken); if (retryApply.success) { updateEntry(entry, { status: 'synced', error: undefined, warning: retryApply.warning, selected: false, previewToken: retryApply.previewToken || refreshedPreview.previewToken, }); continue; } updateEntry(entry, { status: 'failed', error: retryApply.error || 'BC sync failed', warning: retryApply.warning, selected: true }); continue; } } updateEntry(entry, { status: 'failed', error: apply.error || 'BC sync failed', warning: apply.warning, selected: true }); continue; } updateEntry(entry, { status: 'synced', error: undefined, warning: apply.warning, selected: false, previewToken: apply.previewToken || previewToken, }); } } finally { setBcSyncBusy(false); } }; if (loading) { return (

Fetching history...

); } return (
{isFullscreen && ( )}

Change History

{history.length > 0 ? ( <>{history.length} total records{filteredHistory.length !== history.length && <> — showing {filteredHistory.length}} ) : 'Review and revert any changes made to products.'}

{userFilter && ( )}
setSearch(e.target.value)} className="pl-10 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition-colors w-64" /> {search && ( )}
{selectedEntries.length} selected ยท {statusCounts.bc_pending} pending {statusCounts.previewed} previewed {statusCounts.preview_only} preview only {statusCounts.synced} synced {statusCounts.failed} failed
{filteredHistory.length === 0 ? (

No history records found.

{search ? "Try adjusting your search filters." : "Changes are recorded when you 'Save All' pending modifications."}

) : (
{filteredHistory.map((entry, filteredIdx) => { const isExpanded = expandedId === entry.id; const changes = getChangedFields(entry.old_data, entry.new_data); const globalNumber = history.indexOf(entry) + 1; const status = getEntryStatus(entry); const selected = getEntrySelected(entry); return (
{/* Summary Row */}
setExpandedId(isExpanded ? null : entry.id)} > e.stopPropagation()} onChange={() => updateEntry(entry, { selected: !selected })} className="accent-blue-500" /> {isExpanded ? : }
#{globalNumber}
{entry.product_id.slice(0, 2).toUpperCase()}
{entry.article_name}
ID: {entry.product_id}
{entry.changed_by}
{formatDate(entry.changed_at)}
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
{status === 'failed' && bcHistorySync[getEntryKey(entry)]?.error && (
BC error: {bcHistorySync[getEntryKey(entry)].error}
)}
{/* Details View */} {isExpanded && (
{changes.map((change, idx) => ( ))}
# Column Original Value New Value
{idx + 1} {change.header} {String(change.old ?? '-')} {String(change.new ?? '-')}
Row Index Reference: {entry.product_id}

Reverting will move this record to 'Pending Validation' for final approval before re-syncing.

)}
); })}
)}
); }