import { useState, useEffect, useRef } from 'react'; import { Navigate } from 'react-router-dom'; import { toast } from 'sonner'; import { Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle, AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown, ChevronUp, SkipForward, Plus, CheckCheck, } from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; // ─── User export availability flag ─────────────────────────────────────────── // Flip to true when GET /api/export/user-db and GET /api/export/user-excel exist. const USER_EXPORTS_AVAILABLE = true; // ─── Utilities ──────────────────────────────────────────────────────────────── function fmt(isoStr) { if (!isoStr) return '—'; const d = new Date(isoStr); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function groupRowsBySheet(rows) { const map = new Map(); for (const row of rows) { const key = row.sheet_name || '(unknown sheet)'; if (!map.has(key)) map.set(key, []); map.get(key).push(row); } return Array.from(map.entries()).map(([name, rows]) => ({ name, rows })); } function initialDecisionFromRecommendation(row) { const rec = row.recommendation || {}; const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null); if (!action || row.requires_user_decision) return { action: null }; if (action === 'skip_row') return { action: 'skip_row' }; if (action === 'match_existing_bill') { return { action, bill_id: rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null, bill_name: null, due_day: rec.due_day ?? null, actual_amount: rec.actual_amount ?? row.detected_amount ?? null, payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null, payment_date: rec.payment_date ?? row.detected_paid_date ?? null, notes: row.detected_notes ?? null, }; } if (action === 'create_new_bill') { return { action, bill_id: null, bill_name: rec.bill_name || row.detected_bill_name || '', category_id: rec.category_id ?? null, due_day: rec.due_day ?? null, expected_amount: rec.expected_amount ?? row.detected_amount ?? null, actual_amount: rec.actual_amount ?? row.detected_amount ?? null, payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null, payment_date: rec.payment_date ?? row.detected_paid_date ?? null, notes: row.detected_notes ?? null, }; } return { action }; } function safeRawBillName(row) { const raw = row.raw_values?.find((v) => { const text = String(v || '').trim(); if (!text || text.length > 80) return false; if (/^(?:total|subtotal|sum|grand\s*total)$/i.test(text)) return false; if (/^\$?\(?\d[\d,]*(?:\.\d{1,2})?\)?$/.test(text)) return false; if (/^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(text)) return false; if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return false; return true; }); return raw ? String(raw).trim() : ''; } function buildCreateNewDecision(row, currentDecision = {}) { const rec = row.recommendation || {}; const billName = currentDecision.bill_name || row.detected_bill_name || rec.bill_name || safeRawBillName(row); return { ...currentDecision, action: 'create_new_bill', previous_match_bill_id: currentDecision.bill_id ?? currentDecision.previous_match_bill_id ?? rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null, bill_id: null, bill_name: billName, category_id: currentDecision.category_id ?? rec.category_id ?? null, due_day: currentDecision.due_day ?? rec.due_day ?? null, expected_amount: currentDecision.expected_amount ?? rec.expected_amount ?? row.detected_amount ?? null, actual_amount: currentDecision.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null, payment_amount: currentDecision.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null, payment_date: currentDecision.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null, notes: currentDecision.notes ?? row.detected_notes ?? null, }; } function buildInitialDecisions(rows) { const d = {}; for (const row of rows) { const hasError = row.errors?.length > 0; if (hasError || row.proposed_action === 'skip_row') { d[row.row_id] = { action: 'skip_row' }; } else { d[row.row_id] = initialDecisionFromRecommendation(row); } } return d; } function isDecisionComplete(action, decision) { if (!action) return false; if (action === 'skip_row') return true; if (action === 'create_new_bill') return !!(decision?.bill_name?.trim()); if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note', 'create_payment'].includes(action)) { return !!decision?.bill_id; } return true; } // ─── Badges ─────────────────────────────────────────────────────────────────── function SourceBadge({ source }) { const MAP = { row_date: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', sheet_name: 'bg-blue-500/15 text-blue-600 dark:text-blue-400', default: 'bg-amber-500/15 text-amber-600 dark:text-amber-500', ambiguous: 'bg-red-500/15 text-red-600 dark:text-red-400', }; const LABELS = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' }; return ( {LABELS[source] ?? source} ); } function ConfidenceBadge({ confidence }) { const MAP = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' }; return {confidence}; } function actionLabel(action) { const MAP = { match_existing_bill: 'Match existing bill', create_new_bill: 'Create new bill', skip_row: 'Skip row', ambiguous: 'Needs decision', update_monthly_state: 'Update monthly record', add_monthly_note: 'Add monthly note', create_payment: 'Record as payment', }; return MAP[action] || (action ? action.replace(/_/g, ' ') : 'Needs decision'); } function importErrorState(err, fallback) { const data = err?.data || {}; return { message: err?.message || data.message || data.error || fallback, error: data.error || fallback, code: data.code || err?.code || null, details: Array.isArray(data.details) ? data.details : (Array.isArray(err?.details) ? err.details : []), error_id: data.error_id || null, }; } function SheetStatusBadge({ status }) { const MAP = { parsed: 'bg-emerald-500/15 text-emerald-600', parsed_month_only: 'bg-amber-500/15 text-amber-600', ambiguous: 'bg-orange-500/15 text-orange-600', skipped: 'bg-muted text-muted-foreground', }; const LABELS = { parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped', }; return ( {LABELS[status] ?? status} ); } // ─── Shared SectionCard ─────────────────────────────────────────────────────── function SectionCard({ title, subtitle, children, className }) { return (

{title}

{subtitle &&

{subtitle}

}
{children}
); } // ─── Section 2: Download My Data ───────────────────────────────────────────── function ExportCard({ icon: Icon, title, description, filename, endpoint }) { const [loading, setLoading] = useState(false); const handleDownload = async () => { setLoading(true); try { const res = await fetch(endpoint, { credentials: 'include' }); if (!res.ok) { let data = {}; try { data = await res.json(); } catch {} throw new Error(data.error || `HTTP ${res.status}`); } const disposition = res.headers.get('Content-Disposition'); const match = disposition?.match(/filename="?([^"]+)"?/i); const name = match ? match[1] : filename; const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); toast.success(`${title} downloaded.`); } catch (err) { toast.error(err.message || 'Download failed.'); } finally { setLoading(false); } }; const disabled = !USER_EXPORTS_AVAILABLE || loading; return (

{title}

{!USER_EXPORTS_AVAILABLE && ( Coming soon )}

{description}

); } export function DownloadMyDataSection() { return (

What's included

    {['Bills','Payments','Categories','Monthly bill state','Notes','Export metadata'].map(i => (
  • {i}
  • ))}

What's not included

    {['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
  • {i}
  • ))}
); } function CountPill({ label, value }) { return (

{label}

{value ?? 0}

); } // ─── Section 3: Import My Data Export ──────────────────────────────────────── export function ImportMyDataSection({ onHistoryRefresh }) { const fileRef = useRef(null); const [file, setFile] = useState(null); const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null }); const reset = () => { setFile(null); setPreview({ status: 'idle', data: null, error: null }); setApplyState({ status: 'idle', result: null, error: null }); if (fileRef.current) fileRef.current.value = ''; }; const handlePreview = async () => { if (!file) { toast.error('Choose a SQLite data export first.'); return; } setPreview({ status: 'loading', data: null, error: null }); setApplyState({ status: 'idle', result: null, error: null }); try { const data = await api.previewUserDbImport(file); setPreview({ status: 'ready', data, error: null }); toast.success('SQLite export preview ready.'); } catch (err) { setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') }); toast.error(err.message || 'SQLite import preview failed.'); } }; const handleApply = async () => { if (!preview.data?.import_session_id) return; const ok = window.confirm('Import this SQLite data export into your account? Existing records will be skipped by default.'); if (!ok) return; setApplyState({ status: 'loading', result: null, error: null }); try { const result = await api.applyUserDbImport({ import_session_id: preview.data.import_session_id, options: { overwrite: false }, }); setApplyState({ status: 'done', result, error: null }); toast.success('SQLite data import applied.'); onHistoryRefresh?.(); } catch (err) { setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') }); toast.error(err.message || 'SQLite import apply failed.'); } }; const counts = preview.data?.counts || {}; const summary = preview.data?.summary || {}; return (

Import a SQLite data export created by this app.

This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported.

{preview.status === 'error' && (
{preview.error?.message || 'SQLite import preview failed.'} {preview.error?.details?.length > 0 && (
    {preview.error.details.map((d, i) => (
  • {d.message || d.table || JSON.stringify(d)}
  • ))}
)}
)} {preview.status === 'ready' && preview.data && (

Preview ready

Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'}

User data only
{Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => (

{key.replace(/_/g, ' ')}

create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0}

))}
{preview.data.warnings?.length > 0 && (
{preview.data.warnings.map((warning, i) => (

{warning}

))}
)}

Review the preview before applying. Nothing is imported until you confirm.

)} {applyState.status === 'done' && applyState.result && (

SQLite import applied

)} {applyState.status === 'error' && (
{applyState.error?.message || 'SQLite import apply failed.'}
)}
); } // ─── Section 4: Import History ──────────────────────────────────────────────── export function ImportHistorySection({ history, loading, onRefresh }) { if (loading) { return (
Loading…
); } const rows = history ?? []; return (

{rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`}

{rows.length > 0 && (
{['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => ( ))} {rows.map(r => ( ))}
{h}
{fmt(r.imported_at)} {r.source_filename || '—'} {r.sheet_name || '—'} {r.rows_parsed} {r.rows_created} {r.rows_updated} {r.rows_skipped} {r.rows_errored}
)}
); } // ─── XLSX Import: Workbook Summary ──────────────────────────────────────────── function WorkbookSummaryCard({ workbook }) { const isMulti = workbook.parse_mode === 'all_sheets'; return (

Workbook Summary

{isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names.length} tabs` : `${workbook.row_count} rows · ${workbook.selected_sheet}`}
{isMulti && workbook.sheets?.length > 0 && (
{workbook.sheets.map(s => (
{s.name}
{s.detected_year && s.detected_month && ( {String(s.detected_month).padStart(2,'0')}/{s.detected_year} )} {s.status !== 'skipped' && {s.row_count} rows}
))}
)}
); } // ─── XLSX Import: Row Decision Controls ────────────────────────────────────── const ACTIONS_NEEDING_BILL = new Set(['match_existing_bill','update_monthly_state','add_monthly_note','create_payment']); function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, selected, onSelectedChange }) { const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action); const action = decision?.action ?? null; const isSkip = action === 'skip_row'; const hasError = row.errors?.length > 0; const complete = isDecisionComplete(action, decision); const rec = row.recommendation || {}; const suggestedBills = row.possible_bill_matches ?? []; const suggestedIds = new Set(suggestedBills.map(b => b.bill_id)); const otherBills = allBills.filter(b => !suggestedIds.has(b.id)); const handleAction = (val) => { const next = { ...decision, action: val }; if (val === 'create_new_bill') { Object.assign(next, buildCreateNewDecision(row, decision)); } else if (ACTIONS_NEEDING_BILL.has(val)) { next.bill_name = null; next.bill_id = decision?.bill_id ?? decision?.previous_match_bill_id ?? rec.bill_id ?? suggestedBills[0]?.bill_id ?? null; next.actual_amount = decision?.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null; next.payment_amount = decision?.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null; next.payment_date = decision?.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null; } else { next.bill_id = null; next.bill_name = null; } onDecisionChange(row.row_id, next); if (val === 'skip_row') setExpanded(false); }; const handleBill = (e) => { onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null }); }; const handleBillName = (e) => { onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value }); }; const handleDecisionField = (field, value) => { onDecisionChange(row.row_id, { ...decision, [field]: value }); }; return (
{/* Main row */}
setExpanded(e => !e)} > {/* Selection */}
e.stopPropagation()}> onSelectedChange(row.row_id, e.target.checked)} aria-label={`Select row ${row.source_row_number}`} className="h-4 w-4 rounded border-border accent-primary" />
{/* Status icon */}
{hasError ? : isSkip ? : complete ? : action !== null ? : }
{/* Content */}
#{row.source_row_number} {row.sheet_name && {row.sheet_name}} {row.detected_year && row.detected_month && ( {String(row.detected_month).padStart(2,'0')}/{row.detected_year} )} {row.year_month_source && }
{row.detected_bill_name || '(no bill name)'} {row.detected_amount != null && ( ${row.detected_amount.toFixed(2)} )} {row.detected_paid_date && ( paid {row.detected_paid_date} )} {row.detected_labels?.length > 0 && ( {row.detected_labels.join(', ')} )} {row.detected_notes && ( {row.detected_notes} )}
{/* Right: action status + expand */}
{action === null ? ( Needs decision ) : isSkip ? ( Skipped ) : ( {action.replace(/_/g,' ')} )} {action !== 'skip_row' && ( expanded ? : )}
{/* Expanded decision controls */} {expanded && !hasError && (
{/* Recommendation */} {rec.action && (
Recommended: {actionLabel(rec.action)} {rec.bill_name && rec.action === 'match_existing_bill' && ( → {rec.bill_name} )} {rec.category_name && ( Category: {rec.category_name} )} {rec.due_day && Due day: {rec.due_day}} {rec.actual_amount != null && ${Number(rec.actual_amount).toFixed(2)}}
{rec.reason &&

Reason: {rec.reason}

}
)} {/* Warnings */} {(rec.warnings?.length > 0 || row.warnings?.length > 0) && (
{Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => (

{w}

))}
)} {/* Possible matches hint */} {suggestedBills.length > 0 && (
Suggested: {suggestedBills.slice(0, 3).map(b => ( ))}
)} {/* Action selector */}
{/* Bill selector (for actions that need a bill) */} {ACTIONS_NEEDING_BILL.has(action) && (
)} {/* Bill name input for create_new_bill */} {action === 'create_new_bill' && (
{rec.category_name && Suggested: {rec.category_name}}
handleDecisionField('due_day', e.target.value ? parseInt(e.target.value, 10) : null)} placeholder="Due day" className="h-8 text-sm w-24" /> handleDecisionField('expected_amount', e.target.value === '' ? null : parseFloat(e.target.value))} placeholder="Expected amount" className="h-8 text-sm w-40" />
)} {action && action !== 'skip_row' && (
handleDecisionField('payment_date', e.target.value || null)} className="h-8 text-sm w-40" /> handleDecisionField('payment_amount', e.target.value === '' ? null : parseFloat(e.target.value))} placeholder="Paid amount" className="h-8 text-sm w-36" />
)} {/* Quick skip */} {action !== 'skip_row' && ( )}
)}
); } // ─── XLSX Import: Preview Table ─────────────────────────────────────────────── function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) { const groups = groupRowsBySheet(rows); const multiTab = groups.length > 1; return (
{groups.map(({ name, rows: groupRows }) => (
{multiTab && (
{name} · {groupRows.length} rows
)} {groupRows.map(row => ( ))}
))}
); } function BulkActionBar({ rows, selectedRows, onSelectAll, onClearSelection, onBulkSkip, onBulkCreateNew, onBulkReset, }) { const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id)); const selectedCount = selectedRows.size; return (
{selectedCount > 0 && ( {selectedCount} row{selectedCount === 1 ? '' : 's'} selected )} {selectedCount > 0 && ( <> )}
); } // ─── Section 1: Import Spreadsheet History ──────────────────────────────────── const INITIAL_OPTIONS = { parseAllSheets: true, defaultYear: new Date().getFullYear(), defaultMonth: '', }; export function ImportSpreadsheetSection({ onHistoryRefresh }) { const fileRef = useRef(null); const [file, setFile] = useState(null); const [options, setOptions] = useState(INITIAL_OPTIONS); const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); const [decisions, setDecisions] = useState({}); const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null }); const [allBills, setAllBills] = useState([]); const [categories, setCategories] = useState([]); const [selectedRows, setSelectedRows] = useState(new Set()); // Load bills/categories for the decision controls useEffect(() => { api.bills().then(setAllBills).catch(() => {}); api.categories().then(setCategories).catch(() => {}); }, []); const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v })); // ── Preview ────────────────────────────────────────────────────────────────── const handlePreview = async () => { if (!file) return; setPreview({ status: 'loading', data: null, error: null }); setDecisions({}); setSelectedRows(new Set()); setApplyState({ status: 'idle', result: null, error: null }); try { const data = await api.previewSpreadsheetImport(file, { parseAllSheets: options.parseAllSheets, defaultYear: options.defaultYear ? parseInt(options.defaultYear, 10) : null, defaultMonth: options.defaultMonth ? parseInt(options.defaultMonth, 10) : null, }); setPreview({ status: 'ready', data, error: null }); setDecisions(buildInitialDecisions(data.rows)); } catch (err) { setPreview({ status: 'error', data: null, error: importErrorState(err, 'Preview failed.') }); } }; // ── Decision update ────────────────────────────────────────────────────────── const handleDecisionChange = (rowId, decision) => { setDecisions(prev => ({ ...prev, [rowId]: decision })); }; const handleSelectedChange = (rowId, selected) => { setSelectedRows(prev => { const next = new Set(prev); if (selected) next.add(rowId); else next.delete(rowId); return next; }); }; const clearSelection = () => setSelectedRows(new Set()); const selectAllVisibleRows = () => { setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id))); }; const selectedPreviewRows = () => { const selected = selectedRows; return (preview.data?.rows || []).filter(r => selected.has(r.row_id)); }; const handleBulkSkip = () => { const rows = selectedPreviewRows(); setDecisions(prev => { const next = { ...prev }; rows.forEach(row => { next[row.row_id] = { ...(next[row.row_id] || {}), action: 'skip_row', bill_id: null, bill_name: null }; }); return next; }); toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} marked skipped.`); }; const handleBulkCreateNew = () => { const rows = selectedPreviewRows(); let missingNames = 0; setDecisions(prev => { const next = { ...prev }; rows.forEach(row => { const decision = buildCreateNewDecision(row, next[row.row_id] || {}); if (!decision.bill_name?.trim()) missingNames++; next[row.row_id] = decision; }); return next; }); if (missingNames > 0) { toast.warning(`${missingNames} selected row${missingNames === 1 ? '' : 's'} still need a bill name.`); } else { toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} set to create new bills.`); } }; const handleBulkReset = () => { const rows = selectedPreviewRows(); setDecisions(prev => { const next = { ...prev }; rows.forEach(row => { next[row.row_id] = initialDecisionFromRecommendation(row); }); return next; }); toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} reset to recommendation.`); }; const buildApplyDecision = (row, d) => { if (!d?.action) return null; const base = { row_id: row.row_id, action: d.action, actual_amount: d.actual_amount ?? row.detected_amount ?? undefined, year: row.detected_year ?? undefined, month: row.detected_month ?? undefined, notes: d.notes ?? row.detected_notes ?? undefined, payment_amount: d.payment_amount ?? row.detected_payment_amount ?? undefined, payment_date: d.payment_date ?? row.detected_paid_date ?? undefined, }; if (d.action === 'create_new_bill') { return { ...base, bill_name: d.bill_name?.trim() || undefined, category_id: d.category_id ?? undefined, due_day: d.due_day ?? undefined, expected_amount: d.expected_amount ?? undefined, }; } if (ACTIONS_NEEDING_BILL.has(d.action)) { return { ...base, bill_id: d.bill_id ?? undefined, }; } return base; }; // ── Apply ──────────────────────────────────────────────────────────────────── const handleApply = async () => { if (!preview.data) return; setApplyState({ status: 'loading', result: null, error: null }); try { const decisionsList = preview.data.rows .map(row => { const d = decisions[row.row_id]; if (d?.action === 'skip_row') return null; return buildApplyDecision(row, d); }) .filter(Boolean); if (decisionsList.length === 0) { throw new Error('No rows are selected to import. Choose at least one row to match or create, or keep the preview for review.'); } const result = await api.applySpreadsheetImport({ import_session_id: preview.data.import_session_id, decisions: decisionsList, options: { reviewed_skipped_count: skipRows.length }, }); setApplyState({ status: 'done', result, error: null }); setSelectedRows(new Set()); toast.success(`Import applied — ${result.rows_created} created, ${result.rows_updated} updated.`); onHistoryRefresh(); } catch (err) { const errorState = importErrorState(err, 'Apply failed.'); setApplyState({ status: 'error', result: null, error: errorState }); toast.error(errorState.message || 'Apply failed.'); } }; // ── Reset ──────────────────────────────────────────────────────────────────── const handleReset = () => { setFile(null); setOptions(INITIAL_OPTIONS); setPreview({ status: 'idle', data: null, error: null }); setDecisions({}); setSelectedRows(new Set()); setApplyState({ status: 'idle', result: null, error: null }); if (fileRef.current) fileRef.current.value = ''; }; // ── Derived state ──────────────────────────────────────────────────────────── const previewRows = preview.data?.rows ?? []; const unresolvedRows = previewRows.filter(r => { const d = decisions[r.row_id]; return !d?.action || !isDecisionComplete(d.action, d); }); const pendingRows = previewRows.filter(r => decisions[r.row_id]?.action && decisions[r.row_id]?.action !== 'skip_row'); const skipRows = previewRows.filter(r => decisions[r.row_id]?.action === 'skip_row'); const canApply = unresolvedRows.length === 0 && pendingRows.length > 0 && applyState.status !== 'loading'; // ── Render ──────────────────────────────────────────────────────────────────── return ( {/* ── Upload panel ──────────────────────────────────────────────────────── */}
{/* File picker */}
setFile(e.target.files?.[0] ?? null)} /> {file && ( {file.name} )}
{/* Options */}
opt('parseAllSheets', v)} id="parse-all" />
opt('defaultYear', e.target.value)} className="w-24 h-8 text-sm" />
{!options.parseAllSheets && (
opt('defaultMonth', e.target.value)} className="w-20 h-8 text-sm" />
)}
{/* Preview button */}
{(preview.status === 'ready' || preview.status === 'error' || applyState.status !== 'idle') && ( )}
{/* Error from preview */} {preview.status === 'error' && (
{preview.error?.message || preview.error || 'Preview failed.'} {preview.error?.details?.length > 0 && (
    {preview.error.details.map((d, i) => (
  • {d.row_id ? `${d.row_id}: ` : ''}{d.message}
  • ))}
)}
)}
{/* ── Preview results ────────────────────────────────────────────────────── */} {preview.status === 'ready' && preview.data && !['loading', 'done'].includes(applyState.status) && (
{/* Workbook summary */} {/* Row decision table */} {previewRows.length > 0 ? (

XLSX Review Table

Select preview rows, then apply bulk review decisions before importing.

{previewRows.length} preview row{previewRows.length === 1 ? '' : 's'}
) : (

No data rows found in this file.

)} {/* Apply bar */} {previewRows.length > 0 && (
{previewRows.length} rows reviewed {pendingRows.length} to apply {skipRows.length} skipped {unresolvedRows.length > 0 && ( {unresolvedRows.length} need a decision )}
)}
)} {/* ── Applying ──────────────────────────────────────────────────────────── */} {applyState.status === 'loading' && (
Applying import…
)} {/* ── Apply result ──────────────────────────────────────────────────────── */} {applyState.status === 'done' && applyState.result && (

Import applied successfully

{[ { label: 'Created', value: applyState.result.rows_created, color: 'text-emerald-600' }, { label: 'Updated', value: applyState.result.rows_updated, color: 'text-blue-600' }, { label: 'Skipped', value: applyState.result.rows_skipped, color: 'text-muted-foreground' }, { label: 'Errors', value: applyState.result.rows_errored, color: 'text-red-500' }, ].map(({ label, value, color }) => (

{value}

{label}

))}
)} {/* ── Apply error ───────────────────────────────────────────────────────── */} {applyState.status === 'error' && (
{applyState.error?.message || applyState.error || 'Apply failed.'} {applyState.error?.details?.length > 0 && (
    {applyState.error.details.map((d, i) => (
  • {d.row_id ? `${d.row_id}: ` : ''} {d.field ? `${d.field} - ` : ''} {d.message}
  • ))}
)} {applyState.error?.error_id && (

Error ID: {applyState.error.error_id}

)}
)}
); } // ─── DataPage ───────────────────────────────────────────────────────────────── export default function DataPage() { return ; }