Files
Craze-Data-check/src/components/CosmeticItemsView.tsx
T

374 lines
17 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo } from 'react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Search, ChevronDown, ChevronUp, X, Save, Check, Loader2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { saveRowToSupabase } from '../lib/supabase';
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 [savingCpnp, setSavingCpnp] = useState<number | 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.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();
if (!COSMETIC_LINES.some(l => lineVal === l)) return;
columns.forEach(({ col }) => {
result[col].add(String(row[col] ?? '').replace(/\s+/g, ' ').trim());
});
});
return result;
}, [data, COLUMNS]);
const getUniqueValues = (col: number): string[] =>
Array.from(columnUniqueValues[col] as Set<string> ?? []).sort((a, b) => {
const na = parseFloat(a), nb = parseFloat(b);
if (!isNaN(na) && !isNaN(nb)) return na - nb;
return a.localeCompare(b);
});
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);
});
// 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);
const knownCount = columnUniqueValues[colNum]?.size ?? 0;
// All known values selected → no filtering needed
if (filterValues.length >= knownCount) return;
const normalize = (v: any) => String(v ?? '').replace(/\s+/g, ' ').trim();
const filterSet = new Set(filterValues.map(normalize));
result = result.filter(({ row }) => filterSet.has(normalize(row[colNum])));
});
// 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, columnUniqueValues]);
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 = async (rowIndex: number) => {
if (!editingCpnp || editingCpnp.rowIndex !== rowIndex) return;
const row = data[rowIndex];
const articleNo = String(row[COLUMNS.ARTICLE_NO]);
const newRow = [...row];
newRow[COLUMNS.CPNP_NO] = editingCpnp.value.trim();
setSavingCpnp(rowIndex);
setEditingCpnp(null);
onCaptureState(`Updated CPNP No. for ${articleNo}`);
onSaveRow(rowIndex, newRow);
const result = await saveRowToSupabase(articleNo, newRow);
setSavingCpnp(null);
if (!result.success) {
alert(`Error saving CPNP No. for ${articleNo}: ${result.error}`);
}
};
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={() => handleSort(col)}
>
{label}
{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>
<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 font-mono text-slate-300 text-right" style={{ width: 120 }}>
{row[COLUMNS.ITEM_AVAILABLE]}
</td>
<td className="px-3 py-2" style={{ width: 160 }}>
{savingCpnp === index ? (
<span className="flex items-center gap-1.5 text-[10px] text-slate-400">
<Loader2 className="w-3 h-3 animate-spin" />
Saving...
</span>
) : 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>
) : (() => {
const cpnpVal = String(row[COLUMNS.CPNP_NO] ?? '').trim();
return cpnpVal ? (
<button
onClick={() => startEditCpnp(index, row[COLUMNS.CPNP_NO])}
className="w-full text-left px-2 py-1 rounded border border-slate-600 text-emerald-400 hover:border-indigo-500 hover:text-indigo-400 transition-colors text-[10px] font-mono"
title="Click to edit CPNP No."
>
{cpnpVal}
</button>
) : (
<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>
);
}