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 => ( - - ))} - - - - {rows.map(r => ( - - - - - - - - - - - ))} - -
{h}
- {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' && ( -
-
- - -
-
- - - {rec.category_name && Suggested: {rec.category_name}} -
-
- - handleDecisionField('due_day', e.target.value ? parseInt(e.target.value, 10) : null)} - placeholder="Due day" - className="h-8 text-sm w-24" - /> - handleDecisionField('expected_amount', e.target.value === '' ? null : parseFloat(e.target.value))} - placeholder="Expected amount" - className="h-8 text-sm w-40" - /> -
-
- )} - - {action && action !== 'skip_row' && ( -
- - handleDecisionField('payment_date', e.target.value || null)} - className="h-8 text-sm w-40" - /> - handleDecisionField('payment_amount', e.target.value === '' ? null : parseFloat(e.target.value))} - placeholder="Paid amount" - className="h-8 text-sm w-36" - /> -
- )} - - {/* Quick skip */} - {action !== 'skip_row' && ( - - )} -
- )} -
- ); -} - -// ─── XLSX Import: Preview Table ─────────────────────────────────────────────── - -function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) { - const groups = groupRowsBySheet(rows); - const multiTab = groups.length > 1; - - return ( -
- {groups.map(({ name, rows: groupRows }) => ( -
- {multiTab && ( -
- - {name} - · {groupRows.length} rows -
- )} - {groupRows.map(row => ( - - ))} -
- ))} -
- ); -} - -function BulkActionBar({ - rows, - selectedRows, - onSelectAll, - onClearSelection, - onBulkSkip, - onBulkCreateNew, - onBulkReset, -}) { - const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id)); - const selectedCount = selectedRows.size; - - return ( -
-
- - -
- {selectedCount > 0 && ( - {selectedCount} row{selectedCount === 1 ? '' : 's'} selected - )} - {selectedCount > 0 && ( - <> - - - - - - )} -
-
-
- ); -} - -// ─── Section 1: Import Spreadsheet History ──────────────────────────────────── - -const INITIAL_OPTIONS = { - parseAllSheets: true, - defaultYear: new Date().getFullYear(), - defaultMonth: '', -}; - -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 }) => ( -
-

{value}

-

{label}

-
- ))} -
-
- -
- )} - - {/* ── Apply error ───────────────────────────────────────────────────────── */} - {applyState.status === 'error' && ( -
-
- {applyState.error?.message || applyState.error || 'Apply failed.'} - {applyState.error?.details?.length > 0 && ( -
    - {applyState.error.details.map((d, i) => ( -
  • - {d.row_id ? `${d.row_id}: ` : ''} - {d.field ? `${d.field} - ` : ''} - {d.message} -
  • - ))} -
- )} - {applyState.error?.error_id && ( -

Error ID: {applyState.error.error_id}

- )} -
-
- )} -
- ); -} - -// ─── DataPage ───────────────────────────────────────────────────────────────── - -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 ( - - - - {title} - - -
- {/* Amount */} -
- -
- - $ - - -
-
- - {/* Date Paid */} -
- - -
- - {/* Method */} -
- - -
- - {/* Notes */} -
- - -
-
- - - - - -
-
- ); -} - -// ─── 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 ? ( - - -
- -

No bills for this period

- - Add a bill → - -
-
-
- ) : ( - 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 ── */} - - -
- ); -}