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

545 lines
24 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, ChevronDown, ChevronUp, X, Edit2, Save } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { DateFilterPopover } from './DateFilterPopover';
interface MissingDataViewProps {
data: ExcelRow[];
headers: string[];
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void;
}
function formatDateForInput(val: string): string {
if (!val) return '';
// If it's already YYYY-MM-DD, return it
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return val;
// If it's DD/MM/YYYY, convert to YYYY-MM-DD
const parts = val.split('/');
if (parts.length === 3) {
return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
}
return val;
}
function formatDateFromInput(val: string): string {
// Always output YYYY-MM-DD as requested
return val || '';
}
function formatDateValue(val: any): string {
if (val === null || val === undefined || val === '') return '';
if (typeof val === 'number') {
if (val >= 25569 && val <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + val * 86400000);
return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD
}
return '';
}
return formatDateForInput(String(val));
}
function isEmptyOrEpoch(val: any): boolean {
if (val === null || val === undefined || val === '') return true;
if (typeof val === 'number') {
if (val === 0 || val === 1) return true;
if (val >= 25569 && val <= 60000) return false;
return true;
}
const s = String(val).trim();
if (s === '' || s === '0' || s === '1') return true;
if (s.endsWith('/1900')) return true;
return false;
}
type TabType = 'missingLaunch';
interface EditingState {
rowIndex: number;
classification: string;
launchDate: string;
readyToOrder: string;
}
export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) {
const [activeTab, setActiveTab] = useState<TabType>('missingLaunch');
const [search, setSearch] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const [page, setPage] = useState(1);
const [editing, setEditing] = useState<EditingState | null>(null);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [dateFilters, setDateFilters] = useState<Record<number, { start: string; end: string }>>({});
const [openFilter, setOpenFilter] = useState<number | null>(null);
const pageSize = 100;
const launchDateCol = useMemo(() => {
const idx = headers.findIndex(h => h.toLowerCase().includes('launch'));
if (idx >= 0) return idx;
return headers.findIndex(h => h.toLowerCase().includes('date'));
}, [headers]);
const readyToOrderCol = useMemo(() => {
const idx = headers.findIndex(h => h.toLowerCase().includes('ready'));
if (idx >= 0) return idx;
return headers.findIndex(h => h.toLowerCase().includes('order'));
}, [headers]);
const launchHeader = launchDateCol >= 0 ? headers[launchDateCol] : 'Launch Date';
const readyHeader = readyToOrderCol >= 0 ? headers[readyToOrderCol] : 'Ready to Order';
const columns = [
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 100 },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 220 },
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: launchHeader, width: 130 }] : []),
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: readyHeader, width: 130 }] : []),
];
const columnUniqueValues = useMemo(() => {
const cols = columns.map(c => c.col);
const result: Record<number, Set<string>> = {};
cols.forEach(col => result[col] = new Set());
data.forEach(row => {
cols.forEach(col => {
let val: any = row[col];
if (col === launchDateCol || col === readyToOrderCol) {
val = formatDateValue(val) || String(val ?? '');
} else {
val = String(val ?? '');
}
if (val) result[col].add(val);
});
});
return result;
}, [data, columns, launchDateCol, readyToOrderCol]);
const getUniqueValues = (col: number): string[] => {
const values = columnUniqueValues[col];
if (!values) return [];
return Array.from(values).sort() as string[];
};
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
if (activeTab === 'missingLaunch') {
result = result.filter(r => {
const val = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
return isEmptyOrEpoch(val);
});
}
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
}
(Object.entries(columnFilters) as [string, string[]][]).forEach(([colIdx, filterValues]) => {
if (!filterValues || filterValues.length === 0) return;
const colIdxNum = parseInt(colIdx);
if (colIdxNum === launchDateCol || colIdxNum === readyToOrderCol) return;
result = result.filter(r => {
const val: any = r.row[colIdxNum];
const displayVal = colIdxNum === launchDateCol || colIdxNum === readyToOrderCol
? formatDateValue(val) || String(val ?? '')
: String(val ?? '');
return filterValues.includes(displayVal);
});
});
(Object.entries(dateFilters) as [string, { start: string; end: string }][]).forEach(([colIdx, range]) => {
if (!range.start && !range.end) return;
const colIdxNum = parseInt(colIdx);
result = result.filter(r => {
const val = r.row[colIdxNum];
const dateStr = formatDateValue(val);
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return false;
const rowDate = new Date(dateStr);
if (isNaN(rowDate.getTime())) return false;
if (range.start) {
const startDate = new Date(range.start);
if (rowDate < startDate) return false;
}
if (range.end) {
const endDate = new Date(range.end);
if (rowDate > endDate) return false;
}
return true;
});
});
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, activeTab, search, sortCol, sortDesc, launchDateCol, columnFilters]);
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 openEdit = (rowIndex: number, row: ExcelRow) => {
setEditing({
rowIndex,
classification: String(row[COLUMNS.CLASSIFICATION] || ''),
launchDate: launchDateCol >= 0 ? formatDateForInput(formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '')) : '',
readyToOrder: readyToOrderCol >= 0 ? formatDateForInput(formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '')) : '',
});
};
const handleSave = () => {
if (!editing) return;
const row = data[editing.rowIndex];
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
const newRow = [...row];
newRow[COLUMNS.CLASSIFICATION] = editing.classification;
if (launchDateCol >= 0) newRow[launchDateCol] = formatDateFromInput(editing.launchDate);
if (readyToOrderCol >= 0) newRow[readyToOrderCol] = formatDateFromInput(editing.readyToOrder);
onSaveRow(editing.rowIndex, newRow);
setEditing(null);
};
const tabs: { id: TabType; label: string }[] = [
{ id: 'missingLaunch', label: 'Missing Launch Date' },
];
const editingRow = editing ? data[editing.rowIndex] : null;
return (
<div className="flex flex-col h-full">
<div className="flex flex-wrap gap-2 mb-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); setPage(1); }}
className={cn(
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-indigo-600 text-white shadow-md"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
)}
>
{tab.label}
</button>
))}
</div>
<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 text-xs text-slate-500">
{filteredData.length} items
</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 allValues = getUniqueValues(col);
const filterCount = selectedFilters.length;
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" />
)}
</div>
<div className="mt-1 relative">
{col === launchDateCol || col === readyToOrderCol ? (
<>
<button
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",
dateFilters[col]?.start || dateFilters[col]?.end
? "border-indigo-500 text-white"
: "border-slate-600 text-slate-400 hover:border-slate-500"
)}
>
<span className="truncate">
{dateFilters[col]?.start || dateFilters[col]?.end
? `${dateFilters[col].start ? dateFilters[col].start : '...'} - ${dateFilters[col].end ? dateFilters[col].end : '...'}`
: 'Filter...'}
</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
</button>
{openFilter === col && (
<DateFilterPopover
selectedRange={dateFilters[col] || { start: '', end: '' }}
onRangeChange={(range) => setDateFilters(prev => ({ ...prev, [col]: range }))}
onClose={() => setOpenFilter(null)}
/>
)}
</>
) : (
<>
<button
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
uniqueValues={allValues}
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>
);})}
<th className="px-2 py-2 font-medium text-right" style={{ width: 60 }}></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{paginatedData.map(({ row, index }) => (
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 100 }}>
{row[COLUMNS.ARTICLE_NO]}
</td>
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 220 }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]}
</td>
<td className="px-3 py-2 truncate" style={{ width: 130 }}>
{row[COLUMNS.CLASSIFICATION] && String(row[COLUMNS.CLASSIFICATION]).trim() !== '' ? (
<span className={cn(
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-slate-700/50 text-slate-400 border-slate-600/50"
)}>
{row[COLUMNS.CLASSIFICATION]}
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
)}
</td>
{launchDateCol >= 0 && (
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
{isEmptyOrEpoch(row[launchDateCol]) ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
) : (
formatDateValue(row[launchDateCol]) || String(row[launchDateCol])
)}
</td>
)}
{readyToOrderCol >= 0 && (
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
{row[readyToOrderCol] !== null && row[readyToOrderCol] !== undefined && row[readyToOrderCol] !== '' ? (
formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol])
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-700/50 text-slate-500 border border-slate-600/50"></span>
)}
</td>
)}
<td className="px-3 py-2 text-right" style={{ width: 60 }}>
<button
onClick={() => openEdit(index, row)}
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-indigo-400/10 rounded transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
{paginatedData.length === 0 && (
<tr>
<td colSpan={columns.length + 1} className="px-4 py-8 text-center text-slate-500">
No items found.
</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>
{/* Focused edit panel */}
{editing && editingRow && (
<>
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-40" onClick={() => setEditing(null)} />
<div className="fixed right-0 top-0 bottom-0 w-[400px] bg-slate-800 border-l border-slate-700 shadow-2xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
<div className="flex items-center justify-between p-6 border-b border-slate-700 bg-slate-800/50">
<div>
<h2 className="text-lg font-bold text-white">Edit Fields</h2>
<p className="text-xs text-slate-400 mt-0.5">
{editingRow[COLUMNS.ARTICLE_NO]} {editingRow[COLUMNS.ARTICLE_NAME]}
</p>
</div>
<button
onClick={() => setEditing(null)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-6 space-y-5">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Classification</label>
<select
value={editing.classification}
onChange={e => setEditing(prev => prev ? { ...prev, classification: e.target.value } : prev)}
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
editing.classification !== String(editingRow[COLUMNS.CLASSIFICATION] || '')
? "border-blue-500 focus:ring-blue-500"
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
>
<option value="">Select classification...</option>
{getUniqueValues(COLUMNS.CLASSIFICATION).map(val => (
<option key={val} value={val}>{val}</option>
))}
</select>
</div>
{launchDateCol >= 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{launchHeader}</label>
<input
type="date"
value={editing.launchDate}
onChange={e => setEditing(prev => prev ? { ...prev, launchDate: e.target.value } : prev)}
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
editing.launchDate !== (formatDateValue(editingRow[launchDateCol]) || String(editingRow[launchDateCol] ?? ''))
? "border-blue-500 focus:ring-blue-500"
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
/>
</div>
)}
{readyToOrderCol >= 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{readyHeader}</label>
<input
type="date"
value={editing.readyToOrder}
onChange={e => setEditing(prev => prev ? { ...prev, readyToOrder: e.target.value } : prev)}
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
editing.readyToOrder !== (formatDateValue(editingRow[readyToOrderCol]) || String(editingRow[readyToOrderCol] ?? ''))
? "border-blue-500 focus:ring-blue-500"
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
/>
</div>
)}
</div>
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
<button
onClick={() => setEditing(null)}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
>
<Save className="w-4 h-4" />
Queue Changes
</button>
</div>
</div>
</>
)}
</div>
);
}