feat: add MOQ column to PricingView positioned after container columns

This commit is contained in:
Christian Vidal Wolf
2026-04-28 09:18:43 +02:00
parent d23f4e625a
commit 712a9dac16
2 changed files with 207 additions and 19 deletions
+42 -1
View File
@@ -21,6 +21,34 @@ import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView';
import { MissingDataView } from './components/MissingDataView';
const FORCED_ZERO_STOCK_SKUS = new Set([
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
'2260VC', '2355VC', '2356VC', '2360VC', '2400VC', '2401VC', '2402VC', '2405VC',
'2406VC', '2414VC', '2415VC', '2425VC', '2447VC', '2450VC', '2510VC', '2517VC',
'2522VC', '2611VC', '2648VC', '2660VC', '2665VC', '2857VC', '2858VC', '2990VC',
'3011VC', '3012VC', '3013VC', '3015VC', '3018VC', '3019VC', '3020VC', '3021VC',
'3027VC', '3030VC', '3035VC', '3038VC', '3041VC', '3043VC', '3044VC', '3045VC',
'3046VC', '3048VC', '3052VC', '3053VC', '3054VC', '3055VC', '3062VC', '3063VC',
'3064VC', '3069VC', '3070VC', '3071VC', '3072VC', '3074VC', '3089VC', '3116VC',
'3117VC', '3118VC', '3119VC', '3120VC', '3121VC', '3123VC', '3125VC', '3126VC',
'3127VC', '3130VC', '3133VC', '3134VC', '3135VC', '3200VC', '3201VC', '3230VC',
'3311VC', '3314VC', '3315VC', '3354VC', '3372VC', '3380VC', '3391VC', '3392VC',
'3423VC', '3432VC', '3437VC', '3484VC', '3487VC', '3555VC', '3557VC', '3558VC',
'3559VC', '3712VC', '3824VC', '4107VC', '4108VC', '4110VC', '4115VC', '4125VC',
'4130VC', '4134VC', '4140VC', '4155VC', '4158VC', '4165VC', '4231VC', '4232VC',
'4255VC', '4259VC', '4261VC', '4263VC', '4264VC', '4265VC', '4285VC', '4288VC',
'4330VC', '4361VC', '4442VC', '4445VC', '4462VC', '4528VC', '4531VC', '4534VC',
'4558VC', '4657VC', '4721VC', '4722VC', '4727VC', '4735VC', '4737VC', '4745VC',
'4752VC', '4758VC', '4765VC', '4785VC', '4790VC', '4801VC', '4815VC', '4874VC',
'4882VC', '5032VC', '5113VC', '5382VC', '5383VC', '5440VC', '5442VC', '5456VC',
'5540VC', '5571VC', '5572VC', '5574VC', '5575VC', '5588VC', '5592VC', '5685VC',
'5712VC', '5721VC', '5807VC', '5823VC', '5825VC', '5835VC', '5852VC', '5871VC',
'5872VC', '5873VC', '5874VC', '5882VC', '5883VC', '5885VC', '5987VC', '5991VC',
'5995VC', '6001VC', '6007VC', '6040VC', '6151VC', '6252VC', '6255VC', '6270VC',
'6471VC', '5658VCI'
]);
export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
@@ -231,6 +259,11 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
}
}
// Force stock 0 for specific SKUs (User request)
if (FORCED_ZERO_STOCK_SKUS.has(articleNo)) {
finalRow[COLUMNS.ITEM_AVAILABLE] = 0;
}
return finalRow.map((val: any, idx: number) => {
if (val === undefined || val === null || val === '') return val;
const header = (extendedHeaders[idx] || '').toLowerCase();
@@ -317,8 +350,9 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
const headers = data[0];
const rawRows = data.slice(1);
const resolvedCols = resolveColumnIndices(headers);
const processedRows = rawRows.map(row => {
return row.map((val, idx) => {
const finalRow = row.map((val, idx) => {
if (val === undefined || val === null || val === '') return val;
const header = (headers[idx] || '').toLowerCase();
@@ -356,6 +390,13 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
}
return val;
});
// Force stock 0 for specific SKUs (User request)
const articleNo = String(finalRow[resolvedCols.ARTICLE_NO] || '');
if (FORCED_ZERO_STOCK_SKUS.has(articleNo)) {
finalRow[resolvedCols.ITEM_AVAILABLE] = 0;
}
return finalRow;
});
setAppState({
+149 -2
View File
@@ -71,6 +71,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
outerW: 80,
outerL: 80,
outerH: 80,
moq: 80,
check: 100,
actions: 60,
});
@@ -100,6 +101,8 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
const [outerWFilter, setOuterWFilter] = usePersistentState<string[]>('pricing-outerWFilter', []);
const [outerLFilter, setOuterLFilter] = usePersistentState<string[]>('pricing-outerLFilter', []);
const [outerHFilter, setOuterHFilter] = usePersistentState<string[]>('pricing-outerHFilter', []);
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());
@@ -111,6 +114,62 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
const [pinnedColumns, setPinnedColumns] = usePersistentState<Set<string>>('pricing-pinnedColumns', new Set(['articleNo', 'articleName']));
const [showPinPanel, setShowPinPanel] = useState(false);
// Drag-to-select state
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const isDraggingRef = useRef(false);
const dragStartIndexRef = useRef<number | null>(null);
const dragModeRef = useRef<'add' | 'remove'>('add');
const lastDragIndexRef = useRef<number | null>(null);
const visibleDataIndexesRef = useRef<number[]>([]);
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;
if (e.ctrlKey || e.metaKey) {
dragModeRef.current = selectedRows.has(dataIndex) ? 'remove' : 'add';
setSelectedRows(prev => {
const next = new Set(prev);
if (next.has(dataIndex)) next.delete(dataIndex); else next.add(dataIndex);
return next;
});
return;
}
dragModeRef.current = selectedRows.has(dataIndex) ? 'remove' : 'add';
setSelectedRows(new Set([dataIndex]));
}, [selectedRows]);
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];
setSelectedRows(prev => {
const next = new Set(prev);
visibleDataIndexesRef.current.forEach((idx: number) => {
if (idx >= lo && idx <= hi) {
if (dragModeRef.current === 'add') next.add(idx);
else next.delete(idx);
}
});
return next;
});
}, []);
useEffect(() => {
const handleMouseUp = () => { isDraggingRef.current = false; };
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, []);
// Close search dropdown on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -313,6 +372,14 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
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;
@@ -408,6 +475,12 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
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 => {
@@ -491,6 +564,9 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
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 => r.dataIndex);
const totalPages = Math.ceil(sortedRows.length / pageSize);
const handleSort = (key: string | number) => {
@@ -591,6 +667,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
{ 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 });
@@ -615,7 +692,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
// Calculate sticky left position for each pinned column
const getStickyLeft = useMemo(() => {
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH'];
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}`));
@@ -640,7 +717,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
// 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'];
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}`));
@@ -1658,6 +1735,23 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
);
})}
<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>
</div>
<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>
@@ -1690,12 +1784,60 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</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>
@@ -1920,6 +2062,11 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
);
})}
{/* 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">
{(() => {