mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 13:05:25 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20a0c2d786 | ||
|
|
976f38eef8 | ||
|
|
29f3a469fe | ||
|
|
f3f9f1547f | ||
|
|
89aa222470 | ||
|
|
786c66fd87 | ||
|
|
71af4e1915 | ||
|
|
04d198ea59 | ||
|
|
8fa001c6ed | ||
|
|
24042a6aed | ||
|
|
935cd5b510 | ||
|
|
ec1b506341 |
+8
-1
@@ -11,6 +11,7 @@ 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 { UndoToast } from './components/UndoToast';
|
||||
|
||||
export default function App() {
|
||||
@@ -32,7 +33,7 @@ export default function App() {
|
||||
fileDate: null,
|
||||
hasUnsavedChanges: false
|
||||
});
|
||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'matrix' | 'dimensions' | 'pricing'>('descriptions');
|
||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing'>('descriptions');
|
||||
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
||||
@@ -373,6 +374,12 @@ export default function App() {
|
||||
onEdit={(index) => setEditingRowIndex(index)}
|
||||
/>
|
||||
)}
|
||||
{activeModule === 'article_details' && (
|
||||
<ArticleDetails
|
||||
data={appState.data}
|
||||
onEdit={(index) => setEditingRowIndex(index)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface ArticleDetailsProps {
|
||||
data: ExcelRow[];
|
||||
onEdit: (index: number) => void;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
||||
|
||||
export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Tab filter
|
||||
if (activeTab === 'missingDetailsDE') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE]);
|
||||
if (activeTab === 'missingDetailsEN') result = result.filter(r => !r.row[COLUMNS.DETAILS_EN]);
|
||||
if (activeTab === 'missingAnyDetails') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE] || !r.row[COLUMNS.DETAILS_EN]);
|
||||
if (activeTab === 'lowStock') result = result.filter(r => Number(r.row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0);
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown filters
|
||||
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
|
||||
|
||||
// Column-specific filters (Excel-like)
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const vals = selectedValues as string[];
|
||||
if (vals.length > 0) {
|
||||
result = result.filter(r => vals.includes(String(r.row[Number(colIdx)] || '')));
|
||||
}
|
||||
});
|
||||
|
||||
// Sorting
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
const valA = a.row[sortCol];
|
||||
const valB = b.row[sortCol];
|
||||
|
||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
return sortDesc ? valB - valA : valA - valB;
|
||||
}
|
||||
|
||||
const sA = String(valA || '');
|
||||
const sB = String(valB || '');
|
||||
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, activeTab, search, lineFilter, sortCol, sortDesc]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, page, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
const handleSort = (col: number) => {
|
||||
if (sortCol === col) {
|
||||
setSortDesc(!sortDesc);
|
||||
} else {
|
||||
setSortCol(col);
|
||||
setSortDesc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
};
|
||||
|
||||
const toggleColumnFilter = (col: number, value: string) => {
|
||||
setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, [col]: next };
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setBatchColumnFilter = (col: number, values: string[]) => {
|
||||
setColumnFilters(prev => ({ ...prev, [col]: values }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getBadge = (val: any, type: 'success' | 'warning' | 'error' | 'info' = 'info') => {
|
||||
if (!val) return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>;
|
||||
|
||||
const styles = {
|
||||
success: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
error: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
info: "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
};
|
||||
|
||||
return <span className={cn("inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border", styles[type])}>{val}</span>;
|
||||
};
|
||||
|
||||
const tabs: { id: TabType; label: string }[] = [
|
||||
{ id: 'all', label: 'All Articles' },
|
||||
{ id: 'missingDetailsDE', label: 'No Details DE' },
|
||||
{ id: 'missingDetailsEN', label: 'No Details EN' },
|
||||
{ id: 'missingAnyDetails', label: 'Missing Details' },
|
||||
{ id: 'lowStock', label: 'Out of Stock' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
activeTab === tab.id
|
||||
? "bg-indigo-600 text-white shadow-md"
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
value={lineFilter}
|
||||
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
|
||||
className="bg-slate-900 border border-slate-700 rounded-md px-4 py-2 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All Lines</option>
|
||||
{lines.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{[
|
||||
{ col: COLUMNS.ARTICLE_NO, label: 'SKU' },
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Name' },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Class' },
|
||||
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Stock' },
|
||||
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
|
||||
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
|
||||
].map(({ col, label }) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-3 py-3 font-medium transition-colors select-none group relative"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
|
||||
{label}
|
||||
{sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
||||
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openFilterCol === col && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
selectedValues={columnFilters[col] || []}
|
||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||
onSelectAll={(vals) => setBatchColumnFilter(col, vals)}
|
||||
onClear={() => {
|
||||
setColumnFilters(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[col];
|
||||
return next;
|
||||
});
|
||||
setOpenFilterCol(null);
|
||||
}}
|
||||
onClose={() => setOpenFilterCol(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-3 font-medium text-right">Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/30">
|
||||
{paginatedData.map(({ row, index }) => (
|
||||
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-indigo-400">{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-200 max-w-[150px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>
|
||||
{row[COLUMNS.ARTICLE_NAME]}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={cn(
|
||||
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||
)}>
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={cn(
|
||||
"font-mono font-bold",
|
||||
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
|
||||
)}>
|
||||
{row[COLUMNS.ITEM_AVAILABLE] || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{row[COLUMNS.DETAILS_DE] ? (
|
||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
||||
) : getBadge(null)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{row[COLUMNS.DETAILS_EN] ? (
|
||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
||||
) : getBadge(null)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
onClick={() => onEdit(index)}
|
||||
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-indigo-400/10 rounded transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{paginatedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
||||
No articles found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
|
||||
<div>Showing {paginatedData.length} of {filteredData.length} articles</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-slate-300">Page {page} of {totalPages || 1}</span>
|
||||
<button
|
||||
disabled={page === totalPages || totalPages === 0}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Check, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ColumnFilterPopoverProps {
|
||||
uniqueValues: string[];
|
||||
selectedValues: string[];
|
||||
onToggle: (val: string) => void;
|
||||
onSelectAll: (vals: string[]) => void;
|
||||
onClear: () => void;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColumnFilterPopover({
|
||||
uniqueValues,
|
||||
selectedValues,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onClose,
|
||||
title,
|
||||
className
|
||||
}: ColumnFilterPopoverProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
return uniqueValues.filter(v =>
|
||||
String(v || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [uniqueValues, search]);
|
||||
|
||||
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
|
||||
className
|
||||
)}>
|
||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter values..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
|
||||
{filteredValues.map(val => (
|
||||
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
|
||||
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
|
||||
)}>
|
||||
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="hidden"
|
||||
checked={selectedValues.includes(val)}
|
||||
onChange={() => onToggle(val)}
|
||||
/>
|
||||
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
||||
</label>
|
||||
))}
|
||||
{filteredValues.length === 0 && (
|
||||
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isAllSelected) {
|
||||
onSelectAll([]);
|
||||
} else {
|
||||
onSelectAll(uniqueValues);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
|
||||
>
|
||||
{isAllSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<span className="text-slate-600 font-bold">•</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
|
||||
>
|
||||
Clear Current
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95 uppercase"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export function DataCompleteness({ data, headers }: DataCompletenessProps) {
|
||||
COLUMNS.TARIFF_CODE,
|
||||
COLUMNS.COUNTRY_ORIGIN,
|
||||
COLUMNS.RECOMMENDED_AGE,
|
||||
COLUMNS.DETAILS_DE,
|
||||
COLUMNS.DETAILS_EN,
|
||||
COLUMNS.LONG_DE,
|
||||
COLUMNS.LONG_EN,
|
||||
COLUMNS.SHORT_DE,
|
||||
@@ -70,7 +72,7 @@ export function DataCompleteness({ data, headers }: DataCompletenessProps) {
|
||||
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<h2 className="text-lg font-semibold text-white">Data Completeness</h2>
|
||||
<p className="text-sm text-slate-400">Evaluating 10 key fields per product.</p>
|
||||
<p className="text-sm text-slate-400">Evaluating 12 key fields per product.</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto flex-1">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2 } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface DimensionsViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -41,6 +42,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
sourceRow: ExcelRow,
|
||||
fieldType: 'outer' | 'units' | 'moq' | 'all'
|
||||
} | null>(null);
|
||||
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
|
||||
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
|
||||
clusterIndex: number;
|
||||
targetGroupKey: string;
|
||||
selectedIndices: number[];
|
||||
} | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState<string[]>([]);
|
||||
const [classFilter, setClassFilter] = useState<string[]>([]);
|
||||
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
|
||||
@@ -106,8 +118,33 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
}, [data]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups;
|
||||
}, [groups, showOnlyInconsistent]);
|
||||
let result = groups;
|
||||
if (showOnlyInconsistent) result = result.filter(g => g.isInconsistent);
|
||||
|
||||
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(g => {
|
||||
const matchesSearch = !search || g.rows.some(({ row }) =>
|
||||
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
const matchesLine = lineFilter.length === 0 || g.rows.some(({ row }) => lineFilter.includes(String(row[COLUMNS.LINE] || '')));
|
||||
const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
|
||||
|
||||
return matchesSearch && matchesLine && matchesClass;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter]);
|
||||
|
||||
const uniqueLines = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
|
||||
, [data]);
|
||||
|
||||
const uniqueClasses = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort()
|
||||
, [data]);
|
||||
|
||||
const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => {
|
||||
// Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute
|
||||
@@ -177,6 +214,38 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
setPendingAction({ group, sourceRow, fieldType: 'all' });
|
||||
};
|
||||
|
||||
const executeNearDupSync = async () => {
|
||||
if (!pendingNearDupSync) return;
|
||||
const { clusterIndex, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||
|
||||
const cluster = nearDuplicateClusters[clusterIndex];
|
||||
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
|
||||
if (!targetGroup) return;
|
||||
|
||||
const sourceRow = targetGroup.rows[0].row;
|
||||
const innerL = sourceRow[COLUMNS.INNER_L];
|
||||
const innerW = sourceRow[COLUMNS.INNER_W];
|
||||
const innerH = sourceRow[COLUMNS.INNER_H];
|
||||
|
||||
onCaptureState(`Synced inner dimensions to ${targetGroupKey} cm for ${selectedIndices.length} products`);
|
||||
setPendingNearDupSync(null);
|
||||
|
||||
for (const idx of selectedIndices) {
|
||||
const row = data[idx];
|
||||
const updatedRow = [...row];
|
||||
updatedRow[COLUMNS.INNER_L] = innerL;
|
||||
updatedRow[COLUMNS.INNER_W] = innerW;
|
||||
updatedRow[COLUMNS.INNER_H] = innerH;
|
||||
await onSaveRow(idx, updatedRow);
|
||||
}
|
||||
|
||||
setClusterSelections(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[clusterIndex];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const executeSync = async () => {
|
||||
if (!pendingAction) return;
|
||||
const { group, sourceRow, fieldType } = pendingAction;
|
||||
@@ -222,17 +291,83 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between bg-slate-800/50 p-4 rounded-lg border border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
||||
<Boxes className="text-blue-400" />
|
||||
Dimension Consistency Check
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Grouping products by Inner Box dimensions to find Packaging or MOQ discrepancies.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 bg-slate-800/50 p-4 rounded-lg border border-slate-700">
|
||||
<div className="relative flex-1 min-w-[250px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name in groups..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||
lineFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Line {lineFilter.length > 0 && `(${lineFilter.length})`}
|
||||
</button>
|
||||
{openFilter === 'line' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueLines}
|
||||
selectedValues={lineFilter}
|
||||
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={setLineFilter}
|
||||
onClear={() => setLineFilter([])}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
title="Filter by Line"
|
||||
className="left-auto right-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||
classFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Class {classFilter.length > 0 && `(${classFilter.length})`}
|
||||
</button>
|
||||
{openFilter === 'class' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueClasses}
|
||||
selectedValues={classFilter}
|
||||
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={setClassFilter}
|
||||
onClear={() => setClassFilter([])}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
title="Filter by Classification"
|
||||
className="left-auto right-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(lineFilter.length > 0 || classFilter.length > 0 || search) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setLineFilter([]); setClassFilter([]); }}
|
||||
className="p-2 text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Clear all filters"
|
||||
>
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-slate-700 mx-2 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -240,10 +375,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
onChange={e => setShowOnlyInconsistent(e.target.checked)}
|
||||
className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Show only inconsistent groups
|
||||
Show only inconsistent
|
||||
</label>
|
||||
<div className="text-xs text-slate-500 bg-slate-900 px-3 py-1.5 rounded-full border border-slate-700">
|
||||
{groups.filter(g => g.isInconsistent).length} Inconsistencies found
|
||||
<div className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded border border-amber-500/20 whitespace-nowrap">
|
||||
{groups.filter(g => g.isInconsistent).length} ISSUES
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,49 +392,119 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
{nearDuplicateClusters.length} cluster{nearDuplicateClusters.length !== 1 ? 's' : ''} with dimensions differing <1 cm per axis
|
||||
</span>
|
||||
</div>
|
||||
{nearDuplicateClusters.map((cluster, ci) => (
|
||||
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = new Set(expandedNearDuplicates);
|
||||
next.has(ci) ? next.delete(ci) : next.add(ci);
|
||||
setExpandedNearDuplicates(next);
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
|
||||
>
|
||||
{expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{cluster.groups.map((g, gi) => (
|
||||
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
|
||||
{g.key} cm
|
||||
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs text-violet-400/70">
|
||||
— max diff {cluster.maxDiffPct.toFixed(1)} cm
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{expandedNearDuplicates.has(ci) && (
|
||||
<div className="border-t border-violet-500/20 px-4 py-3 space-y-2">
|
||||
{cluster.groups.map((g, gi) => (
|
||||
<div key={g.key} className="flex items-start gap-4 text-xs">
|
||||
<span className="font-mono text-violet-300 w-32 shrink-0 pt-0.5">{g.key} cm</span>
|
||||
<div>
|
||||
<span className="text-slate-400">{cluster.volumes[gi].toLocaleString()} cm³</span>
|
||||
<span className="text-slate-600 mx-2">·</span>
|
||||
<span className="text-slate-500">{g.rows.length} product{g.rows.length !== 1 ? 's' : ''}: </span>
|
||||
<span className="text-slate-400">
|
||||
{g.rows.slice(0, 5).map(r => r.row[COLUMNS.ARTICLE_NO]).join(', ')}
|
||||
{g.rows.length > 5 && <span className="text-slate-600"> +{g.rows.length - 5} more</span>}
|
||||
</span>
|
||||
</div>
|
||||
{nearDuplicateClusters.map((cluster, ci) => {
|
||||
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
||||
const selection = clusterSelections[ci] ?? new Set<number>();
|
||||
const targetKey = clusterSyncTargets[ci] ?? cluster.groups[0].key;
|
||||
|
||||
return (
|
||||
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = new Set(expandedNearDuplicates);
|
||||
next.has(ci) ? next.delete(ci) : next.add(ci);
|
||||
setExpandedNearDuplicates(next);
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
|
||||
>
|
||||
{expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />}
|
||||
<div className="flex items-center gap-3 flex-wrap flex-1">
|
||||
{cluster.groups.map((g, gi) => (
|
||||
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
|
||||
{g.key} cm
|
||||
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs text-violet-400/70">— max diff {cluster.maxDiffPct.toFixed(1)} cm</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 shrink-0">{allClusterRows.length} products</span>
|
||||
</button>
|
||||
|
||||
{expandedNearDuplicates.has(ci) && (
|
||||
<div className="border-t border-violet-500/20">
|
||||
{/* Sync toolbar */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
|
||||
<span className="text-xs text-slate-400">Sync selected to:</span>
|
||||
<select
|
||||
value={targetKey}
|
||||
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [ci]: e.target.value }))}
|
||||
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
|
||||
>
|
||||
{cluster.groups.map(g => {
|
||||
const repr = g.rows[0].row;
|
||||
return (
|
||||
<option key={g.key} value={g.key}>
|
||||
{repr[COLUMNS.INNER_L]} × {repr[COLUMNS.INNER_W]} × {repr[COLUMNS.INNER_H]} cm
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
disabled={selection.size === 0}
|
||||
onClick={() => setPendingNearDupSync({ clusterIndex: ci, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
||||
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Layers className="w-3 h-3" />
|
||||
Sync {selection.size} selected
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set(allClusterRows.map(r => r.index)) }))}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
{selection.size > 0 && (
|
||||
<button
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set() }))}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Flat product list */}
|
||||
<div className="divide-y divide-violet-500/10">
|
||||
{allClusterRows.map(({ row, index }) => {
|
||||
const isSelected = selection.has(index);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors",
|
||||
isSelected && "bg-violet-500/10"
|
||||
)}
|
||||
onClick={() => setClusterSelections(prev => {
|
||||
const current = new Set(prev[ci] ?? []);
|
||||
if (current.has(index)) current.delete(index); else current.add(index);
|
||||
return { ...prev, [ci]: current };
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-slate-300 font-medium shrink-0">{row[COLUMNS.ARTICLE_NO]}</span>
|
||||
<span className="text-xs text-slate-500 truncate">{row[COLUMNS.ARTICLE_NAME]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded shrink-0">
|
||||
{row[COLUMNS.INNER_L]} × {row[COLUMNS.INNER_W]} × {row[COLUMNS.INNER_H]} cm
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -484,6 +689,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
type="warning"
|
||||
confirmText="Sync Group"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!pendingNearDupSync}
|
||||
onConfirm={executeNearDupSync}
|
||||
onCancel={() => setPendingNearDupSync(null)}
|
||||
title="Sync Inner Dimensions"
|
||||
message={`Update inner dimensions to ${pendingNearDupSync?.targetGroupKey} cm for ${pendingNearDupSync?.selectedIndices.length} selected product${(pendingNearDupSync?.selectedIndices.length ?? 0) !== 1 ? 's' : ''}?`}
|
||||
type="warning"
|
||||
confirmText="Sync Dimensions"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { X, Sparkles, Save, Loader2, Languages, Package } from 'lucide-react';
|
||||
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react';
|
||||
import { generateGemini } from '../services/gemini';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
@@ -19,6 +19,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
longEn: row[COLUMNS.LONG_EN] || '',
|
||||
shortDe: row[COLUMNS.SHORT_DE] || '',
|
||||
shortEn: row[COLUMNS.SHORT_EN] || '',
|
||||
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
||||
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
||||
innerW: row[COLUMNS.INNER_W] || '',
|
||||
innerL: row[COLUMNS.INNER_L] || '',
|
||||
innerH: row[COLUMNS.INNER_H] || '',
|
||||
@@ -33,6 +35,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
||||
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
||||
|
||||
const isModified = (field: keyof typeof formData) => {
|
||||
const colMap: Record<string, number> = {
|
||||
@@ -48,8 +51,12 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
outerH: COLUMNS.OUTER_H,
|
||||
unitsOuter: COLUMNS.UNITS_OUTER,
|
||||
moq: COLUMNS.MOQ,
|
||||
detailsDe: COLUMNS.DETAILS_DE,
|
||||
detailsEn: COLUMNS.DETAILS_EN,
|
||||
};
|
||||
return formData[field] !== (row[colMap[field]] || '');
|
||||
const colIndex = (colMap as Record<string, number>)[field as string];
|
||||
if (colIndex === undefined) return false;
|
||||
return formData[field] !== (row[colIndex] || '');
|
||||
};
|
||||
|
||||
const handleGenerate = async (field: keyof typeof formData) => {
|
||||
@@ -65,7 +72,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
|
||||
try {
|
||||
let prompt = '';
|
||||
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${row[COLUMNS.DETAILS_EN] || 'N/A'}\nArticle Details (DE): ${row[COLUMNS.DETAILS_DE] || 'N/A'}`;
|
||||
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
|
||||
|
||||
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. You excel at translating product descriptions between German and English, maintaining the commercial and professional tone while ensuring all technical toy details are accurate. Use clear, engaging language.";
|
||||
|
||||
@@ -101,7 +108,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
if (formData.shortEn) {
|
||||
prompt = `Translate exactly this short English product description into professional German for the toy market:\n\n${formData.shortEn}`;
|
||||
} else if (formData.longDe) {
|
||||
prompt = `Create a short version (2-4 sentences max) of the following German product description:\n\n${formData.longDe}`;
|
||||
const targetChars = Math.round(formData.longDe.length * 0.3);
|
||||
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a short commercial description in German (2-4 sentences max).\n\n${baseContext}`;
|
||||
}
|
||||
@@ -109,7 +117,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
if (formData.shortDe) {
|
||||
prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${formData.shortDe}`;
|
||||
} else if (formData.longEn) {
|
||||
prompt = `Create a short version (2-4 sentences max) of the following English product description:\n\n${formData.longEn}`;
|
||||
const targetChars = Math.round(formData.longEn.length * 0.3);
|
||||
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a short commercial description in English (2-4 sentences max).\n\n${baseContext}`;
|
||||
}
|
||||
@@ -117,6 +126,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
|
||||
const generatedText = await generateGemini(prompt, systemPrompt);
|
||||
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
||||
setGeneratedFields(prev => new Set(prev).add(field));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred during generation.');
|
||||
} finally {
|
||||
@@ -143,6 +153,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
newRow[COLUMNS.OUTER_H] = formData.outerH;
|
||||
newRow[COLUMNS.UNITS_OUTER] = formData.unitsOuter;
|
||||
newRow[COLUMNS.MOQ] = formData.moq;
|
||||
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
|
||||
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
|
||||
onSave(rowIndex, newRow);
|
||||
};
|
||||
|
||||
@@ -185,6 +197,12 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
|
||||
</button>
|
||||
</div>
|
||||
{generatedFields.has(field) && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
AI Generated - You can still edit manually
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={formData[field]}
|
||||
@@ -230,11 +248,27 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
|
||||
<p className="text-sm text-slate-300 line-clamp-2" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE] || '-'}</p>
|
||||
<textarea
|
||||
value={formData.detailsDe}
|
||||
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
|
||||
className={cn(
|
||||
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||
)}
|
||||
placeholder="Enter details in German..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
|
||||
<p className="text-sm text-slate-300 line-clamp-2" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN] || '-'}</p>
|
||||
<textarea
|
||||
value={formData.detailsEn}
|
||||
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
|
||||
className={cn(
|
||||
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||
)}
|
||||
placeholder="Enter details in English..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+160
-10
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { Search, Filter, ChevronDown, ChevronUp, X as XIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface MatrixViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -9,11 +12,88 @@ interface MatrixViewProps {
|
||||
export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [search, setSearch] = useState('');
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Global search
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
r.row.some(cell => String(cell || '').toLowerCase().includes(s))
|
||||
);
|
||||
}
|
||||
|
||||
// Column-specific filters
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const vals = selectedValues as string[];
|
||||
if (vals.length > 0) {
|
||||
result = result.filter(r => vals.includes(String(r.row[Number(colIdx)] || '')));
|
||||
}
|
||||
});
|
||||
|
||||
// Sorting
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
const valA = String(a.row[sortCol] || '').toLowerCase();
|
||||
const valB = String(b.row[sortCol] || '').toLowerCase();
|
||||
|
||||
// Handle numbers sorting correctly
|
||||
const numA = parseFloat(valA.replace(',', '.'));
|
||||
const numB = parseFloat(valB.replace(',', '.'));
|
||||
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return sortDesc ? numB - numA : numA - numB;
|
||||
}
|
||||
|
||||
return sortDesc
|
||||
? valB.localeCompare(valA)
|
||||
: valA.localeCompare(valB);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, search, columnFilters, sortCol, sortDesc]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return data.slice(start, start + pageSize);
|
||||
}, [data, page, pageSize]);
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, page, pageSize]);
|
||||
|
||||
const handleSort = (col: number) => {
|
||||
if (sortCol === col) {
|
||||
setSortDesc(!sortDesc);
|
||||
} else {
|
||||
setSortCol(col);
|
||||
setSortDesc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
};
|
||||
|
||||
const toggleColumnFilter = (col: number, value: string) => {
|
||||
setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, [col]: next };
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setBatchColumnFilter = (col: number, values: string[]) => {
|
||||
setColumnFilters(prev => ({ ...prev, [col]: values }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const formatCellValue = (val: any, header: string = '') => {
|
||||
if (val === undefined || val === null || val === '') return '';
|
||||
@@ -72,13 +152,39 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
return val;
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
|
||||
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
|
||||
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative min-w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search all columns..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto flex-1">
|
||||
@@ -86,14 +192,58 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th key={index} className="px-4 py-3 font-medium border-b border-slate-700">
|
||||
{header}
|
||||
<th
|
||||
key={index}
|
||||
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-white"
|
||||
onClick={() => handleSort(index)}
|
||||
>
|
||||
{header}
|
||||
{sortCol === index && (
|
||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === index ? null : index);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
||||
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openFilterCol === index && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={getUniqueValues(index)}
|
||||
selectedValues={columnFilters[index] || []}
|
||||
onToggle={(val) => toggleColumnFilter(index, val)}
|
||||
onSelectAll={(vals) => setBatchColumnFilter(index, vals)}
|
||||
onClear={() => {
|
||||
setColumnFilters(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
setOpenFilterCol(null);
|
||||
}}
|
||||
onClose={() => setOpenFilterCol(null)}
|
||||
title={header}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{paginatedData.map((row, rowIndex) => (
|
||||
{paginatedData.map(({ row, index: rowIndex }) => (
|
||||
<tr key={rowIndex} className="hover:bg-slate-700/30 transition-colors">
|
||||
{headers.map((header, colIndex) => {
|
||||
const formattedValue = formatCellValue(row[colIndex], header);
|
||||
@@ -122,7 +272,7 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Showing {Math.min((page - 1) * pageSize + 1, data.length)} to {Math.min(page * pageSize, data.length)} of {data.length} entries</span>
|
||||
<span>Showing {Math.min((page - 1) * pageSize + 1, filteredData.length)} to {Math.min(page * pageSize, filteredData.length)} of {filteredData.length} entries</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||
|
||||
+200
-35
@@ -10,8 +10,12 @@ import {
|
||||
X,
|
||||
ChevronDown,
|
||||
Edit2,
|
||||
Filter,
|
||||
Check,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface PricingViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -43,9 +47,14 @@ function findCol(headers: string[], ...keywords: string[]): number {
|
||||
|
||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) {
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [lineMultiFilter, setLineMultiFilter] = useState<string[]>([]);
|
||||
const [classificationFilter, setClassificationFilter] = useState<string[]>([]);
|
||||
const [nameColFilter, setNameColFilter] = useState('');
|
||||
const [openFilter, setOpenFilter] = useState<'name' | 'line' | 'classification' | null>(null);
|
||||
|
||||
// ── Dynamic column detection ──────────────────────────────────────────────
|
||||
const { uvpIdx, srpCols, containerCols } = useMemo(() => {
|
||||
@@ -127,15 +136,49 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
return { total: analyzedRows.length, withPricing, withUnits, withAny, allOk };
|
||||
}, [analyzedRows]);
|
||||
|
||||
// ── Unique values for column filters ─────────────────────────────────────
|
||||
const uniqueLines = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort(),
|
||||
[data]);
|
||||
|
||||
const uniqueClassifications = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort(),
|
||||
[data]);
|
||||
|
||||
// ── Filtered rows ─────────────────────────────────────────────────────────
|
||||
const filteredRows = useMemo(() => {
|
||||
let result = analyzedRows;
|
||||
|
||||
// Mode filter
|
||||
switch (filterMode) {
|
||||
case 'all_errors': return analyzedRows.filter(r => r.hasErrors);
|
||||
case 'pricing_errors': return analyzedRows.filter(r => r.pricingErrors.length > 0);
|
||||
case 'units_errors': return analyzedRows.filter(r => r.unitErrors.length > 0);
|
||||
default: return analyzedRows;
|
||||
case 'all_errors': result = analyzedRows.filter(r => r.hasErrors); break;
|
||||
case 'pricing_errors': result = analyzedRows.filter(r => r.pricingErrors.length > 0); break;
|
||||
case 'units_errors': result = analyzedRows.filter(r => r.unitErrors.length > 0); break;
|
||||
}
|
||||
}, [analyzedRows, filterMode]);
|
||||
|
||||
// Global search (SKU + Name)
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// Column filters
|
||||
if (nameColFilter) {
|
||||
const s = nameColFilter.toLowerCase();
|
||||
result = result.filter(r => String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s));
|
||||
}
|
||||
if (lineMultiFilter.length > 0) {
|
||||
result = result.filter(r => lineMultiFilter.includes(String(r.row[COLUMNS.LINE] || '')));
|
||||
}
|
||||
if (classificationFilter.length > 0) {
|
||||
result = result.filter(r => classificationFilter.includes(String(r.row[COLUMNS.CLASSIFICATION] || '')));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [analyzedRows, filterMode, search, nameColFilter, lineMultiFilter, classificationFilter]);
|
||||
|
||||
// ── Inline edit helpers ───────────────────────────────────────────────────
|
||||
const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => {
|
||||
@@ -228,6 +271,47 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* ── Search bar ── */}
|
||||
<div className="flex-1 max-w-md relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
|
||||
<Package className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filter tabs ── */}
|
||||
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilterMode(f.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-700 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
<span className={cn(
|
||||
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-600 text-white'
|
||||
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
|
||||
)}>
|
||||
{f.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Column detection warning ── */}
|
||||
{missingCols.length > 0 && (
|
||||
@@ -248,32 +332,6 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
<StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" />
|
||||
</div>
|
||||
|
||||
{/* ── Filter tabs ── */}
|
||||
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilterMode(f.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-700 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
<span className={cn(
|
||||
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-600 text-white'
|
||||
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
|
||||
)}>
|
||||
{f.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl">
|
||||
{filteredRows.length === 0 ? (
|
||||
@@ -289,11 +347,77 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap">
|
||||
Art. No.
|
||||
</th>
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700">
|
||||
Article Name
|
||||
{/* Article Name with text filter */}
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Article Name</span>
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'name' ? null : 'name')}
|
||||
className={cn(
|
||||
'p-0.5 rounded hover:bg-slate-700 transition-colors',
|
||||
nameColFilter ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{openFilter === 'name' && (
|
||||
<TextFilterPopover
|
||||
value={nameColFilter}
|
||||
onChange={setNameColFilter}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap">
|
||||
Line
|
||||
{/* Line with multi-select filter */}
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Line</span>
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
|
||||
className={cn(
|
||||
'p-0.5 rounded hover:bg-slate-700 transition-colors',
|
||||
lineMultiFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{openFilter === 'line' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueLines}
|
||||
selectedValues={lineMultiFilter}
|
||||
onToggle={val => setLineMultiFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={vals => setLineMultiFilter(vals)}
|
||||
onClear={() => { setLineMultiFilter([]); setOpenFilter(null); }}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
{/* Classification with multi-select filter */}
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Classification</span>
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'classification' ? null : 'classification')}
|
||||
className={cn(
|
||||
'p-0.5 rounded hover:bg-slate-700 transition-colors',
|
||||
classificationFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{openFilter === 'classification' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueClassifications}
|
||||
selectedValues={classificationFilter}
|
||||
onToggle={val => setClassificationFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={vals => setClassificationFilter(vals)}
|
||||
onClear={() => { setClassificationFilter([]); setOpenFilter(null); }}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
|
||||
{/* Editable pricing columns */}
|
||||
@@ -359,6 +483,20 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
{row[COLUMNS.LINE] || '—'}
|
||||
</td>
|
||||
|
||||
{/* Classification */}
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded text-[10px] font-bold border whitespace-nowrap',
|
||||
String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().includes('CORE')
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().includes('OOC')
|
||||
? 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
: 'bg-slate-700/50 text-slate-400 border-slate-600/50'
|
||||
)}>
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Editable pricing cells */}
|
||||
{pricingEditableCols.map(col => {
|
||||
const isEditing = editingCell?.rowIndex === dataIndex && editingCell?.colIndex === col.index;
|
||||
@@ -484,6 +622,33 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Text filter popover ───────────────────────────────────────────────────────
|
||||
function TextFilterPopover({ value, onChange, onClose }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute top-full left-0 mt-1 w-56 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search article name…"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1 border-t border-slate-700">
|
||||
<button onClick={() => { onChange(''); onClose(); }} className="text-[10px] font-medium text-slate-400 hover:text-white transition-colors">Clear</button>
|
||||
<button onClick={onClose} className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded transition-colors">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat card ─────────────────────────────────────────────────────────────────
|
||||
function StatCard({
|
||||
label,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface ProductDescriptionsProps {
|
||||
data: ExcelRow[];
|
||||
onEdit: (index: number) => void;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingDeLong' | 'missingEnLong' | 'missingDeShort' | 'missingEnShort' | 'complete' | 'incomplete';
|
||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingLongAny' | 'missingShortDE' | 'missingShortEN' | 'missingShortAny' | 'complete' | 'incomplete';
|
||||
|
||||
// Description columns that should only have Present/Missing filters
|
||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
||||
|
||||
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
@@ -19,6 +23,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
||||
@@ -27,17 +33,26 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Tab filter
|
||||
if (activeTab === 'missingDeLong') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||
if (activeTab === 'missingEnLong') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
||||
if (activeTab === 'missingDeShort') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
||||
if (activeTab === 'missingEnShort') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'complete') result = result.filter(r => r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] && r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'incomplete') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] || !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
||||
if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
|
||||
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
||||
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'missingShortAny') result = result.filter(r => !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
|
||||
|
||||
if (activeTab === 'complete') result = result.filter(r =>
|
||||
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
|
||||
r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN]
|
||||
);
|
||||
if (activeTab === 'incomplete') result = result.filter(r =>
|
||||
!r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] ||
|
||||
!r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]
|
||||
);
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
@@ -47,6 +62,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
|
||||
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
||||
|
||||
// Column-specific filters (Excel-like)
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const col = Number(colIdx);
|
||||
const vals = selectedValues as string[];
|
||||
if (vals.length > 0) {
|
||||
// For description columns, filter by present/missing
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
result = result.filter(r => {
|
||||
const hasValue = Boolean(r.row[col]);
|
||||
const shouldInclude = vals.includes('Present') && hasValue || vals.includes('Missing') && !hasValue;
|
||||
return shouldInclude;
|
||||
});
|
||||
} else {
|
||||
// For other columns, use regular value matching
|
||||
result = result.filter(r => vals.includes(String(r.row[col] || '')));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sorting
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
@@ -75,6 +109,32 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
// For description columns, return only 'Present' and 'Missing'
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
return ['Present', 'Missing'];
|
||||
}
|
||||
// For other columns, return actual unique values
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
};
|
||||
|
||||
const toggleColumnFilter = (col: number, value: string) => {
|
||||
setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, [col]: next };
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setBatchColumnFilter = (col: number, values: string[]) => {
|
||||
setColumnFilters(prev => ({ ...prev, [col]: values }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getRowColor = (row: ExcelRow) => {
|
||||
// EOL Rule: OOC Classification and 0 or negative stock (Item Available)
|
||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
||||
@@ -84,7 +144,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
return 'bg-yellow-500/10 hover:bg-yellow-500/20'; // EOL Highlight
|
||||
}
|
||||
|
||||
const fields = [row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN], row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]];
|
||||
const fields = [
|
||||
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
|
||||
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
|
||||
];
|
||||
const filled = fields.filter(Boolean).length;
|
||||
if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20';
|
||||
if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20';
|
||||
@@ -93,13 +156,13 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
|
||||
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
|
||||
if (content) return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">✓</span>;
|
||||
|
||||
|
||||
// EOL Exception: OOC and stock <= 0
|
||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
||||
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
|
||||
|
||||
|
||||
if (classification === 'OOC' && stock <= 0) {
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">EOL not neccessary</span>;
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">EOL not neccessary</span>;
|
||||
}
|
||||
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30">✗ Missing</span>;
|
||||
@@ -107,10 +170,12 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
|
||||
const tabs: { id: TabType; label: string }[] = [
|
||||
{ id: 'all', label: 'All Products' },
|
||||
{ id: 'missingDeLong', label: 'Missing DE Long' },
|
||||
{ id: 'missingEnLong', label: 'Missing EN Long' },
|
||||
{ id: 'missingDeShort', label: 'Missing DE Short' },
|
||||
{ id: 'missingEnShort', label: 'Missing EN Short' },
|
||||
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
||||
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
||||
{ id: 'missingLongAny', label: 'Missing Long DE/EN' },
|
||||
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
||||
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
||||
{ id: 'missingShortAny', label: 'Missing Short DE/EN' },
|
||||
{ id: 'complete', label: 'Complete' },
|
||||
{ id: 'incomplete', label: 'Incomplete' },
|
||||
];
|
||||
@@ -124,8 +189,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
activeTab === tab.id
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
activeTab === tab.id
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||
)}
|
||||
>
|
||||
@@ -145,6 +210,18 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
value={lineFilter}
|
||||
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
|
||||
@@ -173,22 +250,54 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
||||
{ col: COLUMNS.LINE, label: 'Line' },
|
||||
{ col: COLUMNS.LICENSE, label: 'License' },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
||||
{ col: COLUMNS.LONG_DE, label: 'Long DE' },
|
||||
{ col: COLUMNS.LONG_EN, label: 'Long EN' },
|
||||
{ col: COLUMNS.SHORT_DE, label: 'Short DE' },
|
||||
{ col: COLUMNS.SHORT_EN, label: 'Short EN' },
|
||||
].map(({ col, label }) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors select-none"
|
||||
onClick={() => handleSort(col)}
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-3 font-medium transition-colors select-none group relative"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
{sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
|
||||
{label}
|
||||
{sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
||||
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openFilterCol === col && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
selectedValues={columnFilters[col] || []}
|
||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||
onSelectAll={(vals) => setBatchColumnFilter(col, vals)}
|
||||
onClear={() => {
|
||||
setColumnFilters(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[col];
|
||||
return next;
|
||||
});
|
||||
setOpenFilterCol(null);
|
||||
}}
|
||||
onClose={() => setOpenFilterCol(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
@@ -197,10 +306,20 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{paginatedData.map(({ row, index }) => (
|
||||
<tr key={index} className={cn("transition-colors", getRowColor(row))}>
|
||||
<td className="px-4 py-3 font-mono text-slate-300">{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-4 py-3 font-medium text-white max-w-[300px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{row[COLUMNS.LINE]}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{row[COLUMNS.LICENSE]}</td>
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs">{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-4 py-3 font-medium text-white max-w-[200px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs">{row[COLUMNS.LINE]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate max-w-[120px]" title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(
|
||||
"px-2 py-0.5 rounded text-[10px] font-bold border",
|
||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
||||
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
: "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||
)}>
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
||||
@@ -226,7 +345,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Showing {Math.min((page - 1) * pageSize + 1, filteredData.length)} to {Math.min(page * pageSize, filteredData.length)} of {filteredData.length} entries</span>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from 'react';
|
||||
import { FileText, Table, Box, DollarSign } from 'lucide-react';
|
||||
import { FileText, Table, Box, DollarSign, Package } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: string;
|
||||
setActiveModule: (m: 'descriptions' | 'completeness' | 'matrix' | 'dimensions' | 'pricing') => void;
|
||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing') => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
||||
const navItems = [
|
||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user