diff --git a/.gitignore b/.gitignore
index c54ee95..46fbcf4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md
# MkDocs docs site (auto-generated, not part of app source)
mkdocs/
+
+# Root bill tracker DB (empty artifact, never commit)
+/bills.db
diff --git a/bills.db b/bills.db
deleted file mode 100644
index e69de29..0000000
diff --git a/client/pages/DataPage.jsx.backup b/client/pages/DataPage.jsx.backup
deleted file mode 100644
index 49e996f..0000000
--- a/client/pages/DataPage.jsx.backup
+++ /dev/null
@@ -1,1548 +0,0 @@
-import { useState, useEffect, useRef } from 'react';
-import { toast } from 'sonner';
-import {
- Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
- AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
- ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
-} from 'lucide-react';
-import { api } from '@/api';
-import { cn } from '@/lib/utils';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Switch } from '@/components/ui/switch';
-
-// ─── User export availability flag ───────────────────────────────────────────
-// Flip to true when GET /api/export/user-db and GET /api/export/user-excel exist.
-const USER_EXPORTS_AVAILABLE = true;
-
-// ─── Utilities ────────────────────────────────────────────────────────────────
-
-function fmt(isoStr) {
- if (!isoStr) return '—';
- const d = new Date(isoStr);
- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
- + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-}
-
-function groupRowsBySheet(rows) {
- const map = new Map();
- for (const row of rows) {
- const key = row.sheet_name || '(unknown sheet)';
- if (!map.has(key)) map.set(key, []);
- map.get(key).push(row);
- }
- return Array.from(map.entries()).map(([name, rows]) => ({ name, rows }));
-}
-
-function initialDecisionFromRecommendation(row) {
- const rec = row.recommendation || {};
- const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null);
-
- if (!action || row.requires_user_decision) return { action: null };
- if (action === 'skip_row') return { action: 'skip_row' };
- if (action === 'match_existing_bill') {
- return {
- action,
- bill_id: rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null,
- bill_name: null,
- due_day: rec.due_day ?? null,
- actual_amount: rec.actual_amount ?? row.detected_amount ?? null,
- payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null,
- payment_date: rec.payment_date ?? row.detected_paid_date ?? null,
- notes: row.detected_notes ?? null,
- };
- }
- if (action === 'create_new_bill') {
- return {
- action,
- bill_id: null,
- bill_name: rec.bill_name || row.detected_bill_name || '',
- category_id: rec.category_id ?? null,
- due_day: rec.due_day ?? null,
- expected_amount: rec.expected_amount ?? row.detected_amount ?? null,
- actual_amount: rec.actual_amount ?? row.detected_amount ?? null,
- payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null,
- payment_date: rec.payment_date ?? row.detected_paid_date ?? null,
- notes: row.detected_notes ?? null,
- };
- }
- return { action };
-}
-
-function safeRawBillName(row) {
- const raw = row.raw_values?.find((v) => {
- const text = String(v || '').trim();
- if (!text || text.length > 80) return false;
- if (/^(?:total|subtotal|sum|grand\s*total)$/i.test(text)) return false;
- if (/^\$?\(?\d[\d,]*(?:\.\d{1,2})?\)?$/.test(text)) return false;
- if (/^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(text)) return false;
- if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return false;
- return true;
- });
- return raw ? String(raw).trim() : '';
-}
-
-function buildCreateNewDecision(row, currentDecision = {}) {
- const rec = row.recommendation || {};
- const billName = currentDecision.bill_name
- || row.detected_bill_name
- || rec.bill_name
- || safeRawBillName(row);
-
- return {
- ...currentDecision,
- action: 'create_new_bill',
- previous_match_bill_id: currentDecision.bill_id ?? currentDecision.previous_match_bill_id ?? rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null,
- bill_id: null,
- bill_name: billName,
- category_id: currentDecision.category_id ?? rec.category_id ?? null,
- due_day: currentDecision.due_day ?? rec.due_day ?? null,
- expected_amount: currentDecision.expected_amount ?? rec.expected_amount ?? row.detected_amount ?? null,
- actual_amount: currentDecision.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null,
- payment_amount: currentDecision.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null,
- payment_date: currentDecision.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null,
- notes: currentDecision.notes ?? row.detected_notes ?? null,
- };
-}
-
-function buildInitialDecisions(rows) {
- const d = {};
- for (const row of rows) {
- const hasError = row.errors?.length > 0;
- if (hasError || row.proposed_action === 'skip_row') {
- d[row.row_id] = { action: 'skip_row' };
- } else {
- d[row.row_id] = initialDecisionFromRecommendation(row);
- }
- }
- return d;
-}
-
-function isDecisionComplete(action, decision) {
- if (!action) return false;
- if (action === 'skip_row') return true;
- if (action === 'create_new_bill') return !!(decision?.bill_name?.trim());
- if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note', 'create_payment'].includes(action)) {
- return !!decision?.bill_id;
- }
- return true;
-}
-
-// ─── Badges ───────────────────────────────────────────────────────────────────
-
-function SourceBadge({ source }) {
- const MAP = {
- row_date: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
- sheet_name: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
- default: 'bg-amber-500/15 text-amber-600 dark:text-amber-500',
- ambiguous: 'bg-red-500/15 text-red-600 dark:text-red-400',
- };
- const LABELS = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' };
- return (
-
- {LABELS[source] ?? source}
-
- );
-}
-
-function ConfidenceBadge({ confidence }) {
- const MAP = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' };
- return {confidence};
-}
-
-function actionLabel(action) {
- const MAP = {
- match_existing_bill: 'Match existing bill',
- create_new_bill: 'Create new bill',
- skip_row: 'Skip row',
- ambiguous: 'Needs decision',
- update_monthly_state: 'Update monthly record',
- add_monthly_note: 'Add monthly note',
- create_payment: 'Record as payment',
- };
- return MAP[action] || (action ? action.replace(/_/g, ' ') : 'Needs decision');
-}
-
-function importErrorState(err, fallback) {
- const data = err?.data || {};
- return {
- message: err?.message || data.message || data.error || fallback,
- error: data.error || fallback,
- code: data.code || err?.code || null,
- details: Array.isArray(data.details) ? data.details : (Array.isArray(err?.details) ? err.details : []),
- error_id: data.error_id || null,
- };
-}
-
-function SheetStatusBadge({ status }) {
- const MAP = {
- parsed: 'bg-emerald-500/15 text-emerald-600',
- parsed_month_only: 'bg-amber-500/15 text-amber-600',
- ambiguous: 'bg-orange-500/15 text-orange-600',
- skipped: 'bg-muted text-muted-foreground',
- };
- const LABELS = {
- parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped',
- };
- return (
-
- {LABELS[status] ?? status}
-
- );
-}
-
-// ─── Shared SectionCard ───────────────────────────────────────────────────────
-
-function SectionCard({ title, subtitle, children, className }) {
- return (
-
-
-
{title}
- {subtitle &&
{subtitle}
}
-
-
{children}
-
- );
-}
-
-// ─── Section 2: Download My Data ─────────────────────────────────────────────
-
-function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
- const [loading, setLoading] = useState(false);
-
- const handleDownload = async () => {
- setLoading(true);
- try {
- const res = await fetch(endpoint, { credentials: 'include' });
- if (!res.ok) {
- let data = {};
- try { data = await res.json(); } catch {}
- throw new Error(data.error || `HTTP ${res.status}`);
- }
- const disposition = res.headers.get('Content-Disposition');
- const match = disposition?.match(/filename="?([^"]+)"?/i);
- const name = match ? match[1] : filename;
- const blob = await res.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url; a.download = name; a.click();
- URL.revokeObjectURL(url);
- toast.success(`${title} downloaded.`);
- } catch (err) {
- toast.error(err.message || 'Download failed.');
- } finally {
- setLoading(false);
- }
- };
-
- const disabled = !USER_EXPORTS_AVAILABLE || loading;
- return (
-
-
-
-
-
-
-
-
{title}
- {!USER_EXPORTS_AVAILABLE && (
-
- Coming soon
-
- )}
-
-
{description}
-
-
-
-
-
-
- );
-}
-
-export function DownloadMyDataSection() {
- return (
-
-
-
-
-
-
What's included
-
- {['Bills','Payments','Categories','Monthly bill state','Notes','Export metadata'].map(i => (
- -
- {i}
-
- ))}
-
-
-
-
What's not included
-
- {['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
- -
- {i}
-
- ))}
-
-
-
-
- );
-}
-
-function CountPill({ label, value }) {
- return (
-
-
{label}
-
{value ?? 0}
-
- );
-}
-
-// ─── Section 3: Import My Data Export ────────────────────────────────────────
-
-export function ImportMyDataSection({ onHistoryRefresh }) {
- const fileRef = useRef(null);
- const [file, setFile] = useState(null);
- const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
- const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
-
- const reset = () => {
- setFile(null);
- setPreview({ status: 'idle', data: null, error: null });
- setApplyState({ status: 'idle', result: null, error: null });
- if (fileRef.current) fileRef.current.value = '';
- };
-
- const handlePreview = async () => {
- if (!file) {
- toast.error('Choose a SQLite data export first.');
- return;
- }
- setPreview({ status: 'loading', data: null, error: null });
- setApplyState({ status: 'idle', result: null, error: null });
- try {
- const data = await api.previewUserDbImport(file);
- setPreview({ status: 'ready', data, error: null });
- toast.success('SQLite export preview ready.');
- } catch (err) {
- setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') });
- toast.error(err.message || 'SQLite import preview failed.');
- }
- };
-
- const handleApply = async () => {
- if (!preview.data?.import_session_id) return;
- const ok = window.confirm('Import this SQLite data export into your account? Existing records will be skipped by default.');
- if (!ok) return;
- setApplyState({ status: 'loading', result: null, error: null });
- try {
- const result = await api.applyUserDbImport({
- import_session_id: preview.data.import_session_id,
- options: { overwrite: false },
- });
- setApplyState({ status: 'done', result, error: null });
- toast.success('SQLite data import applied.');
- onHistoryRefresh?.();
- } catch (err) {
- setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') });
- toast.error(err.message || 'SQLite import apply failed.');
- }
- };
-
- const counts = preview.data?.counts || {};
- const summary = preview.data?.summary || {};
-
- return (
-
-
-
-
-
-
-
Import a SQLite data export created by this app.
-
- This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported.
-
-
-
-
-
-
-
-
-
-
-
-
-
- {preview.status === 'error' && (
-
-
- {preview.error?.message || 'SQLite import preview failed.'}
- {preview.error?.details?.length > 0 && (
-
- {preview.error.details.map((d, i) => (
- - {d.message || d.table || JSON.stringify(d)}
- ))}
-
- )}
-
- )}
-
- {preview.status === 'ready' && preview.data && (
-
-
-
-
-
Preview ready
-
- Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'}
-
-
-
- User data only
-
-
-
-
-
-
-
-
-
-
- {Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => (
-
-
{key.replace(/_/g, ' ')}
-
- create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0}
-
-
- ))}
-
- {preview.data.warnings?.length > 0 && (
-
- {preview.data.warnings.map((warning, i) => (
-
- {warning}
-
- ))}
-
- )}
-
-
-
-
Review the preview before applying. Nothing is imported until you confirm.
-
-
-
- )}
-
- {applyState.status === 'done' && applyState.result && (
-
-
SQLite import applied
-
-
-
-
-
-
-
- )}
-
- {applyState.status === 'error' && (
-
-
{applyState.error?.message || 'SQLite import apply failed.'}
-
- )}
-
-
- );
-}
-
-// ─── Section 4: Import History ────────────────────────────────────────────────
-
-export function ImportHistorySection({ history, loading, onRefresh }) {
- if (loading) {
- return (
-
- Loading…
-
- );
- }
-
- const rows = history ?? [];
-
- return (
-
-
-
- {rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`}
-
-
-
- {rows.length > 0 && (
-
-
-
-
- {['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => (
- | {h} |
- ))}
-
-
-
- {rows.map(r => (
-
- |
- {fmt(r.imported_at)}
- |
- {r.source_filename || '—'} |
- {r.sheet_name || '—'} |
- {r.rows_parsed} |
- {r.rows_created} |
- {r.rows_updated} |
- {r.rows_skipped} |
- {r.rows_errored} |
-
- ))}
-
-
-
- )}
-
- );
-}
-
-// ─── XLSX Import: Workbook Summary ────────────────────────────────────────────
-
-function WorkbookSummaryCard({ workbook }) {
- const isMulti = workbook.parse_mode === 'all_sheets';
-
- return (
-
-
-
Workbook Summary
-
- {isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names.length} tabs` : `${workbook.row_count} rows · ${workbook.selected_sheet}`}
-
-
- {isMulti && workbook.sheets?.length > 0 && (
-
- {workbook.sheets.map(s => (
-
-
{s.name}
-
- {s.detected_year && s.detected_month && (
-
- {String(s.detected_month).padStart(2,'0')}/{s.detected_year}
-
- )}
-
- {s.status !== 'skipped' && {s.row_count} rows}
-
-
- ))}
-
- )}
-
- );
-}
-
-// ─── XLSX Import: Row Decision Controls ──────────────────────────────────────
-
-const ACTIONS_NEEDING_BILL = new Set(['match_existing_bill','update_monthly_state','add_monthly_note','create_payment']);
-
-function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, selected, onSelectedChange }) {
- const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action);
-
- const action = decision?.action ?? null;
- const isSkip = action === 'skip_row';
- const hasError = row.errors?.length > 0;
- const complete = isDecisionComplete(action, decision);
- const rec = row.recommendation || {};
-
- const suggestedBills = row.possible_bill_matches ?? [];
- const suggestedIds = new Set(suggestedBills.map(b => b.bill_id));
- const otherBills = allBills.filter(b => !suggestedIds.has(b.id));
-
- const handleAction = (val) => {
- const next = { ...decision, action: val };
- if (val === 'create_new_bill') {
- Object.assign(next, buildCreateNewDecision(row, decision));
- } else if (ACTIONS_NEEDING_BILL.has(val)) {
- next.bill_name = null;
- next.bill_id = decision?.bill_id ?? decision?.previous_match_bill_id ?? rec.bill_id ?? suggestedBills[0]?.bill_id ?? null;
- next.actual_amount = decision?.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null;
- next.payment_amount = decision?.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null;
- next.payment_date = decision?.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null;
- } else {
- next.bill_id = null;
- next.bill_name = null;
- }
- onDecisionChange(row.row_id, next);
- if (val === 'skip_row') setExpanded(false);
- };
-
- const handleBill = (e) => {
- onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null });
- };
-
- const handleBillName = (e) => {
- onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value });
- };
-
- const handleDecisionField = (field, value) => {
- onDecisionChange(row.row_id, { ...decision, [field]: value });
- };
-
- return (
-
- {/* Main row */}
-
setExpanded(e => !e)}
- >
- {/* Selection */}
-
e.stopPropagation()}>
- onSelectedChange(row.row_id, e.target.checked)}
- aria-label={`Select row ${row.source_row_number}`}
- className="h-4 w-4 rounded border-border accent-primary"
- />
-
-
- {/* Status icon */}
-
- {hasError ?
:
- isSkip ?
:
- complete ?
:
- action !== null ?
:
-
}
-
-
- {/* Content */}
-
-
- #{row.source_row_number}
- {row.sheet_name && {row.sheet_name}}
- {row.detected_year && row.detected_month && (
-
- {String(row.detected_month).padStart(2,'0')}/{row.detected_year}
-
- )}
- {row.year_month_source && }
-
-
-
- {row.detected_bill_name || '(no bill name)'}
-
- {row.detected_amount != null && (
-
- ${row.detected_amount.toFixed(2)}
-
- )}
- {row.detected_paid_date && (
-
- paid {row.detected_paid_date}
-
- )}
- {row.detected_labels?.length > 0 && (
- {row.detected_labels.join(', ')}
- )}
- {row.detected_notes && (
- {row.detected_notes}
- )}
-
-
-
- {/* Right: action status + expand */}
-
- {action === null ? (
- Needs decision
- ) : isSkip ? (
- Skipped
- ) : (
- {action.replace(/_/g,' ')}
- )}
- {action !== 'skip_row' && (
- expanded ? :
- )}
-
-
-
- {/* Expanded decision controls */}
- {expanded && !hasError && (
-
- {/* Recommendation */}
- {rec.action && (
-
-
- Recommended: {actionLabel(rec.action)}
- {rec.bill_name && rec.action === 'match_existing_bill' && (
- → {rec.bill_name}
- )}
- {rec.category_name && (
- Category: {rec.category_name}
- )}
- {rec.due_day && Due day: {rec.due_day}}
- {rec.actual_amount != null && ${Number(rec.actual_amount).toFixed(2)}}
-
-
- {rec.reason &&
Reason: {rec.reason}
}
-
- )}
-
- {/* Warnings */}
- {(rec.warnings?.length > 0 || row.warnings?.length > 0) && (
-
- {Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => (
-
- {w}
-
- ))}
-
- )}
-
- {/* Possible matches hint */}
- {suggestedBills.length > 0 && (
-
- Suggested:
- {suggestedBills.slice(0, 3).map(b => (
-
- ))}
-
- )}
-
- {/* Action selector */}
-
-
-
-
-
- {/* Bill selector (for actions that need a bill) */}
- {ACTIONS_NEEDING_BILL.has(action) && (
-
-
-
-
- )}
-
- {/* Bill name input for create_new_bill */}
- {action === 'create_new_bill' && (
-
- )}
-
- {action && action !== 'skip_row' && (
-
-
- handleDecisionField('payment_date', e.target.value || null)}
- className="h-8 text-sm w-40"
- />
- handleDecisionField('payment_amount', e.target.value === '' ? null : parseFloat(e.target.value))}
- placeholder="Paid amount"
- className="h-8 text-sm w-36"
- />
-
- )}
-
- {/* Quick skip */}
- {action !== 'skip_row' && (
-
- )}
-
- )}
-
- );
-}
-
-// ─── XLSX Import: Preview Table ───────────────────────────────────────────────
-
-function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) {
- const groups = groupRowsBySheet(rows);
- const multiTab = groups.length > 1;
-
- return (
-
- {groups.map(({ name, rows: groupRows }) => (
-
- {multiTab && (
-
-
- {name}
- · {groupRows.length} rows
-
- )}
- {groupRows.map(row => (
-
- ))}
-
- ))}
-
- );
-}
-
-function BulkActionBar({
- rows,
- selectedRows,
- onSelectAll,
- onClearSelection,
- onBulkSkip,
- onBulkCreateNew,
- onBulkReset,
-}) {
- const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id));
- const selectedCount = selectedRows.size;
-
- return (
-
-
-
-
-
- {selectedCount > 0 && (
- {selectedCount} row{selectedCount === 1 ? '' : 's'} selected
- )}
- {selectedCount > 0 && (
- <>
-
-
-
-
- >
- )}
-
-
-
- );
-}
-
-// ─── Section 1: Import Spreadsheet History ────────────────────────────────────
-
-const INITIAL_OPTIONS = {
- parseAllSheets: true,
- defaultYear: new Date().getFullYear(),
- defaultMonth: '',
-};
-
-export function ImportSpreadsheetSection({ onHistoryRefresh }) {
- const fileRef = useRef(null);
- const [file, setFile] = useState(null);
- const [options, setOptions] = useState(INITIAL_OPTIONS);
- const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
- const [decisions, setDecisions] = useState({});
- const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
- const [allBills, setAllBills] = useState([]);
- const [categories, setCategories] = useState([]);
- const [selectedRows, setSelectedRows] = useState(new Set());
-
- // Load bills/categories for the decision controls
- useEffect(() => {
- api.bills().then(setAllBills).catch(() => {});
- api.categories().then(setCategories).catch(() => {});
- }, []);
-
- const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v }));
-
- // ── Preview ──────────────────────────────────────────────────────────────────
- const handlePreview = async () => {
- if (!file) return;
- setPreview({ status: 'loading', data: null, error: null });
- setDecisions({});
- setSelectedRows(new Set());
- setApplyState({ status: 'idle', result: null, error: null });
- try {
- const data = await api.previewSpreadsheetImport(file, {
- parseAllSheets: options.parseAllSheets,
- defaultYear: options.defaultYear ? parseInt(options.defaultYear, 10) : null,
- defaultMonth: options.defaultMonth ? parseInt(options.defaultMonth, 10) : null,
- });
- setPreview({ status: 'ready', data, error: null });
- setDecisions(buildInitialDecisions(data.rows));
- } catch (err) {
- setPreview({ status: 'error', data: null, error: importErrorState(err, 'Preview failed.') });
- }
- };
-
- // ── Decision update ──────────────────────────────────────────────────────────
- const handleDecisionChange = (rowId, decision) => {
- setDecisions(prev => ({ ...prev, [rowId]: decision }));
- };
-
- const handleSelectedChange = (rowId, selected) => {
- setSelectedRows(prev => {
- const next = new Set(prev);
- if (selected) next.add(rowId);
- else next.delete(rowId);
- return next;
- });
- };
-
- const clearSelection = () => setSelectedRows(new Set());
-
- const selectAllVisibleRows = () => {
- setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id)));
- };
-
- const selectedPreviewRows = () => {
- const selected = selectedRows;
- return (preview.data?.rows || []).filter(r => selected.has(r.row_id));
- };
-
- const handleBulkSkip = () => {
- const rows = selectedPreviewRows();
- setDecisions(prev => {
- const next = { ...prev };
- rows.forEach(row => {
- next[row.row_id] = { ...(next[row.row_id] || {}), action: 'skip_row', bill_id: null, bill_name: null };
- });
- return next;
- });
- toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} marked skipped.`);
- };
-
- const handleBulkCreateNew = () => {
- const rows = selectedPreviewRows();
- let missingNames = 0;
- setDecisions(prev => {
- const next = { ...prev };
- rows.forEach(row => {
- const decision = buildCreateNewDecision(row, next[row.row_id] || {});
- if (!decision.bill_name?.trim()) missingNames++;
- next[row.row_id] = decision;
- });
- return next;
- });
- if (missingNames > 0) {
- toast.warning(`${missingNames} selected row${missingNames === 1 ? '' : 's'} still need a bill name.`);
- } else {
- toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} set to create new bills.`);
- }
- };
-
- const handleBulkReset = () => {
- const rows = selectedPreviewRows();
- setDecisions(prev => {
- const next = { ...prev };
- rows.forEach(row => {
- next[row.row_id] = initialDecisionFromRecommendation(row);
- });
- return next;
- });
- toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} reset to recommendation.`);
- };
-
- const buildApplyDecision = (row, d) => {
- if (!d?.action) return null;
-
- const base = {
- row_id: row.row_id,
- action: d.action,
- actual_amount: d.actual_amount ?? row.detected_amount ?? undefined,
- year: row.detected_year ?? undefined,
- month: row.detected_month ?? undefined,
- notes: d.notes ?? row.detected_notes ?? undefined,
- payment_amount: d.payment_amount ?? row.detected_payment_amount ?? undefined,
- payment_date: d.payment_date ?? row.detected_paid_date ?? undefined,
- };
-
- if (d.action === 'create_new_bill') {
- return {
- ...base,
- bill_name: d.bill_name?.trim() || undefined,
- category_id: d.category_id ?? undefined,
- due_day: d.due_day ?? undefined,
- expected_amount: d.expected_amount ?? undefined,
- };
- }
-
- if (ACTIONS_NEEDING_BILL.has(d.action)) {
- return {
- ...base,
- bill_id: d.bill_id ?? undefined,
- };
- }
-
- return base;
- };
-
- // ── Apply ────────────────────────────────────────────────────────────────────
- const handleApply = async () => {
- if (!preview.data) return;
- setApplyState({ status: 'loading', result: null, error: null });
- try {
- const decisionsList = preview.data.rows
- .map(row => {
- const d = decisions[row.row_id];
- if (d?.action === 'skip_row') return null;
- return buildApplyDecision(row, d);
- })
- .filter(Boolean);
-
- if (decisionsList.length === 0) {
- throw new Error('No rows are selected to import. Choose at least one row to match or create, or keep the preview for review.');
- }
-
- const result = await api.applySpreadsheetImport({
- import_session_id: preview.data.import_session_id,
- decisions: decisionsList,
- options: { reviewed_skipped_count: skipRows.length },
- });
- setApplyState({ status: 'done', result, error: null });
- setSelectedRows(new Set());
- toast.success(`Import applied — ${result.rows_created} created, ${result.rows_updated} updated.`);
- onHistoryRefresh();
- } catch (err) {
- const errorState = importErrorState(err, 'Apply failed.');
- setApplyState({ status: 'error', result: null, error: errorState });
- toast.error(errorState.message || 'Apply failed.');
- }
- };
-
- // ── Reset ────────────────────────────────────────────────────────────────────
- const handleReset = () => {
- setFile(null);
- setOptions(INITIAL_OPTIONS);
- setPreview({ status: 'idle', data: null, error: null });
- setDecisions({});
- setSelectedRows(new Set());
- setApplyState({ status: 'idle', result: null, error: null });
- if (fileRef.current) fileRef.current.value = '';
- };
-
- // ── Derived state ────────────────────────────────────────────────────────────
- const previewRows = preview.data?.rows ?? [];
- const unresolvedRows = previewRows.filter(r => {
- const d = decisions[r.row_id];
- return !d?.action || !isDecisionComplete(d.action, d);
- });
- const pendingRows = previewRows.filter(r => decisions[r.row_id]?.action && decisions[r.row_id]?.action !== 'skip_row');
- const skipRows = previewRows.filter(r => decisions[r.row_id]?.action === 'skip_row');
- const canApply = unresolvedRows.length === 0 && pendingRows.length > 0 && applyState.status !== 'loading';
-
- // ── Render ────────────────────────────────────────────────────────────────────
- return (
-
-
- {/* ── Upload panel ──────────────────────────────────────────────────────── */}
-
-
- {/* File picker */}
-
-
-
- setFile(e.target.files?.[0] ?? null)} />
-
- {file && (
-
- {file.name}
-
- )}
-
-
-
- {/* Options */}
-
-
- opt('parseAllSheets', v)}
- id="parse-all" />
-
-
-
-
- opt('defaultYear', e.target.value)}
- className="w-24 h-8 text-sm" />
-
- {!options.parseAllSheets && (
-
-
- opt('defaultMonth', e.target.value)}
- className="w-20 h-8 text-sm" />
-
- )}
-
-
- {/* Preview button */}
-
-
- {(preview.status === 'ready' || preview.status === 'error' || applyState.status !== 'idle') && (
-
- )}
-
-
- {/* Error from preview */}
- {preview.status === 'error' && (
-
-
{preview.error?.message || preview.error || 'Preview failed.'}
- {preview.error?.details?.length > 0 && (
-
- {preview.error.details.map((d, i) => (
- - {d.row_id ? `${d.row_id}: ` : ''}{d.message}
- ))}
-
- )}
-
- )}
-
-
- {/* ── Preview results ────────────────────────────────────────────────────── */}
- {preview.status === 'ready' && preview.data && !['loading', 'done'].includes(applyState.status) && (
-
-
- {/* Workbook summary */}
-
-
- {/* Row decision table */}
- {previewRows.length > 0 ? (
-
-
-
-
XLSX Review Table
-
Select preview rows, then apply bulk review decisions before importing.
-
-
{previewRows.length} preview row{previewRows.length === 1 ? '' : 's'}
-
-
-
-
- ) : (
-
No data rows found in this file.
- )}
-
- {/* Apply bar */}
- {previewRows.length > 0 && (
-
-
- {previewRows.length} rows reviewed
- {pendingRows.length} to apply
- {skipRows.length} skipped
- {unresolvedRows.length > 0 && (
- {unresolvedRows.length} need a decision
- )}
-
-
-
- )}
-
- )}
-
- {/* ── Applying ──────────────────────────────────────────────────────────── */}
- {applyState.status === 'loading' && (
-
-
- Applying import…
-
- )}
-
- {/* ── Apply result ──────────────────────────────────────────────────────── */}
- {applyState.status === 'done' && applyState.result && (
-
-
-
-
-
Import applied successfully
-
-
- {[
- { label: 'Created', value: applyState.result.rows_created, color: 'text-emerald-600' },
- { label: 'Updated', value: applyState.result.rows_updated, color: 'text-blue-600' },
- { label: 'Skipped', value: applyState.result.rows_skipped, color: 'text-muted-foreground' },
- { label: 'Errors', value: applyState.result.rows_errored, color: 'text-red-500' },
- ].map(({ label, value, color }) => (
-
- ))}
-
-
-
-
- )}
-
- {/* ── Apply error ───────────────────────────────────────────────────────── */}
- {applyState.status === 'error' && (
-
-
-
{applyState.error?.message || applyState.error || 'Apply failed.'}
- {applyState.error?.details?.length > 0 && (
-
- {applyState.error.details.map((d, i) => (
- -
- {d.row_id ? `${d.row_id}: ` : ''}
- {d.field ? `${d.field} - ` : ''}
- {d.message}
-
- ))}
-
- )}
- {applyState.error?.error_id && (
-
Error ID: {applyState.error.error_id}
- )}
-
-
- )}
-
- );
-}
-
-// ─── DataPage ─────────────────────────────────────────────────────────────────
-
-function SeedDemoDataSection({ onSeeded }) {
- const [loading, setLoading] = useState(false);
- const [seeded, setSeeded] = useState(false);
- const [result, setResult] = useState(null);
-
- const handleSeed = async () => {
- setLoading(true);
- try {
- const data = await api.seedDemoData();
- setResult(data);
- setSeeded(true);
- toast.success(`Created ${data.billsCreated} demo bills successfully.`);
- onSeeded?.();
- } catch (err) {
- toast.error(err.message || 'Failed to seed demo data.');
- } finally {
- setLoading(false);
- }
- };
-
- if (seeded) {
-
- const [clearing, setClearing] = useState(false);
- const [showClearConfirm, setShowClearConfirm] = useState(false);
-
- const handleClearDemoData = async () => {
- setClearing(true);
- try {
- const data = await api.clearDemoData();
- setSeeded(false);
- setResult(null);
- setShowClearConfirm(false);
- toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
- onSeeded?.();
- } catch (err) {
- toast.error(err.message || "Failed to clear demo data.");
- } finally {
- setClearing(false);
- }
- };
- return (
-
-
-
Seed complete
-
-
-
Bills Created
-
{result?.billsCreated || 0}
-
-
-
Categories Created
-
{result?.categoriesCreated || 0}
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
- Create 20 realistic demo bills and 8 demo categories for testing purposes.
- The data will be associated with your account.
-
-
-
-
-
- {seeded && (
-
-
-
- This will remove only seeded demo bills and categories from your account.
-
-
-
-
- )}
-
-
-
- );
-}
-
-export default function DataPage() {
- const [history, setHistory] = useState(null);
- const [historyLoading, setHistoryLoading] = useState(true);
-
- const loadHistory = async () => {
- setHistoryLoading(true);
- try {
- const { history } = await api.importHistory();
- setHistory(history);
- } catch {
- setHistory([]);
- } finally {
- setHistoryLoading(false);
- }
- };
-
- useEffect(() => { loadHistory(); }, []);
-
- return (
-
-
-
-
Data
-
- Import, export, and review your user-owned bill tracker records.
-
-
-
- User data only
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/client/pages/TrackerPage-bk.jsx b/client/pages/TrackerPage-bk.jsx
deleted file mode 100644
index 247118f..0000000
--- a/client/pages/TrackerPage-bk.jsx
+++ /dev/null
@@ -1,727 +0,0 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { toast } from 'sonner';
-import { ChevronLeft, ChevronRight, MoreHorizontal, ReceiptText } from 'lucide-react';
-import { api } from '@/api.js';
-import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
-import { Button, buttonVariants } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from '@/components/ui/dialog';
-import {
- AlertDialog,
- AlertDialogContent,
- AlertDialogHeader,
- AlertDialogFooter,
- AlertDialogTitle,
- AlertDialogDescription,
- AlertDialogCancel,
- AlertDialogAction,
-} from '@/components/ui/alert-dialog';
-import {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
-} from '@/components/ui/dropdown-menu';
-import {
- Select,
- SelectTrigger,
- SelectValue,
- SelectContent,
- SelectItem,
-} from '@/components/ui/select';
-import {
- Table,
- TableHeader,
- TableBody,
- TableHead,
- TableRow,
- TableCell,
-} from '@/components/ui/table';
-
-// ─── Constants ────────────────────────────────────────────────────────────────
-
-const MONTH_NAMES = [
- 'January', 'February', 'March', 'April', 'May', 'June',
- 'July', 'August', 'September', 'October', 'November', 'December',
-];
-
-const PAYMENT_METHODS = [
- { value: 'bank_transfer', label: 'Bank Transfer' },
- { value: 'card', label: 'Card' },
- { value: 'autopay', label: 'Autopay' },
- { value: 'check', label: 'Check' },
- { value: 'cash', label: 'Cash' },
- { value: 'other', label: 'Other' },
-];
-
-// ─── Status Config ────────────────────────────────────────────────────────────
-
-const STATUS = {
- paid: { label: 'Paid', dot: 'bg-emerald-400', chip: 'bg-emerald-500/10 text-emerald-500' },
- upcoming: { label: 'Upcoming', dot: 'bg-slate-400', chip: 'bg-slate-500/10 text-slate-500 dark:text-slate-400' },
- due_soon: { label: 'Due Soon', dot: 'bg-amber-400', chip: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' },
- late: { label: 'Late', dot: 'bg-orange-400', chip: 'bg-orange-500/10 text-orange-600 dark:text-orange-400' },
- missed: { label: 'Missed', dot: 'bg-red-400', chip: 'bg-red-500/10 text-red-500' },
- autodraft: { label: 'Autodraft', dot: 'bg-violet-400', chip: 'bg-violet-500/10 text-violet-500' },
-};
-
-// ─── Status Chip ──────────────────────────────────────────────────────────────
-
-function StatusChip({ status }) {
- const cfg = STATUS[status] ?? { label: status, dot: 'bg-slate-400', chip: 'bg-slate-500/10 text-slate-500' };
- return (
-
-
- {cfg.label}
-
- );
-}
-
-// ─── Payment Dialog ───────────────────────────────────────────────────────────
-
-function PaymentDialog({ open, onOpenChange, bill, payment, onSuccess }) {
- const isEdit = !!payment;
-
- const [form, setForm] = useState({
- amount: '',
- paid_date: todayStr(),
- method: '',
- notes: '',
- });
- const [saving, setSaving] = useState(false);
-
- useEffect(() => {
- if (!open) return;
- if (isEdit) {
- setForm({
- amount: String(payment.amount ?? ''),
- paid_date: payment.paid_date ?? todayStr(),
- method: payment.method ?? '',
- notes: payment.notes ?? '',
- });
- } else {
- setForm({
- amount: String(bill?.expected_amount ?? ''),
- paid_date: todayStr(),
- method: '',
- notes: '',
- });
- }
- }, [open, isEdit, payment, bill]);
-
- const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
-
- async function handleSubmit() {
- if (!form.amount || !form.paid_date) {
- toast.error('Amount and date are required');
- return;
- }
- const amount = parseFloat(form.amount);
- if (isNaN(amount) || amount <= 0) {
- toast.error('Enter a valid amount');
- return;
- }
- setSaving(true);
- try {
- const payload = {
- amount,
- paid_date: form.paid_date,
- method: form.method || null,
- notes: form.notes || null,
- };
- if (isEdit) {
- await api.updatePayment(payment.id, payload);
- toast.success('Payment updated');
- } else {
- await api.quickPay({ bill_id: bill.id, ...payload });
- toast.success(`Paid ${fmt(amount)} for ${bill.name}`);
- }
- onOpenChange(false);
- onSuccess?.();
- } catch (err) {
- toast.error(err.message);
- } finally {
- setSaving(false);
- }
- }
-
- const title = isEdit
- ? `Edit Payment — ${bill?.name ?? ''}`
- : `Record Payment — ${bill?.name ?? ''}`;
-
- return (
-
- );
-}
-
-// ─── Remove Payment Alert Dialog ──────────────────────────────────────────────
-
-function RemovePaymentDialog({ open, onOpenChange, bill, paymentId, onSuccess }) {
- const [removing, setRemoving] = useState(false);
-
- async function handleConfirm() {
- setRemoving(true);
- try {
- await api.deletePayment(paymentId);
- toast.success('Payment removed');
- onOpenChange(false);
- onSuccess?.();
- } catch (err) {
- toast.error(err.message);
- } finally {
- setRemoving(false);
- }
- }
-
- return (
-
-
-
- Remove payment?
-
- Removing this payment will mark{' '}
- {bill?.name}{' '}
- as unpaid. The bill itself will not be deleted.
-
-
-
- Cancel
-
- {removing ? 'Removing…' : 'Remove Payment'}
-
-
-
-
- );
-}
-
-// ─── Bill Form stub ───────────────────────────────────────────────────────────
-
-function openBillForm(bill) {
- // TODO: replace with shared BillFormDialog extracted from BillsPage
- console.log('[BillFormDialog] Edit bill:', bill);
-}
-
-// ─── Row Actions Dropdown ─────────────────────────────────────────────────────
-
-function RowActions({ row, payInputRef, onRefresh }) {
- const [payDialogOpen, setPayDialogOpen] = useState(false);
- const [editDialogOpen, setEditDialogOpen] = useState(false);
- const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
-
- const isPaid = row.status === 'paid' || row.status === 'autodraft';
- const latestPayment = row.payments?.length
- ? [...row.payments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
- : null;
-
- async function handleQuickPay() {
- const raw = payInputRef?.current?.value ?? String(row.expected_amount ?? '');
- const amount = parseFloat(raw);
- if (isNaN(amount) || amount <= 0) {
- toast.error('Enter a valid amount');
- return;
- }
- try {
- await api.quickPay({ bill_id: row.id, amount, paid_date: todayStr() });
- toast.success(`Paid ${fmt(amount)} for ${row.name}`);
- onRefresh();
- } catch (err) {
- toast.error(err.message);
- }
- }
-
- return (
- <>
-
-
-
-
-
- {!isPaid ? (
- <>
-
- Quick Pay
-
- setPayDialogOpen(true)}>
- Record Payment…
-
- >
- ) : (
- <>
- setEditDialogOpen(true)}>
- Edit Payment…
-
- setRemoveDialogOpen(true)}
- >
- Remove Payment…
-
- >
- )}
-
- openBillForm(row)}>
- Edit Bill…
-
-
-
-
- {/* Record Payment dialog (unpaid) */}
-
-
- {/* Edit Payment dialog (paid) */}
-
-
- {/* Remove Payment alert dialog */}
-
- >
- );
-}
-
-// ─── Bill Row ─────────────────────────────────────────────────────────────────
-
-function BillRow({ row, onRefresh }) {
- const payInputRef = useRef(null);
- const isPaid = row.status === 'paid' || row.status === 'autodraft';
-
- return (
-
- {/* Bill name + category */}
-
-
- {row.autopay_enabled && (
-
- )}
-
-
{row.name}
- {row.category_name && (
-
{row.category_name}
- )}
-
-
-
-
- {/* Due date */}
-
- {fmtDate(row.due_date)}
-
-
- {/* Expected */}
-
-
- {fmt(row.expected_amount)}
-
-
-
- {/* Paid Date */}
-
- {row.last_paid_date ? (
-
- {fmtDate(row.last_paid_date)}
-
- ) : (
- —
- )}
-
-
- {/* Paid — input for unpaid, amount for paid */}
-
- {isPaid ? (
-
- {fmt(row.total_paid)}
-
- ) : (
-
- )}
-
-
- {/* Status */}
-
-
-
-
- {/* Actions */}
-
-
-
-
- );
-}
-
-// ─── Bucket Section ───────────────────────────────────────────────────────────
-
-function BucketSection({ title, rows, paidAmount, totalAmount, loading, onRefresh }) {
- return (
-
- {/* Floating bucket header — no card wrapper */}
-
-
- {title}
-
- {!loading && rows.length > 0 && (
-
- {fmt(paidAmount)} paid of {fmt(totalAmount)}
-
- )}
-
-
- {/* Table wrapped in card */}
-
-
-
-
-
- Bill
-
-
- Due
-
-
- Expected
-
-
- Paid Date
-
-
- Paid
-
-
- Status
-
-
-
-
-
- {loading ? (
- [1, 2, 3].map((i) => (
-
-
-
-
-
- ))
- ) : rows.length === 0 ? (
-
-
-
-
-
- ) : (
- rows.map((row) => (
-
- ))
- )}
-
-
-
-
- );
-}
-
-// ─── TrackerPage ──────────────────────────────────────────────────────────────
-
-export default function TrackerPage() {
- const now = new Date();
- const [year, setYear] = useState(now.getFullYear());
- const [month, setMonth] = useState(now.getMonth() + 1);
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
-
- const load = useCallback(async () => {
- setLoading(true);
- try {
- const res = await api.tracker(year, month);
- setData(res);
- } catch (err) {
- toast.error(err.message);
- } finally {
- setLoading(false);
- }
- }, [year, month]);
-
- useEffect(() => { load(); }, [load]);
-
- function navigate(direction) {
- if (direction === -1) {
- if (month === 1) { setYear((y) => y - 1); setMonth(12); }
- else setMonth((m) => m - 1);
- } else {
- if (month === 12) { setYear((y) => y + 1); setMonth(1); }
- else setMonth((m) => m + 1);
- }
- }
-
- function goToToday() {
- const t = new Date();
- setYear(t.getFullYear());
- setMonth(t.getMonth() + 1);
- }
-
- const rows = data?.rows ?? [];
- const summary = data?.summary ?? {};
-
- const isCurrentMonth = year === now.getFullYear() && month === now.getMonth() + 1;
-
- const paidCount = rows.filter((r) => r.status === 'paid' || r.status === 'autodraft').length;
- const overdueCount = rows.filter((r) => r.status === 'late' || r.status === 'missed').length;
- const allPaid = rows.length > 0 && paidCount === rows.length;
-
- const bucket1 = rows.filter((r) => r.bucket === '1st');
- const bucket15 = rows.filter((r) => r.bucket === '15th');
-
- function bucketPaid(bucket) {
- return bucket.reduce((sum, r) => {
- const isPaid = r.status === 'paid' || r.status === 'autodraft';
- return sum + (isPaid ? (r.total_paid || 0) : 0);
- }, 0);
- }
- function bucketTotal(bucket) {
- return bucket.reduce((sum, r) => sum + (r.expected_amount || 0), 0);
- }
-
- return (
-
- {/* ── Page Header — floats on page background, no card ── */}
-
-
-
- {MONTH_NAMES[month - 1]} {year}
-
-
Monthly overview
-
-
-
-
- {!isCurrentMonth && (
-
- )}
-
-
-
- {/* ── Summary Stat Tiles ── */}
-
- {/* Total Expected */}
-
-
- Total Expected
-
-
- {fmt(summary.total_expected)}
-
-
- {rows.length} bill{rows.length !== 1 ? 's' : ''} this month
-
-
-
- {/* Paid */}
-
-
- Paid
-
-
- {fmt(summary.total_paid)}
-
-
- {paidCount} of {rows.length} paid
-
-
-
- {/* Remaining */}
-
-
- Remaining
-
-
- {fmt(summary.remaining)}
-
-
0 ? 'text-orange-400' : 'text-muted-foreground')}>
- {overdueCount > 0
- ? `includes ${fmt(summary.overdue)} overdue`
- : `${rows.length - paidCount} unpaid`}
-
-
-
- {/* Overdue */}
-
-
- Overdue
-
-
0 ? 'text-red-500' : 'text-muted-foreground')}>
- {fmt(summary.overdue)}
-
-
- {overdueCount > 0
- ? `${overdueCount} bill${overdueCount !== 1 ? 's' : ''} overdue`
- : 'All clear'}
-
-
-
-
- {/* ── Bucket Sections ── */}
-
-
-
- );
-}