BillTracker/client/pages/DataPage.jsx

1416 lines
63 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 />;
}