diff --git a/src/App.tsx b/src/App.tsx index 10aacd8..2bc42c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => 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(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' && ( + setEditingRowIndex(index)} + /> + )} + {activeModule === 'history' && ( + { + // 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), + } + })); + } + }} + /> + )} )} diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx new file mode 100644 index 0000000..9f9294f --- /dev/null +++ b/src/components/HistoryView.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState(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 ( +
+
+

Fetching history...

+
+ ); + } + + return ( +
+
+
+

+ + Change History +

+

Review and revert any changes made to products.

+
+
+
+ + 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" + /> +
+ +
+
+ +
+ {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) => { + const isExpanded = expandedId === entry.id; + const changes = getChangedFields(entry.old_data, entry.new_data); + + return ( +
+ {/* Summary Row */} +
setExpandedId(isExpanded ? null : entry.id)} + > + {isExpanded ? : } + +
+
+
+ {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'} + + +
+
+
+ + {/* Details View */} + {isExpanded && ( +
+
+ + + + + + + + + + {changes.map((change, idx) => ( + + + + + + ))} + +
FieldOriginal ValueNew Value
+ {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. +

+
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/PendingValidationView.tsx b/src/components/PendingValidationView.tsx new file mode 100644 index 0000000..bfa7f49 --- /dev/null +++ b/src/components/PendingValidationView.tsx @@ -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; + rowStatuses: Record; + 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 ( +
+
+
+ +

Pending Validation

+ + {pendingEntries.length} change{pendingEntries.length !== 1 ? 's' : ''} pending + +
+
+
+ + 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" + /> +
+
+
+ + {filteredEntries.length === 0 ? ( +
+ + {search ? ( +

No pending changes match your search

+ ) : ( +

No pending validation changes

+ )} +
+ ) : ( +
+ {filteredEntries.map(([articleNo, { rowIndex, originalData, newData, articleName }]) => { + const changes = getChangedFields(originalData, newData); + const status = rowStatuses[articleNo]; + + return ( +
+
+
+
+ {articleNo} +
+
{articleName}
+
+
+ + +
+
+ +
+
+ Changes ({changes.length}) +
+
+ {changes.map((change, idx) => ( +
+ {change.field}: + {change.from} + + {change.to} +
+ ))} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3bafc23..f46379d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 ( diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 125001c..44fc523 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -109,3 +109,65 @@ export async function resetAllPendingRows(): Promise { 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 { + 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 { + 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 []; + } +}