import React, { useState, useEffect, useRef, useMemo } from 'react';
import { toast } from 'sonner';
import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
List, Building2, ChevronLeft, FileText, Link2, Link2Off,
EyeOff, Eye, Search,
} 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// ─── 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}
{loading ? <> Downloading…>
: <> {USER_EXPORTS_AVAILABLE ? 'Download' : 'Not Available Yet'}>}
);
}
export function DownloadMyDataSection() {
return (
Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.
What's included
{['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','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 (
);
}
// ─── Section 2: Transaction Matching ─────────────────────────────────────────
const TRANSACTION_FILTERS = [
{ id: 'open', label: 'Open', params: { ignored: 'false' } },
{ id: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', ignored: 'false' } },
{ id: 'matched', label: 'Matched', params: { match_status: 'matched', ignored: 'false' } },
{ id: 'ignored', label: 'Ignored', params: { match_status: 'ignored', ignored: 'true' } },
{ id: 'all', label: 'All', params: { ignored: 'all' } },
];
function transactionStatus(tx) {
if (tx?.ignored) return 'ignored';
return tx?.match_status || 'unmatched';
}
function TransactionStatusBadge({ tx }) {
const status = transactionStatus(tx);
const styles = {
matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground',
unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
};
return (
{status}
);
}
function formatTransactionAmount(amount, currency = 'USD') {
const value = Math.abs(Number(amount || 0)) / 100;
const sign = Number(amount || 0) < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
}
function transactionDate(tx) {
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
}
function transactionTitle(tx) {
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
}
function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) {
const [query, setQuery] = useState('');
const [selectedBillId, setSelectedBillId] = useState('');
useEffect(() => {
if (open) {
setQuery('');
setSelectedBillId(transaction?.matched_bill_id ? String(transaction.matched_bill_id) : '');
}
}, [open, transaction?.id, transaction?.matched_bill_id]);
const filteredBills = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return bills.slice(0, 40);
return bills
.filter(bill => String(bill.name || '').toLowerCase().includes(q))
.slice(0, 40);
}, [bills, query]);
const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId));
return (
Match Transaction
Choose the bill this transaction paid. Nothing changes until you confirm.
{transaction && (
{transactionTitle(transaction)}
{transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'}
{formatTransactionAmount(transaction.amount, transaction.currency)}
{transaction.description && transaction.description !== transactionTitle(transaction) && (
{transaction.description}
)}
)}
Find bill
setQuery(e.target.value)}
placeholder="Search bills"
className="pl-8"
/>
{filteredBills.length === 0 ? (
No bills found.
) : (
{filteredBills.map(bill => (
setSelectedBillId(String(bill.id))}
className={cn(
'flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30',
String(selectedBillId) === String(bill.id) && 'bg-primary/5',
)}
>
{bill.name}
Due day {bill.due_day ?? '—'} · expected ${Number(bill.expected_amount || 0).toFixed(2)}
{String(selectedBillId) === String(bill.id) && (
)}
))}
)}
onOpenChange(false)}>
Cancel
selectedBill && onConfirm(selectedBill.id)}
>
{loading ? <> Matching…> : <> Confirm Match>}
);
}
function TransactionMatchingSection({ refreshKey }) {
const [transactions, setTransactions] = useState([]);
const [bills, setBills] = useState([]);
const [filter, setFilter] = useState('open');
const [loading, setLoading] = useState(true);
const [billsLoading, setBillsLoading] = useState(true);
const [actionId, setActionId] = useState(null);
const [matchOpen, setMatchOpen] = useState(false);
const [matchTransaction, setMatchTransaction] = useState(null);
const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0];
const loadTransactions = async () => {
setLoading(true);
try {
const data = await api.transactions({ limit: 100, ...currentFilter.params });
setTransactions(data || []);
} catch (err) {
toast.error(err.message || 'Failed to load transactions.');
setTransactions([]);
} finally {
setLoading(false);
}
};
const loadBills = async () => {
setBillsLoading(true);
try {
const data = await api.bills();
setBills(data || []);
} catch {
setBills([]);
} finally {
setBillsLoading(false);
}
};
useEffect(() => { loadBills(); }, []);
useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
const openMatchDialog = (tx) => {
setMatchTransaction(tx);
setMatchOpen(true);
if (!bills.length && !billsLoading) loadBills();
};
const runTransactionAction = async (tx, action) => {
setActionId(`${action}:${tx.id}`);
try {
if (action === 'unmatch') {
await api.unmatchTransaction(tx.id);
toast.success('Transaction unmatched.');
} else if (action === 'ignore') {
await api.ignoreTransaction(tx.id);
toast.success('Transaction ignored.');
} else if (action === 'unignore') {
await api.unignoreTransaction(tx.id);
toast.success('Transaction restored.');
}
await loadTransactions();
} catch (err) {
toast.error(err.message || 'Transaction action failed.');
} finally {
setActionId(null);
}
};
const confirmMatch = async (billId) => {
if (!matchTransaction) return;
setActionId(`match:${matchTransaction.id}`);
try {
await api.matchTransaction(matchTransaction.id, billId);
toast.success('Transaction matched to bill.');
setMatchOpen(false);
setMatchTransaction(null);
await loadTransactions();
} catch (err) {
toast.error(err.message || 'Transaction match failed.');
} finally {
setActionId(null);
}
};
return (
{TRANSACTION_FILTERS.map(item => (
setFilter(item.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
filter === item.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{item.label}
))}
{loading ? : }
Refresh
{loading ? (
Loading transactions…
) : transactions.length === 0 ? (
No transactions found for this filter.
) : (
Date
Transaction
Match
Amount
Actions
{transactions.map(tx => {
const status = transactionStatus(tx);
const busy = actionId?.endsWith(`:${tx.id}`);
return (
{transactionDate(tx)}
{transactionTitle(tx)}
{[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'}
{tx.matched_bill_name ? (
{tx.matched_bill_name}
) : (
No bill linked
)}
{formatTransactionAmount(tx.amount, tx.currency)}
{status === 'ignored' ? (
runTransactionAction(tx, 'unignore')}>
{busy ? : }
Unignore
) : (
<>
{status === 'matched' ? (
runTransactionAction(tx, 'unmatch')}>
{busy ? : }
Unmatch
) : (
openMatchDialog(tx)}>
Match
)}
runTransactionAction(tx, 'ignore')}>
{busy ? : }
Ignore
>
)}
);
})}
)}
);
}
// ─── Section 1: Import Transaction CSV ───────────────────────────────────────
const CSV_MAPPING_FIELDS = [
'posted_date',
'amount',
'debit_amount',
'credit_amount',
'description',
'payee',
'memo',
'category',
'account',
'transaction_id',
'transaction_type',
'currency',
'transacted_at',
];
function compactMapping(mapping) {
return Object.fromEntries(
Object.entries(mapping || {}).filter(([, value]) => value),
);
}
function canCommitCsvMapping(mapping) {
return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount);
}
const CSV_IMPORT_STEPS = ['Upload', 'Preview', 'Map', 'Commit', 'Results'];
function csvImportStepIndex(preview, mapping, commitState) {
if (commitState.status === 'done') return 4;
if (commitState.status === 'loading') return 3;
if (preview.status === 'ready') return canCommitCsvMapping(mapping) ? 3 : 2;
if (preview.status === 'loading' || preview.status === 'error') return 1;
return 0;
}
function CsvImportStepper({ activeIndex }) {
return (
{CSV_IMPORT_STEPS.map((step, index) => {
const complete = index < activeIndex;
const active = index === activeIndex;
return (
{complete ? : index + 1}
{step}
);
})}
);
}
function csvFieldRequirement(field, mapping) {
if (field === 'posted_date') return 'Required';
if (['amount', 'debit_amount', 'credit_amount'].includes(field)) {
return canCommitCsvMapping({ ...mapping, posted_date: mapping?.posted_date || '__date__' })
? 'Amount source'
: 'One required';
}
return 'Optional';
}
function csvFieldSamples(preview, header) {
if (!header) return [];
const values = [];
for (const row of preview?.sampleRows || []) {
const value = String(row?.[header] || '').trim();
if (value && !values.includes(value)) values.push(value);
if (values.length >= 3) break;
}
return values;
}
function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = false }) {
const headers = preview?.headers || [];
const suggested = preview?.suggestedMapping?.[field] || '';
const current = mapping[field] || '';
const used = new Set(Object.entries(mapping)
.filter(([key, value]) => key !== field && value)
.map(([, value]) => value));
const requirement = csvFieldRequirement(field, mapping);
const missingRequired = (requirement === 'Required' || requirement === 'One required') && !current;
const samples = csvFieldSamples(preview, current);
const suggestedAvailable = suggested && suggested !== current && !used.has(suggested);
return (
onChange(field, e.target.value)}
disabled={disabled}
className={cn(
'h-9 w-full rounded-md border bg-background px-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-60',
missingRequired ? 'border-destructive/50' : 'border-input',
)}
>
Not mapped
{headers.map(header => (
{header}{used.has(header) ? ' (assigned)' : ''}
))}
{current && current === suggested && (
Suggested match
)}
{suggestedAvailable && !disabled && (
onChange(field, suggested)}
className="rounded-full border border-border/70 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Use {suggested}
)}
{missingRequired && (
Needs a column
)}
{samples.length > 0 ? (
{samples.map(value => (
{value}
))}
) : (
{current ? 'No sample values' : 'Map a column to preview values'}
)}
);
}
function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSuggested, onClearMapping, disabled = false }) {
const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]);
const mappedCount = mappingFields.filter(field => mapping[field]).length;
const hasSuggestedMapping = Object.values(preview?.suggestedMapping || {}).some(Boolean);
const missingRequired = [
!mapping.posted_date ? 'Posted date' : null,
!(mapping.amount || mapping.debit_amount || mapping.credit_amount) ? 'Amount' : null,
].filter(Boolean);
return (
Column mapping
{mappedCount} of {mappingFields.length} fields mapped
{missingRequired.length > 0 && ` · missing ${missingRequired.join(' and ')}`}
Use Suggested
Clear Mapping
Field
CSV Column
Sample Values
{mappingFields.map(field => (
))}
);
}
function CsvSampleTable({ preview }) {
const headers = preview?.headers || [];
const sampleRows = preview?.sampleRows || [];
const visibleHeaders = headers.slice(0, 8);
const hiddenCount = Math.max(0, headers.length - visibleHeaders.length);
if (sampleRows.length === 0) {
return No sample rows found.
;
}
return (
{visibleHeaders.map(header => (
{header}
))}
{hiddenCount > 0 && (
+{hiddenCount}
)}
{sampleRows.map((row, index) => (
{visibleHeaders.map(header => (
{row[header] || '—'}
))}
{hiddenCount > 0 && (
more columns
)}
))}
);
}
function formatCsvRowDetail(detail) {
if (!detail) return '';
const field = detail.field ? `${detail.field}: ` : '';
return `${field}${detail.message || detail.value || JSON.stringify(detail)}`;
}
export function ImportTransactionCsvSection({ onHistoryRefresh }) {
const fileRef = useRef(null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
const [mapping, setMapping] = useState({});
const [commitState, setCommitState] = useState({ status: 'idle', result: null, error: null });
const reset = () => {
setFile(null);
setPreview({ status: 'idle', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
if (fileRef.current) fileRef.current.value = '';
};
const handleMappingChange = (field, header) => {
if (commitState.status === 'done') return;
setMapping(prev => {
const next = { ...prev };
if (header) next[field] = header;
else delete next[field];
return next;
});
setCommitState({ status: 'idle', result: null, error: null });
};
const handlePreview = async () => {
if (!file) {
toast.error('Choose a CSV file first.');
return;
}
setPreview({ status: 'loading', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
try {
const data = await api.previewCsvTransactionImport(file);
setPreview({ status: 'ready', data, error: null });
setMapping(compactMapping(data.suggestedMapping || {}));
toast.success('CSV preview ready.');
} catch (err) {
const errorState = importErrorState(err, 'CSV preview failed.');
setPreview({ status: 'error', data: null, error: errorState });
toast.error(errorState.message || 'CSV preview failed.');
}
};
const handleCommit = async () => {
if (!preview.data?.import_session_id || !canCommitCsvMapping(mapping)) return;
setCommitState({ status: 'loading', result: null, error: null });
try {
const result = await api.commitCsvTransactionImport({
import_session_id: preview.data.import_session_id,
mapping: compactMapping(mapping),
});
setCommitState({ status: 'done', result, error: null });
toast.success(`CSV imported — ${result.imported} imported, ${result.skipped} skipped.`);
onHistoryRefresh?.();
} catch (err) {
const errorState = importErrorState(err, 'CSV import failed.');
setCommitState({ status: 'error', result: null, error: errorState });
toast.error(errorState.message || 'CSV import failed.');
}
};
const applySuggestedMapping = () => {
if (commitState.status === 'done') return;
setMapping(compactMapping(preview.data?.suggestedMapping || {}));
setCommitState({ status: 'idle', result: null, error: null });
};
const clearMapping = () => {
if (commitState.status === 'done') return;
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
};
const fields = preview.data?.fields || {};
const canCommit = preview.status === 'ready'
&& preview.data?.import_session_id
&& canCommitCsvMapping(mapping)
&& commitState.status !== 'loading'
&& commitState.status !== 'done';
const activeStep = csvImportStepIndex(preview, mapping, commitState);
const failedRows = (commitState.result?.details || []).filter(d => d.result === 'failed');
const skippedRows = (commitState.result?.details || []).filter(d => d.result === 'skipped_duplicate');
return (
Import transaction rows from CSV.
This importer creates shared transaction records only. It does not match transactions to bills yet.
{preview.status === 'error' && (
{preview.error?.message || 'CSV preview failed.'}
{preview.error?.details?.length > 0 && (
{preview.error.details.map((d, i) => (
{d.message || JSON.stringify(d)}
))}
)}
)}
{preview.status === 'ready' && preview.data && (
CSV Preview
{file?.name || 'Transaction CSV'}
{preview.data.errors?.length > 0 && (
Review mapping
{preview.data.errors.map((issue, i) => (
{issue.message || JSON.stringify(issue)}
))}
)}
{canCommitCsvMapping(mapping) ? 'Ready to commit' : 'Mapping incomplete'}
Duplicates are skipped using a CSV transaction ID when available, otherwise a stable row hash.
{commitState.status === 'done' ? (
New CSV import
) : (
{commitState.status === 'loading'
? <> Importing…>
: <> Commit Import>}
)}
)}
{commitState.status === 'done' && commitState.result && (
CSV transaction import complete
{skippedRows.length > 0 && (
Skipped duplicates ({skippedRows.length})
{skippedRows.map(row => (
Row {row.row}: {row.provider_transaction_id}
))}
)}
{failedRows.length > 0 && (
Failed rows ({failedRows.length})
)}
)}
{commitState.status === 'error' && (
{commitState.error?.message || 'CSV import failed.'}
{commitState.error?.details?.length > 0 && (
{commitState.error.details.map((d, i) => (
{d.message || JSON.stringify(d)}
))}
)}
)}
);
}
// ─── 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 [confirmOpen, setConfirmOpen] = useState(false);
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 = () => {
if (!preview.data?.import_session_id) return;
setConfirmOpen(true);
};
const handleConfirmImport = async () => {
setConfirmOpen(false);
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 === 'loading'
? <> Applying…>
: 'Apply Import'}
)}
{applyState.status === 'done' && applyState.result && (
)}
{applyState.status === 'error' && (
{applyState.error?.message || 'SQLite import apply failed.'}
)}
{/* Import confirmation dialog */}
Import SQLite data export?
Import this SQLite data export into your account? Existing records will be skipped by default.
Cancel
Confirm Import
>
);
}
// ─── 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'}`}
Refresh
{rows.length > 0 && (
{['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => (
{h}
))}
{rows.map(r => (
{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 => (
{
onDecisionChange(row.row_id, {
...decision,
action: 'match_existing_bill',
bill_id: b.bill_id,
previous_match_bill_id: b.bill_id,
bill_name: null,
});
setExpanded(false);
}}
className="text-xs px-2 py-0.5 rounded-full border border-border bg-background hover:bg-muted transition-colors">
{b.bill_name}
{b.match_confidence === 'high' && }
))}
)}
{/* Action selector */}
Action
handleAction(e.target.value || null)}
className="text-sm border border-input bg-background rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-ring"
>
— choose action —
Match existing bill
Create new bill
Update monthly record
Add monthly note
Record as payment
Skip this row
{/* Bill selector (for actions that need a bill) */}
{ACTIONS_NEEDING_BILL.has(action) && (
Bill
— select bill —
{suggestedBills.length > 0 && (
{suggestedBills.map(b => (
{b.bill_name}{b.expected_amount ? ` ($${b.expected_amount})` : ''} · {b.match_confidence}
))}
)}
{otherBills.length > 0 && (
{otherBills.map(b => (
{b.name}
))}
)}
)}
{/* Bill name input for create_new_bill */}
{action === 'create_new_bill' && (
)}
{action && action !== 'skip_row' && (
Paid
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' && (
{ handleAction('skip_row'); setExpanded(false); }}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors">
Skip this 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 (
);
}
// ─── 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 (
All bills
{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' })}
)}
)}
{isImporting && }
{remainingCount === 0 ? 'All imported' : `Import all ${remainingCount}`}
{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 && (
setSelectedBillId(bill.id)}
className="text-[10px] text-primary/70 hover:text-primary transition-colors">
+{rows.length - 3} more — view all
)}
{importResult ? (
onImportBill(g)}
disabled={!!importingBillId || remainingCount === 0} className="h-7 text-xs px-3 gap-1.5">
{remainingCount === 0 ? 'All imported' : `Import ${remainingCount}`}
) : (
onImportBill(g)}
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5">
{isImporting && }
Import {rows.length}
)}
setSelectedBillId(bill.id)}
disabled={!!importingBillId} className="h-7 text-xs px-3">
Review
);
})}
);
}
// ─────────────────────────────────────────────────────────────────────────────
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());
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 */}
{/* Options */}
{/* Preview button */}
{preview.status === 'loading'
? <> Parsing…>
: <> Preview Import>}
{(preview.status === 'ready' || preview.status === 'error' || applyState.status !== 'idle') && (
New import
)}
{/* 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 */}
setViewMode('rows')}
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'rows'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground')}>
Rows ({previewRows.length})
setViewMode('bills')}
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'bills'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground')}>
Bills
{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
)}
Apply {pendingRows.length} rows
)}
)}
{/* ── 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 }) => (
))}
New Import
)}
{/* ── 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 ─────────────────────────────────────────────────────────────────
function SeedDemoDataSection({ onSeeded }) {
const [loading, setLoading] = useState(false);
const [seeded, setSeeded] = useState(false);
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
const [clearing, setClearing] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
useEffect(() => {
api.seededStatus()
.then(data => {
setSeeded(data.seeded);
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
})
.catch(err => console.error('Failed to check seeded status:', err))
.finally(() => setStatusLoading(false));
}, []);
const handleSeed = async () => {
setLoading(true);
try {
const data = await api.seedDemoData();
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
setSeeded(true);
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
setTimeout(() => onSeeded?.(), 100);
} catch (err) {
console.error('Seed error:', err);
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
} finally {
setLoading(false);
}
};
const handleClearDemoData = async () => {
setClearing(true);
try {
const data = await api.clearDemoData();
setSeeded(false);
setCounts({ bills: 0, categories: 0 });
setShowClearConfirm(false);
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
onSeeded?.();
} catch (err) {
toast.error(err.message || 'Failed to clear demo data.');
} finally {
setClearing(false);
}
};
return (
{statusLoading ? (
Loading…
) : seeded ? (
<>
Demo data seeded
Categories
{counts.categories}
>
) : (
Create 20 realistic demo bills and 8 demo categories for testing purposes.
The data will be associated with your account.
)}
{loading ? <> Seeding…> : 'Seed Demo Data'}
{clearing ? <> Clearing…> : 'Clear Demo Data'}
Clear Demo Data
This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone.
Cancel
{clearing ? <> Clearing…> : 'Clear Data'}
);
}
export default function DataPage() {
const [history, setHistory] = useState(null);
const [historyLoading, setHistoryLoading] = useState(true);
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
const loadHistory = async () => {
setHistoryLoading(true);
try {
const { history } = await api.importHistory();
setHistory(history);
} catch {
setHistory([]);
} finally {
setHistoryLoading(false);
}
};
useEffect(() => { loadHistory(); }, []);
const handleTransactionImportComplete = () => {
loadHistory();
setTransactionRefreshKey(key => key + 1);
};
return (
Data
Import, export, and review your user-owned bill tracker records.
User data only
);
}