Author SHA1 Message Date
Christian Vidal WolfandQwen-Coder 20a0c2d786 feat: restrict description column filters to Present/Missing only
Long DE, Long EN, Short DE, and Short EN columns now only show
Present/Missing filter options instead of all unique values

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-08 17:11:27 +02:00
Christian Vidal Wolf 976f38eef8 feat: final Select All styling and sync to main 2026-04-08 17:03:00 +02:00
Christian Vidal Wolf 29f3a469fe test: change Clear to BORRAR to verify user view 2026-04-08 17:02:35 +02:00
Christian Vidal Wolf f3f9f1547f style: move Select All to bottom bar of ColumnFilterPopover 2026-04-08 16:59:39 +02:00
Christian Vidal Wolf 89aa222470 feat: standardize ColumnFilterPopover and add SELECT ALL to all views/tabs 2026-04-08 16:56:43 +02:00
Christian Vidal Wolf 786c66fd87 feat: add Select All to column filters and refine views 2026-04-08 16:50:53 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 71af4e1915 feat: add Classification column and Excel-style column filters to Pricing & Units tab
- Added Classification column next to Line with color-coded badges (Core=blue, OOC=amber)
- Added Excel-style filter icons on Article Name (text search), Line, and Classification headers
- Filter icons appear on hover; turn blue when active
- Multi-select popover for Line and Classification; free-text popover for Article Name
- Filters work additively alongside the existing global search bar
- ArticleDetails: removed Tariff/Barcode/Origin columns and tab to simplify the view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:37:40 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 04d198ea59 feat: add Excel-like column filters and License/Classification columns to Product Descriptions
- Added per-column filter popover (Excel-style) with search, multi-select checkboxes, and clear button
- Filter icon appears on hover for each column header; turns blue when active
- "Clear All Column Filters" button appears in toolbar when any column filter is active
- Added License and Classification columns to the table with OOC badge styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:18:24 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 8fa001c6ed feat: short description AI targeting 30% length + checkbox sync for near-duplicate dimensions
- EditPanel: short description prompts now target ~30% of the long description character count, preserving key highlights as flowing prose
- DimensionsView: near-duplicate clusters expanded view replaced with a flat checkbox list (SKU + name + current dims per row), a target-dimension dropdown, and a "Sync N selected" button that unifies inner dimensions across selected products via ConfirmModal

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