2026-05-15 10:51:01 +02:00
|
|
|
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';
|
2026-04-10 09:47:35 +02:00
|
|
|
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
|
2026-04-22 16:52:59 +02:00
|
|
|
import { ExcelRow } from '../types';
|
|
|
|
|
import { useColumns } from '../contexts/ColumnsContext';
|
2026-04-09 09:24:17 +02:00
|
|
|
import { cn } from '../lib/utils';
|
2026-04-24 14:19:09 +02:00
|
|
|
import { usePersistentState } from '../contexts/FilterContext';
|
2026-05-15 10:51:01 +02:00
|
|
|
import { previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
|
|
|
|
|
import { SyncStatusPill } from './SyncStatusPill';
|
2026-04-09 09:24:17 +02:00
|
|
|
|
|
|
|
|
interface HistoryViewProps {
|
|
|
|
|
headers: string[];
|
2026-04-10 09:42:04 +02:00
|
|
|
data: ExcelRow[];
|
2026-04-23 18:58:52 +02:00
|
|
|
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: string) => void;
|
2026-04-10 12:46:26 +02:00
|
|
|
onEdit?: (rowIndex: number) => void;
|
2026-04-09 09:24:17 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
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 {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 12:37:04 +02:00
|
|
|
export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProps) {
|
2026-04-22 16:52:59 +02:00
|
|
|
const COLUMNS = useColumns();
|
2026-04-09 09:24:17 +02:00
|
|
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
2026-04-24 16:09:49 +02:00
|
|
|
const [search, setSearch] = usePersistentState('history-search', '');
|
2026-05-14 20:15:34 +02:00
|
|
|
const [userFilter, setUserFilter] = usePersistentState('history-userFilter', '');
|
2026-05-15 10:51:01 +02:00
|
|
|
const [statusFilter, setStatusFilter] = usePersistentState<'all' | 'bc_pending' | 'previewed' | 'synced' | 'failed'>('history-statusFilter', 'all');
|
|
|
|
|
const [bcHistorySync, setBcHistorySync] = useState<HistorySyncMap>(() => readStoredHistorySyncMap());
|
|
|
|
|
const [bcSyncBusy, setBcSyncBusy] = useState(false);
|
2026-04-23 14:53:43 +02:00
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
2026-04-09 09:24:17 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadHistory();
|
2026-04-23 18:47:32 +02:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (data.length > 0) {
|
|
|
|
|
loadHistory();
|
|
|
|
|
}
|
2026-04-23 18:43:18 +02:00
|
|
|
}, [data]);
|
2026-04-09 09:24:17 +02:00
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem('history-bcSync', JSON.stringify(bcHistorySync));
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore storage quota or serialization errors.
|
|
|
|
|
}
|
|
|
|
|
}, [bcHistorySync]);
|
|
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
const loadHistory = async () => {
|
|
|
|
|
setLoading(true);
|
2026-04-23 18:51:07 +02:00
|
|
|
const historyData = await getHistory();
|
|
|
|
|
setHistory(historyData);
|
2026-04-09 09:24:17 +02:00
|
|
|
setLoading(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
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]);
|
|
|
|
|
|
2026-04-10 09:47:35 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 20:15:34 +02:00
|
|
|
const uniqueUsers = Array.from(new Set(history.map(e => e.changed_by))).sort();
|
|
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
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 => {
|
2026-05-14 20:15:34 +02:00
|
|
|
if (userFilter && entry.changed_by !== userFilter) return false;
|
2026-05-15 10:51:01 +02:00
|
|
|
const status = getEntryStatus(entry);
|
|
|
|
|
if (statusFilter !== 'all' && status !== statusFilter) return false;
|
2026-04-24 12:20:14 +02:00
|
|
|
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));
|
|
|
|
|
});
|
2026-04-09 09:24:17 +02:00
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
|
|
|
|
|
<p className="text-lg">Fetching history...</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-23 14:53:43 +02:00
|
|
|
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"}>
|
|
|
|
|
{isFullscreen && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setIsFullscreen(false)}
|
|
|
|
|
className="fixed top-4 right-4 z-50 p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-04-09 09:24:17 +02:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
|
|
|
<History className="w-8 h-8 text-blue-500" />
|
|
|
|
|
Change History
|
|
|
|
|
</h1>
|
2026-05-14 18:33:07 +02:00
|
|
|
<p className="text-slate-400 mt-1">
|
|
|
|
|
{history.length > 0 ? (
|
|
|
|
|
<><span className="text-white font-semibold">{history.length}</span> total records{filteredHistory.length !== history.length && <> — showing <span className="text-white font-semibold">{filteredHistory.length}</span></>}</>
|
|
|
|
|
) : 'Review and revert any changes made to products.'}
|
|
|
|
|
</p>
|
2026-04-09 09:24:17 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-4">
|
2026-05-14 20:15:34 +02:00
|
|
|
<div className="relative">
|
|
|
|
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
|
|
|
|
<select
|
|
|
|
|
value={userFilter}
|
|
|
|
|
onChange={e => setUserFilter(e.target.value)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"pl-9 pr-8 py-2 bg-slate-800 border rounded-md text-sm focus:outline-none focus:border-blue-500 transition-colors w-52 appearance-none",
|
|
|
|
|
userFilter ? "border-blue-500 text-white" : "border-slate-700 text-slate-400"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<option value="">All users</option>
|
|
|
|
|
{uniqueUsers.map(u => (
|
|
|
|
|
<option key={u} value={u}>{u}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
{userFilter && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setUserFilter('')}
|
|
|
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-09 09:24:17 +02:00
|
|
|
<div className="relative">
|
|
|
|
|
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
2026-05-14 20:15:34 +02:00
|
|
|
<input
|
2026-04-09 09:24:17 +02:00
|
|
|
type="text"
|
|
|
|
|
placeholder="Search history..."
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
2026-04-10 12:39:31 +02:00
|
|
|
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"
|
2026-04-09 09:24:17 +02:00
|
|
|
/>
|
2026-04-10 12:39:31 +02:00
|
|
|
{search && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSearch('')}
|
|
|
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-04-09 09:24:17 +02:00
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={loadHistory}
|
|
|
|
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors flex items-center gap-2 border border-slate-700"
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="w-4 h-4" />
|
|
|
|
|
Refresh
|
|
|
|
|
</button>
|
2026-04-23 14:53:43 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
|
|
|
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
|
|
|
|
|
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
|
|
|
>
|
|
|
|
|
<Maximize2 className="w-5 h-5" />
|
|
|
|
|
</button>
|
2026-04-09 09:24:17 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
<div className="bg-[#0a1628] border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
|
|
|
|
|
{filteredHistory.length === 0 ? (
|
|
|
|
|
<div className="p-12 text-center">
|
|
|
|
|
<History className="w-12 h-12 text-slate-700 mx-auto mb-4" />
|
|
|
|
|
<p className="text-slate-500 text-lg">No history records found.</p>
|
|
|
|
|
<p className="text-slate-600 text-sm mt-1">
|
|
|
|
|
{search ? "Try adjusting your search filters." : "Changes are recorded when you 'Save All' pending modifications."}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="divide-y divide-slate-800">
|
2026-05-14 18:33:07 +02:00
|
|
|
{filteredHistory.map((entry, filteredIdx) => {
|
2026-04-09 09:24:17 +02:00
|
|
|
const isExpanded = expandedId === entry.id;
|
|
|
|
|
const changes = getChangedFields(entry.old_data, entry.new_data);
|
2026-05-15 10:51:01 +02:00
|
|
|
const globalNumber = history.indexOf(entry) + 1;
|
|
|
|
|
const status = getEntryStatus(entry);
|
|
|
|
|
const selected = getEntrySelected(entry);
|
2026-05-14 18:33:07 +02:00
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
return (
|
|
|
|
|
<div key={entry.id} className={cn(
|
|
|
|
|
"transition-colors",
|
|
|
|
|
isExpanded ? "bg-blue-600/5" : "hover:bg-slate-800/30"
|
|
|
|
|
)}>
|
|
|
|
|
{/* Summary Row */}
|
2026-05-14 18:33:07 +02:00
|
|
|
<div
|
2026-04-09 09:24:17 +02:00
|
|
|
className="p-4 flex items-center gap-4 cursor-pointer"
|
|
|
|
|
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
|
|
|
|
|
>
|
2026-05-15 10:51:01 +02:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selected}
|
|
|
|
|
onClick={e => e.stopPropagation()}
|
|
|
|
|
onChange={() => updateEntry(entry, { selected: !selected })}
|
|
|
|
|
className="accent-blue-500"
|
|
|
|
|
/>
|
2026-04-09 09:24:17 +02:00
|
|
|
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
|
2026-05-14 18:33:07 +02:00
|
|
|
|
|
|
|
|
<div className="w-8 text-right shrink-0">
|
|
|
|
|
<span className="text-xs font-mono text-slate-500">#{globalNumber}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
<div className="flex-1 grid grid-cols-4 gap-4 items-center">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500 font-bold shrink-0">
|
|
|
|
|
{entry.product_id.slice(0, 2).toUpperCase()}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="font-semibold text-slate-200 truncate">{entry.article_name}</div>
|
|
|
|
|
<div className="text-xs text-slate-500 font-mono">ID: {entry.product_id}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 text-slate-400">
|
|
|
|
|
<User className="w-4 h-4" />
|
|
|
|
|
<span className="text-sm truncate">{entry.changed_by}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 text-slate-400">
|
|
|
|
|
<Calendar className="w-4 h-4" />
|
|
|
|
|
<span className="text-sm">{formatDate(entry.changed_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-04-09 09:24:17 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Details View */}
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<div className="px-14 pb-6 pt-2 animate-in slide-in-from-top-2 duration-300">
|
|
|
|
|
<div className="bg-[#040d1a] border border-slate-700/50 rounded-lg overflow-hidden">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="bg-slate-800/50 text-slate-400 text-left">
|
2026-05-14 18:33:07 +02:00
|
|
|
<th className="px-4 py-2 font-medium w-8">#</th>
|
|
|
|
|
<th className="px-4 py-2 font-medium">Column</th>
|
2026-04-09 09:24:17 +02:00
|
|
|
<th className="px-4 py-2 font-medium">Original Value</th>
|
|
|
|
|
<th className="px-4 py-2 font-medium">New Value</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-slate-800">
|
|
|
|
|
{changes.map((change, idx) => (
|
|
|
|
|
<tr key={idx} className="hover:bg-slate-700/20">
|
2026-05-14 18:33:07 +02:00
|
|
|
<td className="px-4 py-2 text-slate-600 text-xs font-mono">{idx + 1}</td>
|
2026-04-09 09:24:17 +02:00
|
|
|
<td className="px-4 py-2 text-slate-300 font-medium whitespace-nowrap">
|
|
|
|
|
{change.header}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-2">
|
|
|
|
|
<span className="text-red-400/80 line-through decoration-red-500/50">
|
|
|
|
|
{String(change.old ?? '-')}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-2">
|
|
|
|
|
<span className="text-emerald-400 font-medium">
|
|
|
|
|
{String(change.new ?? '-')}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-4 flex items-center justify-between px-2">
|
|
|
|
|
<div className="text-xs text-slate-500 flex items-center gap-1">
|
|
|
|
|
<Tag className="w-3 h-3" />
|
|
|
|
|
Row Index Reference: {entry.product_id}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-slate-500 italic">
|
|
|
|
|
Reverting will move this record to 'Pending Validation' for final approval before re-syncing.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|