BillTracker/client/pages/DataPage.tsx

461 lines
16 KiB
TypeScript
Raw Normal View History

import {
useState,
useEffect,
useCallback,
useRef,
Suspense,
lazy,
type ComponentType,
type ReactNode,
} from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import {
Landmark,
ArrowRightLeft,
Upload,
DatabaseBackup,
FileSpreadsheet,
FileText,
FlaskConical,
Download,
RotateCcw,
History,
ShieldAlert,
} from 'lucide-react';
2026-05-03 19:51:57 -05:00
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import ConnectionHero from '@/components/data/ConnectionHero';
import DataNav, { type DataNavSection } from '@/components/data/DataNav';
import BankSyncSection from '@/components/data/BankSyncSection';
import BillRulesManager from '@/components/BillRulesManager';
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
import ImportOfxSection from '@/components/data/ImportOfxSection';
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
import SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
import ImportHistorySection, {
type ImportHistoryRow,
} from '@/components/data/ImportHistorySection';
import EraseDataSection from '@/components/data/EraseDataSection';
2026-05-09 13:03: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'));
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[] = [
{ 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,
},
];
const SECTION_IDS = SECTIONS.map((s) => s.id);
const DEFAULT_SECTION = SECTIONS[0]!.id;
const SECTION_KEY = 'billtracker:data.section';
const LEGACY_KEY = 'billtracker:data.activeTab'; // old 3-tab key → migrate
function storedSection(): string | null {
if (typeof window === 'undefined') return null;
const s = window.localStorage.getItem(SECTION_KEY);
return s && SECTION_IDS.includes(s) ? s : null;
}
function PaneSkeleton() {
return (
<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" />
</div>
);
}
2026-05-03 19:51:57 -05:00
export default function DataPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [history, setHistory] = useState<ImportHistoryRow[] | null>(null);
2026-05-04 23:34:24 -05:00
const [historyLoading, setHistoryLoading] = useState(true);
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
const [simplefinConn, setSimplefinConn] = useState<Conn | null>(null);
const [syncEnabled, setSyncEnabled] = useState(true);
const [hasConnections, setHasConnections] = useState(false);
const [syncLoading, setSyncLoading] = useState(true);
const [syncError, setSyncError] = useState(false);
const [unmatchedCount, setUnmatchedCount] = useState(0);
const [txnTotal, setTxnTotal] = useState<number | null>(null);
const reduceMotion = useReducedMotion();
const paneRef = useRef<HTMLDivElement>(null);
const firstRender = useRef(true);
// Active section: URL ?section= is source of truth → localStorage → default.
const urlSection = searchParams.get('section');
const activeSection =
urlSection && SECTION_IDS.includes(urlSection)
? urlSection
: storedSection() || DEFAULT_SECTION;
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],
);
// 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(() => {
if (urlSection && SECTION_IDS.includes(urlSection)) return;
const legacy = window.localStorage.getItem(LEGACY_KEY);
const migrated = legacy
? ({ sync: 'bank-sync', import: 'import', export: 'export' } as Record<string, string>)[
legacy
]
: undefined;
const target = storedSection() || migrated || DEFAULT_SECTION;
setSearchParams(
(prev) => {
const p = new URLSearchParams(prev);
p.set('section', target);
return p;
},
{ replace: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Move focus to the pane on section change (keyboard/SR), but not on first load.
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
paneRef.current?.focus?.();
}, [activeSection]);
2026-05-04 23:34:24 -05:00
const loadHistory = useCallback(async () => {
2026-05-04 23:34:24 -05:00
setHistoryLoading(true);
try {
const res = (await api.importHistory()) as { history?: ImportHistoryRow[] };
setHistory(res.history ?? []);
} catch (err) {
2026-05-04 23:34:24 -05:00
setHistory([]);
toast.error(errMessage(err, 'Failed to load import history.'));
2026-05-04 23:34:24 -05:00
} finally {
setHistoryLoading(false);
}
}, []);
2026-05-04 23:34:24 -05:00
const loadSimplefinSummary = useCallback(async () => {
setSyncLoading(true);
setSyncError(false);
try {
const [status, sources] = await Promise.all([
api.simplefinStatus() as Promise<{ enabled?: boolean; has_connections?: boolean }>,
api.dataSources({ type: 'provider_sync' }),
]);
setSyncEnabled(Boolean(status.enabled));
setHasConnections(Boolean(status.has_connections));
const conns = Array.isArray(sources)
? (sources as Conn[]).filter((s) => s.provider === 'simplefin')
: [];
setSimplefinConn(conns[0] || null);
} catch {
setSyncError(true);
setSimplefinConn(null);
} finally {
setSyncLoading(false);
}
}, []);
// Cheap "N to review" + transaction count (summary is aggregate, so limit:1 is enough).
const loadTxnStats = useCallback(async () => {
try {
const data = (await api.bankTransactionsLedger({ limit: 1 })) as {
summary?: { unmatched?: number; total?: number };
};
setUnmatchedCount(data?.summary?.unmatched || 0);
setTxnTotal(data?.summary?.total ?? null);
} catch {
setUnmatchedCount(0);
}
}, []);
useEffect(() => {
loadHistory();
loadSimplefinSummary();
}, [loadHistory, loadSimplefinSummary]);
useEffect(() => {
loadTxnStats();
}, [loadTxnStats, transactionRefreshKey]);
2026-05-04 23:34:24 -05:00
const handleTransactionImportComplete = useCallback(() => {
loadHistory();
setTransactionRefreshKey((k) => k + 1);
}, [loadHistory]);
// BankSyncSection reports connect/sync/disconnect changes.
const handleConnectionChange = useCallback((conn: Conn | null) => {
setSimplefinConn(conn || null);
setHasConnections(Boolean(conn));
setSyncLoading(false);
setTransactionRefreshKey((k) => k + 1);
}, []);
const handleSynced = useCallback(() => {
loadSimplefinSummary();
setTransactionRefreshKey((k) => k + 1);
}, [loadSimplefinSummary]);
const handleErased = useCallback(() => {
loadHistory();
loadSimplefinSummary();
setTransactionRefreshKey((k) => k + 1);
}, [loadHistory, loadSimplefinSummary]);
const renderPane = (): ReactNode => {
switch (activeSection) {
case 'bank-sync':
return (
<BankSyncSection
onConnectionChange={handleConnectionChange}
cardProps={{
title: 'Connect your bank',
subtitle: 'Securely sync transactions automatically (via SimpleFIN).',
icon: Landmark,
}}
/>
);
case 'transactions':
return (
<div className="space-y-5">
<TransactionMatchingSection
refreshKey={transactionRefreshKey}
simplefinConn={simplefinConn}
cardProps={{
title: 'Review & match transactions',
subtitle: 'Confirm which transactions paid which bills.',
icon: ArrowRightLeft,
}}
/>
<BillRulesManager />
</div>
);
case 'import':
return (
<div className="space-y-5">
<Suspense fallback={<PaneSkeleton />}>
<ImportSpreadsheetSection
onHistoryRefresh={loadHistory}
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.',
}}
/>
</Suspense>
<ImportTransactionCsvSection
onHistoryRefresh={handleTransactionImportComplete}
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.',
}}
/>
<ImportOfxSection
onHistoryRefresh={handleTransactionImportComplete}
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.',
}}
/>
<SeedDemoDataSection
onSeeded={loadHistory}
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.',
}}
/>
</div>
);
case 'export':
return (
<div className="space-y-5">
<DownloadMyDataSection
cardProps={{
title: 'Download your data',
subtitle: 'Export a full SQLite backup or an Excel file.',
icon: Download,
}}
/>
<Suspense fallback={<PaneSkeleton />}>
<ImportMyDataSection
onHistoryRefresh={loadHistory}
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.',
}}
/>
</Suspense>
<ImportHistorySection
history={history}
loading={historyLoading}
onRefresh={loadHistory}
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'}.`,
}}
/>
<EraseDataSection
onErased={handleErased}
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.',
}}
/>
</div>
);
default:
return null;
}
};
const motionProps = reduceMotion
? {}
: {
initial: { opacity: 0, y: 6 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -6 },
transition: { duration: 0.15 },
};
// Health dot for Bank sync + "N to review" badge for Transactions.
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-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>
<p className="mt-0.5 text-sm text-muted-foreground">
Manage how your bills, payments &amp; transactions get in and out.
2026-05-04 23:34:24 -05:00
</p>
</div>
<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>
<TooltipContent>
This page only manages your own records other users' data is not accessible here
</TooltipContent>
</Tooltip>
</TooltipProvider>
2026-05-04 23:34:24 -05:00
</div>
<ConnectionHero
loading={syncLoading}
error={syncError}
enabled={syncEnabled}
hasConnections={hasConnections}
conn={simplefinConn}
txnTotal={txnTotal}
onRetry={loadSimplefinSummary}
onGoTo={goTo}
onSynced={handleSynced}
/>
<div className="grid gap-5 lg:grid-cols-[220px_1fr]">
<DataNav sections={navSections} active={activeSection} onSelect={goTo} />
<div ref={paneRef} tabIndex={-1} className="min-w-0 outline-none">
<AnimatePresence mode="wait">
<motion.div key={activeSection} {...motionProps}>
{renderPane()}
</motion.div>
</AnimatePresence>
</div>
2026-05-04 23:34:24 -05:00
</div>
</div>
);
2026-05-03 19:51:57 -05:00
}