2026-07-06 14:23:53 -05:00
|
|
|
import {
|
|
|
|
|
useState,
|
|
|
|
|
useEffect,
|
|
|
|
|
useCallback,
|
|
|
|
|
useRef,
|
|
|
|
|
Suspense,
|
|
|
|
|
lazy,
|
|
|
|
|
type ComponentType,
|
|
|
|
|
type ReactNode,
|
|
|
|
|
} from 'react';
|
2026-07-03 15:02:36 -05:00
|
|
|
import { useSearchParams } from 'react-router-dom';
|
2026-05-29 01:06:20 -05:00
|
|
|
import { toast } from 'sonner';
|
2026-07-03 15:02:36 -05:00
|
|
|
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
|
|
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Landmark,
|
|
|
|
|
ArrowRightLeft,
|
|
|
|
|
Upload,
|
|
|
|
|
DatabaseBackup,
|
|
|
|
|
FileSpreadsheet,
|
|
|
|
|
FileText,
|
|
|
|
|
FlaskConical,
|
|
|
|
|
Download,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
History,
|
|
|
|
|
ShieldAlert,
|
2026-07-03 15:02:36 -05:00
|
|
|
} from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { api } from '@/api';
|
2026-07-04 22:21:48 -05:00
|
|
|
import { errMessage } from '@/lib/utils';
|
2026-06-06 23:53:53 -05:00
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
2026-07-03 15:02:36 -05:00
|
|
|
import ConnectionHero from '@/components/data/ConnectionHero';
|
2026-07-04 22:21:48 -05:00
|
|
|
import DataNav, { type DataNavSection } from '@/components/data/DataNav';
|
2026-05-28 22:06:15 -05:00
|
|
|
import BankSyncSection from '@/components/data/BankSyncSection';
|
2026-06-04 20:45:11 -05:00
|
|
|
import BillRulesManager from '@/components/BillRulesManager';
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
feat(import): OFX/QFX transaction import (Batch 3)
New services/ofxImportService.js parses OFX 1.x (SGML, unclosed leaf tags),
OFX 2.x (XML) and QFX (+ Intuit tags ignored) into the same normalized shape the
CSV path produces, then writes through the SAME shared primitives (session table,
(user_id, data_source_id, provider_transaction_id) dedupe, import_history) — now
exported from csvTransactionImportService (additive; CSV tests still pass).
- Routes POST /api/import/ofx/{preview,commit} mirror the CSV two-step (raw
upload → structured commit; no column mapping since OFX is structured).
- UI: ImportOfxSection (upload → preview list → import) in the Import pane;
amounts shown via formatCentsUSD; toasts on preview/commit/malformed.
- Gap handling: signed TRNAMT → signed cents; DTPOSTED → YYYY-MM-DD; FITID →
stable provider id (hash fallback); non-OFX / empty files rejected clearly.
Tests: tests/ofxImportService.test.js (SGML + XML/QFX parse, entity decode,
signed cents, preview→commit, re-import dedupe, import_history). Server 129 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:11:59 -05:00
|
|
|
import ImportOfxSection from '@/components/data/ImportOfxSection';
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
|
|
|
|
|
import SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
|
|
|
|
|
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
|
2026-07-06 14:23:53 -05:00
|
|
|
import ImportHistorySection, {
|
|
|
|
|
type ImportHistoryRow,
|
|
|
|
|
} from '@/components/data/ImportHistorySection';
|
feat(data): "Erase my data" danger zone (Batch 5)
New services/userDataService.js eraseUserData() permanently wipes a user's
financial + imported data in one transaction (child → parent order for FK
safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges),
transactions/accounts/data_sources, categories/groups, templates, snowball,
spending rules/budgets, merchant rules, imports, and per-user hint tables. It
PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences —
this resets your data, not your account — then re-seeds default categories and
writes an audit row to import_history.
- POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a
type-to-confirm token ("ERASE"), structured errors.
- UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented,
"download a backup first" nudge, type-to-confirm AlertDialog, toasts; on
success DataPage reloads all state.
Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B +
account + session, re-seeds categories, audited. Server 139 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:21:07 -05:00
|
|
|
import EraseDataSection from '@/components/data/EraseDataSection';
|
2026-05-09 13:03:36 -05:00
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
// Heavy panes (XLSX parsing / SQLite restore) — code-split, loaded on demand.
|
|
|
|
|
const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection'));
|
|
|
|
|
const ImportMyDataSection = lazy(() => import('@/components/data/ImportMyDataSection'));
|
2026-05-30 21:52:02 -05:00
|
|
|
|
2026-07-04 22:21:48 -05:00
|
|
|
interface Conn {
|
|
|
|
|
id: number | string;
|
|
|
|
|
name?: string;
|
|
|
|
|
provider?: string;
|
|
|
|
|
status?: string;
|
|
|
|
|
last_error?: string | null;
|
|
|
|
|
last_sync_at?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Section {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
description: string;
|
|
|
|
|
icon: ComponentType<{ className?: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SECTIONS: Section[] = [
|
2026-07-06 14:23:53 -05:00
|
|
|
{ id: 'bank-sync', label: 'Bank sync', description: 'Connect & sync your bank', icon: Landmark },
|
|
|
|
|
{
|
|
|
|
|
id: 'transactions',
|
|
|
|
|
label: 'Transactions',
|
|
|
|
|
description: 'Review & match',
|
|
|
|
|
icon: ArrowRightLeft,
|
|
|
|
|
},
|
|
|
|
|
{ id: 'import', label: 'Import', description: 'Bring in existing data', icon: Upload },
|
|
|
|
|
{
|
|
|
|
|
id: 'export',
|
|
|
|
|
label: 'Export & backups',
|
|
|
|
|
description: 'Download & restore',
|
|
|
|
|
icon: DatabaseBackup,
|
|
|
|
|
},
|
2026-07-03 15:02:36 -05:00
|
|
|
];
|
2026-07-06 14:23:53 -05:00
|
|
|
const SECTION_IDS = SECTIONS.map((s) => s.id);
|
2026-07-04 22:21:48 -05:00
|
|
|
const DEFAULT_SECTION = SECTIONS[0]!.id;
|
2026-07-03 15:02:36 -05:00
|
|
|
const SECTION_KEY = 'billtracker:data.section';
|
|
|
|
|
const LEGACY_KEY = 'billtracker:data.activeTab'; // old 3-tab key → migrate
|
2026-05-30 21:52:02 -05:00
|
|
|
|
2026-07-04 22:21:48 -05:00
|
|
|
function storedSection(): string | null {
|
2026-07-03 15:02:36 -05:00
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
const s = window.localStorage.getItem(SECTION_KEY);
|
2026-07-04 22:21:48 -05:00
|
|
|
return s && SECTION_IDS.includes(s) ? s : null;
|
2026-05-30 21:52:02 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
function PaneSkeleton() {
|
2026-05-30 21:52:02 -05:00
|
|
|
return (
|
2026-07-03 15:02:36 -05:00
|
|
|
<div className="surface p-6">
|
|
|
|
|
<div className="h-5 w-40 animate-pulse rounded bg-muted/50" />
|
|
|
|
|
<div className="mt-3 h-24 animate-pulse rounded bg-muted/30" />
|
2026-05-30 21:52:02 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
export default function DataPage() {
|
2026-07-03 15:02:36 -05:00
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
2026-07-04 22:21:48 -05:00
|
|
|
const [history, setHistory] = useState<ImportHistoryRow[] | null>(null);
|
2026-05-04 23:34:24 -05:00
|
|
|
const [historyLoading, setHistoryLoading] = useState(true);
|
2026-05-16 21:36:04 -05:00
|
|
|
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
|
2026-07-04 22:21:48 -05:00
|
|
|
const [simplefinConn, setSimplefinConn] = useState<Conn | null>(null);
|
2026-07-03 15:02:36 -05:00
|
|
|
const [syncEnabled, setSyncEnabled] = useState(true);
|
|
|
|
|
const [hasConnections, setHasConnections] = useState(false);
|
|
|
|
|
const [syncLoading, setSyncLoading] = useState(true);
|
|
|
|
|
const [syncError, setSyncError] = useState(false);
|
2026-07-03 15:05:25 -05:00
|
|
|
const [unmatchedCount, setUnmatchedCount] = useState(0);
|
2026-07-04 22:21:48 -05:00
|
|
|
const [txnTotal, setTxnTotal] = useState<number | null>(null);
|
2026-07-03 15:02:36 -05:00
|
|
|
const reduceMotion = useReducedMotion();
|
2026-07-04 22:21:48 -05:00
|
|
|
const paneRef = useRef<HTMLDivElement>(null);
|
2026-07-03 15:02:36 -05:00
|
|
|
const firstRender = useRef(true);
|
|
|
|
|
|
|
|
|
|
// Active section: URL ?section= is source of truth → localStorage → default.
|
|
|
|
|
const urlSection = searchParams.get('section');
|
2026-07-06 14:23:53 -05:00
|
|
|
const activeSection =
|
|
|
|
|
urlSection && SECTION_IDS.includes(urlSection)
|
|
|
|
|
? urlSection
|
|
|
|
|
: storedSection() || DEFAULT_SECTION;
|
2026-07-03 15:02:36 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const goTo = useCallback(
|
|
|
|
|
(id: string) => {
|
|
|
|
|
if (!SECTION_IDS.includes(id)) return;
|
|
|
|
|
window.localStorage.setItem(SECTION_KEY, id);
|
|
|
|
|
setSearchParams((prev) => {
|
|
|
|
|
const p = new URLSearchParams(prev);
|
|
|
|
|
p.set('section', id);
|
|
|
|
|
return p;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[setSearchParams],
|
|
|
|
|
);
|
2026-07-03 15:02:36 -05:00
|
|
|
|
|
|
|
|
// Reflect the resolved section into the URL once, so refresh/back-button work
|
|
|
|
|
// (and migrate the old 3-tab key). Runs only when the URL lacks a valid section.
|
|
|
|
|
useEffect(() => {
|
2026-07-04 22:21:48 -05:00
|
|
|
if (urlSection && SECTION_IDS.includes(urlSection)) return;
|
2026-07-03 15:02:36 -05:00
|
|
|
const legacy = window.localStorage.getItem(LEGACY_KEY);
|
2026-07-06 14:23:53 -05:00
|
|
|
const migrated = legacy
|
|
|
|
|
? ({ sync: 'bank-sync', import: 'import', export: 'export' } as Record<string, string>)[
|
|
|
|
|
legacy
|
|
|
|
|
]
|
|
|
|
|
: undefined;
|
2026-07-03 15:02:36 -05:00
|
|
|
const target = storedSection() || migrated || DEFAULT_SECTION;
|
2026-07-06 14:23:53 -05:00
|
|
|
setSearchParams(
|
|
|
|
|
(prev) => {
|
|
|
|
|
const p = new URLSearchParams(prev);
|
|
|
|
|
p.set('section', target);
|
|
|
|
|
return p;
|
|
|
|
|
},
|
|
|
|
|
{ replace: true },
|
|
|
|
|
);
|
2026-07-03 15:02:36 -05:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Move focus to the pane on section change (keyboard/SR), but not on first load.
|
|
|
|
|
useEffect(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
if (firstRender.current) {
|
|
|
|
|
firstRender.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-03 15:02:36 -05:00
|
|
|
paneRef.current?.focus?.();
|
|
|
|
|
}, [activeSection]);
|
2026-05-04 23:34:24 -05:00
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
const loadHistory = useCallback(async () => {
|
2026-05-04 23:34:24 -05:00
|
|
|
setHistoryLoading(true);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const res = (await api.importHistory()) as { history?: ImportHistoryRow[] };
|
2026-07-04 22:21:48 -05:00
|
|
|
setHistory(res.history ?? []);
|
2026-05-29 01:06:20 -05:00
|
|
|
} catch (err) {
|
2026-05-04 23:34:24 -05:00
|
|
|
setHistory([]);
|
2026-07-04 22:21:48 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to load import history.'));
|
2026-05-04 23:34:24 -05:00
|
|
|
} finally {
|
|
|
|
|
setHistoryLoading(false);
|
|
|
|
|
}
|
2026-07-03 15:02:36 -05:00
|
|
|
}, []);
|
2026-05-04 23:34:24 -05:00
|
|
|
|
2026-05-30 21:52:02 -05:00
|
|
|
const loadSimplefinSummary = useCallback(async () => {
|
|
|
|
|
setSyncLoading(true);
|
2026-07-03 15:02:36 -05:00
|
|
|
setSyncError(false);
|
2026-05-30 21:52:02 -05:00
|
|
|
try {
|
|
|
|
|
const [status, sources] = await Promise.all([
|
2026-07-04 22:21:48 -05:00
|
|
|
api.simplefinStatus() as Promise<{ enabled?: boolean; has_connections?: boolean }>,
|
2026-05-30 21:52:02 -05:00
|
|
|
api.dataSources({ type: 'provider_sync' }),
|
|
|
|
|
]);
|
2026-07-03 15:02:36 -05:00
|
|
|
setSyncEnabled(Boolean(status.enabled));
|
|
|
|
|
setHasConnections(Boolean(status.has_connections));
|
2026-07-06 14:23:53 -05:00
|
|
|
const conns = Array.isArray(sources)
|
|
|
|
|
? (sources as Conn[]).filter((s) => s.provider === 'simplefin')
|
|
|
|
|
: [];
|
2026-05-30 21:52:02 -05:00
|
|
|
setSimplefinConn(conns[0] || null);
|
|
|
|
|
} catch {
|
2026-07-03 15:02:36 -05:00
|
|
|
setSyncError(true);
|
2026-05-30 21:52:02 -05:00
|
|
|
setSimplefinConn(null);
|
|
|
|
|
} finally {
|
|
|
|
|
setSyncLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-03 15:05:25 -05:00
|
|
|
// Cheap "N to review" + transaction count (summary is aggregate, so limit:1 is enough).
|
|
|
|
|
const loadTxnStats = useCallback(async () => {
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const data = (await api.bankTransactionsLedger({ limit: 1 })) as {
|
|
|
|
|
summary?: { unmatched?: number; total?: number };
|
|
|
|
|
};
|
2026-07-03 15:05:25 -05:00
|
|
|
setUnmatchedCount(data?.summary?.unmatched || 0);
|
|
|
|
|
setTxnTotal(data?.summary?.total ?? null);
|
|
|
|
|
} catch {
|
|
|
|
|
setUnmatchedCount(0);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
loadHistory();
|
|
|
|
|
loadSimplefinSummary();
|
|
|
|
|
}, [loadHistory, loadSimplefinSummary]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadTxnStats();
|
|
|
|
|
}, [loadTxnStats, transactionRefreshKey]);
|
2026-05-04 23:34:24 -05:00
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
const handleTransactionImportComplete = useCallback(() => {
|
2026-05-16 21:36:04 -05:00
|
|
|
loadHistory();
|
2026-07-06 14:23:53 -05:00
|
|
|
setTransactionRefreshKey((k) => k + 1);
|
2026-07-03 15:02:36 -05:00
|
|
|
}, [loadHistory]);
|
2026-05-16 21:36:04 -05:00
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
// BankSyncSection reports connect/sync/disconnect changes.
|
2026-07-04 22:21:48 -05:00
|
|
|
const handleConnectionChange = useCallback((conn: Conn | null) => {
|
2026-05-28 22:06:15 -05:00
|
|
|
setSimplefinConn(conn || null);
|
2026-07-03 15:02:36 -05:00
|
|
|
setHasConnections(Boolean(conn));
|
2026-05-30 21:52:02 -05:00
|
|
|
setSyncLoading(false);
|
2026-07-06 14:23:53 -05:00
|
|
|
setTransactionRefreshKey((k) => k + 1);
|
2026-05-28 22:06:15 -05:00
|
|
|
}, []);
|
|
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
const handleSynced = useCallback(() => {
|
|
|
|
|
loadSimplefinSummary();
|
2026-07-06 14:23:53 -05:00
|
|
|
setTransactionRefreshKey((k) => k + 1);
|
2026-07-03 15:02:36 -05:00
|
|
|
}, [loadSimplefinSummary]);
|
|
|
|
|
|
feat(data): "Erase my data" danger zone (Batch 5)
New services/userDataService.js eraseUserData() permanently wipes a user's
financial + imported data in one transaction (child → parent order for FK
safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges),
transactions/accounts/data_sources, categories/groups, templates, snowball,
spending rules/budgets, merchant rules, imports, and per-user hint tables. It
PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences —
this resets your data, not your account — then re-seeds default categories and
writes an audit row to import_history.
- POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a
type-to-confirm token ("ERASE"), structured errors.
- UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented,
"download a backup first" nudge, type-to-confirm AlertDialog, toasts; on
success DataPage reloads all state.
Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B +
account + session, re-seeds categories, audited. Server 139 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:21:07 -05:00
|
|
|
const handleErased = useCallback(() => {
|
|
|
|
|
loadHistory();
|
|
|
|
|
loadSimplefinSummary();
|
2026-07-06 14:23:53 -05:00
|
|
|
setTransactionRefreshKey((k) => k + 1);
|
feat(data): "Erase my data" danger zone (Batch 5)
New services/userDataService.js eraseUserData() permanently wipes a user's
financial + imported data in one transaction (child → parent order for FK
safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges),
transactions/accounts/data_sources, categories/groups, templates, snowball,
spending rules/budgets, merchant rules, imports, and per-user hint tables. It
PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences —
this resets your data, not your account — then re-seeds default categories and
writes an audit row to import_history.
- POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a
type-to-confirm token ("ERASE"), structured errors.
- UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented,
"download a backup first" nudge, type-to-confirm AlertDialog, toasts; on
success DataPage reloads all state.
Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B +
account + session, re-seeds categories, audited. Server 139 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:21:07 -05:00
|
|
|
}, [loadHistory, loadSimplefinSummary]);
|
|
|
|
|
|
2026-07-04 22:21:48 -05:00
|
|
|
const renderPane = (): ReactNode => {
|
2026-07-03 15:02:36 -05:00
|
|
|
switch (activeSection) {
|
|
|
|
|
case 'bank-sync':
|
|
|
|
|
return (
|
|
|
|
|
<BankSyncSection
|
|
|
|
|
onConnectionChange={handleConnectionChange}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Connect your bank',
|
|
|
|
|
subtitle: 'Securely sync transactions automatically (via SimpleFIN).',
|
|
|
|
|
icon: Landmark,
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
case 'transactions':
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
<TransactionMatchingSection
|
|
|
|
|
refreshKey={transactionRefreshKey}
|
|
|
|
|
simplefinConn={simplefinConn}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Review & match transactions',
|
|
|
|
|
subtitle: 'Confirm which transactions paid which bills.',
|
|
|
|
|
icon: ArrowRightLeft,
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
|
|
|
|
<BillRulesManager />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
case 'import':
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
<Suspense fallback={<PaneSkeleton />}>
|
|
|
|
|
<ImportSpreadsheetSection
|
|
|
|
|
onHistoryRefresh={loadHistory}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Import a spreadsheet',
|
|
|
|
|
subtitle: 'Bring in bill & payment history from Excel.',
|
|
|
|
|
icon: FileSpreadsheet,
|
|
|
|
|
collapsible: true,
|
|
|
|
|
defaultOpen: true,
|
|
|
|
|
storageKey: 'billtracker:data.card.spreadsheet',
|
|
|
|
|
summary: 'Import bill/payment history from an XLSX workbook.',
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
|
|
|
|
</Suspense>
|
|
|
|
|
<ImportTransactionCsvSection
|
|
|
|
|
onHistoryRefresh={handleTransactionImportComplete}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Import transactions (CSV)',
|
|
|
|
|
subtitle: 'Upload a bank or card CSV export.',
|
|
|
|
|
icon: FileText,
|
|
|
|
|
collapsible: true,
|
|
|
|
|
defaultOpen: false,
|
|
|
|
|
storageKey: 'billtracker:data.card.transactionCsv',
|
|
|
|
|
summary: 'Upload bank or credit-card CSV transaction files.',
|
|
|
|
|
}}
|
feat(import): OFX/QFX transaction import (Batch 3)
New services/ofxImportService.js parses OFX 1.x (SGML, unclosed leaf tags),
OFX 2.x (XML) and QFX (+ Intuit tags ignored) into the same normalized shape the
CSV path produces, then writes through the SAME shared primitives (session table,
(user_id, data_source_id, provider_transaction_id) dedupe, import_history) — now
exported from csvTransactionImportService (additive; CSV tests still pass).
- Routes POST /api/import/ofx/{preview,commit} mirror the CSV two-step (raw
upload → structured commit; no column mapping since OFX is structured).
- UI: ImportOfxSection (upload → preview list → import) in the Import pane;
amounts shown via formatCentsUSD; toasts on preview/commit/malformed.
- Gap handling: signed TRNAMT → signed cents; DTPOSTED → YYYY-MM-DD; FITID →
stable provider id (hash fallback); non-OFX / empty files rejected clearly.
Tests: tests/ofxImportService.test.js (SGML + XML/QFX parse, entity decode,
signed cents, preview→commit, re-import dedupe, import_history). Server 129 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:11:59 -05:00
|
|
|
/>
|
|
|
|
|
<ImportOfxSection
|
|
|
|
|
onHistoryRefresh={handleTransactionImportComplete}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Import transactions (OFX/QFX)',
|
|
|
|
|
subtitle: 'Upload a bank OFX or QFX export — no column mapping needed.',
|
|
|
|
|
icon: FileText,
|
|
|
|
|
collapsible: true,
|
|
|
|
|
defaultOpen: false,
|
|
|
|
|
storageKey: 'billtracker:data.card.ofx',
|
|
|
|
|
summary: 'Upload an OFX/QFX transaction file.',
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
|
|
|
|
<SeedDemoDataSection
|
|
|
|
|
onSeeded={loadHistory}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Sample data',
|
|
|
|
|
subtitle: 'Load or clear demo data to explore features.',
|
|
|
|
|
icon: FlaskConical,
|
|
|
|
|
collapsible: true,
|
|
|
|
|
defaultOpen: false,
|
|
|
|
|
storageKey: 'billtracker:data.card.demo',
|
|
|
|
|
summary: 'Seed or clear demo data for testing.',
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
case 'export':
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
<DownloadMyDataSection
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Download your data',
|
|
|
|
|
subtitle: 'Export a full SQLite backup or an Excel file.',
|
|
|
|
|
icon: Download,
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
|
|
|
|
<Suspense fallback={<PaneSkeleton />}>
|
|
|
|
|
<ImportMyDataSection
|
|
|
|
|
onHistoryRefresh={loadHistory}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Restore from a backup',
|
|
|
|
|
subtitle: 'Load a previous SQLite export of your data.',
|
|
|
|
|
icon: RotateCcw,
|
|
|
|
|
collapsible: true,
|
|
|
|
|
defaultOpen: false,
|
|
|
|
|
storageKey: 'billtracker:data.card.sqliteImport',
|
|
|
|
|
summary: 'Restore data from a user SQLite export.',
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
|
|
|
|
</Suspense>
|
|
|
|
|
<ImportHistorySection
|
|
|
|
|
history={history}
|
|
|
|
|
loading={historyLoading}
|
|
|
|
|
onRefresh={loadHistory}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Recent activity',
|
|
|
|
|
subtitle: 'A log of your past imports.',
|
|
|
|
|
icon: History,
|
|
|
|
|
collapsible: true,
|
|
|
|
|
defaultOpen: false,
|
|
|
|
|
storageKey: 'billtracker:data.card.history',
|
|
|
|
|
summary: historyLoading
|
|
|
|
|
? 'Loading…'
|
|
|
|
|
: `${history?.length || 0} import record${(history?.length || 0) === 1 ? '' : 's'}.`,
|
|
|
|
|
}}
|
2026-07-03 15:02:36 -05:00
|
|
|
/>
|
feat(data): "Erase my data" danger zone (Batch 5)
New services/userDataService.js eraseUserData() permanently wipes a user's
financial + imported data in one transaction (child → parent order for FK
safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges),
transactions/accounts/data_sources, categories/groups, templates, snowball,
spending rules/budgets, merchant rules, imports, and per-user hint tables. It
PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences —
this resets your data, not your account — then re-seeds default categories and
writes an audit row to import_history.
- POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a
type-to-confirm token ("ERASE"), structured errors.
- UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented,
"download a backup first" nudge, type-to-confirm AlertDialog, toasts; on
success DataPage reloads all state.
Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B +
account + session, re-seeds categories, audited. Server 139 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:21:07 -05:00
|
|
|
<EraseDataSection
|
|
|
|
|
onErased={handleErased}
|
2026-07-06 14:23:53 -05:00
|
|
|
cardProps={{
|
|
|
|
|
title: 'Erase my data',
|
|
|
|
|
subtitle: 'Permanently wipe your financial data and start fresh.',
|
|
|
|
|
icon: ShieldAlert,
|
|
|
|
|
collapsible: true,
|
|
|
|
|
defaultOpen: false,
|
|
|
|
|
storageKey: 'billtracker:data.card.erase',
|
|
|
|
|
summary: 'Danger zone — permanently delete your data.',
|
|
|
|
|
}}
|
feat(data): "Erase my data" danger zone (Batch 5)
New services/userDataService.js eraseUserData() permanently wipes a user's
financial + imported data in one transaction (child → parent order for FK
safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges),
transactions/accounts/data_sources, categories/groups, templates, snowball,
spending rules/budgets, merchant rules, imports, and per-user hint tables. It
PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences —
this resets your data, not your account — then re-seeds default categories and
writes an audit row to import_history.
- POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a
type-to-confirm token ("ERASE"), structured errors.
- UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented,
"download a backup first" nudge, type-to-confirm AlertDialog, toasts; on
success DataPage reloads all state.
Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B +
account + session, re-seeds categories, audited. Server 139 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:21:07 -05:00
|
|
|
/>
|
2026-07-03 15:02:36 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const motionProps = reduceMotion
|
|
|
|
|
? {}
|
2026-07-06 14:23:53 -05:00
|
|
|
: {
|
|
|
|
|
initial: { opacity: 0, y: 6 },
|
|
|
|
|
animate: { opacity: 1, y: 0 },
|
|
|
|
|
exit: { opacity: 0, y: -6 },
|
|
|
|
|
transition: { duration: 0.15 },
|
|
|
|
|
};
|
2026-07-03 15:02:36 -05:00
|
|
|
|
2026-07-03 15:05:25 -05:00
|
|
|
// Health dot for Bank sync + "N to review" badge for Transactions.
|
2026-07-06 14:23:53 -05:00
|
|
|
const bankDot =
|
|
|
|
|
syncError || !syncEnabled || !hasConnections
|
|
|
|
|
? 'gray'
|
|
|
|
|
: simplefinConn?.last_error
|
|
|
|
|
? 'amber'
|
|
|
|
|
: 'green';
|
|
|
|
|
const navSections: DataNavSection[] = SECTIONS.map((s) =>
|
|
|
|
|
s.id === 'bank-sync'
|
|
|
|
|
? { ...s, dot: bankDot }
|
|
|
|
|
: s.id === 'transactions'
|
|
|
|
|
? { ...s, badge: unmatchedCount || undefined }
|
|
|
|
|
: s,
|
2026-07-03 15:05:25 -05:00
|
|
|
);
|
|
|
|
|
|
2026-05-04 23:34:24 -05:00
|
|
|
return (
|
|
|
|
|
<div className="mx-auto w-full max-w-6xl space-y-5">
|
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
|
2026-07-03 15:02:36 -05:00
|
|
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
|
|
|
Manage how your bills, payments & transactions get in and out.
|
2026-05-04 23:34:24 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-06-06 23:53:53 -05:00
|
|
|
<TooltipProvider delayDuration={300}>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground cursor-default">
|
|
|
|
|
User data only
|
|
|
|
|
</div>
|
|
|
|
|
</TooltipTrigger>
|
2026-07-06 14:23:53 -05:00
|
|
|
<TooltipContent>
|
|
|
|
|
This page only manages your own records — other users' data is not accessible here
|
|
|
|
|
</TooltipContent>
|
2026-06-06 23:53:53 -05:00
|
|
|
</Tooltip>
|
|
|
|
|
</TooltipProvider>
|
2026-05-04 23:34:24 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
<ConnectionHero
|
|
|
|
|
loading={syncLoading}
|
|
|
|
|
error={syncError}
|
|
|
|
|
enabled={syncEnabled}
|
|
|
|
|
hasConnections={hasConnections}
|
|
|
|
|
conn={simplefinConn}
|
2026-07-03 15:05:25 -05:00
|
|
|
txnTotal={txnTotal}
|
2026-07-03 15:02:36 -05:00
|
|
|
onRetry={loadSimplefinSummary}
|
|
|
|
|
onGoTo={goTo}
|
|
|
|
|
onSynced={handleSynced}
|
|
|
|
|
/>
|
2026-05-30 21:52:02 -05:00
|
|
|
|
2026-07-03 15:02:36 -05:00
|
|
|
<div className="grid gap-5 lg:grid-cols-[220px_1fr]">
|
2026-07-03 15:05:25 -05:00
|
|
|
<DataNav sections={navSections} active={activeSection} onSelect={goTo} />
|
2026-07-03 15:02:36 -05:00
|
|
|
<div ref={paneRef} tabIndex={-1} className="min-w-0 outline-none">
|
|
|
|
|
<AnimatePresence mode="wait">
|
|
|
|
|
<motion.div key={activeSection} {...motionProps}>
|
|
|
|
|
{renderPane()}
|
|
|
|
|
</motion.div>
|
|
|
|
|
</AnimatePresence>
|
2026-05-30 21:52:02 -05:00
|
|
|
</div>
|
2026-05-04 23:34:24 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|