mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:55:24 +02:00
feat: add Pending Validation tab showing all pending changes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user