mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:55:24 +02:00
feat: add Cosmetic Items tab with inline CPNP No. editing
New sidebar tab for cosmetic product lines (INKEE, BATH FUN, TOP FASHION, SENSES, BODYNESS) filtered to items with empty CPNP No. Column filters on Item Available allow toggling zero/non-zero stock. CPNP_NO added as virtual column (index 107) stored in Supabase. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
co-authored by
claude-flow
parent
d9b9664a9d
commit
1556aea081
+14
-2
@@ -20,6 +20,7 @@ import { HistoryView } from './components/HistoryView';
|
||||
import { UndoToast } from './components/UndoToast';
|
||||
import { PendingValidationView } from './components/PendingValidationView';
|
||||
import { MissingDataView } from './components/MissingDataView';
|
||||
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
||||
|
||||
const FORCED_ZERO_STOCK_SKUS = new Set([
|
||||
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
||||
@@ -60,7 +61,7 @@ export default function App() {
|
||||
hasUnsavedChanges: false,
|
||||
asinColumnIndex: null
|
||||
});
|
||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data'>('descriptions');
|
||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items'>('descriptions');
|
||||
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
||||
@@ -179,6 +180,7 @@ export default function App() {
|
||||
[COLUMNS.ITEM_TO_LOGISTIC]: 'Item to Logistic',
|
||||
[COLUMNS.ANNA_CHECK]: 'Anna Check',
|
||||
[COLUMNS.ANNA_NOTE]: 'Anna Note',
|
||||
[COLUMNS.CPNP_NO]: 'CPNP No.',
|
||||
};
|
||||
Object.entries(VIRTUAL_COLS).forEach(([idxStr, name]) => {
|
||||
const idx = Number(idxStr);
|
||||
@@ -204,7 +206,8 @@ export default function App() {
|
||||
resolvedCols.VALIDATED_CHECK,
|
||||
resolvedCols.VALIDATED_NOTE,
|
||||
resolvedCols.PRODUCT_TYPE,
|
||||
resolvedCols.ITEM_TO_LOGISTIC
|
||||
resolvedCols.ITEM_TO_LOGISTIC,
|
||||
resolvedCols.CPNP_NO
|
||||
]);
|
||||
|
||||
headers.forEach((h: any, i: number) => {
|
||||
@@ -787,6 +790,15 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
||||
onCaptureState={captureState}
|
||||
/>
|
||||
)}
|
||||
{activeModule === 'cosmetic_items' && (
|
||||
<CosmeticItemsView
|
||||
data={appState.data}
|
||||
headers={appState.headers}
|
||||
onSaveRow={handleSaveRow}
|
||||
onCaptureState={captureState}
|
||||
rowStatuses={rowStatuses}
|
||||
/>
|
||||
)}
|
||||
{activeModule === 'history' && (
|
||||
<HistoryView
|
||||
headers={appState.headers}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { Search, ChevronDown, ChevronUp, X, Save, Check } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
|
||||
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
|
||||
|
||||
interface CosmeticItemsViewProps {
|
||||
data: ExcelRow[];
|
||||
headers: string[];
|
||||
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
|
||||
onCaptureState: (message: string) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses }: CosmeticItemsViewProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [search, setSearch] = usePersistentState('cosmeticItems-search', '');
|
||||
const [sortCol, setSortCol] = usePersistentState<number | null>('cosmeticItems-sortCol', null);
|
||||
const [sortDesc, setSortDesc] = usePersistentState('cosmeticItems-sortDesc', false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('cosmeticItems-columnFilters', {});
|
||||
const [openFilter, setOpenFilter] = useState<number | null>(null);
|
||||
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
|
||||
|
||||
const pageSize = 100;
|
||||
|
||||
const columns = [
|
||||
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 110 },
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 240 },
|
||||
{ col: COLUMNS.LINE, label: 'Line', width: 120 },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
|
||||
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Item Available', width: 120 },
|
||||
{ col: COLUMNS.CPNP_NO, label: 'CPNP No.', width: 160 },
|
||||
];
|
||||
|
||||
const columnUniqueValues = useMemo(() => {
|
||||
const result: Record<number, Set<string>> = {};
|
||||
columns.forEach(c => { result[c.col] = new Set(); });
|
||||
|
||||
data.forEach(row => {
|
||||
const lineVal = String(row[COLUMNS.LINE] ?? '').trim().toUpperCase();
|
||||
const isCosmeticLine = COSMETIC_LINES.some(l => lineVal === l);
|
||||
if (!isCosmeticLine) return;
|
||||
|
||||
columns.forEach(({ col }) => {
|
||||
result[col].add(String(row[col] ?? ''));
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [data, COLUMNS]);
|
||||
|
||||
const getUniqueValues = (col: number): string[] =>
|
||||
Array.from(columnUniqueValues[col] ?? []).sort();
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Filter: only cosmetic lines
|
||||
result = result.filter(({ row }) => {
|
||||
const lineVal = String(row[COLUMNS.LINE] ?? '').trim().toUpperCase();
|
||||
return COSMETIC_LINES.some(l => lineVal === l);
|
||||
});
|
||||
|
||||
// Filter: CPNP No. is empty
|
||||
result = result.filter(({ row }) => {
|
||||
const cpnp = row[COLUMNS.CPNP_NO];
|
||||
return cpnp === null || cpnp === undefined || String(cpnp).trim() === '';
|
||||
});
|
||||
|
||||
// Global search
|
||||
if (search) {
|
||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
result = result.filter(({ row }) => {
|
||||
const articleNo = String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
||||
const articleName = String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
||||
return terms.every(t => articleNo.includes(t) || articleName.includes(t));
|
||||
});
|
||||
}
|
||||
|
||||
// Column filters
|
||||
(Object.entries(columnFilters) as [string, string[]][]).forEach(([colIdx, filterValues]) => {
|
||||
if (!filterValues || filterValues.length === 0) return;
|
||||
const colNum = parseInt(colIdx);
|
||||
result = result.filter(({ row }) => {
|
||||
const val = String(row[colNum] ?? '');
|
||||
return filterValues.includes(val);
|
||||
});
|
||||
});
|
||||
|
||||
// Sort
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
const valA = a.row[sortCol];
|
||||
const valB = b.row[sortCol];
|
||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
return sortDesc ? valB - valA : valA - valB;
|
||||
}
|
||||
const sA = String(valA ?? '');
|
||||
const sB = String(valB ?? '');
|
||||
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, search, sortCol, sortDesc, columnFilters, COLUMNS]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, page]);
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
const handleSort = (col: number) => {
|
||||
if (sortCol === col) setSortDesc(d => !d);
|
||||
else { setSortCol(col); setSortDesc(false); }
|
||||
};
|
||||
|
||||
const startEditCpnp = (rowIndex: number, currentValue: any) => {
|
||||
setEditingCpnp({ rowIndex, value: String(currentValue ?? '') });
|
||||
};
|
||||
|
||||
const saveCpnp = (rowIndex: number) => {
|
||||
if (!editingCpnp || editingCpnp.rowIndex !== rowIndex) return;
|
||||
const row = data[rowIndex];
|
||||
onCaptureState(`Updated CPNP No. for ${row[COLUMNS.ARTICLE_NO]}`);
|
||||
const newRow = [...row];
|
||||
newRow[COLUMNS.CPNP_NO] = editingCpnp.value.trim();
|
||||
onSaveRow(rowIndex, newRow);
|
||||
setEditingCpnp(null);
|
||||
};
|
||||
|
||||
const cancelEditCpnp = () => setEditingCpnp(null);
|
||||
|
||||
const lineBadgeColor = (line: string) => {
|
||||
const l = String(line).toUpperCase();
|
||||
if (l === 'INKEE') return 'bg-pink-500/10 text-pink-400 border-pink-500/20';
|
||||
if (l === 'BATH FUN') return 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20';
|
||||
if (l === 'TOP FASHION') return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
|
||||
if (l === 'SENSES') return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20';
|
||||
return 'bg-slate-700/50 text-slate-400 border-slate-600/50';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-9 pr-10 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setPage(1); }}
|
||||
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>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span className="text-slate-400 font-medium">{filteredData.length} items</span>
|
||||
<span className="text-slate-600">·</span>
|
||||
<span>Lines: {COSMETIC_LINES.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{columns.map(({ col, label, width }) => {
|
||||
const selectedFilters = columnFilters[col] || [];
|
||||
const filterCount = selectedFilters.length;
|
||||
const isCpnp = col === COLUMNS.CPNP_NO;
|
||||
return (
|
||||
<th
|
||||
key={col}
|
||||
style={{ width, minWidth: width }}
|
||||
className="px-2 py-2 font-medium border-r border-slate-700/30 relative"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer select-none hover:text-white"
|
||||
onClick={() => !isCpnp && handleSort(col)}
|
||||
>
|
||||
{label}
|
||||
{!isCpnp && sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||
)}
|
||||
{isCpnp && (
|
||||
<span className="ml-1 text-[9px] text-indigo-400 font-normal">(editable)</span>
|
||||
)}
|
||||
</div>
|
||||
{!isCpnp && (
|
||||
<div className="mt-1 relative">
|
||||
<button
|
||||
id={`cosmetic-filter-trigger-${col}`}
|
||||
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
|
||||
filterCount > 0 ? "border-indigo-500 text-white" : "border-slate-600 text-slate-400 hover:border-slate-500"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{filterCount > 0 ? `${filterCount} selected` : 'Filter...'}</span>
|
||||
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
|
||||
</button>
|
||||
{openFilter === col && (
|
||||
<ColumnFilterPopover
|
||||
triggerId={`cosmetic-filter-trigger-${col}`}
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
selectedValues={selectedFilters}
|
||||
onToggle={(val) => setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
if (current.includes(val)) {
|
||||
return { ...prev, [col]: current.filter(v => v !== val) };
|
||||
}
|
||||
return { ...prev, [col]: [...current, val] };
|
||||
})}
|
||||
onSelectAll={(vals) => setColumnFilters(prev => ({ ...prev, [col]: vals }))}
|
||||
onClear={() => { setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; }); }}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/30">
|
||||
{paginatedData.map(({ row, index }) => {
|
||||
const articleNo = String(row[COLUMNS.ARTICLE_NO] ?? '');
|
||||
const status = rowStatuses[articleNo];
|
||||
const isEditing = editingCpnp?.rowIndex === index;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
"hover:bg-slate-700/20 transition-colors",
|
||||
status === 'pending' && "bg-amber-500/5"
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 110 }}>
|
||||
{articleNo}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 240 }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||
{row[COLUMNS.ARTICLE_NAME]}
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ width: 120 }}>
|
||||
<span className={cn(
|
||||
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||
lineBadgeColor(String(row[COLUMNS.LINE] ?? ''))
|
||||
)}>
|
||||
{row[COLUMNS.LINE]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 truncate text-slate-300" style={{ width: 130 }}>
|
||||
{row[COLUMNS.CLASSIFICATION] || <span className="text-slate-600">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-300 text-right" style={{ width: 120 }}>
|
||||
{row[COLUMNS.ITEM_AVAILABLE]}
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ width: 160 }}>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingCpnp.value}
|
||||
autoFocus
|
||||
onChange={e => setEditingCpnp(prev => prev ? { ...prev, value: e.target.value } : prev)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveCpnp(index);
|
||||
if (e.key === 'Escape') cancelEditCpnp();
|
||||
}}
|
||||
className="flex-1 min-w-0 bg-slate-900 border border-indigo-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="Enter CPNP No."
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveCpnp(index)}
|
||||
className="p-1 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-400/10 rounded transition-colors"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditCpnp}
|
||||
className="p-1 text-slate-500 hover:text-slate-300 hover:bg-slate-700 rounded transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEditCpnp(index, row[COLUMNS.CPNP_NO])}
|
||||
className="w-full text-left px-2 py-1 rounded border border-dashed border-slate-600 text-slate-500 hover:border-indigo-500 hover:text-indigo-400 transition-colors text-[10px]"
|
||||
title="Click to enter CPNP No."
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Save className="w-3 h-3 opacity-50" />
|
||||
Click to fill...
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{paginatedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-500">
|
||||
No cosmetic items pending CPNP registration.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
|
||||
<div>Showing {paginatedData.length} of {filteredData.length} items</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-slate-300">Page {page} of {totalPages || 1}</span>
|
||||
<button
|
||||
disabled={page === totalPages || totalPages === 0}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from 'react';
|
||||
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle } from 'lucide-react';
|
||||
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: string;
|
||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data') => void;
|
||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items') => void;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
|
||||
const isMasterUser = userEmail?.toLowerCase() === 'christian.vidal@craze-group.com';
|
||||
|
||||
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data';
|
||||
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items';
|
||||
|
||||
const navItems: { id: ModuleId; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||
@@ -20,6 +20,7 @@ export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarPro
|
||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
||||
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
|
||||
...(isMasterUser ? [
|
||||
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
|
||||
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
|
||||
|
||||
+3
-1
@@ -43,7 +43,8 @@ export const COLUMNS = {
|
||||
PRODUCT_TYPE: 103,
|
||||
ITEM_TO_LOGISTIC: 104,
|
||||
ANNA_CHECK: 105,
|
||||
ANNA_NOTE: 106
|
||||
ANNA_NOTE: 106,
|
||||
CPNP_NO: 107
|
||||
};
|
||||
|
||||
// Search patterns for dynamic detection
|
||||
@@ -81,6 +82,7 @@ export const COLUMN_PATTERNS: Record<keyof typeof COLUMNS, string[]> = {
|
||||
ITEM_TO_LOGISTIC: ['item', 'logistic'],
|
||||
ANNA_CHECK: ['anna', 'check'],
|
||||
ANNA_NOTE: ['anna', 'note'],
|
||||
CPNP_NO: ['cpnp'],
|
||||
// Virtual/Extra columns stay hardcoded and are NOT auto-detected from headers
|
||||
// to prevent internal data from being shifted or overwritten by Excel column shifts.
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user