feat: add Missing Data module as new sidebar entry

Replaces the tab approach with a dedicated 'Missing Data' module in the
sidebar. The module has two sub-tabs: 'Missing Classification' and
'Missing Launch Date'. Shows SKU, Name, Classification, Launch Date and
Ready to Order columns. Includes a local Excel serial date formatter so
dates that slipped through the App.tsx pre-processing are rendered
correctly instead of showing as raw numbers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-04-12 15:11:57 +02:00
co-authored by Claude Sonnet 4.6
parent fdf878a4f8
commit 7eeccdaf8c
4 changed files with 322 additions and 96 deletions
+38 -88
View File
@@ -6,23 +6,13 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
interface ArticleDetailsProps {
data: ExcelRow[];
headers: string[];
onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
}
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock' | 'missingClassOrLaunch';
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
function isEmptyLaunchDate(val: any): boolean {
if (val === null || val === undefined || val === '') return true;
const s = String(val).trim();
if (s === '' || s === '0' || s === '1') return true;
// "00/01/1900" or "01/01/1900" variants
if (s.endsWith('/1900')) return true;
return false;
}
export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDetailsProps) {
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState('');
@@ -32,12 +22,6 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const launchDateCol = useMemo(() =>
headers.findIndex(h => h.toLowerCase().includes('launch')), [headers]);
const readyToOrderCol = useMemo(() =>
headers.findIndex(h => h.toLowerCase().includes('ready')), [headers]);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
[COLUMNS.ARTICLE_NO]: 100,
[COLUMNS.ARTICLE_NAME]: 200,
@@ -57,16 +41,11 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
if (activeTab === 'missingDetailsEN') result = result.filter(r => !r.row[COLUMNS.DETAILS_EN]);
if (activeTab === 'missingAnyDetails') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE] || !r.row[COLUMNS.DETAILS_EN]);
if (activeTab === 'lowStock') result = result.filter(r => Number(r.row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0);
if (activeTab === 'missingClassOrLaunch') result = result.filter(r => {
const missingClass = !r.row[COLUMNS.CLASSIFICATION] || String(r.row[COLUMNS.CLASSIFICATION]).trim() === '';
const missingLaunch = launchDateCol >= 0 ? isEmptyLaunchDate(r.row[launchDateCol]) : false;
return missingClass || missingLaunch;
});
// Search filter
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
@@ -88,11 +67,11 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
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);
@@ -160,7 +139,7 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
const getBadge = (val: any, type: 'success' | 'warning' | 'error' | 'info' = 'info') => {
if (!val) return <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>;
const styles = {
success: "bg-green-500/10 text-green-400 border-green-500/20",
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
@@ -177,7 +156,6 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
{ id: 'missingDetailsEN', label: 'No Details EN' },
{ id: 'missingAnyDetails', label: 'Missing Details' },
{ id: 'lowStock', label: 'Out of Stock' },
{ id: 'missingClassOrLaunch', label: 'Missing Class / Launch' },
];
return (
@@ -189,8 +167,8 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
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"
activeTab === tab.id
? "bg-indigo-600 text-white shadow-md"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
)}
>
@@ -248,18 +226,13 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
{[
{ col: COLUMNS.ARTICLE_NO, label: 'SKU' },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name' },
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
...(activeTab === 'missingClassOrLaunch' ? [
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: headers[launchDateCol] || 'Launch Date' }] : []),
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: headers[readyToOrderCol] || 'Ready to Order' }] : []),
] : [
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Stock' },
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
]),
{ col: COLUMNS.CLASSIFICATION, label: 'Class' },
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Stock' },
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
].map(({ col, label }) => (
<th
key={col}
<th
key={col}
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
>
@@ -329,54 +302,31 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
{row[COLUMNS.ARTICLE_NAME]}
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
{row[COLUMNS.CLASSIFICATION] ? (
<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={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>
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
<span className={cn(
"font-mono font-bold",
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
)}>
{row[COLUMNS.ITEM_AVAILABLE] || 0}
</span>
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
{row[COLUMNS.DETAILS_DE] ? (
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
{row[COLUMNS.DETAILS_EN] ? (
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
) : getBadge(null)}
</td>
{activeTab === 'missingClassOrLaunch' ? (
<>
{launchDateCol >= 0 && (
<td className="px-3 py-2 truncate">
{isEmptyLaunchDate(row[launchDateCol]) ? getBadge(null) : (
<span className="font-mono text-slate-300">{String(row[launchDateCol])}</span>
)}
</td>
)}
{readyToOrderCol >= 0 && (
<td className="px-3 py-2 truncate">
{row[readyToOrderCol] !== null && row[readyToOrderCol] !== undefined && row[readyToOrderCol] !== '' ? (
<span className="font-mono text-slate-300">{String(row[readyToOrderCol])}</span>
) : getBadge(null)}
</td>
)}
</>
) : (
<>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
<span className={cn(
"font-mono font-bold",
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
)}>
{row[COLUMNS.ITEM_AVAILABLE] || 0}
</span>
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
{row[COLUMNS.DETAILS_DE] ? (
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
{row[COLUMNS.DETAILS_EN] ? (
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
) : getBadge(null)}
</td>
</>
)}
<td className="px-3 py-2 text-right">
<button
onClick={() => onEdit(index)}
@@ -398,7 +348,7 @@ export function ArticleDetails({ data, headers, onEdit, rowStatuses }: ArticleDe
</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} articles</div>
<div className="flex items-center gap-2">