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:
Christian Vidal Wolf
2026-05-08 13:28:39 +02:00
co-authored by claude-flow
parent d9b9664a9d
commit 1556aea081
4 changed files with 376 additions and 6 deletions
+355
View File
@@ -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>
);
}