import React, { useState, useEffect, useRef, useMemo } from 'react'; import { toast } from 'sonner'; import { Upload, FileSpreadsheet, AlertTriangle, CheckCircle2, CheckCheck, Loader2, RefreshCw, ChevronDown, ChevronUp, SkipForward, Plus, List, Building2, ChevronLeft, FileText, XCircle, Sparkles, } 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'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { SectionCard, CountPill, fmt, importErrorState } from './dataShared'; 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 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} ); } 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: '', }; // ─── Bill History Import helpers ────────────────────────────────────────────── function ConfidenceDot({ level }) { const cls = level === 'high' ? 'bg-emerald-500' : level === 'medium' ? 'bg-amber-500' : 'bg-muted-foreground/30'; return ; } function useBillGroups(previewRows, allBills) { return useMemo(() => { const billMap = new Map(allBills.map(b => [b.id, b])); const groups = new Map(); for (const row of previewRows) { for (const match of (row.possible_bill_matches ?? [])) { if (!billMap.has(match.bill_id)) continue; if (!groups.has(match.bill_id)) { groups.set(match.bill_id, { bill: billMap.get(match.bill_id), rows: [], counts: { high: 0, medium: 0, low: 0 }, }); } const g = groups.get(match.bill_id); if (!g.rows.find(r => r.row_id === row.row_id)) { g.rows.push({ ...row, _match: match }); g.counts[match.match_confidence] = (g.counts[match.match_confidence] || 0) + 1; } } } return [...groups.values()].sort((a, b) => b.rows.length !== a.rows.length ? b.rows.length - a.rows.length : b.counts.high - a.counts.high ); }, [previewRows, allBills]); } function rowDateLabel(row) { if (row.detected_year && row.detected_month) return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`; return row.detected_paid_date ?? '—'; } function billImportProgress(rows, importResult) { const completedRowIds = importResult?.completedRowIds ?? new Set(); const remainingRows = rows.filter(row => !completedRowIds.has(row.row_id)); return { completedCount: rows.length - remainingRows.length, remainingRows, remainingCount: remainingRows.length, }; } function detailImportedAnything(detail) { return ['created', 'updated', 'overwritten', 'imported'].includes(detail?.result) || detail?.payment === 'created'; } function detailCompletesImport(detail) { if (!detail?.row_id) return false; if (['error', 'ambiguous', 'skipped_conflict'].includes(detail.result)) return false; if (detail.result === 'skipped') return false; return detailImportedAnything(detail) || detail.result === 'skipped_duplicate' || detail.payment === 'skipped_duplicate'; } function BillDetailView({ group, onBack, onImport, isImporting, importResult }) { const { bill, rows } = group; const { completedCount, remainingCount } = billImportProgress(rows, importResult); const sorted = [...rows].sort((a, b) => { const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0); const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0); return da - db; }); return (
{bill.name}
{importResult && (
{completedCount === rows.length ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`} {importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`} {importResult.duplicates > 0 && importResult.earliestDup && (

{importResult.duplicates} dup{importResult.duplicates > 1 ? 's' : ''} · recorded{' '} {importResult.earliestDup.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}

)}
)}
{sorted.map(row => (
{rowDateLabel(row)} {row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'} {row.detected_name ?? '—'} {row._match.match_confidence}
))}
); } function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) { const [selectedBillId, setSelectedBillId] = useState(null); const billGroups = useBillGroups(previewRows, allBills); if (billGroups.length === 0) { return (
No existing bills matched rows in this file.
); } if (selectedBillId) { const group = billGroups.find(g => g.bill.id === selectedBillId); return group ? setSelectedBillId(null)} onImport={() => onImportBill(group)} /> : null; } return (
{billGroups.map(g => { const { bill, rows, counts } = g; const isImporting = importingBillId === bill.id; const importResult = billImportResults.get(bill.id) ?? null; const { completedCount, remainingCount } = billImportProgress(rows, importResult); const sorted3 = [...rows] .sort((a, b) => { const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0); const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0); return da - db; }) .slice(0, 3); return (
{bill.name} {rows.length} row{rows.length !== 1 ? 's' : ''} {counts.high > 0 && {counts.high} high} {counts.medium > 0 && {counts.medium} med} {counts.low > 0 && {counts.low} low} {importResult && (() => { const allImported = completedCount === rows.length; const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : ''; return (
{allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`} {importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`} {importResult.errored > 0 && ` · ${importResult.errored} errors`} {importResult.duplicates > 0 && (

{importResult.duplicates} duplicate{importResult.duplicates > 1 ? 's' : ''}{dupDate}

)}
); })()}
{sorted3.map(row => (
{rowDateLabel(row)} {row.detected_amount != null && ( ${Number(row.detected_amount).toFixed(2)} )} {row.detected_name && row.detected_name.toLowerCase() !== bill.name.toLowerCase() && ( "{row.detected_name}" )}
))} {rows.length > 3 && ( )}
{importResult ? ( ) : ( )}
); })}
); } // ───────────────────────────────────────────────────────────────────────────── export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps = {} }) { 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()); const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills' const [importingBillId, setImportingBillId] = useState(null); const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id → { created, updated, errored } // 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 }); setViewMode('rows'); setImportingBillId(null); setBillImportResults(new Map()); 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()); // ── Bill-history direct import ──────────────────────────────────────────── // Applies all matching rows for a bill immediately — no queue, no review step. const handleDirectImportBill = async (group) => { const sessionId = preview.data?.import_session_id; if (!sessionId || importingBillId) return; const previousResult = billImportResults.get(group.bill.id) ?? null; const rowsToImport = billImportProgress(group.rows, previousResult).remainingRows; if (rowsToImport.length === 0) { toast.info(`All rows for "${group.bill.name}" have already been imported.`); return; } setImportingBillId(group.bill.id); try { const decisionsList = rowsToImport.map(row => ({ row_id: row.row_id, action: 'match_existing_bill', bill_id: group.bill.id, actual_amount: row.detected_amount ?? null, payment_amount: row.detected_payment_amount ?? row.detected_amount ?? null, payment_date: row.detected_paid_date ?? null, })); const result = await api.applySpreadsheetImport({ import_session_id: sessionId, decisions: decisionsList, options: {}, }); const created = result.rows_created ?? 0; const updated = result.rows_updated ?? 0; const errored = result.rows_errored ?? 0; const details = result.details ?? []; const duplicateRowIds = new Set( details .filter(d => d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') .map(d => d.row_id) .filter(Boolean), ); const duplicates = duplicateRowIds.size || (result.rows_duplicates ?? 0); // Collect created_at dates from duplicate detail entries so we can show // when the existing payments were originally recorded. const dupDates = details .filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at) .map(d => new Date(d.existing_created_at)) .filter(d => !isNaN(d.getTime())) .sort((a, b) => a - b); const earliestDup = dupDates[0] ?? null; const latestDup = dupDates.at(-1) ?? null; const completedRowIds = new Set(previousResult?.completedRowIds ?? []); const erroredRowIds = new Set(previousResult?.erroredRowIds ?? []); for (const detail of details) { if (detailCompletesImport(detail)) { completedRowIds.add(detail.row_id); erroredRowIds.delete(detail.row_id); } else if (['error', 'ambiguous', 'skipped_conflict'].includes(detail?.result)) { erroredRowIds.add(detail.row_id); } } const mergedResult = { created: (previousResult?.created ?? 0) + created, updated: (previousResult?.updated ?? 0) + updated, errored: erroredRowIds.size, duplicates: (previousResult?.duplicates ?? 0) + duplicates, earliestDup: previousResult?.earliestDup && earliestDup ? (previousResult.earliestDup < earliestDup ? previousResult.earliestDup : earliestDup) : (previousResult?.earliestDup ?? earliestDup), latestDup: previousResult?.latestDup && latestDup ? (previousResult.latestDup > latestDup ? previousResult.latestDup : latestDup) : (previousResult?.latestDup ?? latestDup), completedRowIds, erroredRowIds, }; setBillImportResults(prev => new Map(prev).set(group.bill.id, mergedResult)); const imported = created + updated; const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount; if (imported === 0 && duplicates > 0) { const dateHint = earliestDup ? ` (first recorded ${fmtDate(earliestDup)})` : ''; toast.warning( remainingCount === 0 ? `All rows for "${group.bill.name}" are now imported${dateHint}.` : `${duplicates} row${duplicates > 1 ? 's' : ''} for "${group.bill.name}" already exist${dateHint}. ${remainingCount} remaining.`, ); } else { const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`]; if (duplicates > 0) { const dateHint = earliestDup ? ` (recorded ${fmtDate(earliestDup)})` : ''; parts.push(`${duplicates} already existed${dateHint}`); } if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`); if (remainingCount > 0) parts.push(`${remainingCount} remaining`); toast.success(`${group.bill.name} — ${parts.join(' · ')}`); } onHistoryRefresh?.(); } catch (err) { toast.error(err.message || `Import failed for "${group.bill.name}"`); } finally { setImportingBillId(null); } }; 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 }); setViewMode('rows'); setImportingBillId(null); setBillImportResults(new Map()); 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 ? (
{/* Tab header */}
{viewMode === 'rows' ? 'Select rows, apply bulk decisions, then import.' : 'Click a bill to queue its entire history from this file.'}
{/* Rows view */} {viewMode === 'rows' && ( <> )} {/* Bills view */} {viewMode === 'bills' && ( )}
) : (

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 ─────────────────────────────────────────────────────────────────