BillTracker/client/pages/DataPage.jsx

1416 lines
63 KiB
React
Raw Permalink Normal View History

2026-05-03 19:51:57 -05:00
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 (
<span className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', MAP[source] ?? MAP.ambiguous)}>
{LABELS[source] ?? source}
</span>
);
}
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 <span className={cn('text-[10px] font-semibold uppercase', MAP[confidence] ?? '')}>{confidence}</span>;
}
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 (
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium', MAP[status] ?? MAP.ambiguous)}>
{LABELS[status] ?? status}
</span>
);
}
// ─── Shared SectionCard ───────────────────────────────────────────────────────
function SectionCard({ title, subtitle, children, className }) {
return (
<div className={cn('table-surface mb-6', className)}>
<div className="px-6 py-4 border-b border-border/50">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>}
</div>
<div className="divide-y divide-border/50">{children}</div>
</div>
);
}
// ─── 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 (
<div className="px-6 py-5 flex items-start justify-between gap-6">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="mt-0.5 h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<p className="text-sm font-medium">{title}</p>
{!USER_EXPORTS_AVAILABLE && (
<span className="inline-flex items-center rounded-full bg-muted border border-border px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Coming soon
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="shrink-0 pt-0.5">
<Button size="sm" variant="outline" disabled={disabled}
onClick={USER_EXPORTS_AVAILABLE ? handleDownload : undefined}>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Downloading</>
: <><Download className="h-3.5 w-3.5 mr-1.5" />{USER_EXPORTS_AVAILABLE ? 'Download' : 'Not Available Yet'}</>}
</Button>
</div>
</div>
);
}
export function DownloadMyDataSection() {
return (
<SectionCard
title="Download My Data"
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
>
<ExportCard icon={Database} title="SQLite Data Export"
description="Download a portable SQLite database containing your bill tracker data."
filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" />
<ExportCard icon={FileSpreadsheet} title="Excel Databook"
description="Download an Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" />
<div className="px-6 py-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's included</p>
<ul className="space-y-1.5">
{['Bills','Payments','Categories','Monthly bill state','Notes','Export metadata'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-foreground/80">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />{i}
</li>
))}
</ul>
</div>
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's not included</p>
<ul className="space-y-1.5">
{['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<XCircle className="h-3.5 w-3.5 text-muted-foreground/40 shrink-0" />{i}
</li>
))}
</ul>
</div>
</div>
</SectionCard>
);
}
function CountPill({ label, value }) {
return (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{value ?? 0}</p>
</div>
);
}
// ─── 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 (
<SectionCard title="Import My Data Export"
subtitle="Restore data from a SQLite export created by this app for your account.">
<div className="px-6 py-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<div className="flex items-start gap-3">
<Database className="mt-0.5 h-5 w-5 text-primary shrink-0" />
<div>
<p className="text-sm font-medium">Import a SQLite data export created by this app.</p>
<p className="mt-1 text-xs text-muted-foreground">
This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported.
</p>
</div>
</div>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex-1 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">SQLite export file</span>
<Input
ref={fileRef}
type="file"
accept=".sqlite,.db,application/octet-stream,application/x-sqlite3,application/vnd.sqlite3"
onChange={e => {
setFile(e.target.files?.[0] || null);
setPreview({ status: 'idle', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null });
}}
/>
</label>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={reset}>
Clear
</Button>
<Button size="sm" type="button" disabled={!file || preview.status === 'loading'} onClick={handlePreview}>
{preview.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Previewing</>
: <><Upload className="h-3.5 w-3.5 mr-1.5" />Preview</>}
</Button>
</div>
</div>
{preview.status === 'error' && (
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />
{preview.error?.message || 'SQLite import preview failed.'}
{preview.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => (
<li key={i}>{d.message || d.table || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
{preview.status === 'ready' && preview.data && (
<div className="mt-4 space-y-4">
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">Preview ready</p>
<p className="mt-1 text-xs text-muted-foreground">
Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'}
</p>
</div>
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
User data only
</span>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<CountPill label="Bills" value={counts.bills} />
<CountPill label="Categories" value={counts.categories} />
<CountPill label="Payments" value={counts.payments} />
<CountPill label="Monthly" value={counts.monthly_bill_state} />
<CountPill label="Notes" value={counts.notes} />
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => (
<div key={key} className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs">
<p className="font-semibold capitalize">{key.replace(/_/g, ' ')}</p>
<p className="mt-1 text-muted-foreground">
create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0}
</p>
</div>
))}
</div>
{preview.data.warnings?.length > 0 && (
<div className="mt-4 space-y-1">
{preview.data.warnings.map((warning, i) => (
<p key={i} className="text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" />{warning}
</p>
))}
</div>
)}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Review the preview before applying. Nothing is imported until you confirm.</p>
<Button size="sm" type="button" disabled={applyState.status === 'loading'} onClick={handleApply}>
{applyState.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Applying</>
: 'Apply Import'}
</Button>
</div>
</div>
)}
{applyState.status === 'done' && applyState.result && (
<div className="mt-4 rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
<p className="text-sm font-medium text-emerald-600">SQLite import applied</p>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<CountPill label="Created" value={applyState.result.rows_created} />
<CountPill label="Skipped" value={applyState.result.rows_skipped} />
<CountPill label="Conflicts" value={applyState.result.rows_conflicted} />
<CountPill label="Errors" value={applyState.result.rows_errored} />
</div>
</div>
)}
{applyState.status === 'error' && (
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{applyState.error?.message || 'SQLite import apply failed.'}
</div>
)}
</div>
</SectionCard>
);
}
// ─── Section 4: Import History ────────────────────────────────────────────────
export function ImportHistorySection({ history, loading, onRefresh }) {
if (loading) {
return (
<SectionCard title="Import History">
<div className="px-6 py-6 text-sm text-muted-foreground">Loading</div>
</SectionCard>
);
}
const rows = history ?? [];
return (
<SectionCard title="Import History">
<div className="px-6 py-4 flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`}
</p>
<Button size="sm" variant="ghost" onClick={onRefresh} className="h-7 text-xs gap-1.5">
<RefreshCw className="h-3 w-3" />Refresh
</Button>
</div>
{rows.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 text-muted-foreground">
{['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => (
<th key={h} className="px-4 py-2 text-left font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{rows.map(r => (
<tr key={r.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" />{fmt(r.imported_at)}</span>
</td>
<td className="px-4 py-2 text-muted-foreground">{r.source_filename || '—'}</td>
<td className="px-4 py-2 text-muted-foreground">{r.sheet_name || '—'}</td>
<td className="px-4 py-2 tabular-nums">{r.rows_parsed}</td>
<td className="px-4 py-2 tabular-nums text-emerald-600">{r.rows_created}</td>
<td className="px-4 py-2 tabular-nums text-blue-600">{r.rows_updated}</td>
<td className="px-4 py-2 tabular-nums text-muted-foreground">{r.rows_skipped}</td>
<td className="px-4 py-2 tabular-nums text-red-500">{r.rows_errored}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SectionCard>
);
}
// ─── XLSX Import: Workbook Summary ────────────────────────────────────────────
function WorkbookSummaryCard({ workbook }) {
const isMulti = workbook.parse_mode === 'all_sheets';
return (
<div className="rounded-lg border border-border bg-muted/30 p-4 mb-4 space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Workbook Summary</p>
<span className="text-xs text-muted-foreground">
{isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names.length} tabs` : `${workbook.row_count} rows · ${workbook.selected_sheet}`}
</span>
</div>
{isMulti && workbook.sheets?.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{workbook.sheets.map(s => (
<div key={s.name} className={cn(
'flex items-center justify-between rounded px-3 py-1.5 text-xs',
s.is_non_month_sheet || s.status === 'skipped' ? 'bg-muted/50 text-muted-foreground/60' : 'bg-background border border-border/60'
)}>
<span className="font-medium truncate mr-2">{s.name}</span>
<div className="flex items-center gap-2 shrink-0">
{s.detected_year && s.detected_month && (
<span className="tabular-nums text-muted-foreground">
{String(s.detected_month).padStart(2,'0')}/{s.detected_year}
</span>
)}
<SheetStatusBadge status={s.status} />
{s.status !== 'skipped' && <span className="text-muted-foreground">{s.row_count} rows</span>}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ─── 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 (
<div className={cn(
'border-b border-border/40 last:border-0',
isSkip && 'opacity-50',
!complete && action !== null && 'bg-amber-500/5',
)}>
{/* Main row */}
<div
className="flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-muted/20 transition-colors"
onClick={() => setExpanded(e => !e)}
>
{/* Selection */}
<div className="mt-0.5 shrink-0" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selected}
onChange={e => 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"
/>
</div>
{/* Status icon */}
<div className="mt-0.5 shrink-0">
{hasError ? <XCircle className="h-4 w-4 text-muted-foreground/40" /> :
isSkip ? <SkipForward className="h-4 w-4 text-muted-foreground/40" /> :
complete ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> :
action !== null ? <AlertTriangle className="h-4 w-4 text-amber-500" /> :
<AlertTriangle className="h-4 w-4 text-orange-500" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground tabular-nums">#{row.source_row_number}</span>
{row.sheet_name && <span className="text-xs text-muted-foreground">{row.sheet_name}</span>}
{row.detected_year && row.detected_month && (
<span className="text-xs text-muted-foreground tabular-nums">
{String(row.detected_month).padStart(2,'0')}/{row.detected_year}
</span>
)}
{row.year_month_source && <SourceBadge source={row.year_month_source} />}
</div>
<div className="flex items-baseline gap-3 mt-0.5 flex-wrap">
<span className={cn('text-sm font-medium', !row.detected_bill_name && 'italic text-muted-foreground/60')}>
{row.detected_bill_name || '(no bill name)'}
</span>
{row.detected_amount != null && (
<span className="text-xs text-muted-foreground tabular-nums">
${row.detected_amount.toFixed(2)}
</span>
)}
{row.detected_paid_date && (
<span className="text-xs text-emerald-500 tabular-nums">
paid {row.detected_paid_date}
</span>
)}
{row.detected_labels?.length > 0 && (
<span className="text-xs text-muted-foreground/70">{row.detected_labels.join(', ')}</span>
)}
{row.detected_notes && (
<span className="text-xs text-muted-foreground/70 italic truncate max-w-[200px]">{row.detected_notes}</span>
)}
</div>
</div>
{/* Right: action status + expand */}
<div className="flex items-center gap-2 shrink-0">
{action === null ? (
<span className="text-xs font-medium text-orange-500">Needs decision</span>
) : isSkip ? (
<span className="text-xs text-muted-foreground">Skipped</span>
) : (
<span className="text-xs text-muted-foreground capitalize">{action.replace(/_/g,' ')}</span>
)}
{action !== 'skip_row' && (
expanded ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
</div>
{/* Expanded decision controls */}
{expanded && !hasError && (
<div className="px-11 pb-4 space-y-3">
{/* Recommendation */}
{rec.action && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">Recommended: {actionLabel(rec.action)}</span>
{rec.bill_name && rec.action === 'match_existing_bill' && (
<span className="text-xs text-muted-foreground"> {rec.bill_name}</span>
)}
{rec.category_name && (
<span className="text-xs text-muted-foreground">Category: {rec.category_name}</span>
)}
{rec.due_day && <span className="text-xs text-muted-foreground">Due day: {rec.due_day}</span>}
{rec.actual_amount != null && <span className="text-xs text-muted-foreground">${Number(rec.actual_amount).toFixed(2)}</span>}
<ConfidenceBadge confidence={rec.confidence} />
</div>
{rec.reason && <p className="text-xs text-muted-foreground">Reason: {rec.reason}</p>}
</div>
)}
{/* Warnings */}
{(rec.warnings?.length > 0 || row.warnings?.length > 0) && (
<div className="space-y-1">
{Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => (
<p key={i} className="text-xs text-amber-600 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />{w}
</p>
))}
</div>
)}
{/* Possible matches hint */}
{suggestedBills.length > 0 && (
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-muted-foreground self-center">Suggested:</span>
{suggestedBills.slice(0, 3).map(b => (
<button key={b.bill_id} type="button"
onClick={() => {
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' && <CheckCircle2 className="inline h-2.5 w-2.5 ml-1 text-emerald-500" />}
</button>
))}
</div>
)}
{/* Action selector */}
<div className="flex items-center gap-3 flex-wrap">
<label className="text-xs text-muted-foreground w-14 shrink-0">Action</label>
<select
value={action ?? ''}
onChange={e => 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"
>
<option value=""> choose action </option>
<option value="match_existing_bill">Match existing bill</option>
<option value="create_new_bill">Create new bill</option>
<option value="update_monthly_state">Update monthly record</option>
<option value="add_monthly_note">Add monthly note</option>
<option value="create_payment">Record as payment</option>
<option value="skip_row">Skip this row</option>
</select>
</div>
{/* Bill selector (for actions that need a bill) */}
{ACTIONS_NEEDING_BILL.has(action) && (
<div className="flex items-center gap-3 flex-wrap">
<label className="text-xs text-muted-foreground w-14 shrink-0">Bill</label>
<select
value={decision?.bill_id ?? ''}
onChange={handleBill}
className="text-sm border border-input bg-background rounded-md px-2 py-1 flex-1 min-w-[200px] focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value=""> select bill </option>
{suggestedBills.length > 0 && (
<optgroup label="Suggested matches">
{suggestedBills.map(b => (
<option key={b.bill_id} value={b.bill_id}>
{b.bill_name}{b.expected_amount ? ` ($${b.expected_amount})` : ''} · {b.match_confidence}
</option>
))}
</optgroup>
)}
{otherBills.length > 0 && (
<optgroup label={suggestedBills.length ? 'All other bills' : 'All bills'}>
{otherBills.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</optgroup>
)}
</select>
</div>
)}
{/* Bill name input for create_new_bill */}
{action === 'create_new_bill' && (
<div className="space-y-2">
<div className="flex items-center gap-3">
<label className="text-xs text-muted-foreground w-14 shrink-0">Name</label>
<Input
value={decision?.bill_name ?? ''}
onChange={handleBillName}
placeholder="Bill name"
className="h-8 text-sm flex-1 max-w-sm"
/>
</div>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-xs text-muted-foreground w-14 shrink-0">Category</label>
<select
value={decision?.category_id ?? ''}
onChange={e => handleDecisionField('category_id', parseInt(e.target.value, 10) || null)}
className="text-sm border border-input bg-background rounded-md px-2 py-1 min-w-[180px] focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">No category</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
{rec.category_name && <span className="text-xs text-muted-foreground">Suggested: {rec.category_name}</span>}
</div>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-xs text-muted-foreground w-14 shrink-0">Details</label>
<Input
type="number"
min="1"
max="31"
value={decision?.due_day ?? ''}
onChange={e => handleDecisionField('due_day', e.target.value ? parseInt(e.target.value, 10) : null)}
placeholder="Due day"
className="h-8 text-sm w-24"
/>
<Input
type="number"
min="0"
step="0.01"
value={decision?.expected_amount ?? ''}
onChange={e => handleDecisionField('expected_amount', e.target.value === '' ? null : parseFloat(e.target.value))}
placeholder="Expected amount"
className="h-8 text-sm w-40"
/>
</div>
</div>
)}
{action && action !== 'skip_row' && (
<div className="flex items-center gap-3 flex-wrap">
<label className="text-xs text-muted-foreground w-14 shrink-0">Paid</label>
<Input
type="date"
value={decision?.payment_date ?? ''}
onChange={e => handleDecisionField('payment_date', e.target.value || null)}
className="h-8 text-sm w-40"
/>
<Input
type="number"
min="0"
step="0.01"
value={decision?.payment_amount ?? ''}
onChange={e => handleDecisionField('payment_amount', e.target.value === '' ? null : parseFloat(e.target.value))}
placeholder="Paid amount"
className="h-8 text-sm w-36"
/>
</div>
)}
{/* Quick skip */}
{action !== 'skip_row' && (
<button type="button"
onClick={() => { handleAction('skip_row'); setExpanded(false); }}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors">
<SkipForward className="h-3 w-3" />Skip this row
</button>
)}
</div>
)}
</div>
);
}
// ─── XLSX Import: Preview Table ───────────────────────────────────────────────
function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) {
const groups = groupRowsBySheet(rows);
const multiTab = groups.length > 1;
return (
<div>
{groups.map(({ name, rows: groupRows }) => (
<div key={name}>
{multiTab && (
<div className="px-4 py-2 bg-muted/40 border-b border-border/60 flex items-center gap-2">
<FileSpreadsheet className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold text-muted-foreground">{name}</span>
<span className="text-xs text-muted-foreground/60">· {groupRows.length} rows</span>
</div>
)}
{groupRows.map(row => (
<RowDecisionRow
key={row.row_id}
row={row}
decision={decisions[row.row_id]}
onDecisionChange={onDecisionChange}
allBills={allBills}
categories={categories}
selected={selectedRows.has(row.row_id)}
onSelectedChange={onSelectedChange}
/>
))}
</div>
))}
</div>
);
}
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 (
<div className="border-b border-border bg-muted/25 px-4 py-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={allSelected}
onChange={e => e.target.checked ? onSelectAll() : onClearSelection()}
className="h-4 w-4 rounded border-border accent-primary"
/>
Select all visible
</label>
<div className="flex items-center gap-2 flex-wrap">
{selectedCount > 0 && (
<span className="text-xs font-medium text-foreground">{selectedCount} row{selectedCount === 1 ? '' : 's'} selected</span>
)}
{selectedCount > 0 && (
<>
<Button size="sm" variant="outline" onClick={onBulkSkip} className="h-8 text-xs">
Skip selected
</Button>
<Button size="sm" variant="outline" onClick={onBulkCreateNew} className="h-8 text-xs">
Create new bills
</Button>
<Button size="sm" variant="ghost" onClick={onBulkReset} className="h-8 text-xs">
Reset to recommendation
</Button>
<Button size="sm" variant="ghost" onClick={onClearSelection} className="h-8 text-xs text-muted-foreground">
Clear selection
</Button>
</>
)}
</div>
</div>
</div>
);
}
// ─── 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 (
<SectionCard
title="Import Spreadsheet History"
subtitle='Export your Google Sheet as .xlsx, upload it here, review the matches, then apply only the rows you approve.'
>
{/* ── Upload panel ──────────────────────────────────────────────────────── */}
<div className="px-6 py-5 space-y-5">
{/* File picker */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">XLSX File</label>
<div className="flex items-center gap-3">
<input ref={fileRef} type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden" id="xlsx-upload"
onChange={e => setFile(e.target.files?.[0] ?? null)} />
<label htmlFor="xlsx-upload">
<Button size="sm" variant="outline" asChild>
<span className="cursor-pointer gap-2">
<Upload className="h-3.5 w-3.5" />
{file ? 'Change file' : 'Choose .xlsx file'}
</span>
</Button>
</label>
{file && (
<span className="text-sm text-muted-foreground truncate max-w-[260px]">
<FileSpreadsheet className="inline h-3.5 w-3.5 mr-1" />{file.name}
</span>
)}
</div>
</div>
{/* Options */}
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
<div className="flex items-center gap-2.5">
<Switch checked={options.parseAllSheets} onCheckedChange={v => opt('parseAllSheets', v)}
id="parse-all" />
<label htmlFor="parse-all" className="text-sm cursor-pointer">Parse all sheet tabs</label>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Default year</label>
<Input type="number" min="2000" max="2100" placeholder="2026"
value={options.defaultYear}
onChange={e => opt('defaultYear', e.target.value)}
className="w-24 h-8 text-sm" />
</div>
{!options.parseAllSheets && (
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Default month</label>
<Input type="number" min="1" max="12" placeholder="112"
value={options.defaultMonth}
onChange={e => opt('defaultMonth', e.target.value)}
className="w-20 h-8 text-sm" />
</div>
)}
</div>
{/* Preview button */}
<div className="flex items-center gap-3">
<Button size="sm" onClick={handlePreview}
disabled={!file || preview.status === 'loading'}
className="gap-2">
{preview.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Parsing</>
: <><FileSpreadsheet className="h-3.5 w-3.5" />Preview Import</>}
</Button>
{(preview.status === 'ready' || preview.status === 'error' || applyState.status !== 'idle') && (
<Button size="sm" variant="ghost" onClick={handleReset} className="text-muted-foreground">
New import
</Button>
)}
</div>
{/* Error from preview */}
{preview.status === 'error' && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{preview.error?.message || preview.error || 'Preview failed.'}
{preview.error?.details?.length > 0 && (
<ul className="mt-2 space-y-1 text-xs">
{preview.error.details.map((d, i) => (
<li key={i}>{d.row_id ? `${d.row_id}: ` : ''}{d.message}</li>
))}
</ul>
)}
</div>
)}
</div>
{/* ── Preview results ────────────────────────────────────────────────────── */}
{preview.status === 'ready' && preview.data && !['loading', 'done'].includes(applyState.status) && (
<div className="px-6 pb-5 space-y-4">
{/* Workbook summary */}
<WorkbookSummaryCard workbook={preview.data.workbook} />
{/* Row decision table */}
{previewRows.length > 0 ? (
<div className="rounded-lg border border-border overflow-hidden bg-background">
<div className="px-4 py-3 border-b border-border bg-muted/40 flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">XLSX Review Table</p>
<p className="text-xs text-muted-foreground mt-0.5">Select preview rows, then apply bulk review decisions before importing.</p>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{previewRows.length} preview row{previewRows.length === 1 ? '' : 's'}</span>
</div>
<BulkActionBar
rows={previewRows}
selectedRows={selectedRows}
onSelectAll={selectAllVisibleRows}
onClearSelection={clearSelection}
onBulkSkip={handleBulkSkip}
onBulkCreateNew={handleBulkCreateNew}
onBulkReset={handleBulkReset}
/>
<PreviewTable
rows={previewRows}
decisions={decisions}
onDecisionChange={handleDecisionChange}
allBills={allBills}
categories={categories}
selectedRows={selectedRows}
onSelectedChange={handleSelectedChange}
/>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No data rows found in this file.</p>
)}
{/* Apply bar */}
{previewRows.length > 0 && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
<span className="text-foreground font-medium">{previewRows.length} rows reviewed</span>
<span className="text-emerald-600">{pendingRows.length} to apply</span>
<span>{skipRows.length} skipped</span>
{unresolvedRows.length > 0 && (
<span className="text-orange-500 font-medium">{unresolvedRows.length} need a decision</span>
)}
</div>
<Button size="sm" onClick={handleApply} disabled={!canApply} className="gap-2">
<CheckCheck className="h-3.5 w-3.5" />
Apply {pendingRows.length} rows
</Button>
</div>
)}
</div>
)}
{/* ── Applying ──────────────────────────────────────────────────────────── */}
{applyState.status === 'loading' && (
<div className="px-6 py-8 flex items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm">Applying import</span>
</div>
)}
{/* ── Apply result ──────────────────────────────────────────────────────── */}
{applyState.status === 'done' && applyState.result && (
<div className="px-6 pb-6 space-y-4">
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2 mb-3">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
<p className="text-sm font-medium text-emerald-600">Import applied successfully</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ 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 }) => (
<div key={label} className="text-center">
<p className={cn('text-xl font-bold tabular-nums', color)}>{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
))}
</div>
</div>
<Button size="sm" variant="outline" onClick={handleReset} className="gap-2">
<Plus className="h-3.5 w-3.5" />New Import
</Button>
</div>
)}
{/* ── Apply error ───────────────────────────────────────────────────────── */}
{applyState.status === 'error' && (
<div className="px-6 pb-5">
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{applyState.error?.message || applyState.error || 'Apply failed.'}
{applyState.error?.details?.length > 0 && (
<ul className="mt-2 space-y-1 text-xs">
{applyState.error.details.map((d, i) => (
<li key={i}>
{d.row_id ? `${d.row_id}: ` : ''}
{d.field ? `${d.field} - ` : ''}
{d.message}
</li>
))}
</ul>
)}
{applyState.error?.error_id && (
<p className="mt-2 text-xs opacity-80">Error ID: {applyState.error.error_id}</p>
)}
</div>
</div>
)}
</SectionCard>
);
}
// ─── DataPage ─────────────────────────────────────────────────────────────────
export default function DataPage() {
return <Navigate to="/profile" replace />;
}