mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 18:55:24 +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 { LoginPage } from './components/LoginPage';
|
||||||
import { DimensionsView } from './components/DimensionsView';
|
import { DimensionsView } from './components/DimensionsView';
|
||||||
import { PricingView } from './components/PricingView';
|
import { PricingView } from './components/PricingView';
|
||||||
|
import { ArticleDetails } from './components/ArticleDetails';
|
||||||
import { UndoToast } from './components/UndoToast';
|
import { UndoToast } from './components/UndoToast';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -32,7 +33,7 @@ export default function App() {
|
|||||||
fileDate: null,
|
fileDate: null,
|
||||||
hasUnsavedChanges: false
|
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 [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
||||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||||
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
||||||
@@ -373,6 +374,12 @@ export default function App() {
|
|||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeModule === 'article_details' && (
|
||||||
|
<ArticleDetails
|
||||||
|
data={appState.data}
|
||||||
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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.TARIFF_CODE,
|
||||||
COLUMNS.COUNTRY_ORIGIN,
|
COLUMNS.COUNTRY_ORIGIN,
|
||||||
COLUMNS.RECOMMENDED_AGE,
|
COLUMNS.RECOMMENDED_AGE,
|
||||||
|
COLUMNS.DETAILS_DE,
|
||||||
|
COLUMNS.DETAILS_EN,
|
||||||
COLUMNS.LONG_DE,
|
COLUMNS.LONG_DE,
|
||||||
COLUMNS.LONG_EN,
|
COLUMNS.LONG_EN,
|
||||||
COLUMNS.SHORT_DE,
|
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="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">
|
<div className="p-4 border-b border-slate-700 bg-slate-800/50">
|
||||||
<h2 className="text-lg font-semibold text-white">Data Completeness</h2>
|
<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>
|
||||||
|
|
||||||
<div className="overflow-x-auto flex-1">
|
<div className="overflow-x-auto flex-1">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
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 { cn } from '../lib/utils';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
|
||||||
interface DimensionsViewProps {
|
interface DimensionsViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -41,6 +42,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
sourceRow: ExcelRow,
|
sourceRow: ExcelRow,
|
||||||
fieldType: 'outer' | 'units' | 'moq' | 'all'
|
fieldType: 'outer' | 'units' | 'moq' | 'all'
|
||||||
} | null>(null);
|
} | 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 groups = useMemo(() => {
|
||||||
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
|
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
|
||||||
@@ -106,8 +118,33 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups;
|
let result = groups;
|
||||||
}, [groups, showOnlyInconsistent]);
|
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[] => {
|
const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => {
|
||||||
// Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute
|
// 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' });
|
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 () => {
|
const executeSync = async () => {
|
||||||
if (!pendingAction) return;
|
if (!pendingAction) return;
|
||||||
const { group, sourceRow, fieldType } = pendingAction;
|
const { group, sourceRow, fieldType } = pendingAction;
|
||||||
@@ -222,17 +291,83 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 className="flex flex-wrap items-center gap-4 bg-slate-800/50 p-4 rounded-lg border border-slate-700">
|
||||||
<div>
|
<div className="relative flex-1 min-w-[250px]">
|
||||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
<Boxes className="text-blue-400" />
|
<input
|
||||||
Dimension Consistency Check
|
type="text"
|
||||||
</h2>
|
placeholder="Search SKU or Name in groups..."
|
||||||
<p className="text-sm text-slate-400 mt-1">
|
value={search}
|
||||||
Grouping products by Inner Box dimensions to find Packaging or MOQ discrepancies.
|
onChange={e => setSearch(e.target.value)}
|
||||||
</p>
|
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>
|
||||||
<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">
|
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -240,10 +375,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
onChange={e => setShowOnlyInconsistent(e.target.checked)}
|
onChange={e => setShowOnlyInconsistent(e.target.checked)}
|
||||||
className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
|
className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
Show only inconsistent groups
|
Show only inconsistent
|
||||||
</label>
|
</label>
|
||||||
<div className="text-xs text-slate-500 bg-slate-900 px-3 py-1.5 rounded-full border border-slate-700">
|
<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} Inconsistencies found
|
{groups.filter(g => g.isInconsistent).length} ISSUES
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
{nearDuplicateClusters.length} cluster{nearDuplicateClusters.length !== 1 ? 's' : ''} with dimensions differing <1 cm per axis
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{nearDuplicateClusters.map((cluster, ci) => (
|
{nearDuplicateClusters.map((cluster, ci) => {
|
||||||
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
||||||
<button
|
const selection = clusterSelections[ci] ?? new Set<number>();
|
||||||
onClick={() => {
|
const targetKey = clusterSyncTargets[ci] ?? cluster.groups[0].key;
|
||||||
const next = new Set(expandedNearDuplicates);
|
|
||||||
next.has(ci) ? next.delete(ci) : next.add(ci);
|
return (
|
||||||
setExpandedNearDuplicates(next);
|
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||||
}}
|
<button
|
||||||
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
|
onClick={() => {
|
||||||
>
|
const next = new Set(expandedNearDuplicates);
|
||||||
{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" />}
|
next.has(ci) ? next.delete(ci) : next.add(ci);
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
setExpandedNearDuplicates(next);
|
||||||
{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">
|
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
|
||||||
{g.key} cm
|
>
|
||||||
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
|
{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" />}
|
||||||
</span>
|
<div className="flex items-center gap-3 flex-wrap flex-1">
|
||||||
))}
|
{cluster.groups.map((g, gi) => (
|
||||||
<span className="text-xs text-violet-400/70">
|
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
|
||||||
— max diff {cluster.maxDiffPct.toFixed(1)} cm
|
{g.key} cm
|
||||||
</span>
|
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
|
||||||
</div>
|
</span>
|
||||||
</button>
|
))}
|
||||||
{expandedNearDuplicates.has(ci) && (
|
<span className="text-xs text-violet-400/70">— max diff {cluster.maxDiffPct.toFixed(1)} cm</span>
|
||||||
<div className="border-t border-violet-500/20 px-4 py-3 space-y-2">
|
</div>
|
||||||
{cluster.groups.map((g, gi) => (
|
<span className="text-xs text-slate-500 shrink-0">{allClusterRows.length} products</span>
|
||||||
<div key={g.key} className="flex items-start gap-4 text-xs">
|
</button>
|
||||||
<span className="font-mono text-violet-300 w-32 shrink-0 pt-0.5">{g.key} cm</span>
|
|
||||||
<div>
|
{expandedNearDuplicates.has(ci) && (
|
||||||
<span className="text-slate-400">{cluster.volumes[gi].toLocaleString()} cm³</span>
|
<div className="border-t border-violet-500/20">
|
||||||
<span className="text-slate-600 mx-2">·</span>
|
{/* Sync toolbar */}
|
||||||
<span className="text-slate-500">{g.rows.length} product{g.rows.length !== 1 ? 's' : ''}: </span>
|
<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-slate-400">
|
<span className="text-xs text-slate-400">Sync selected to:</span>
|
||||||
{g.rows.slice(0, 5).map(r => r.row[COLUMNS.ARTICLE_NO]).join(', ')}
|
<select
|
||||||
{g.rows.length > 5 && <span className="text-slate-600"> +{g.rows.length - 5} more</span>}
|
value={targetKey}
|
||||||
</span>
|
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [ci]: e.target.value }))}
|
||||||
</div>
|
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">
|
||||||
</div>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -484,6 +689,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
type="warning"
|
type="warning"
|
||||||
confirmText="Sync Group"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
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 { generateGemini } from '../services/gemini';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
@@ -19,6 +19,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
longEn: row[COLUMNS.LONG_EN] || '',
|
longEn: row[COLUMNS.LONG_EN] || '',
|
||||||
shortDe: row[COLUMNS.SHORT_DE] || '',
|
shortDe: row[COLUMNS.SHORT_DE] || '',
|
||||||
shortEn: row[COLUMNS.SHORT_EN] || '',
|
shortEn: row[COLUMNS.SHORT_EN] || '',
|
||||||
|
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
||||||
|
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
||||||
innerW: row[COLUMNS.INNER_W] || '',
|
innerW: row[COLUMNS.INNER_W] || '',
|
||||||
innerL: row[COLUMNS.INNER_L] || '',
|
innerL: row[COLUMNS.INNER_L] || '',
|
||||||
innerH: row[COLUMNS.INNER_H] || '',
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||||
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
||||||
|
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const isModified = (field: keyof typeof formData) => {
|
const isModified = (field: keyof typeof formData) => {
|
||||||
const colMap: Record<string, number> = {
|
const colMap: Record<string, number> = {
|
||||||
@@ -48,8 +51,12 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
outerH: COLUMNS.OUTER_H,
|
outerH: COLUMNS.OUTER_H,
|
||||||
unitsOuter: COLUMNS.UNITS_OUTER,
|
unitsOuter: COLUMNS.UNITS_OUTER,
|
||||||
moq: COLUMNS.MOQ,
|
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) => {
|
const handleGenerate = async (field: keyof typeof formData) => {
|
||||||
@@ -65,7 +72,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let prompt = '';
|
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.";
|
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) {
|
if (formData.shortEn) {
|
||||||
prompt = `Translate exactly this short English product description into professional German for the toy market:\n\n${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) {
|
} 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 {
|
} else {
|
||||||
prompt = `Based on the following product details, generate a short commercial description in German (2-4 sentences max).\n\n${baseContext}`;
|
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) {
|
if (formData.shortDe) {
|
||||||
prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${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) {
|
} 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 {
|
} else {
|
||||||
prompt = `Based on the following product details, generate a short commercial description in English (2-4 sentences max).\n\n${baseContext}`;
|
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);
|
const generatedText = await generateGemini(prompt, systemPrompt);
|
||||||
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
||||||
|
setGeneratedFields(prev => new Set(prev).add(field));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'An error occurred during generation.');
|
setError(err.message || 'An error occurred during generation.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -143,6 +153,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
newRow[COLUMNS.OUTER_H] = formData.outerH;
|
newRow[COLUMNS.OUTER_H] = formData.outerH;
|
||||||
newRow[COLUMNS.UNITS_OUTER] = formData.unitsOuter;
|
newRow[COLUMNS.UNITS_OUTER] = formData.unitsOuter;
|
||||||
newRow[COLUMNS.MOQ] = formData.moq;
|
newRow[COLUMNS.MOQ] = formData.moq;
|
||||||
|
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
|
||||||
|
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
|
||||||
onSave(rowIndex, newRow);
|
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'}
|
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData[field]}
|
value={formData[field]}
|
||||||
@@ -230,11 +248,27 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
|
<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>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+160
-10
@@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow } from '../types';
|
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 {
|
interface MatrixViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -9,11 +12,88 @@ interface MatrixViewProps {
|
|||||||
export function MatrixView({ data, headers }: MatrixViewProps) {
|
export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
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 paginatedData = useMemo(() => {
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
return data.slice(start, start + pageSize);
|
return filteredData.slice(start, start + pageSize);
|
||||||
}, [data, page, 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 = '') => {
|
const formatCellValue = (val: any, header: string = '') => {
|
||||||
if (val === undefined || val === null || val === '') return '';
|
if (val === undefined || val === null || val === '') return '';
|
||||||
@@ -72,13 +152,39 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
|||||||
return val;
|
return val;
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = Math.ceil(data.length / pageSize);
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
|
<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">
|
<div className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
|
<div>
|
||||||
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
<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>
|
||||||
|
|
||||||
<div className="overflow-auto flex-1">
|
<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">
|
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
{headers.map((header, index) => (
|
{headers.map((header, index) => (
|
||||||
<th key={index} className="px-4 py-3 font-medium border-b border-slate-700">
|
<th
|
||||||
{header}
|
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>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/50">
|
<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">
|
<tr key={rowIndex} className="hover:bg-slate-700/30 transition-colors">
|
||||||
{headers.map((header, colIndex) => {
|
{headers.map((header, colIndex) => {
|
||||||
const formattedValue = formatCellValue(row[colIndex], header);
|
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="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">
|
<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
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
|
|||||||
+200
-35
@@ -10,8 +10,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Edit2,
|
Edit2,
|
||||||
|
Filter,
|
||||||
|
Check,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
|
||||||
interface PricingViewProps {
|
interface PricingViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -43,9 +47,14 @@ function findCol(headers: string[], ...keywords: string[]): number {
|
|||||||
|
|
||||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) {
|
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) {
|
||||||
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
|
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(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 ──────────────────────────────────────────────
|
// ── Dynamic column detection ──────────────────────────────────────────────
|
||||||
const { uvpIdx, srpCols, containerCols } = useMemo(() => {
|
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 };
|
return { total: analyzedRows.length, withPricing, withUnits, withAny, allOk };
|
||||||
}, [analyzedRows]);
|
}, [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 ─────────────────────────────────────────────────────────
|
// ── Filtered rows ─────────────────────────────────────────────────────────
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
|
let result = analyzedRows;
|
||||||
|
|
||||||
|
// Mode filter
|
||||||
switch (filterMode) {
|
switch (filterMode) {
|
||||||
case 'all_errors': return analyzedRows.filter(r => r.hasErrors);
|
case 'all_errors': result = analyzedRows.filter(r => r.hasErrors); break;
|
||||||
case 'pricing_errors': return analyzedRows.filter(r => r.pricingErrors.length > 0);
|
case 'pricing_errors': result = analyzedRows.filter(r => r.pricingErrors.length > 0); break;
|
||||||
case 'units_errors': return analyzedRows.filter(r => r.unitErrors.length > 0);
|
case 'units_errors': result = analyzedRows.filter(r => r.unitErrors.length > 0); break;
|
||||||
default: return analyzedRows;
|
|
||||||
}
|
}
|
||||||
}, [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 ───────────────────────────────────────────────────
|
// ── Inline edit helpers ───────────────────────────────────────────────────
|
||||||
const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => {
|
const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => {
|
||||||
@@ -228,6 +271,47 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full gap-4">
|
<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 ── */}
|
{/* ── Column detection warning ── */}
|
||||||
{missingCols.length > 0 && (
|
{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" />
|
<StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" />
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* ── Table ── */}
|
{/* ── Table ── */}
|
||||||
<div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl">
|
<div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl">
|
||||||
{filteredRows.length === 0 ? (
|
{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">
|
<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.
|
Art. No.
|
||||||
</th>
|
</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 with text filter */}
|
||||||
Article Name
|
<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>
|
||||||
<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 with multi-select filter */}
|
||||||
Line
|
<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>
|
</th>
|
||||||
|
|
||||||
{/* Editable pricing columns */}
|
{/* Editable pricing columns */}
|
||||||
@@ -359,6 +483,20 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
|||||||
{row[COLUMNS.LINE] || '—'}
|
{row[COLUMNS.LINE] || '—'}
|
||||||
</td>
|
</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 */}
|
{/* Editable pricing cells */}
|
||||||
{pricingEditableCols.map(col => {
|
{pricingEditableCols.map(col => {
|
||||||
const isEditing = editingCell?.rowIndex === dataIndex && editingCell?.colIndex === col.index;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Stat card ─────────────────────────────────────────────────────────────────
|
||||||
function StatCard({
|
function StatCard({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
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 { cn } from '../lib/utils';
|
||||||
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
|
||||||
interface ProductDescriptionsProps {
|
interface ProductDescriptionsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
onEdit: (index: number) => void;
|
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) {
|
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||||
@@ -19,6 +23,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [page, setPage] = useState(1);
|
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 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]);
|
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
||||||
@@ -27,12 +33,21 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
let result = data.map((row, index) => ({ row, index }));
|
let result = data.map((row, index) => ({ row, index }));
|
||||||
|
|
||||||
// Tab filter
|
// Tab filter
|
||||||
if (activeTab === 'missingDeLong') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||||
if (activeTab === 'missingEnLong') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
||||||
if (activeTab === 'missingDeShort') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
|
||||||
if (activeTab === 'missingEnShort') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
||||||
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 === 'missingShortEN') result = result.filter(r => !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 === '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
|
// Search filter
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -47,6 +62,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
|
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
|
||||||
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
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
|
// Sorting
|
||||||
if (sortCol !== null) {
|
if (sortCol !== null) {
|
||||||
result.sort((a, b) => {
|
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) => {
|
const getRowColor = (row: ExcelRow) => {
|
||||||
// EOL Rule: OOC Classification and 0 or negative stock (Item Available)
|
// EOL Rule: OOC Classification and 0 or negative stock (Item Available)
|
||||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
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
|
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;
|
const filled = fields.filter(Boolean).length;
|
||||||
if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20';
|
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';
|
if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20';
|
||||||
@@ -99,7 +162,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
|
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
|
||||||
|
|
||||||
if (classification === 'OOC' && stock <= 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>;
|
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 }[] = [
|
const tabs: { id: TabType; label: string }[] = [
|
||||||
{ id: 'all', label: 'All Products' },
|
{ id: 'all', label: 'All Products' },
|
||||||
{ id: 'missingDeLong', label: 'Missing DE Long' },
|
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
||||||
{ id: 'missingEnLong', label: 'Missing EN Long' },
|
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
||||||
{ id: 'missingDeShort', label: 'Missing DE Short' },
|
{ id: 'missingLongAny', label: 'Missing Long DE/EN' },
|
||||||
{ id: 'missingEnShort', label: 'Missing EN Short' },
|
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
||||||
|
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
||||||
|
{ id: 'missingShortAny', label: 'Missing Short DE/EN' },
|
||||||
{ id: 'complete', label: 'Complete' },
|
{ id: 'complete', label: 'Complete' },
|
||||||
{ id: 'incomplete', label: 'Incomplete' },
|
{ id: 'incomplete', label: 'Incomplete' },
|
||||||
];
|
];
|
||||||
@@ -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"
|
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>
|
</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
|
<select
|
||||||
value={lineFilter}
|
value={lineFilter}
|
||||||
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
|
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
|
||||||
@@ -173,6 +250,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
||||||
{ col: COLUMNS.LINE, label: 'Line' },
|
{ col: COLUMNS.LINE, label: 'Line' },
|
||||||
{ col: COLUMNS.LICENSE, label: 'License' },
|
{ col: COLUMNS.LICENSE, label: 'License' },
|
||||||
|
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
||||||
{ col: COLUMNS.LONG_DE, label: 'Long DE' },
|
{ col: COLUMNS.LONG_DE, label: 'Long DE' },
|
||||||
{ col: COLUMNS.LONG_EN, label: 'Long EN' },
|
{ col: COLUMNS.LONG_EN, label: 'Long EN' },
|
||||||
{ col: COLUMNS.SHORT_DE, label: 'Short DE' },
|
{ col: COLUMNS.SHORT_DE, label: 'Short DE' },
|
||||||
@@ -180,15 +258,46 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
].map(({ col, label }) => (
|
].map(({ col, label }) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className="px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors select-none"
|
className="px-4 py-3 font-medium transition-colors select-none group relative"
|
||||||
onClick={() => handleSort(col)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
{label}
|
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
|
||||||
{sortCol === col && (
|
{label}
|
||||||
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
{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>
|
</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>
|
||||||
))}
|
))}
|
||||||
<th className="px-4 py-3 font-medium text-right">Actions</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">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{paginatedData.map(({ row, index }) => (
|
{paginatedData.map(({ row, index }) => (
|
||||||
<tr key={index} className={cn("transition-colors", getRowColor(row))}>
|
<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-mono text-slate-300 text-xs">{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 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">{row[COLUMNS.LINE]}</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">{row[COLUMNS.LICENSE]}</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_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.LONG_EN]} row={row} /></td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import React from 'react';
|
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';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeModule: string;
|
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) {
|
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||||
|
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user