mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 11:55:23 +02:00
Dragging the mouse over rows now directly toggles Check Ying on all covered rows on mouseup. The check value (on/off) is determined by the state of the first row touched, so dragging over unchecked rows marks them and vice versa. Rows light up blue during the drag to show what will be affected. Co-Authored-By: claude-flow <ruv@ruv.net>
2489 lines
128 KiB
TypeScript
2489 lines
128 KiB
TypeScript
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { ExcelRow } from '../types';
|
|
import { useColumns } from '../contexts/ColumnsContext';
|
|
import {
|
|
AlertTriangle,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
DollarSign,
|
|
Package,
|
|
Save,
|
|
X,
|
|
ChevronDown,
|
|
Edit2,
|
|
Filter,
|
|
Check,
|
|
Search,
|
|
MessageSquare,
|
|
Maximize2,
|
|
Pin,
|
|
PinOff,
|
|
} from 'lucide-react';
|
|
import { cn } from '../lib/utils';
|
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
|
import { usePersistentState } from '../contexts/FilterContext';
|
|
|
|
interface PricingViewProps {
|
|
data: ExcelRow[];
|
|
headers: string[];
|
|
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
|
onCaptureState: (message: string) => void;
|
|
onEdit: (index: number) => void;
|
|
rowStatuses: Record<string, string>;
|
|
}
|
|
|
|
interface DetectedCol {
|
|
index: number;
|
|
name: string;
|
|
}
|
|
|
|
type FilterMode = 'all' | 'all_errors' | 'pricing_errors' | 'units_errors';
|
|
|
|
interface EditingCell {
|
|
rowIndex: number;
|
|
colIndex: number;
|
|
value: string;
|
|
}
|
|
|
|
// Flexible multi-keyword column finder
|
|
function findCol(headers: string[], ...keywords: string[]): number {
|
|
return headers.findIndex(h =>
|
|
keywords.every(kw => (h || '').toLowerCase().includes(kw.toLowerCase()))
|
|
);
|
|
}
|
|
|
|
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) {
|
|
const COLUMNS = useColumns();
|
|
const [filterMode, setFilterMode] = usePersistentState<FilterMode>('pricing-filterMode', 'all_errors');
|
|
const [search, setSearch] = usePersistentState('pricing-search', '');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [hoveredNote, setHoveredNote] = useState<{ text: string, pos: { top: number, left: number }, isAnna: boolean } | null>(null);
|
|
const pageSize = 100;
|
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({
|
|
articleNo: 100,
|
|
articleName: 220,
|
|
line: 100,
|
|
classification: 130,
|
|
productType: 140,
|
|
itemToLogistic: 160,
|
|
unitsOuter: 100,
|
|
outerW: 80,
|
|
outerL: 80,
|
|
outerH: 80,
|
|
moq: 80,
|
|
check: 100,
|
|
actions: 60,
|
|
});
|
|
const [resizingColumn, setResizingColumn] = useState<string | null>(null);
|
|
const [resizeStartX, setResizeStartX] = useState<number | null>(null);
|
|
const [resizeStartWidth, setResizeStartWidth] = useState<number | null>(null);
|
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
|
const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [editingType, setEditingType] = useState<{ rowIndex: number; value: string } | null>(null);
|
|
const [editingLogistic, setEditingLogistic] = useState<{ rowIndex: number; value: string } | null>(null);
|
|
const [typeSuggestions, setTypeSuggestions] = useState<string[]>([]);
|
|
const typeInputRef = useRef<HTMLInputElement>(null);
|
|
const logisticInputRef = useRef<HTMLInputElement>(null);
|
|
const [sortConfig, setSortConfig] = usePersistentState<{ key: string | number; direction: 'asc' | 'desc' | null }>('pricing-sortConfig', { key: null, direction: null });
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
const [lineMultiFilter, setLineMultiFilter] = usePersistentState<string[]>('pricing-lineFilter', []);
|
|
const [classificationFilter, setClassificationFilter] = usePersistentState<string[]>('pricing-classificationFilter', []);
|
|
const [productTypeFilter, setProductTypeFilter] = usePersistentState<string[]>('pricing-productTypeFilter', []);
|
|
const [nameColFilter, setNameColFilter] = usePersistentState<{ terms: string[]; op: 'and' | 'or' }>('pricing-nameColFilter', { terms: [''], op: 'and' });
|
|
const [articleNoColFilter, setArticleNoColFilter] = usePersistentState<{ terms: string[]; op: 'and' | 'or' }>('pricing-articleNoColFilter', { terms: [''], op: 'and' });
|
|
const [globalAdvancedFilter, setGlobalAdvancedFilter] = usePersistentState<{ terms: string[]; op: 'and' | 'or' }>('pricing-globalAdvancedFilter', { terms: [''], op: 'and' });
|
|
const [unitsOuterFilter, setUnitsOuterFilter] = usePersistentState<string[]>('pricing-unitsOuterFilter', []);
|
|
const [itemToLogisticFilter, setItemToLogisticFilter] = usePersistentState<string[]>('pricing-itemToLogisticFilter', []);
|
|
const [outerWFilter, setOuterWFilter] = usePersistentState<string[]>('pricing-outerWFilter', []);
|
|
const [outerLFilter, setOuterLFilter] = usePersistentState<string[]>('pricing-outerLFilter', []);
|
|
const [outerHFilter, setOuterHFilter] = usePersistentState<string[]>('pricing-outerHFilter', []);
|
|
const [moqFilter, setMoqFilter] = usePersistentState<string[]>('pricing-moqFilter', []);
|
|
const [checkYingFilter, setCheckYingFilter] = usePersistentState<string[]>('pricing-checkYingFilter', []);
|
|
const [checkAnnaFilter, setCheckAnnaFilter] = usePersistentState<string[]>('pricing-checkAnnaFilter', []);
|
|
const [dynamicColFilters, setDynamicColFilters] = usePersistentState<Record<number, string[]>>('pricing-dynamicColFilters', {});
|
|
const [weightIssueFilter, setWeightIssueFilter] = usePersistentState<'all' | 'with' | 'without'>('pricing-weightIssueFilter', 'all');
|
|
const [selectedSearchItems, setSelectedSearchItems] = usePersistentState<Set<string>>('pricing-selectedSearchItems', new Set());
|
|
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Pinned columns state
|
|
const [pinnedColumns, setPinnedColumns] = usePersistentState<Set<string>>('pricing-pinnedColumns', new Set(['articleNo', 'articleName']));
|
|
const [showPinPanel, setShowPinPanel] = useState(false);
|
|
|
|
// Drag-to-check state (Excel-style drag to toggle Check Ying)
|
|
const [dragSelectedRows, setDragSelectedRows] = useState<Set<number>>(new Set());
|
|
const isDraggingRef = useRef(false);
|
|
const dragStartIndexRef = useRef<number | null>(null);
|
|
const dragCheckValueRef = useRef<boolean>(true); // what value to set on mouseup
|
|
const lastDragIndexRef = useRef<number | null>(null);
|
|
const visibleDataIndexesRef = useRef<number[]>([]);
|
|
const dataRef = useRef(data);
|
|
dataRef.current = data;
|
|
|
|
const handleRowMouseDown = useCallback((e: React.MouseEvent, dataIndex: number) => {
|
|
if ((e.target as HTMLElement).closest('input, button, a')) return;
|
|
e.preventDefault();
|
|
isDraggingRef.current = true;
|
|
dragStartIndexRef.current = dataIndex;
|
|
lastDragIndexRef.current = dataIndex;
|
|
// Toggle based on current state of the first row touched
|
|
dragCheckValueRef.current = !dataRef.current[dataIndex]?.[COLUMNS.VALIDATED_CHECK];
|
|
setDragSelectedRows(new Set([dataIndex]));
|
|
}, [COLUMNS.VALIDATED_CHECK]);
|
|
|
|
const handleRowMouseEnter = useCallback((dataIndex: number) => {
|
|
if (!isDraggingRef.current || dragStartIndexRef.current === null) return;
|
|
if (dataIndex === lastDragIndexRef.current) return;
|
|
lastDragIndexRef.current = dataIndex;
|
|
|
|
const start = dragStartIndexRef.current;
|
|
const end = dataIndex;
|
|
const [lo, hi] = start <= end ? [start, end] : [end, start];
|
|
|
|
setDragSelectedRows(() => {
|
|
const next = new Set<number>();
|
|
visibleDataIndexesRef.current.forEach((idx: number) => {
|
|
if (idx >= lo && idx <= hi) next.add(idx);
|
|
});
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleMouseUp = async () => {
|
|
if (!isDraggingRef.current) return;
|
|
isDraggingRef.current = false;
|
|
const rows = new Set(dragSelectedRows);
|
|
if (rows.size === 0) return;
|
|
setDragSelectedRows(new Set());
|
|
const targetValue = dragCheckValueRef.current;
|
|
for (const idx of Array.from(rows)) {
|
|
await handleToggleCheck(idx as number, targetValue);
|
|
}
|
|
};
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
return () => document.removeEventListener('mouseup', handleMouseUp);
|
|
}, [dragSelectedRows]);
|
|
|
|
// Close search dropdown on click outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) {
|
|
setIsSearchOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const searchSuggestions = useMemo(() => {
|
|
if (!search) return data.slice(0, 50);
|
|
|
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (terms.length === 0) return data.slice(0, 50);
|
|
|
|
return data.filter(r => {
|
|
const articleNo = String(r[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
|
const articleName = String(r[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
|
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
|
|
}).slice(0, 50);
|
|
}, [data, search, selectedSearchItems, COLUMNS]);
|
|
|
|
const handleSearchItemClick = (sku: string) => {
|
|
const newSelected = new Set(selectedSearchItems);
|
|
if (newSelected.has(sku)) {
|
|
newSelected.delete(sku);
|
|
} else {
|
|
newSelected.add(sku);
|
|
}
|
|
setSelectedSearchItems(newSelected);
|
|
};
|
|
|
|
const handleApplySearch = () => {
|
|
setIsSearchOpen(false);
|
|
};
|
|
|
|
const handleClearSearch = () => {
|
|
setSelectedSearchItems(new Set());
|
|
};
|
|
|
|
const handleResizeStart = (e: React.MouseEvent, colKey: string, currentWidth: number) => {
|
|
console.log('Resize started for:', colKey, 'at x:', e.clientX);
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setResizingColumn(colKey);
|
|
setResizeStartX(e.clientX);
|
|
setResizeStartWidth(currentWidth);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (!resizingColumn || resizeStartX === null || resizeStartWidth === null) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const delta = e.clientX - resizeStartX;
|
|
const newWidth = Math.max(40, Math.min(800, resizeStartWidth + delta));
|
|
setColumnWidths(prev => ({ ...prev, [resizingColumn]: newWidth }));
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setResizingColumn(null);
|
|
setResizeStartX(null);
|
|
setResizeStartWidth(null);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
|
|
|
// ── Dynamic column detection ──────────────────────────────────────────────
|
|
const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx } = useMemo(() => {
|
|
const uvpIdx = findCol(headers, 'uvp');
|
|
|
|
// All SRP columns, sorted: INT first, UK second, then alphabetically
|
|
const srp: DetectedCol[] = headers
|
|
.map((h, i) => ({ index: i, name: h || '' }))
|
|
.filter(({ name }) => /srp/i.test(name))
|
|
.sort((a, b) => {
|
|
const order = (n: string) => {
|
|
if (/int/i.test(n)) return 0;
|
|
if (/uk/i.test(n)) return 1;
|
|
return 2;
|
|
};
|
|
return order(a.name) - order(b.name);
|
|
});
|
|
|
|
// All 40' container columns (HC, HQ, etc.)
|
|
const container: DetectedCol[] = headers
|
|
.map((h, i) => ({ index: i, name: h || '' }))
|
|
.filter(({ name }) => /40/i.test(name) && /h[cq]/i.test(name));
|
|
|
|
// Net weight / Gross weight columns
|
|
const nwIdx = findCol(headers, 'nw');
|
|
const gwIdx = findCol(headers, 'gw');
|
|
|
|
const unitsOuterIdx = COLUMNS.UNITS_OUTER;
|
|
|
|
return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx };
|
|
}, [headers, COLUMNS]);
|
|
|
|
// ── Error analysis per row ────────────────────────────────────────────────
|
|
const analyzedRows = useMemo(() => {
|
|
return data.map((row, dataIndex) => {
|
|
const pricingErrors: string[] = [];
|
|
const unitErrors: string[] = [];
|
|
|
|
// UVP check
|
|
if (uvpIdx >= 0) {
|
|
const v = row[uvpIdx];
|
|
if (v === undefined || v === null || v === '' || Number(v) === 0) {
|
|
pricingErrors.push('UVP missing');
|
|
}
|
|
}
|
|
|
|
// SRP checks
|
|
srpCols.forEach(({ index, name }) => {
|
|
const v = row[index];
|
|
if (v === undefined || v === null || v === '' || Number(v) === 0) {
|
|
pricingErrors.push(`${name} missing`);
|
|
}
|
|
});
|
|
|
|
// Units per Outer check
|
|
const unitsOuter = Number(row[unitsOuterIdx]);
|
|
if (!unitsOuter || unitsOuter === 0) {
|
|
unitErrors.push('Units/Outer: missing');
|
|
}
|
|
|
|
// 40' container checks
|
|
containerCols.forEach(({ index, name }) => {
|
|
const v = Number(row[index]);
|
|
if (!v || v === 0) unitErrors.push(`${name}: empty`);
|
|
else if (v === 1) unitErrors.push(`${name}: value is 1`);
|
|
});
|
|
|
|
// NW > GW check
|
|
if (nwIdx >= 0 && gwIdx >= 0) {
|
|
const nw = parseFloat(String(row[nwIdx] ?? '').replace(',', '.'));
|
|
const gw = parseFloat(String(row[gwIdx] ?? '').replace(',', '.'));
|
|
if (!isNaN(nw) && !isNaN(gw) && nw > gw) {
|
|
unitErrors.push(`NW (${nw}) > GW (${gw})`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
row,
|
|
dataIndex,
|
|
pricingErrors,
|
|
unitErrors,
|
|
hasErrors: pricingErrors.length > 0 || unitErrors.length > 0,
|
|
isCritical: unitErrors.length > 0,
|
|
};
|
|
});
|
|
}, [data, uvpIdx, srpCols, containerCols]);
|
|
|
|
// ── Stats ─────────────────────────────────────────────────────────────────
|
|
const stats = useMemo(() => {
|
|
const withPricing = analyzedRows.filter(r => r.pricingErrors.length > 0).length;
|
|
const withUnits = analyzedRows.filter(r => r.unitErrors.length > 0).length;
|
|
const withAny = analyzedRows.filter(r => r.hasErrors).length;
|
|
const allOk = analyzedRows.length - withAny;
|
|
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]);
|
|
|
|
const uniqueProductTypes = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[COLUMNS.PRODUCT_TYPE] || '')).filter(Boolean))).sort(),
|
|
[data]);
|
|
|
|
const uniqueUnitsOuter = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[unitsOuterIdx] || '')))).filter(v => v).sort(),
|
|
[data, unitsOuterIdx]);
|
|
|
|
const uniqueOuterW = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[COLUMNS.OUTER_W] || '')))).filter(v => v).sort((a, b) => Number(a) - Number(b)),
|
|
[data]);
|
|
|
|
const uniqueOuterL = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[COLUMNS.OUTER_L] || '')))).filter(v => v).sort((a, b) => Number(a) - Number(b)),
|
|
[data]);
|
|
|
|
const uniqueOuterH = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[COLUMNS.OUTER_H] || '')))).filter(v => v).sort((a, b) => Number(a) - Number(b)),
|
|
[data]);
|
|
|
|
const uniqueMoq = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[COLUMNS.MOQ] || '')))).filter(v => v).sort((a, b) => Number(a) - Number(b)),
|
|
[data]);
|
|
|
|
const uniqueItemToLogistic = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[COLUMNS.ITEM_TO_LOGISTIC] || '')))).filter(v => v).sort(),
|
|
[data]);
|
|
|
|
const uniqueCheckYing = useMemo(() =>
|
|
Array.from(new Set(data.map(r => r[COLUMNS.VALIDATED_CHECK] ? 'Checked' : 'Unchecked'))).sort(),
|
|
[data, COLUMNS.VALIDATED_CHECK]);
|
|
|
|
const uniqueCheckAnna = useMemo(() =>
|
|
Array.from(new Set(data.map(r => r[COLUMNS.ANNA_CHECK] ? 'Checked' : 'Unchecked'))).sort(),
|
|
[data, COLUMNS.ANNA_CHECK]);
|
|
|
|
// ── Filtered rows ─────────────────────────────────────────────────────────
|
|
const filteredRows = useMemo(() => {
|
|
let result = analyzedRows;
|
|
|
|
// Mode filter
|
|
switch (filterMode) {
|
|
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;
|
|
}
|
|
|
|
// Global search (SKU + Name) - Multi-word AND support
|
|
if (search) {
|
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
|
if (terms.length > 0) {
|
|
result = result.filter(r => {
|
|
const articleNo = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
|
const articleName = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
|
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
|
|
});
|
|
}
|
|
}
|
|
|
|
// Global Advanced Filter (Multi-term AND/OR)
|
|
if (globalAdvancedFilter.terms.some(t => t.trim() !== '')) {
|
|
result = result.filter(r => {
|
|
const articleNo = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
|
const articleName = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
|
const activeTerms = globalAdvancedFilter.terms.filter(t => t.trim() !== '').map(t => t.toLowerCase());
|
|
if (activeTerms.length === 0) return true;
|
|
|
|
return globalAdvancedFilter.op === 'and'
|
|
? activeTerms.every(term => articleNo.includes(term) || articleName.includes(term))
|
|
: activeTerms.some(term => articleNo.includes(term) || articleName.includes(term));
|
|
});
|
|
}
|
|
|
|
// Selected items from search dropdown
|
|
if (selectedSearchItems.size > 0) {
|
|
result = result.filter(r => {
|
|
const articleNo = String(r.row[COLUMNS.ARTICLE_NO] || '');
|
|
return selectedSearchItems.has(articleNo);
|
|
});
|
|
}
|
|
|
|
// Column filters
|
|
// Column-specific name filter - Multi-term AND/OR
|
|
if (nameColFilter.terms.some(t => t.trim() !== '')) {
|
|
result = result.filter(r => {
|
|
const name = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
|
const activeTerms = nameColFilter.terms.filter(t => t.trim() !== '').map(t => t.toLowerCase());
|
|
if (activeTerms.length === 0) return true;
|
|
|
|
return nameColFilter.op === 'and'
|
|
? activeTerms.every(term => name.includes(term))
|
|
: activeTerms.some(term => name.includes(term));
|
|
});
|
|
}
|
|
|
|
// Column-specific SKU filter - Multi-term AND/OR
|
|
if (articleNoColFilter.terms.some(t => t.trim() !== '')) {
|
|
result = result.filter(r => {
|
|
const sku = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
|
const activeTerms = articleNoColFilter.terms.filter(t => t.trim() !== '').map(t => t.toLowerCase());
|
|
if (activeTerms.length === 0) return true;
|
|
|
|
return articleNoColFilter.op === 'and'
|
|
? activeTerms.every(term => sku.includes(term))
|
|
: activeTerms.some(term => sku.includes(term));
|
|
});
|
|
}
|
|
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] || '')));
|
|
}
|
|
if ((productTypeFilter || []).length > 0) {
|
|
result = result.filter(r => (productTypeFilter || []).includes(String(r.row[COLUMNS.PRODUCT_TYPE] || '')));
|
|
}
|
|
if ((unitsOuterFilter || []).length > 0) {
|
|
result = result.filter(r => (unitsOuterFilter || []).includes(String(r.row[unitsOuterIdx] || '')));
|
|
}
|
|
if ((outerWFilter || []).length > 0) {
|
|
result = result.filter(r => (outerWFilter || []).includes(String(r.row[COLUMNS.OUTER_W] || '')));
|
|
}
|
|
if ((outerLFilter || []).length > 0) {
|
|
result = result.filter(r => (outerLFilter || []).includes(String(r.row[COLUMNS.OUTER_L] || '')));
|
|
}
|
|
if ((outerHFilter || []).length > 0) {
|
|
result = result.filter(r => (outerHFilter || []).includes(String(r.row[COLUMNS.OUTER_H] || '')));
|
|
}
|
|
if ((moqFilter || []).length > 0) {
|
|
result = result.filter(r => (moqFilter || []).includes(String(r.row[COLUMNS.MOQ] || '')));
|
|
}
|
|
if ((itemToLogisticFilter || []).length > 0) {
|
|
result = result.filter(r => (itemToLogisticFilter || []).includes(String(r.row[COLUMNS.ITEM_TO_LOGISTIC] || '')));
|
|
}
|
|
if ((checkYingFilter || []).length > 0) {
|
|
result = result.filter(r => (checkYingFilter || []).includes(r.row[COLUMNS.VALIDATED_CHECK] ? 'Checked' : 'Unchecked'));
|
|
}
|
|
if ((checkAnnaFilter || []).length > 0) {
|
|
result = result.filter(r => (checkAnnaFilter || []).includes(r.row[COLUMNS.ANNA_CHECK] ? 'Checked' : 'Unchecked'));
|
|
}
|
|
|
|
// Dynamic column filters (SRP, container)
|
|
(Object.keys(dynamicColFilters) as string[]).forEach(colIdx => {
|
|
const filterVals = dynamicColFilters[Number(colIdx)];
|
|
if (filterVals && filterVals.length > 0) {
|
|
const col = Number(colIdx);
|
|
result = result.filter(r => filterVals.includes(String(r.row[col] || '')));
|
|
}
|
|
});
|
|
|
|
// Weight issue filter
|
|
if (weightIssueFilter === 'with') {
|
|
result = result.filter(r => r.unitErrors.some(e => e.startsWith('NW')));
|
|
} else if (weightIssueFilter === 'without') {
|
|
result = result.filter(r => !r.unitErrors.some(e => e.startsWith('NW')));
|
|
}
|
|
|
|
return result;
|
|
}, [analyzedRows, filterMode, search, nameColFilter, articleNoColFilter, globalAdvancedFilter, lineMultiFilter, classificationFilter, productTypeFilter, unitsOuterFilter, outerWFilter, outerLFilter, outerHFilter, moqFilter, dynamicColFilters, weightIssueFilter, selectedSearchItems]);
|
|
|
|
// ── Sorted rows ──────────────────────────────────────────────────────────
|
|
const sortedRows = useMemo(() => {
|
|
if (sortConfig.key === null || sortConfig.direction === null) return filteredRows;
|
|
|
|
const { key, direction } = sortConfig;
|
|
let colIndex: number;
|
|
|
|
if (typeof key === 'number') {
|
|
colIndex = key;
|
|
} else {
|
|
// Map string keys to COLUMNS indices
|
|
switch (key) {
|
|
case 'articleNo': colIndex = COLUMNS.ARTICLE_NO; break;
|
|
case 'articleName': colIndex = COLUMNS.ARTICLE_NAME; break;
|
|
case 'line': colIndex = COLUMNS.LINE; break;
|
|
case 'classification': colIndex = COLUMNS.CLASSIFICATION; break;
|
|
case 'productType': colIndex = COLUMNS.PRODUCT_TYPE; break;
|
|
case 'unitsOuter': colIndex = unitsOuterIdx; break;
|
|
case 'outerW': colIndex = COLUMNS.OUTER_W; break;
|
|
case 'outerL': colIndex = COLUMNS.OUTER_L; break;
|
|
case 'outerH': colIndex = COLUMNS.OUTER_H; break;
|
|
case 'moq': colIndex = COLUMNS.MOQ; break;
|
|
case 'itemToLogistic': colIndex = COLUMNS.ITEM_TO_LOGISTIC; break;
|
|
default: return filteredRows;
|
|
}
|
|
}
|
|
|
|
if (colIndex === undefined || colIndex === null) return filteredRows;
|
|
|
|
const sorted = [...filteredRows].sort((a, b) => {
|
|
const valA = a.row[colIndex];
|
|
const valB = b.row[colIndex];
|
|
|
|
// Handle null/undefined
|
|
if (valA === valB) return 0;
|
|
if (valA === null || valA === undefined || valA === '') return 1;
|
|
if (valB === null || valB === undefined || valB === '') return -1;
|
|
|
|
// Handle numeric comparison
|
|
const numA = typeof valA === 'number' ? valA : parseFloat(String(valA).replace(',', '.'));
|
|
const numB = typeof valB === 'number' ? valB : parseFloat(String(valB).replace(',', '.'));
|
|
|
|
if (!isNaN(numA) && !isNaN(numB)) {
|
|
return direction === 'asc' ? numA - numB : numB - numA;
|
|
}
|
|
|
|
// Handle string comparison
|
|
const strA = String(valA).toLowerCase();
|
|
const strB = String(valB).toLowerCase();
|
|
|
|
if (strA < strB) return direction === 'asc' ? -1 : 1;
|
|
if (strA > strB) return direction === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
return sorted;
|
|
}, [filteredRows, sortConfig]);
|
|
|
|
// ── Pagination ─────────────────────────────────────────────────────────
|
|
const paginatedRows = useMemo(() => {
|
|
const start = (currentPage - 1) * pageSize;
|
|
return sortedRows.slice(start, start + pageSize);
|
|
}, [sortedRows, currentPage]);
|
|
|
|
// Keep ref in sync so drag handlers can read current visible indexes without stale closure
|
|
visibleDataIndexesRef.current = paginatedRows.map((r: { dataIndex: number }) => r.dataIndex);
|
|
|
|
const totalPages = Math.ceil(sortedRows.length / pageSize);
|
|
|
|
const handleSort = (key: string | number) => {
|
|
setSortConfig(prev => {
|
|
if (prev.key === key) {
|
|
if (prev.direction === 'asc') return { key, direction: 'desc' };
|
|
return { key: null, direction: null };
|
|
}
|
|
return { key, direction: 'asc' };
|
|
});
|
|
};
|
|
|
|
// ── Inline edit helpers ───────────────────────────────────────────────────
|
|
const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => {
|
|
setEditingCell({ rowIndex, colIndex, value: currentValue });
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
};
|
|
|
|
const commitEdit = useCallback(async () => {
|
|
if (!editingCell) return;
|
|
const { rowIndex, colIndex, value } = editingCell;
|
|
const original = data[rowIndex];
|
|
const newRow = [...original];
|
|
newRow[colIndex] = value;
|
|
setSavingCell({ rowIndex, colIndex });
|
|
setEditingCell(null);
|
|
onCaptureState(`Updated pricing for ${original[COLUMNS.ARTICLE_NO]}`);
|
|
await onSaveRow(rowIndex, newRow);
|
|
setSavingCell(null);
|
|
}, [editingCell, data, onSaveRow, onCaptureState]);
|
|
|
|
const cancelEdit = () => setEditingCell(null);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') commitEdit();
|
|
if (e.key === 'Escape') cancelEdit();
|
|
};
|
|
|
|
// ── All unique product types (for autocomplete) ───────────────────────────
|
|
const allProductTypes = useMemo(() =>
|
|
Array.from(new Set(data.map(r => String(r[COLUMNS.PRODUCT_TYPE] || '')).filter(Boolean))).sort()
|
|
, [data]);
|
|
|
|
const startEditType = (rowIndex: number, currentValue: string) => {
|
|
setEditingType({ rowIndex, value: currentValue });
|
|
setTypeSuggestions(allProductTypes);
|
|
setTimeout(() => typeInputRef.current?.focus(), 0);
|
|
};
|
|
|
|
const commitTypeEdit = useCallback(async (rowIndex: number, value: string) => {
|
|
setEditingType(null);
|
|
setTypeSuggestions([]);
|
|
const original = data[rowIndex];
|
|
const newRow = [...original];
|
|
newRow[COLUMNS.PRODUCT_TYPE] = value;
|
|
onCaptureState(`Updated type for ${original[COLUMNS.ARTICLE_NO]}`);
|
|
await onSaveRow(rowIndex, newRow);
|
|
}, [data, onSaveRow, onCaptureState]);
|
|
|
|
const handleTypeInputChange = (value: string) => {
|
|
setEditingType(prev => prev ? { ...prev, value } : null);
|
|
const lower = value.toLowerCase();
|
|
setTypeSuggestions(
|
|
lower ? allProductTypes.filter(t => t.toLowerCase().includes(lower)) : allProductTypes
|
|
);
|
|
};
|
|
|
|
const startEditLogistic = (rowIndex: number, currentValue: string) => {
|
|
setEditingLogistic({ rowIndex, value: currentValue });
|
|
setTimeout(() => logisticInputRef.current?.focus(), 0);
|
|
};
|
|
|
|
const commitLogisticEdit = useCallback(async (rowIndex: number, value: string) => {
|
|
setEditingLogistic(null);
|
|
const original = data[rowIndex];
|
|
const newRow = [...original];
|
|
newRow[COLUMNS.ITEM_TO_LOGISTIC] = value;
|
|
onCaptureState(`Updated logistic info for ${original[COLUMNS.ARTICLE_NO]}`);
|
|
await onSaveRow(rowIndex, newRow);
|
|
}, [data, onSaveRow, onCaptureState, COLUMNS]);
|
|
|
|
// ── Column helpers ────────────────────────────────────────────────────────
|
|
const pricingEditableCols: DetectedCol[] = [
|
|
...(uvpIdx >= 0 ? [{ index: uvpIdx, name: headers[uvpIdx] || 'UVP' }] : []),
|
|
...srpCols,
|
|
];
|
|
|
|
// ── Pinned columns for freeze ──────────────────────────────────────────────
|
|
const allPinnableColumns = useMemo(() => {
|
|
const cols: { key: string; label: string; index: number }[] = [
|
|
{ key: 'articleNo', label: 'Article No', index: COLUMNS.ARTICLE_NO },
|
|
{ key: 'articleName', label: 'Article Name', index: COLUMNS.ARTICLE_NAME },
|
|
{ key: 'line', label: 'Line', index: COLUMNS.LINE },
|
|
{ key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION },
|
|
{ key: 'productType', label: 'Type', index: COLUMNS.PRODUCT_TYPE },
|
|
{ key: 'itemToLogistic', label: 'Item to Logistic', index: COLUMNS.ITEM_TO_LOGISTIC },
|
|
{ key: 'unitsOuter', label: 'Units/Outer', index: unitsOuterIdx },
|
|
{ key: 'outerW', label: 'Outer W', index: COLUMNS.OUTER_W },
|
|
{ key: 'outerL', label: 'Outer L', index: COLUMNS.OUTER_L },
|
|
{ key: 'outerH', label: 'Outer H', index: COLUMNS.OUTER_H },
|
|
{ key: 'moq', label: 'MOQ', index: COLUMNS.MOQ },
|
|
];
|
|
pricingEditableCols.forEach(col => {
|
|
cols.push({ key: `prc_${col.index}`, label: col.name, index: col.index });
|
|
});
|
|
containerCols.forEach(col => {
|
|
cols.push({ key: `con_${col.index}`, label: col.name, index: col.index });
|
|
});
|
|
return cols;
|
|
}, [COLUMNS, unitsOuterIdx, pricingEditableCols, containerCols]);
|
|
|
|
const togglePinnedColumn = (key: string) => {
|
|
setPinnedColumns(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Calculate sticky left position for each pinned column
|
|
const getStickyLeft = useMemo(() => {
|
|
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
|
|
const dynamicOrder: string[] = [];
|
|
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
|
|
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
|
|
const fullOrder = [...baseOrder, ...dynamicOrder];
|
|
|
|
const pinnedOrder = fullOrder.filter(k => pinnedColumns.has(k));
|
|
return (key: string): number | null => {
|
|
if (!pinnedColumns.has(key)) return null;
|
|
const idx = pinnedOrder.indexOf(key);
|
|
if (idx === -1) return null;
|
|
let left = 0;
|
|
for (let i = 0; i < idx; i++) {
|
|
const k = pinnedOrder[i];
|
|
const width = k.startsWith('prc_') || k.startsWith('con_')
|
|
? (columnWidths[k] ?? 100)
|
|
: (columnWidths[k] || 100);
|
|
left += width;
|
|
}
|
|
return left;
|
|
};
|
|
}, [pinnedColumns, pricingEditableCols, containerCols, columnWidths]);
|
|
|
|
// Calculate z-index for sticky columns (so they stack correctly)
|
|
const getStickyRank = useMemo(() => {
|
|
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
|
|
const dynamicOrder: string[] = [];
|
|
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
|
|
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
|
|
const fullOrder = [...baseOrder, ...dynamicOrder];
|
|
|
|
const pinnedOrder = fullOrder.filter(k => pinnedColumns.has(k));
|
|
return (key: string): number | null => {
|
|
if (!pinnedColumns.has(key)) return null;
|
|
const idx = pinnedOrder.indexOf(key);
|
|
if (idx === -1) return null;
|
|
return pinnedOrder.length - idx + 10;
|
|
};
|
|
}, [pinnedColumns, pricingEditableCols, containerCols]);
|
|
|
|
// Header pinned cells need a stacking context above ALL body pinned cells
|
|
// so filter popovers (rendered inside <th>) appear over sticky body columns.
|
|
const getHeaderStickyRank = (key: string): number | null => {
|
|
const rank = getStickyRank(key);
|
|
return rank === null ? null : rank + 100;
|
|
};
|
|
|
|
const isPinned = (key: string) => pinnedColumns.has(key);
|
|
|
|
// ═ Unique values for dynamic pricing columns ═════════════════════════════
|
|
const dynamicColUniqueValues = useMemo(() => {
|
|
const result: Record<number, string[]> = {};
|
|
pricingEditableCols.forEach(col => {
|
|
result[col.index] = Array.from(new Set(data.map(r => String(r[col.index] || '')))).filter(v => v).sort();
|
|
});
|
|
containerCols.forEach(col => {
|
|
result[col.index] = Array.from(new Set(data.map(r => String(r[col.index] || '')))).filter(v => v).sort();
|
|
});
|
|
return result;
|
|
}, [data, uvpIdx, srpCols, containerCols, headers]);
|
|
|
|
const formatPrice = (val: any) => {
|
|
if (val === undefined || val === null || val === '') return '';
|
|
const n = parseFloat(String(val).replace(',', '.'));
|
|
return isNaN(n) ? String(val) : n.toFixed(2);
|
|
};
|
|
|
|
const formatUnits = (val: any) => {
|
|
if (val === undefined || val === null || val === '') return '—';
|
|
return String(val);
|
|
};
|
|
|
|
const unitBadge = (val: any, label: string) => {
|
|
const n = Number(val);
|
|
if (!val || n === 0) return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-bold bg-red-500/20 text-red-400 border border-red-500/40">
|
|
<AlertCircle className="w-3 h-3" /> 0
|
|
</span>
|
|
);
|
|
if (n === 1) return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-bold bg-amber-500/20 text-amber-400 border border-amber-500/40">
|
|
<AlertTriangle className="w-3 h-3" /> 1
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30">
|
|
{n.toLocaleString()}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const unitOuterBadge = (val: any) => {
|
|
const n = Number(val);
|
|
if (!val || n === 0) return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-bold bg-red-500/20 text-red-400 border border-red-500/40">
|
|
<AlertCircle className="w-3 h-3" /> Missing
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-slate-500/20 text-slate-300 border border-slate-600/50">
|
|
{n}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// ── Matrix columns (the rest) ─────────────────────────────────────────────
|
|
const displayedIndices = useMemo(() => {
|
|
const set = new Set<number>([
|
|
COLUMNS.ARTICLE_NO,
|
|
COLUMNS.ARTICLE_NAME,
|
|
COLUMNS.LINE,
|
|
COLUMNS.CLASSIFICATION,
|
|
unitsOuterIdx,
|
|
COLUMNS.OUTER_W,
|
|
COLUMNS.OUTER_L,
|
|
COLUMNS.OUTER_H,
|
|
COLUMNS.VALIDATED_CHECK,
|
|
COLUMNS.VALIDATED_NOTE,
|
|
COLUMNS.ANNA_CHECK,
|
|
COLUMNS.ANNA_NOTE,
|
|
COLUMNS.MOQ,
|
|
]);
|
|
if (uvpIdx >= 0) set.add(uvpIdx);
|
|
srpCols.forEach(c => set.add(c.index));
|
|
containerCols.forEach(c => set.add(c.index));
|
|
return set;
|
|
}, [uvpIdx, srpCols, containerCols]);
|
|
|
|
const matrixCols = useMemo(() => {
|
|
return headers
|
|
.map((h, i) => ({ index: i, name: h || '' }))
|
|
.filter(({ index }) => !displayedIndices.has(index))
|
|
.filter(({ name }) => name.trim() !== ''); // Skip empty headers
|
|
}, [headers, displayedIndices]);
|
|
|
|
const [noteEditor, setNoteEditor] = useState<{ rowIndex: number; text: string } | null>(null);
|
|
const [annaEditor, setAnnaEditor] = useState<{ rowIndex: number; text: string } | null>(null);
|
|
|
|
const handleToggleCheck = async (rowIndex: number, checked: boolean) => {
|
|
const original = data[rowIndex];
|
|
const newRow = [...original];
|
|
newRow[COLUMNS.VALIDATED_CHECK] = checked;
|
|
onCaptureState(`${checked ? 'Checked' : 'Unchecked'} ${original[COLUMNS.ARTICLE_NO]}`);
|
|
await onSaveRow(rowIndex, newRow);
|
|
};
|
|
|
|
const saveNote = async () => {
|
|
if (!noteEditor) return;
|
|
const { rowIndex, text } = noteEditor;
|
|
const original = data[rowIndex];
|
|
const newRow = [...original];
|
|
newRow[COLUMNS.VALIDATED_NOTE] = text;
|
|
setNoteEditor(null);
|
|
onCaptureState(`Updated note for ${original[COLUMNS.ARTICLE_NO]}`);
|
|
await onSaveRow(rowIndex, newRow);
|
|
};
|
|
|
|
const handleToggleAnnaCheck = async (rowIndex: number, checked: boolean) => {
|
|
const original = data[rowIndex];
|
|
const newRow = [...original];
|
|
newRow[COLUMNS.ANNA_CHECK] = checked;
|
|
onCaptureState(`${checked ? 'Anna checked' : 'Anna unchecked'} ${original[COLUMNS.ARTICLE_NO]}`);
|
|
await onSaveRow(rowIndex, newRow);
|
|
};
|
|
|
|
const saveAnnaNote = async () => {
|
|
if (!annaEditor) return;
|
|
const { rowIndex, text } = annaEditor;
|
|
const original = data[rowIndex];
|
|
const newRow = [...original];
|
|
newRow[COLUMNS.ANNA_NOTE] = text;
|
|
setAnnaEditor(null);
|
|
onCaptureState(`Updated Anna note for ${original[COLUMNS.ARTICLE_NO]}`);
|
|
await onSaveRow(rowIndex, newRow);
|
|
};
|
|
|
|
// ── Column detection notice ───────────────────────────────────────────────
|
|
const missingCols: string[] = [];
|
|
if (uvpIdx < 0) missingCols.push('UVP');
|
|
if (srpCols.length === 0) missingCols.push('SRP');
|
|
if (containerCols.length === 0) missingCols.push('Units/40\'');
|
|
|
|
const FILTERS: { id: FilterMode; label: string; count: number; color: string }[] = [
|
|
{ id: 'all', label: 'All Products', count: stats.total, color: 'text-slate-300' },
|
|
{ id: 'all_errors', label: 'All Issues', count: stats.withAny, color: 'text-red-400' },
|
|
{ id: 'pricing_errors', label: 'Pricing Issues', count: stats.withPricing, color: 'text-amber-400' },
|
|
{ id: 'units_errors', label: 'Units Issues', count: stats.withUnits, color: 'text-orange-400' },
|
|
];
|
|
|
|
return (
|
|
<div className={isFullscreen ? "fixed inset-0 z-[100] bg-[#041021] p-6 overflow-auto" : "space-y-6"}>
|
|
{isFullscreen && (
|
|
<button
|
|
onClick={() => setIsFullscreen(false)}
|
|
className="fixed top-4 right-4 z-[110] p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
{/* ── Anna Note Editor Modal ── */}
|
|
{annaEditor && createPortal(
|
|
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-[10000] flex items-center justify-center p-4">
|
|
<div className="bg-slate-800 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md animate-in zoom-in-95 duration-200">
|
|
<div className="p-4 border-b border-slate-700 flex items-center justify-between">
|
|
<h3 className="font-semibold text-white flex items-center gap-2">
|
|
<MessageSquare className="w-4 h-4 text-pink-400" />
|
|
Anna Note
|
|
</h3>
|
|
<p className="text-[10px] text-slate-500 font-mono">{data[annaEditor.rowIndex][COLUMNS.ARTICLE_NO]}</p>
|
|
</div>
|
|
<div className="p-4">
|
|
<textarea
|
|
value={annaEditor.text}
|
|
onChange={e => setAnnaEditor(prev => prev ? { ...prev, text: e.target.value } : null)}
|
|
placeholder="Write a note about this product..."
|
|
className="w-full h-32 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-pink-500 resize-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="p-4 bg-slate-900/50 rounded-b-xl flex justify-end gap-3">
|
|
<button onClick={() => setAnnaEditor(null)} className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">Cancel</button>
|
|
<button
|
|
onClick={saveAnnaNote}
|
|
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white text-sm font-bold rounded-lg shadow-lg active:scale-95 transition-all"
|
|
>
|
|
Save Note
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* ── Note Editor Modal ── */}
|
|
{noteEditor && createPortal(
|
|
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-[10000] flex items-center justify-center p-4">
|
|
<div className="bg-slate-800 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md animate-in zoom-in-95 duration-200">
|
|
<div className="p-4 border-b border-slate-700 flex items-center justify-between">
|
|
<h3 className="font-semibold text-white flex items-center gap-2">
|
|
<MessageSquare className="w-4 h-4 text-blue-400" />
|
|
Validation Note
|
|
</h3>
|
|
<p className="text-[10px] text-slate-500 font-mono">{data[noteEditor.rowIndex][COLUMNS.ARTICLE_NO]}</p>
|
|
</div>
|
|
<div className="p-4">
|
|
<textarea
|
|
value={noteEditor.text}
|
|
onChange={e => setNoteEditor(prev => prev ? { ...prev, text: e.target.value } : null)}
|
|
placeholder="Write a note about this product..."
|
|
className="w-full h-32 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-blue-500 resize-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="p-4 bg-slate-900/50 rounded-b-xl flex justify-end gap-3">
|
|
<button onClick={() => setNoteEditor(null)} className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">Cancel</button>
|
|
<button
|
|
onClick={saveNote}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-bold rounded-lg shadow-lg active:scale-95 transition-all"
|
|
>
|
|
Save Note
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* ── Tooltip Portal ── */}
|
|
{hoveredNote && createPortal(
|
|
<div
|
|
style={{ top: hoveredNote.pos.top, left: hoveredNote.pos.left, transform: 'translateX(-50%)' }}
|
|
className={cn(
|
|
"fixed w-48 p-2 border rounded text-xs text-white shadow-xl z-[99999] pointer-events-none animate-in fade-in zoom-in-95 duration-100",
|
|
hoveredNote.isAnna ? "bg-pink-900/95 border-pink-500/50" : "bg-amber-900/95 border-amber-500/50"
|
|
)}
|
|
>
|
|
{hoveredNote.text}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
{/* ── Search bar with Dropdown ── */}
|
|
<div className="flex-1 max-w-md relative" ref={searchDropdownRef}>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Search SKU or Name (multiple words supported)..."
|
|
value={search}
|
|
onChange={e => {
|
|
setSearch(e.target.value);
|
|
setIsSearchOpen(true);
|
|
}}
|
|
onFocus={() => setIsSearchOpen(true)}
|
|
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"
|
|
/>
|
|
{selectedSearchItems.size > 0 ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleClearSearch();
|
|
}}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700 text-white flex items-center gap-1.5 px-2 py-1 rounded-md transition-all shadow-lg active:scale-95"
|
|
title="Clear selection"
|
|
>
|
|
<span className="text-[10px] font-bold">
|
|
{selectedSearchItems.size}
|
|
</span>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
) : search ? (
|
|
<button
|
|
onClick={() => setSearch('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
) : (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
|
|
<Package className="w-4 h-4" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'global' ? null : 'global'); }}
|
|
className={cn(
|
|
'absolute -right-10 top-1/2 -translate-y-1/2 p-2 rounded-lg border transition-all active:scale-95',
|
|
globalAdvancedFilter.terms.some(t => t.trim() !== '')
|
|
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
|
: 'bg-slate-800 border-slate-700 text-slate-400 hover:text-white hover:border-slate-600'
|
|
)}
|
|
title="Advanced Search"
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
</button>
|
|
|
|
{openFilter === 'global' && (
|
|
<div className="absolute top-full left-0 z-[10000]">
|
|
<TextFilterPopover
|
|
triggerId="global-filter-trigger"
|
|
value={globalAdvancedFilter}
|
|
onChange={setGlobalAdvancedFilter}
|
|
onClose={() => setOpenFilter(null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Dropdown Panel */}
|
|
{isSearchOpen && searchSuggestions.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[10000] overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{searchSuggestions.map((row, idx) => {
|
|
return (
|
|
<button
|
|
key={`${row[COLUMNS.ARTICLE_NO]}-${idx}`}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const key = String(row[COLUMNS.ARTICLE_NO]);
|
|
setSelectedSearchItems(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
}}
|
|
className="w-full text-left px-4 py-2.5 hover:bg-slate-700/50 flex items-center gap-3 border-b border-slate-700/30 last:border-0 transition-colors"
|
|
>
|
|
<div className={cn(
|
|
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
|
selectedSearchItems.has(String(row[COLUMNS.ARTICLE_NO]))
|
|
? "bg-blue-600 border-blue-600"
|
|
: "border-slate-600"
|
|
)}>
|
|
{selectedSearchItems.has(String(row[COLUMNS.ARTICLE_NO])) && (
|
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-mono font-bold text-blue-400">{row[COLUMNS.ARTICLE_NO]}</span>
|
|
<span className="text-xs text-slate-300 truncate">{row[COLUMNS.ARTICLE_NAME]}</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex items-center justify-between px-4 py-2 bg-slate-900/50 border-t border-slate-700">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleClearSearch();
|
|
}}
|
|
className="flex items-center gap-1 text-xs text-red-400 hover:text-red-300 font-medium transition-colors"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
Clear Selection ({selectedSearchItems.size})
|
|
</button>
|
|
<button
|
|
onClick={handleApplySearch}
|
|
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded"
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
{searchSuggestions.length === 50 && (
|
|
<div className="px-4 py-1.5 bg-slate-900/50 text-[10px] text-slate-500 border-t border-slate-700 italic">
|
|
Showing first 50 results...
|
|
</div>
|
|
)}
|
|
</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); setCurrentPage(1); }}
|
|
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 && (
|
|
<div className="flex items-center gap-3 px-4 py-2.5 bg-amber-500/10 border border-amber-500/30 rounded-lg text-amber-300 text-sm">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
<span>
|
|
Could not auto-detect columns for: <strong>{missingCols.join(', ')}</strong>.
|
|
Check that the Excel headers contain "UVP", "SRP", or "40HC"/"40HQ".
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Stat cards ── */}
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatCard label="Total Products" value={stats.total} icon={<Package className="w-4 h-4" />} color="slate" />
|
|
<StatCard label="Pricing Issues" value={stats.withPricing} icon={<DollarSign className="w-4 h-4" />} color="amber" />
|
|
<StatCard label="Units Issues" value={stats.withUnits} icon={<AlertCircle className="w-4 h-4" />} color="red" />
|
|
<StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" />
|
|
<button
|
|
onClick={() => setIsFullscreen(true)}
|
|
className="flex items-center justify-center w-full h-full min-h-[80px] bg-slate-800/60 hover:bg-slate-700 border border-slate-700/50 rounded-xl transition-colors"
|
|
title="Maximize view"
|
|
>
|
|
<Maximize2 className="w-5 h-5 text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Pin columns control ── */}
|
|
<div className="flex items-center gap-3 bg-slate-800/60 border border-slate-700/50 rounded-xl p-3">
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowPinPanel(!showPinPanel)}
|
|
className={cn(
|
|
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all',
|
|
pinnedColumns.size > 0
|
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
|
)}
|
|
>
|
|
<Pin className="w-4 h-4" />
|
|
<span>Pin Columns</span>
|
|
{pinnedColumns.size > 0 && (
|
|
<span className="bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">{pinnedColumns.size}</span>
|
|
)}
|
|
</button>
|
|
|
|
{showPinPanel && (
|
|
<div className="absolute top-full left-0 mt-2 w-72 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden animate-in fade-in zoom-in-95 duration-100">
|
|
<div className="p-3 border-b border-slate-700 bg-slate-900/50">
|
|
<p className="text-xs font-bold text-slate-300">Select columns to pin (freeze)</p>
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto p-2 grid grid-cols-2 gap-1">
|
|
{allPinnableColumns.map(col => (
|
|
<button
|
|
key={col.key}
|
|
onClick={() => togglePinnedColumn(col.key)}
|
|
className={cn(
|
|
'flex items-center gap-2 px-3 py-2 rounded-lg text-xs transition-all text-left',
|
|
pinnedColumns.has(col.key)
|
|
? 'bg-blue-600/20 text-blue-400 border border-blue-500/40'
|
|
: 'bg-slate-700/50 text-slate-300 hover:bg-slate-700 border border-transparent'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-4 h-4 rounded border flex items-center justify-center shrink-0',
|
|
pinnedColumns.has(col.key)
|
|
? 'bg-blue-600 border-blue-600'
|
|
: 'border-slate-600'
|
|
)}>
|
|
{pinnedColumns.has(col.key) && (
|
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<span className="truncate">{col.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="p-2 border-t border-slate-700 bg-slate-900/50 flex gap-2">
|
|
<button
|
|
onClick={() => setPinnedColumns(new Set(['articleNo', 'articleName']))}
|
|
className="flex-1 px-3 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
onClick={() => setPinnedColumns(new Set())}
|
|
className="flex-1 px-3 py-1.5 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
|
|
>
|
|
Unpin All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
|
<PinOff className="w-3.5 h-3.5" />
|
|
<span>Pinned:</span>
|
|
{pinnedColumns.size === 0 ? (
|
|
<span className="text-slate-600">None</span>
|
|
) : (
|
|
<div className="flex flex-wrap gap-1">
|
|
{Array.from(pinnedColumns).map(key => {
|
|
const col = allPinnableColumns.find(c => c.key === key);
|
|
return col ? (
|
|
<span key={key} className="bg-blue-500/10 text-blue-400 px-2 py-0.5 rounded border border-blue-500/20">
|
|
{col.label}
|
|
</span>
|
|
) : null;
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Table ── */}
|
|
<div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl">
|
|
{sortedRows.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-slate-400">
|
|
<CheckCircle2 className="w-12 h-12 text-emerald-500 mb-3" />
|
|
<p className="text-lg font-medium text-emerald-400">No issues found</p>
|
|
<p className="text-sm mt-1">All products are correctly configured.</p>
|
|
</div>
|
|
) : (
|
|
<table ref={tableRef} className="w-full text-sm border-collapse table-fixed">
|
|
<thead className="sticky top-0 z-[100] bg-slate-900 border-b border-slate-700">
|
|
<tr>
|
|
<th style={{
|
|
width: columnWidths.articleNo,
|
|
...(isPinned('articleNo')
|
|
? { left: getStickyLeft('articleNo') ?? 0, zIndex: openFilter === 'sku' ? 500 : (getHeaderStickyRank('articleNo') ?? 0) }
|
|
: (openFilter === 'sku' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('articleNo') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('articleNo')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('articleNo') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Article No <SortIcon current={sortConfig.key === 'articleNo' ? sortConfig.direction : null} />
|
|
</span>
|
|
<button
|
|
id="filter-trigger-sku"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'sku' ? null : 'sku'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
articleNoColFilter.terms.some(t => t.trim() !== '') ? '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 === 'sku' && (
|
|
<TextFilterPopover
|
|
triggerId="filter-trigger-sku"
|
|
value={articleNoColFilter}
|
|
onChange={setArticleNoColFilter}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'articleNo', columnWidths.articleNo); }} />
|
|
</th>
|
|
<th style={{
|
|
width: columnWidths.articleName,
|
|
...(isPinned('articleName')
|
|
? { left: getStickyLeft('articleName') ?? 0, zIndex: openFilter === 'name' ? 500 : (getHeaderStickyRank('articleName') ?? 0) }
|
|
: (openFilter === 'name' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('articleName') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('articleName')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('articleName') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Article Name <SortIcon current={sortConfig.key === 'articleName' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-name"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'name' ? null : 'name'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
nameColFilter.terms.some(t => t.trim() !== '') ? '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
|
|
triggerId="filter-trigger-name"
|
|
value={nameColFilter}
|
|
onChange={setNameColFilter}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={500}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'articleName', columnWidths.articleName); }} />
|
|
</th>
|
|
<th style={{
|
|
width: columnWidths.line,
|
|
...(isPinned('line')
|
|
? { left: getStickyLeft('line') ?? 0, zIndex: openFilter === 'line' ? 500 : (getHeaderStickyRank('line') ?? 0) }
|
|
: (openFilter === 'line' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('line') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('line')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('line') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Line <SortIcon current={sortConfig.key === 'line' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-line"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'line' ? null : 'line'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(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
|
|
triggerId="filter-trigger-line"
|
|
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)}
|
|
zIndex={500}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'line', columnWidths.line); }} />
|
|
</th>
|
|
<th style={{
|
|
width: columnWidths.classification,
|
|
...(isPinned('classification')
|
|
? { left: getStickyLeft('classification') ?? 0, zIndex: openFilter === 'classification' ? 500 : (getHeaderStickyRank('classification') ?? 0) }
|
|
: (openFilter === 'classification' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('classification') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('classification')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('classification') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Classification <SortIcon current={sortConfig.key === 'classification' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-class"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'classification' ? null : 'classification'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(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
|
|
triggerId="filter-trigger-class"
|
|
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)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'classification', columnWidths.classification); }} />
|
|
</th>
|
|
|
|
<th style={{ width: columnWidths.productType, ...(isPinned('productType') ? { left: getStickyLeft('productType') ?? 0, zIndex: getHeaderStickyRank('productType') ?? 0 } : {}) }} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-purple-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('productType') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('productType')}>
|
|
<span className="flex items-center gap-1">
|
|
{isPinned('productType') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Type <SortIcon current={sortConfig.key === 'productType' ? sortConfig.direction : null} />
|
|
<button
|
|
id="filter-trigger-type"
|
|
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setOpenFilter(openFilter === 'productType' ? null : 'productType'); }}
|
|
className={cn('ml-1 p-0.5 rounded transition-colors filter-trigger-btn', (productTypeFilter || []).length > 0 ? 'text-purple-400 bg-purple-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100')}
|
|
>
|
|
<Filter className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
{openFilter === 'productType' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-type"
|
|
uniqueValues={uniqueProductTypes || []}
|
|
selectedValues={productTypeFilter || []}
|
|
onToggle={(val: string) => setProductTypeFilter((prev: string[]) => (prev || []).includes(val) ? (prev || []).filter((v: string) => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setProductTypeFilter(vals || [])}
|
|
onClear={() => { setProductTypeFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'productType', columnWidths.productType); }} />
|
|
</th>
|
|
|
|
<th style={{
|
|
width: columnWidths.itemToLogistic,
|
|
...(isPinned('itemToLogistic')
|
|
? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: openFilter === 'logistic' ? 500 : (getHeaderStickyRank('itemToLogistic') ?? 0) }
|
|
: (openFilter === 'logistic' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-pink-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('itemToLogistic') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('itemToLogistic')}>
|
|
<span className="flex items-center gap-1">
|
|
{isPinned('itemToLogistic') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Item to Logistic <SortIcon current={sortConfig.key === 'itemToLogistic' ? sortConfig.direction : null} />
|
|
<button
|
|
id="filter-trigger-logistic"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'logistic' ? null : 'logistic'); }}
|
|
className={cn(
|
|
'ml-1 p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(itemToLogisticFilter || []).length > 0 ? 'text-pink-400 bg-pink-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
|
)}
|
|
>
|
|
<Filter className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
{openFilter === 'logistic' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-logistic"
|
|
uniqueValues={uniqueItemToLogistic || []}
|
|
selectedValues={itemToLogisticFilter || []}
|
|
onToggle={val => setItemToLogisticFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setItemToLogisticFilter(vals || [])}
|
|
onClear={() => { setItemToLogisticFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'itemToLogistic', columnWidths.itemToLogistic); }} />
|
|
</th>
|
|
|
|
{pricingEditableCols.map(col => {
|
|
const colKey = `prc_${col.index}`;
|
|
const width = columnWidths[colKey] ?? 100;
|
|
return (
|
|
<th key={col.index} style={{
|
|
width,
|
|
...(isPinned(colKey)
|
|
? { left: getStickyLeft(colKey) ?? 0, zIndex: openFilter === colKey ? 500 : (getHeaderStickyRank(colKey) ?? 0) }
|
|
: (openFilter === colKey ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-blue-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned(colKey) && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort(col.index)}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned(colKey) && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
{col.name} <SortIcon current={sortConfig.key === col.index ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id={`filter-trigger-${colKey}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenFilter(openFilter === colKey ? null : colKey);
|
|
}}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
dynamicColFilters[col.index]?.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 === colKey && (
|
|
<ColumnFilterPopover
|
|
triggerId={`filter-trigger-${colKey}`}
|
|
uniqueValues={dynamicColUniqueValues[col.index] || []}
|
|
selectedValues={dynamicColFilters[col.index] || []}
|
|
onToggle={val => setDynamicColFilters(prev => {
|
|
const current = prev[col.index] || [];
|
|
return current.includes(val)
|
|
? { ...prev, [col.index]: current.filter(v => v !== val) }
|
|
: { ...prev, [col.index]: [...current, val] };
|
|
})}
|
|
onSelectAll={vals => setDynamicColFilters(prev => ({ ...prev, [col.index]: vals }))}
|
|
onClear={() => { setDynamicColFilters(prev => { const next = { ...prev }; delete next[col.index]; return next; }); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={isPinned(colKey) ? 200 : 50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, colKey, width); }} />
|
|
</th>
|
|
);
|
|
})}
|
|
|
|
<th style={{
|
|
width: columnWidths.unitsOuter,
|
|
...(isPinned('unitsOuter')
|
|
? { left: getStickyLeft('unitsOuter') ?? 0, zIndex: openFilter === 'unitsOuter' ? 500 : (getHeaderStickyRank('unitsOuter') ?? 0) }
|
|
: (openFilter === 'unitsOuter' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('unitsOuter') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('unitsOuter')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('unitsOuter') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Units/Outer <SortIcon current={sortConfig.key === 'unitsOuter' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-unitsOuter"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'unitsOuter' ? null : 'unitsOuter'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(unitsOuterFilter || []).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 === 'unitsOuter' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-unitsOuter"
|
|
uniqueValues={uniqueUnitsOuter || []}
|
|
selectedValues={unitsOuterFilter || []}
|
|
onToggle={val => setUnitsOuterFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setUnitsOuterFilter(vals || [])}
|
|
onClear={() => { setUnitsOuterFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'unitsOuter', columnWidths.unitsOuter); }} />
|
|
</th>
|
|
|
|
<th style={{
|
|
width: columnWidths.outerW,
|
|
...(isPinned('outerW')
|
|
? { left: getStickyLeft('outerW') ?? 0, zIndex: openFilter === 'outerW' ? 500 : (getHeaderStickyRank('outerW') ?? 0) }
|
|
: (openFilter === 'outerW' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('outerW') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('outerW')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('outerW') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Outer W <SortIcon current={sortConfig.key === 'outerW' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-outerW"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'outerW' ? null : 'outerW'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(outerWFilter || []).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 === 'outerW' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-outerW"
|
|
uniqueValues={uniqueOuterW || []}
|
|
selectedValues={outerWFilter || []}
|
|
onToggle={val => setOuterWFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setOuterWFilter(vals || [])}
|
|
onClear={() => { setOuterWFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'outerW', columnWidths.outerW); }} />
|
|
</th>
|
|
|
|
<th style={{
|
|
width: columnWidths.outerL,
|
|
...(isPinned('outerL')
|
|
? { left: getStickyLeft('outerL') ?? 0, zIndex: openFilter === 'outerL' ? 500 : (getHeaderStickyRank('outerL') ?? 0) }
|
|
: (openFilter === 'outerL' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('outerL') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('outerL')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('outerL') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Outer L <SortIcon current={sortConfig.key === 'outerL' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-outerL"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'outerL' ? null : 'outerL'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(outerLFilter || []).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 === 'outerL' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-outerL"
|
|
uniqueValues={uniqueOuterL || []}
|
|
selectedValues={outerLFilter || []}
|
|
onToggle={val => setOuterLFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setOuterLFilter(vals || [])}
|
|
onClear={() => { setOuterLFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'outerL', columnWidths.outerL); }} />
|
|
</th>
|
|
|
|
<th style={{
|
|
width: columnWidths.outerH,
|
|
...(isPinned('outerH')
|
|
? { left: getStickyLeft('outerH') ?? 0, zIndex: openFilter === 'outerH' ? 500 : (getHeaderStickyRank('outerH') ?? 0) }
|
|
: (openFilter === 'outerH' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('outerH') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('outerH')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('outerH') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
Outer H <SortIcon current={sortConfig.key === 'outerH' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-outerH"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'outerH' ? null : 'outerH'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(outerHFilter || []).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 === 'outerH' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-outerH"
|
|
uniqueValues={uniqueOuterH || []}
|
|
selectedValues={outerHFilter || []}
|
|
onToggle={val => setOuterHFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setOuterHFilter(vals || [])}
|
|
onClear={() => { setOuterHFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'outerH', columnWidths.outerH); }} />
|
|
</th>
|
|
|
|
{containerCols.map(col => {
|
|
const colKey = `con_${col.index}`;
|
|
const width = columnWidths[colKey] ?? 110;
|
|
return (
|
|
<th key={col.index} style={{
|
|
width,
|
|
...(isPinned(colKey)
|
|
? { left: getStickyLeft(colKey) ?? 0, zIndex: openFilter === colKey ? 500 : (getHeaderStickyRank(colKey) ?? 0) }
|
|
: (openFilter === colKey ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-orange-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned(colKey) && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort(col.index)}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned(colKey) && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
{col.name} <SortIcon current={sortConfig.key === col.index ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id={`filter-trigger-${colKey}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenFilter(openFilter === colKey ? null : colKey);
|
|
}}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
dynamicColFilters[col.index]?.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 === colKey && (
|
|
<ColumnFilterPopover
|
|
triggerId={`filter-trigger-${colKey}`}
|
|
uniqueValues={dynamicColUniqueValues[col.index] || []}
|
|
selectedValues={dynamicColFilters[col.index] || []}
|
|
onToggle={val => setDynamicColFilters(prev => {
|
|
const current = prev[col.index] || [];
|
|
return current.includes(val)
|
|
? { ...prev, [col.index]: current.filter(v => v !== val) }
|
|
: { ...prev, [col.index]: [...current, val] };
|
|
})}
|
|
onSelectAll={vals => setDynamicColFilters(prev => ({ ...prev, [col.index]: vals }))}
|
|
onClear={() => { setDynamicColFilters(prev => { const next = { ...prev }; delete next[col.index]; return next; }); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={isPinned(colKey) ? 200 : 50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, colKey, width); }} />
|
|
</th>
|
|
);
|
|
})}
|
|
|
|
<th style={{
|
|
width: columnWidths.moq,
|
|
...(isPinned('moq')
|
|
? { left: getStickyLeft('moq') ?? 0, zIndex: openFilter === 'moq' ? 500 : (getHeaderStickyRank('moq') ?? 0) }
|
|
: (openFilter === 'moq' ? { zIndex: 500, position: 'relative' } : {}))
|
|
}} className={cn(
|
|
"text-left px-3 py-3 text-xs font-semibold text-cyan-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
|
isPinned('moq') && "sticky bg-slate-900"
|
|
)} onClick={() => handleSort('moq')}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="truncate flex items-center gap-1">
|
|
{isPinned('moq') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
|
MOQ <SortIcon current={sortConfig.key === 'moq' ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-moq"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'moq' ? null : 'moq'); }}
|
|
className={cn(
|
|
'ml-1 p-0.5 rounded transition-colors filter-trigger-btn',
|
|
(moqFilter || []).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 === 'moq' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-moq"
|
|
uniqueValues={uniqueMoq || []}
|
|
selectedValues={moqFilter || []}
|
|
onToggle={val => setMoqFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setMoqFilter(vals || [])}
|
|
onClear={() => { setMoqFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={isPinned('moq') ? 200 : 50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'moq', columnWidths.moq); }} />
|
|
</th>
|
|
|
|
<th style={{ width: 180 }} className="text-left px-3 py-3 text-xs font-semibold text-red-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap group relative">
|
|
<div className="flex items-center gap-1">
|
|
<span>Weight Issues</span>
|
|
<button
|
|
onClick={() => setOpenFilter(openFilter === 'weightIssue' ? null : 'weightIssue')}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
|
|
weightIssueFilter !== 'all' ? 'text-red-400 bg-red-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
|
)}
|
|
>
|
|
<Filter className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
{openFilter === 'weightIssue' && (
|
|
<div className="absolute top-full left-0 mt-1 w-44 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-1 flex flex-col gap-0.5 animate-in fade-in zoom-in-95 duration-100">
|
|
{(['all', 'with', 'without'] as const).map(opt => (
|
|
<button
|
|
key={opt}
|
|
onClick={() => { setWeightIssueFilter(opt); setOpenFilter(null); }}
|
|
className={cn(
|
|
'w-full text-left px-3 py-1.5 rounded text-xs transition-colors',
|
|
weightIssueFilter === opt ? 'bg-red-500/20 text-red-400' : 'text-slate-300 hover:bg-slate-700'
|
|
)}
|
|
>
|
|
{opt === 'all' ? 'All' : opt === 'with' ? 'With weight issue' : 'No weight issue'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</th>
|
|
|
|
<th style={{ width: columnWidths.check }} className="text-left px-3 py-3 text-xs font-semibold text-blue-400 uppercase tracking-wider group cursor-pointer hover:bg-slate-800/50 transition-colors relative" onClick={() => handleSort(COLUMNS.VALIDATED_CHECK)}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="flex items-center gap-1">Check Ying <SortIcon current={sortConfig.key === COLUMNS.VALIDATED_CHECK ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-check"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'check' ? null : 'check'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(checkYingFilter || []).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 === 'check' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-check"
|
|
uniqueValues={uniqueCheckYing || []}
|
|
selectedValues={checkYingFilter || []}
|
|
onToggle={val => setCheckYingFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setCheckYingFilter(vals || [])}
|
|
onClear={() => { setCheckYingFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'check', columnWidths.check); }} />
|
|
</th>
|
|
|
|
<th style={{ width: columnWidths.checkAnna ?? 100 }} className="text-left px-3 py-3 text-xs font-semibold text-pink-400 uppercase tracking-wider group cursor-pointer hover:bg-slate-800/50 transition-colors relative" onClick={() => handleSort(COLUMNS.ANNA_CHECK)}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="flex items-center gap-1">Check Anna <SortIcon current={sortConfig.key === COLUMNS.ANNA_CHECK ? sortConfig.direction : null} /></span>
|
|
<button
|
|
id="filter-trigger-checkAnna"
|
|
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'checkAnna' ? null : 'checkAnna'); }}
|
|
className={cn(
|
|
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0 filter-trigger-btn',
|
|
(checkAnnaFilter || []).length > 0 ? 'text-pink-400 bg-pink-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
|
)}
|
|
>
|
|
<Filter className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
{openFilter === 'checkAnna' && (
|
|
<ColumnFilterPopover
|
|
triggerId="filter-trigger-checkAnna"
|
|
uniqueValues={uniqueCheckAnna || []}
|
|
selectedValues={checkAnnaFilter || []}
|
|
onToggle={val => setCheckAnnaFilter(prev => (prev || []).includes(val) ? (prev || []).filter(v => v !== val) : [...(prev || []), val])}
|
|
onSelectAll={vals => setCheckAnnaFilter(vals || [])}
|
|
onClear={() => { setCheckAnnaFilter([]); setOpenFilter(null); }}
|
|
onClose={() => setOpenFilter(null)}
|
|
zIndex={50}
|
|
/>
|
|
)}
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'checkAnna', columnWidths.checkAnna ?? 100); }} />
|
|
</th>
|
|
|
|
{matrixCols.map(col => {
|
|
const colKey = `mat_${col.index}`;
|
|
const width = columnWidths[colKey] ?? 120;
|
|
return (
|
|
<th key={col.index} style={{ width }} className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative" title={col.name} onClick={() => handleSort(col.index)}>
|
|
<span className="truncate flex items-center gap-1">{col.name} <SortIcon current={sortConfig.key === col.index ? sortConfig.direction : null} /></span>
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, colKey, width); }} />
|
|
</th>
|
|
);
|
|
})}
|
|
|
|
<th style={{ width: columnWidths.actions }} className="px-3 py-3 border-b border-slate-700 sticky right-0 bg-slate-900 shadow-[-4px_0_8px_rgba(0,0,0,0.2)]">
|
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'actions', columnWidths.actions); }} />
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paginatedRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => {
|
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
|
const isValidated = !!row[COLUMNS.VALIDATED_CHECK];
|
|
const note = String(row[COLUMNS.VALIDATED_NOTE] || '');
|
|
|
|
const isRowSelected = dragSelectedRows.has(dataIndex);
|
|
|
|
return (
|
|
<tr
|
|
key={dataIndex}
|
|
onMouseDown={(e: React.MouseEvent<HTMLTableRowElement>) => handleRowMouseDown(e, dataIndex)}
|
|
onMouseEnter={() => handleRowMouseEnter(dataIndex)}
|
|
style={{ userSelect: 'none' }}
|
|
className={cn(
|
|
'border-b border-slate-700/50 transition-colors group relative hover:z-50 cursor-default',
|
|
isRowSelected
|
|
? 'bg-blue-500/20 border-l-4 border-l-blue-400'
|
|
: isValidated
|
|
? 'bg-green-400/30 border-l-4 border-l-green-400'
|
|
: saveStatus === 'error'
|
|
? 'bg-red-400/20 border-l-4 border-l-red-500'
|
|
: saveStatus === 'pending'
|
|
? 'bg-amber-400/20 border-l-4 border-l-amber-400'
|
|
: isCritical
|
|
? 'bg-red-950/20'
|
|
: pricingErrors.length > 0
|
|
? 'bg-amber-950/10'
|
|
: ''
|
|
)}
|
|
>
|
|
{/* Art. No. */}
|
|
<td className={cn("px-3 py-2.5 font-mono text-xs text-slate-300 whitespace-nowrap truncate", isPinned('articleNo') && "sticky bg-slate-800")} style={isPinned('articleNo') ? { left: getStickyLeft('articleNo') ?? 0, zIndex: getStickyRank('articleNo') ?? 0 } : {}}>
|
|
{row[COLUMNS.ARTICLE_NO]}
|
|
</td>
|
|
|
|
{/* Article Name */}
|
|
<td className={cn("px-3 py-2.5 text-slate-200 max-w-[200px]", isPinned('articleName') && "sticky bg-slate-800")} style={isPinned('articleName') ? { left: getStickyLeft('articleName') ?? 0, zIndex: getStickyRank('articleName') ?? 0 } : {}}>
|
|
<span className="line-clamp-1" title={row[COLUMNS.ARTICLE_NAME]}>
|
|
{row[COLUMNS.ARTICLE_NAME] || '—'}
|
|
</span>
|
|
</td>
|
|
|
|
{/* Line */}
|
|
<td className={cn("px-3 py-2.5 text-slate-400 whitespace-nowrap text-xs truncate", isPinned('line') && "sticky bg-slate-800")} style={isPinned('line') ? { left: getStickyLeft('line') ?? 0, zIndex: getStickyRank('line') ?? 0 } : {}}>
|
|
{row[COLUMNS.LINE] || '—'}
|
|
</td>
|
|
|
|
{/* Classification */}
|
|
<td className={cn("px-3 py-2.5", isPinned('classification') && "sticky bg-slate-800")} style={isPinned('classification') ? { left: getStickyLeft('classification') ?? 0, zIndex: getStickyRank('classification') ?? 0 } : {}}>
|
|
<span className={cn(
|
|
'px-2 py-0.5 rounded text-[10px] font-bold border whitespace-nowrap inline-block max-w-full truncate',
|
|
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>
|
|
|
|
{/* TYPE cell */}
|
|
<td className={cn("px-3 py-2 overflow-visible relative", isPinned('productType') && "sticky bg-slate-800")} style={isPinned('productType') ? { left: getStickyLeft('productType') ?? 0, zIndex: getStickyRank('productType') ?? 0 } : {}}>
|
|
{editingType?.rowIndex === dataIndex ? (
|
|
<div className="relative">
|
|
<input
|
|
ref={typeInputRef}
|
|
type="text"
|
|
value={editingType.value}
|
|
onChange={e => handleTypeInputChange(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') commitTypeEdit(dataIndex, editingType.value);
|
|
if (e.key === 'Escape') { setEditingType(null); setTypeSuggestions([]); }
|
|
}}
|
|
onBlur={() => setTimeout(() => { commitTypeEdit(dataIndex, editingType?.value ?? ''); }, 150)}
|
|
className="w-full bg-slate-900 border border-purple-500 rounded px-2 py-1 text-sm text-white focus:outline-none focus:ring-1 focus:ring-purple-500"
|
|
/>
|
|
{typeSuggestions.length > 0 && (
|
|
<ul className="absolute top-full left-0 mt-1 w-full min-w-[160px] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl z-50 max-h-48 overflow-y-auto">
|
|
{typeSuggestions.map(t => (
|
|
<li
|
|
key={t}
|
|
onMouseDown={e => { e.preventDefault(); commitTypeEdit(dataIndex, t); }}
|
|
className="px-3 py-1.5 text-xs text-slate-200 hover:bg-purple-500/20 cursor-pointer truncate"
|
|
>
|
|
{t}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => startEditType(dataIndex, String(row[COLUMNS.PRODUCT_TYPE] || ''))}
|
|
className={cn(
|
|
'group flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors hover:bg-slate-700/60 w-full overflow-hidden',
|
|
row[COLUMNS.PRODUCT_TYPE]
|
|
? 'text-purple-300 border border-transparent hover:border-slate-600'
|
|
: 'text-slate-500 border border-dashed border-slate-600 hover:border-purple-500/50'
|
|
)}
|
|
>
|
|
<span className="truncate">{row[COLUMNS.PRODUCT_TYPE] || 'Add type…'}</span>
|
|
<Edit2 className="w-2.5 h-2.5 opacity-0 group-hover:opacity-50 shrink-0" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
|
|
{/* ITEM TO LOGISTIC cell */}
|
|
<td className={cn("px-3 py-2 overflow-visible relative", isPinned('itemToLogistic') && "sticky bg-slate-800")} style={isPinned('itemToLogistic') ? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: getStickyRank('itemToLogistic') ?? 0 } : {}}>
|
|
{editingLogistic?.rowIndex === dataIndex ? (
|
|
<input
|
|
ref={logisticInputRef}
|
|
type="text"
|
|
value={editingLogistic.value}
|
|
onChange={e => setEditingLogistic(prev => prev ? { ...prev, value: e.target.value } : null)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') commitLogisticEdit(dataIndex, editingLogistic.value);
|
|
if (e.key === 'Escape') setEditingLogistic(null);
|
|
}}
|
|
onBlur={() => commitLogisticEdit(dataIndex, editingLogistic.value)}
|
|
className="w-full bg-slate-900 border border-pink-500 rounded px-2 py-1 text-sm text-white focus:outline-none focus:ring-1 focus:ring-pink-500"
|
|
/>
|
|
) : (
|
|
<button
|
|
onClick={() => startEditLogistic(dataIndex, String(row[COLUMNS.ITEM_TO_LOGISTIC] || ''))}
|
|
className={cn(
|
|
'group flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors hover:bg-slate-700/60 w-full overflow-hidden',
|
|
row[COLUMNS.ITEM_TO_LOGISTIC]
|
|
? 'text-pink-300 border border-transparent hover:border-slate-600'
|
|
: 'text-slate-500 border border-dashed border-slate-600 hover:border-pink-500/50'
|
|
)}
|
|
>
|
|
<span className="truncate">{row[COLUMNS.ITEM_TO_LOGISTIC] || 'Add info…'}</span>
|
|
<Edit2 className="w-2.5 h-2.5 opacity-0 group-hover:opacity-50 shrink-0" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
|
|
{/* Pricing editable cells */}
|
|
{pricingEditableCols.map(col => {
|
|
const isEditing = editingCell?.rowIndex === dataIndex && editingCell?.colIndex === col.index;
|
|
const isSaving = savingCell?.rowIndex === dataIndex && savingCell?.colIndex === col.index;
|
|
const val = formatPrice(row[col.index]);
|
|
const isEmpty = !row[col.index] || row[col.index] === '' || Number(row[col.index]) === 0;
|
|
const colKey = `prc_${col.index}`;
|
|
|
|
return (
|
|
<td key={col.index} className={cn("px-3 py-2", isPinned(colKey) && "sticky bg-slate-800")} style={isPinned(colKey) ? { left: getStickyLeft(colKey) ?? 0, zIndex: getStickyRank(colKey) ?? 0 } : {}}>
|
|
{isEditing ? (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={editingCell.value}
|
|
onChange={e => setEditingCell(prev => prev ? { ...prev, value: e.target.value } : null)}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={commitEdit}
|
|
className="w-full bg-slate-900 border border-blue-500 rounded px-2 py-1 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => startEdit(dataIndex, col.index, val)}
|
|
className={cn(
|
|
'group flex items-center gap-1.5 px-2 py-1 rounded text-xs font-mono transition-colors hover:bg-slate-700/60 w-full overflow-hidden',
|
|
isEmpty
|
|
? 'text-amber-400 border border-amber-500/40 bg-amber-500/5'
|
|
: 'text-slate-200 border border-transparent hover:border-slate-600'
|
|
)}
|
|
title={isSaving ? 'Saving…' : `Click to edit ${col.name}`}
|
|
>
|
|
{isSaving ? (
|
|
<span className="text-slate-500 italic truncate">saving…</span>
|
|
) : isEmpty ? (
|
|
<div className="flex items-center gap-1 truncate"><AlertTriangle className="w-3 h-3 shrink-0" /><span>Missing</span></div>
|
|
) : (
|
|
<div className="flex items-center justify-between w-full truncate">
|
|
<span>{val}</span>
|
|
<Edit2 className="w-2.5 h-2.5 opacity-0 group-hover:opacity-50 shrink-0" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
|
|
{/* Units columns */}
|
|
<td className={cn("px-3 py-2.5", isPinned('unitsOuter') && "sticky bg-slate-800")} style={isPinned('unitsOuter') ? { left: getStickyLeft('unitsOuter') ?? 0, zIndex: getStickyRank('unitsOuter') ?? 0 } : {}}>
|
|
{unitOuterBadge(row[unitsOuterIdx])}
|
|
</td>
|
|
<td className={cn("px-3 py-2.5 text-slate-400 font-mono text-xs truncate", isPinned('outerW') && "sticky bg-slate-800")} style={isPinned('outerW') ? { left: getStickyLeft('outerW') ?? 0, zIndex: getStickyRank('outerW') ?? 0 } : {}}>
|
|
{row[COLUMNS.OUTER_W] ?? '-'}
|
|
</td>
|
|
<td className={cn("px-3 py-2.5 text-slate-400 font-mono text-xs truncate", isPinned('outerL') && "sticky bg-slate-800")} style={isPinned('outerL') ? { left: getStickyLeft('outerL') ?? 0, zIndex: getStickyRank('outerL') ?? 0 } : {}}>
|
|
{row[COLUMNS.OUTER_L] ?? '-'}
|
|
</td>
|
|
<td className={cn("px-3 py-2.5 text-slate-400 font-mono text-xs truncate", isPinned('outerH') && "sticky bg-slate-800")} style={isPinned('outerH') ? { left: getStickyLeft('outerH') ?? 0, zIndex: getStickyRank('outerH') ?? 0 } : {}}>
|
|
{row[COLUMNS.OUTER_H] ?? '-'}
|
|
</td>
|
|
|
|
{/* Container units columns */}
|
|
{containerCols.map(col => {
|
|
const colKey = `con_${col.index}`;
|
|
return (
|
|
<td key={col.index} className={cn("px-3 py-2.5 truncate", isPinned(colKey) && "sticky bg-slate-800")} style={isPinned(colKey) ? { left: getStickyLeft(colKey) ?? 0, zIndex: getStickyRank(colKey) ?? 0 } : {}}>
|
|
{unitBadge(row[col.index], col.name)}
|
|
</td>
|
|
);
|
|
})}
|
|
|
|
{/* MOQ cell */}
|
|
<td className={cn("px-3 py-2.5 truncate", isPinned('moq') && "sticky bg-slate-800")} style={isPinned('moq') ? { left: getStickyLeft('moq') ?? 0, zIndex: getStickyRank('moq') ?? 0 } : {}}>
|
|
{row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? row[COLUMNS.MOQ] : '-'}
|
|
</td>
|
|
|
|
{/* Issues column (weight only) */}
|
|
<td className="px-3 py-2 overflow-hidden">
|
|
{(() => {
|
|
const weightError = unitErrors.find(e => e.startsWith('NW'));
|
|
return weightError ? (
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-500/15 text-red-400 border border-red-500/30 whitespace-nowrap">
|
|
<AlertCircle className="w-3 h-3 shrink-0" />{weightError}
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-600 text-xs">—</span>
|
|
);
|
|
})()}
|
|
</td>
|
|
|
|
{/* Check Ying Column (Validated + Note) */}
|
|
<td className="px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={isValidated}
|
|
onChange={(e) => handleToggleCheck(dataIndex, e.target.checked)}
|
|
className="w-4 h-4 rounded border-slate-600 bg-slate-900 text-blue-600 focus:ring-blue-500 focus:ring-offset-slate-800 cursor-pointer"
|
|
/>
|
|
<button
|
|
onClick={() => setNoteEditor({ rowIndex: dataIndex, text: note })}
|
|
onMouseEnter={(e) => {
|
|
if (!note) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setHoveredNote({
|
|
text: note,
|
|
isAnna: false,
|
|
pos: { top: rect.bottom + window.scrollY + 8, left: rect.left + window.scrollX + rect.width / 2 }
|
|
});
|
|
}}
|
|
onMouseLeave={() => setHoveredNote(null)}
|
|
className={cn(
|
|
"p-1.5 rounded-md transition-all shrink-0",
|
|
note
|
|
? "bg-amber-500/10 text-amber-400 border border-amber-500/20 hover:bg-amber-500/20"
|
|
: "text-slate-500 hover:text-slate-300 hover:bg-slate-700/50"
|
|
)}
|
|
>
|
|
<MessageSquare className={cn("w-3.5 h-3.5", note && "fill-amber-400/20")} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Check Anna Column */}
|
|
{(() => {
|
|
const annaChecked = !!row[COLUMNS.ANNA_CHECK];
|
|
const annaNote = String(row[COLUMNS.ANNA_NOTE] || '');
|
|
return (
|
|
<td className="px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={annaChecked}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleToggleAnnaCheck(dataIndex, e.target.checked)}
|
|
className="w-4 h-4 rounded border-slate-600 bg-slate-900 text-pink-600 focus:ring-pink-500 focus:ring-offset-slate-800 cursor-pointer"
|
|
/>
|
|
<button
|
|
onClick={() => setAnnaEditor({ rowIndex: dataIndex, text: annaNote })}
|
|
onMouseEnter={(e) => {
|
|
if (!annaNote) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setHoveredNote({
|
|
text: annaNote,
|
|
isAnna: true,
|
|
pos: { top: rect.bottom + window.scrollY + 8, left: rect.left + window.scrollX + rect.width / 2 }
|
|
});
|
|
}}
|
|
onMouseLeave={() => setHoveredNote(null)}
|
|
className={cn(
|
|
"p-1.5 rounded-md transition-all shrink-0",
|
|
annaNote
|
|
? "bg-pink-500/10 text-pink-400 border border-pink-500/20 hover:bg-pink-500/20"
|
|
: "text-slate-500 hover:text-slate-300 hover:bg-slate-700/50"
|
|
)}
|
|
>
|
|
<MessageSquare className={cn("w-3.5 h-3.5", annaNote && "fill-pink-400/20")} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
);
|
|
})()}
|
|
|
|
{/* Matrix columns cells */}
|
|
{matrixCols.map(col => (
|
|
<td key={col.index} className="px-3 py-2.5 text-slate-500 text-xs max-w-[150px] truncate" title={String(row[col.index] || '')}>
|
|
{row[col.index] ?? '—'}
|
|
</td>
|
|
))}
|
|
|
|
<td className={cn(
|
|
"px-2 py-2.5 sticky right-0 shadow-[-4px_0_8px_rgba(0,0,0,0.1)] transition-colors",
|
|
isValidated ? "bg-[#1b4d24]" : "bg-slate-800",
|
|
"group-hover:bg-slate-700/40"
|
|
)}>
|
|
<button
|
|
onClick={() => onEdit(dataIndex)}
|
|
className="p-1.5 text-slate-500 hover:text-slate-200 hover:bg-slate-700 rounded transition-colors"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Footer count & Pagination ── */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<p className="text-xs text-slate-500 pb-1">
|
|
Showing {paginatedRows.length} of {sortedRows.length} products {totalPages > 1 && `(${currentPage}/${totalPages})`}
|
|
{pricingEditableCols.length > 0 && (
|
|
<> · Click any <span className="text-blue-400">price cell</span> to edit inline</>
|
|
)}
|
|
</p>
|
|
{dragSelectedRows.size > 0 && (
|
|
<span className="flex items-center gap-1.5 text-xs text-blue-400 bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded mb-1">
|
|
{dragSelectedRows.size} selected
|
|
<button
|
|
onClick={() => setDragSelectedRows(new Set())}
|
|
className="ml-1 text-blue-300 hover:text-white transition-colors"
|
|
title="Clear selection"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setCurrentPage(1)}
|
|
disabled={currentPage === 1}
|
|
className="px-2 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
««
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="px-2 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
«
|
|
</button>
|
|
<span className="text-xs text-slate-400 px-2">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="px-2 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
»
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage(totalPages)}
|
|
disabled={currentPage === totalPages}
|
|
className="px-2 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
»»
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Text filter popover ───────────────────────────────────────────────────────
|
|
function TextFilterPopover({ value, onChange, onClose, triggerId, zIndex = 9999 }: {
|
|
value: { terms: string[]; op: 'and' | 'or' };
|
|
onChange: (v: { terms: string[]; op: 'and' | 'or' }) => void;
|
|
onClose: () => void;
|
|
triggerId: string;
|
|
zIndex?: number;
|
|
}) {
|
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
|
|
const updatePosition = useCallback(() => {
|
|
const trigger = document.getElementById(triggerId);
|
|
if (trigger) {
|
|
const rect = trigger.getBoundingClientRect();
|
|
setPosition({
|
|
top: rect.bottom + window.scrollY + 4,
|
|
left: Math.min(rect.left + window.scrollX, window.innerWidth + window.scrollX - 300)
|
|
});
|
|
}
|
|
}, [triggerId]);
|
|
|
|
useEffect(() => {
|
|
updatePosition();
|
|
window.addEventListener('scroll', updatePosition, true);
|
|
window.addEventListener('resize', updatePosition);
|
|
return () => {
|
|
window.removeEventListener('scroll', updatePosition, true);
|
|
window.removeEventListener('resize', updatePosition);
|
|
};
|
|
}, [updatePosition]);
|
|
|
|
const addTerm = () => {
|
|
if (value.terms.length < 5) {
|
|
onChange({ ...value, terms: [...value.terms, ''] });
|
|
}
|
|
};
|
|
|
|
const removeTerm = (index: number) => {
|
|
if (value.terms.length > 1) {
|
|
const next = [...value.terms];
|
|
next.splice(index, 1);
|
|
onChange({ ...value, terms: next });
|
|
} else {
|
|
onChange({ ...value, terms: [''] });
|
|
}
|
|
};
|
|
|
|
const updateTerm = (index: number, text: string) => {
|
|
const next = [...value.terms];
|
|
next[index] = text;
|
|
onChange({ ...value, terms: next });
|
|
};
|
|
|
|
return createPortal(
|
|
<div
|
|
className="absolute z-[9999] w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl p-4 flex flex-col gap-4 animate-in fade-in zoom-in-95 duration-100"
|
|
style={{
|
|
top: position.top,
|
|
left: position.left
|
|
}}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Show rows where field contains:</label>
|
|
|
|
<div className="flex flex-col gap-2.5">
|
|
{value.terms.map((term, idx) => (
|
|
<div key={idx} className="relative group">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
|
<input
|
|
type="text"
|
|
placeholder={`Term ${idx + 1}...`}
|
|
value={term}
|
|
onChange={e => updateTerm(idx, e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && onClose()}
|
|
autoFocus={idx === value.terms.length - 1}
|
|
className="w-full bg-slate-900 border border-slate-700 rounded-md p-2 pl-8 pr-8 text-xs text-white focus:outline-none focus:border-blue-500 transition-all"
|
|
/>
|
|
{value.terms.length > 1 && (
|
|
<button
|
|
onClick={() => removeTerm(idx)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{value.terms.length < 5 && (
|
|
<button
|
|
onClick={addTerm}
|
|
className="flex items-center gap-1.5 text-[10px] font-bold text-blue-400 hover:text-blue-300 transition-colors w-fit px-1"
|
|
>
|
|
+ Add another term (max 5)
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex items-center gap-4 px-1 py-1 bg-slate-900/30 rounded-md">
|
|
<label className="flex items-center gap-2 cursor-pointer group">
|
|
<input
|
|
type="radio"
|
|
name={`op-${value.terms.length}`}
|
|
checked={value.op === 'and'}
|
|
onChange={() => onChange({ ...value, op: 'and' })}
|
|
className="w-3 h-3 text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500"
|
|
/>
|
|
<span className={cn("text-[10px] font-bold transition-colors", value.op === 'and' ? "text-blue-400" : "text-slate-500 group-hover:text-slate-300")}>AND (All match)</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer group">
|
|
<input
|
|
type="radio"
|
|
name={`op-${value.terms.length}`}
|
|
checked={value.op === 'or'}
|
|
onChange={() => onChange({ ...value, op: 'or' })}
|
|
className="w-3 h-3 text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500"
|
|
/>
|
|
<span className={cn("text-[10px] font-bold transition-colors", value.op === 'or' ? "text-blue-400" : "text-slate-500 group-hover:text-slate-300")}>OR (Any match)</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-2 border-t border-slate-700/50">
|
|
<button
|
|
onClick={() => { onChange({ terms: [''], op: 'and' }); onClose(); }}
|
|
className="text-[10px] font-bold text-slate-500 hover:text-red-400 transition-colors"
|
|
>
|
|
Clear All
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded shadow-lg shadow-blue-900/20 active:scale-95 transition-all"
|
|
>
|
|
Apply Filter
|
|
</button>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
// ── Sort Icon Helper ───────────────────────────────────────────────────
|
|
function SortIcon({ current }: { current: 'asc' | 'desc' | null }) {
|
|
return (
|
|
<span className="inline-flex flex-col ml-1">
|
|
<ChevronDown className={cn(
|
|
"w-2.5 h-2.5 -mb-0.5 transition-colors",
|
|
current === 'asc' ? "text-blue-400 rotate-180" : "text-slate-600 group-hover:text-slate-400"
|
|
)} />
|
|
<ChevronDown className={cn(
|
|
"w-2.5 h-2.5 transition-colors",
|
|
current === 'desc' ? "text-blue-400" : "text-slate-600 group-hover:text-slate-400"
|
|
)} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Resize Handle Component ─────────────────────────────────────────────
|
|
function ResizeHandle({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => void }) {
|
|
return (
|
|
<div
|
|
onMouseDown={onMouseDown}
|
|
onClick={e => e.stopPropagation()}
|
|
className="absolute right-0 top-0 bottom-0 w-1.5 hover:bg-blue-500/50 cursor-col-resize group/resizer flex items-center justify-center z-20"
|
|
title="Drag to resize"
|
|
>
|
|
<div className="w-[1px] h-4 bg-slate-700 group-hover/resizer:bg-blue-400 transition-colors" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Stat card ─────────────────────────────────────────────────────────────────
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
icon,
|
|
color,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
icon: React.ReactNode;
|
|
color: 'slate' | 'amber' | 'red' | 'emerald';
|
|
}) {
|
|
const colors = {
|
|
slate: { bg: 'bg-slate-800', border: 'border-slate-700', text: 'text-slate-200', icon: 'text-slate-400' },
|
|
amber: { bg: 'bg-amber-950/30', border: 'border-amber-700/40', text: 'text-amber-300', icon: 'text-amber-500' },
|
|
red: { bg: 'bg-red-950/30', border: 'border-red-700/40', text: 'text-red-300', icon: 'text-red-500' },
|
|
emerald: { bg: 'bg-emerald-950/20', border: 'border-emerald-700/30', text: 'text-emerald-300', icon: 'text-emerald-500' },
|
|
};
|
|
const c = colors[color];
|
|
return (
|
|
<div className={cn('rounded-xl border p-4 flex items-center gap-4', c.bg, c.border)}>
|
|
<span className={c.icon}>{icon}</span>
|
|
<div>
|
|
<p className={cn('text-2xl font-bold', c.text)}>{value}</p>
|
|
<p className="text-xs text-slate-400">{label}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|