From 1556aea081f59df1beb59db3c5c9ccdfca1e012e Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Fri, 8 May 2026 13:28:39 +0200 Subject: [PATCH] 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 --- src/App.tsx | 16 +- src/components/CosmeticItemsView.tsx | 355 +++++++++++++++++++++++++++ src/components/Sidebar.tsx | 7 +- src/types.ts | 4 +- 4 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 src/components/CosmeticItemsView.tsx diff --git a/src/App.tsx b/src/App.tsx index 610742b..b5d82ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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' && ( + + )} {activeModule === 'history' && ( void; + onCaptureState: (message: string) => void; + rowStatuses: Record; +} + +export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses }: CosmeticItemsViewProps) { + const COLUMNS = useColumns(); + const [search, setSearch] = usePersistentState('cosmeticItems-search', ''); + const [sortCol, setSortCol] = usePersistentState('cosmeticItems-sortCol', null); + const [sortDesc, setSortDesc] = usePersistentState('cosmeticItems-sortDesc', false); + const [page, setPage] = useState(1); + const [columnFilters, setColumnFilters] = usePersistentState>('cosmeticItems-columnFilters', {}); + const [openFilter, setOpenFilter] = useState(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> = {}; + 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 ( +
+
+
+ + { 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 && ( + + )} +
+
+ {filteredData.length} items + · + Lines: {COSMETIC_LINES.join(', ')} +
+
+ +
+
+ + + + {columns.map(({ col, label, width }) => { + const selectedFilters = columnFilters[col] || []; + const filterCount = selectedFilters.length; + const isCpnp = col === COLUMNS.CPNP_NO; + return ( + + ); + })} + + + + {paginatedData.map(({ row, index }) => { + const articleNo = String(row[COLUMNS.ARTICLE_NO] ?? ''); + const status = rowStatuses[articleNo]; + const isEditing = editingCpnp?.rowIndex === index; + + return ( + + + + + + + + + ); + })} + {paginatedData.length === 0 && ( + + + + )} + +
+
!isCpnp && handleSort(col)} + > + {label} + {!isCpnp && sortCol === col && ( + sortDesc ? : + )} + {isCpnp && ( + (editable) + )} +
+ {!isCpnp && ( +
+ + {openFilter === col && ( + 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)} + /> + )} +
+ )} +
+ {articleNo} + + {row[COLUMNS.ARTICLE_NAME]} + + + {row[COLUMNS.LINE]} + + + {row[COLUMNS.CLASSIFICATION] || } + + {row[COLUMNS.ITEM_AVAILABLE]} + + {isEditing ? ( +
+ 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." + /> + + +
+ ) : ( + + )} +
+ No cosmetic items pending CPNP registration. +
+
+ +
+
Showing {paginatedData.length} of {filteredData.length} items
+
+ + Page {page} of {totalPages || 1} + +
+
+
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index aac2595..b9a7741 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 } diff --git a/src/types.ts b/src/types.ts index 92ea359..19fffd3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { 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. };