feat: add Pending Validation tab showing all pending changes

This commit is contained in:
Christian Vidal Wolf
2026-04-09 09:24:17 +02:00
parent a5c8076e18
commit 899016f0a0
5 changed files with 491 additions and 5 deletions
+44 -3
View File
@@ -6,13 +6,15 @@ import { TopBar } from './components/TopBar';
import { ProductDescriptions } from './components/ProductDescriptions';
import { MatrixView } from './components/MatrixView';
import { EditPanel } from './components/EditPanel';
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows } from './lib/supabase';
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry } from './lib/supabase';
import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView';
import { PricingView } from './components/PricingView';
import { ArticleDetails } from './components/ArticleDetails';
import { HistoryView } from './components/HistoryView';
import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView';
export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
@@ -33,7 +35,7 @@ export default function App() {
fileDate: null,
hasUnsavedChanges: false
});
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing'>('descriptions');
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history'>('descriptions');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
@@ -280,9 +282,12 @@ export default function App() {
if (entries.length === 0) return;
setIsSavingAll(true);
let allSuccess = true;
for (const [articleNo, { newData }] of entries) {
for (const [articleNo, { newData, originalData, articleName }] of entries) {
const success = await saveRowToSupabase(articleNo, newData);
if (success) {
// Also save to history
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
} else {
@@ -435,6 +440,42 @@ export default function App() {
rowStatuses={rowStatuses}
/>
)}
{activeModule === 'pending_validation' && (
<PendingValidationView
data={appState.data}
pendingRows={pendingRows}
rowStatuses={rowStatuses}
onRevertRow={handleRevertRow}
onEdit={(index) => setEditingRowIndex(index)}
/>
)}
{activeModule === 'history' && (
<HistoryView
headers={appState.headers}
onRevert={(articleNo, revertedData) => {
// Find the row in appState.data and update it
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
if (rowIndex !== -1) {
setAppState(prev => {
const newData = [...prev.data];
newData[rowIndex] = revertedData;
return { ...prev, data: newData, hasUnsavedChanges: true };
});
// Mark as pending for sync
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setPendingRows(prev => ({
...prev,
[articleNo]: {
rowIndex,
originalData: appState.data[rowIndex],
newData: revertedData,
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
}
}));
}
}}
/>
)}
</>
)}
</main>
+224
View File
@@ -0,0 +1,224 @@
import React, { useState, useEffect } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag } from 'lucide-react';
import { getHistory, HistoryEntry } from '../lib/supabase';
import { ExcelRow, COLUMNS } from '../types';
import { cn } from '../lib/utils';
interface HistoryViewProps {
headers: string[];
onRevert: (articleNo: string, oldData: ExcelRow) => void;
}
export function HistoryView({ headers, onRevert }: HistoryViewProps) {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
useEffect(() => {
loadHistory();
}, []);
const loadHistory = async () => {
setLoading(true);
const data = await getHistory();
setHistory(data);
setLoading(false);
};
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 filteredHistory = history.filter(entry =>
entry.article_name?.toLowerCase().includes(search.toLowerCase()) ||
entry.product_id.toLowerCase().includes(search.toLowerCase()) ||
entry.changed_by?.toLowerCase().includes(search.toLowerCase())
);
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 (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<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>
<p className="text-slate-400 mt-1">Review and revert any changes made to products.</p>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
placeholder="Search history..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 pr-4 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"
/>
</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>
</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">
<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">
{filteredHistory.map((entry) => {
const isExpanded = expandedId === entry.id;
const changes = getChangedFields(entry.old_data, entry.new_data);
return (
<div key={entry.id} className={cn(
"transition-colors",
isExpanded ? "bg-blue-600/5" : "hover:bg-slate-800/30"
)}>
{/* Summary Row */}
<div
className="p-4 flex items-center gap-4 cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
>
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
<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>
<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 (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
onRevert(entry.product_id, entry.old_data);
}
}}
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>
</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">
<th className="px-4 py-2 font-medium">Field</th>
<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">
<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>
);
}
+157
View File
@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter } from 'lucide-react';
import { cn } from '../lib/utils';
interface PendingValidationViewProps {
data: ExcelRow[];
pendingRows: Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>;
rowStatuses: Record<string, string>;
onRevertRow: (articleNo: string) => void;
onEdit: (index: number) => void;
}
export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: PendingValidationViewProps) {
const [search, setSearch] = useState('');
const pendingEntries = Object.entries(pendingRows);
const filteredEntries = search
? pendingEntries.filter(([articleNo, { articleName }]) =>
articleNo.toLowerCase().includes(search.toLowerCase()) ||
articleName.toLowerCase().includes(search.toLowerCase())
)
: pendingEntries;
const getFieldDiff = (original: ExcelRow, updated: ExcelRow, colIndex: number) => {
const orig = original[colIndex];
const upd = updated[colIndex];
if (orig !== upd) {
return { from: String(orig ?? ''), to: String(upd ?? '') };
}
return null;
};
const getChangedFields = (original: ExcelRow, updated: ExcelRow) => {
const changes: { field: string; from: string; to: string }[] = [];
const fieldConfigs = [
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.INNER_L, label: 'Inner L' },
{ col: COLUMNS.INNER_W, label: 'Inner W' },
{ col: COLUMNS.INNER_H, label: 'Inner H' },
{ col: COLUMNS.OUTER_L, label: 'Outer L' },
{ col: COLUMNS.OUTER_W, label: 'Outer W' },
{ col: COLUMNS.OUTER_H, label: 'Outer H' },
{ col: COLUMNS.UNITS_INNER, label: 'Units Inner' },
{ col: COLUMNS.UNITS_OUTER, label: 'Units Outer' },
{ col: COLUMNS.MOQ, label: 'MOQ' },
{ col: COLUMNS.BARCODE, label: 'Barcode' },
{ col: COLUMNS.TARIFF_CODE, label: 'Tariff Code' },
{ col: COLUMNS.COUNTRY_ORIGIN, label: 'Country' },
{ col: COLUMNS.RECOMMENDED_AGE, label: 'Recommended Age' },
];
fieldConfigs.forEach(({ col, label }) => {
const diff = getFieldDiff(original, updated, col);
if (diff) {
changes.push({ field: label, ...diff });
}
});
return changes;
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-yellow-500" />
<h2 className="text-lg font-semibold text-white">Pending Validation</h2>
<span className="text-sm text-slate-500">
{pendingEntries.length} change{pendingEntries.length !== 1 ? 's' : ''} pending
</span>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search by article no or name..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
/>
</div>
</div>
</div>
{filteredEntries.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500">
<Clock className="w-16 h-16 mb-4 opacity-30" />
{search ? (
<p>No pending changes match your search</p>
) : (
<p>No pending validation changes</p>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredEntries.map(([articleNo, { rowIndex, originalData, newData, articleName }]) => {
const changes = getChangedFields(originalData, newData);
const status = rowStatuses[articleNo];
return (
<div
key={articleNo}
className="border border-yellow-500/30 bg-yellow-500/5 rounded-lg overflow-hidden"
>
<div className="flex items-center justify-between p-4 bg-yellow-500/10 border-b border-yellow-500/20">
<div className="flex items-center gap-4">
<div className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
{articleNo}
</div>
<div className="text-sm text-slate-300">{articleName}</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onEdit(rowIndex)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-xs font-medium transition-colors"
>
<FileText className="w-3 h-3" />
View/Edit
</button>
<button
onClick={() => onRevertRow(articleNo)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 text-red-400 rounded text-xs font-medium transition-colors"
>
<Undo2 className="w-3 h-3" />
Undo
</button>
</div>
</div>
<div className="p-4">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-3">
Changes ({changes.length})
</div>
<div className="space-y-2">
{changes.map((change, idx) => (
<div key={idx} className="flex items-center gap-3 text-sm">
<span className="text-slate-400 w-28 shrink-0">{change.field}:</span>
<span className="text-red-400 line-through opacity-70">{change.from}</span>
<span className="text-slate-500"></span>
<span className="text-green-400 font-medium">{change.to}</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+4 -2
View File
@@ -1,10 +1,10 @@
import React from 'react';
import { FileText, Table, Box, DollarSign, Package } from 'lucide-react';
import { FileText, Table, Box, DollarSign, Package, Clock, History } from 'lucide-react';
import { cn } from '../lib/utils';
interface SidebarProps {
activeModule: string;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing') => void;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history') => void;
}
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
@@ -14,6 +14,8 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
{ id: 'article_details', label: 'Article Details', icon: Package },
{ id: 'dimensions', label: 'Dimensions', icon: Box },
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock },
{ id: 'history', label: 'Change History', icon: History },
] as const;
return (
+62
View File
@@ -109,3 +109,65 @@ export async function resetAllPendingRows(): Promise<boolean> {
return false;
}
}
export interface HistoryEntry {
id: string;
product_id: string;
article_name: string;
old_data: ExcelRow;
new_data: ExcelRow;
changed_at: string;
changed_by: string;
}
export async function saveHistoryEntry(
articleNo: string,
articleName: string,
oldData: ExcelRow,
newData: ExcelRow,
userEmail: 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
})
});
return response.ok;
} catch (error) {
console.error('Error saving history to Supabase:', error);
return false;
}
}
export async function getHistory(): Promise<HistoryEntry[]> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
{
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('Error fetching history from Supabase:', error);
return [];
}
}