271 lines
10 KiB
JavaScript
271 lines
10 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api';
|
|
import { cn } from '@/lib/utils';
|
|
import BankSyncSection from '@/components/data/BankSyncSection';
|
|
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
|
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
|
|
import ImportSpreadsheetSection from '@/components/data/ImportSpreadsheetSection';
|
|
import ImportMyDataSection from '@/components/data/ImportMyDataSection';
|
|
import SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
|
|
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
|
|
import ImportHistorySection from '@/components/data/ImportHistorySection';
|
|
|
|
const DATA_TAB_STORAGE_KEY = 'billtracker:data.activeTab';
|
|
const DATA_TABS = [
|
|
{
|
|
id: 'sync',
|
|
label: 'Sync & Match',
|
|
description: 'Bank connections, synced transactions, and CSV transaction imports.',
|
|
},
|
|
{
|
|
id: 'import',
|
|
label: 'Import Data',
|
|
description: 'Bring in spreadsheet history, restore an app export, or seed demo data.',
|
|
},
|
|
{
|
|
id: 'export',
|
|
label: 'Export & History',
|
|
description: 'Download your data and review previous import activity.',
|
|
},
|
|
];
|
|
|
|
function useStoredTab() {
|
|
const [activeTab, setActiveTabState] = useState(() => {
|
|
if (typeof window === 'undefined') return DATA_TABS[0].id;
|
|
const stored = window.localStorage.getItem(DATA_TAB_STORAGE_KEY);
|
|
return DATA_TABS.some(tab => tab.id === stored) ? stored : DATA_TABS[0].id;
|
|
});
|
|
|
|
const setActiveTab = useCallback((tab) => {
|
|
setActiveTabState(tab);
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem(DATA_TAB_STORAGE_KEY, tab);
|
|
}
|
|
}, []);
|
|
|
|
return [activeTab, setActiveTab];
|
|
}
|
|
|
|
function DataStatusStrip({ history, historyLoading, simplefinConn, syncLoading }) {
|
|
const syncStatus = simplefinConn
|
|
? (simplefinConn.last_error ? 'Needs attention' : 'Connected')
|
|
: syncLoading ? 'Loading…' : 'Not connected';
|
|
const syncTone = simplefinConn?.last_error
|
|
? 'text-amber-600 dark:text-amber-300'
|
|
: simplefinConn
|
|
? 'text-emerald-600 dark:text-emerald-300'
|
|
: 'text-muted-foreground';
|
|
|
|
return (
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">SimpleFIN</p>
|
|
<p className={cn('mt-1 truncate text-sm font-semibold', syncTone)}>{syncStatus}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Sync</p>
|
|
<p className="mt-1 truncate text-sm font-semibold text-foreground">
|
|
{simplefinConn?.last_sync_at ? new Date(simplefinConn.last_sync_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Import History</p>
|
|
<p className="mt-1 truncate text-sm font-semibold text-foreground">
|
|
{historyLoading ? 'Loading…' : `${history?.length || 0} record${(history?.length || 0) === 1 ? '' : 's'}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DataPage() {
|
|
const [history, setHistory] = useState(null);
|
|
const [historyLoading, setHistoryLoading] = useState(true);
|
|
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
|
|
const [simplefinConn, setSimplefinConn] = useState(null);
|
|
const [syncLoading, setSyncLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useStoredTab();
|
|
|
|
const loadHistory = async () => {
|
|
setHistoryLoading(true);
|
|
try {
|
|
const { history } = await api.importHistory();
|
|
setHistory(history);
|
|
} catch (err) {
|
|
setHistory([]);
|
|
toast.error(err.message || 'Failed to load import history.');
|
|
} finally {
|
|
setHistoryLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadSimplefinSummary = useCallback(async () => {
|
|
setSyncLoading(true);
|
|
try {
|
|
const [status, sources] = await Promise.all([
|
|
api.simplefinStatus(),
|
|
api.dataSources({ type: 'provider_sync' }),
|
|
]);
|
|
if (!status.enabled) {
|
|
setSimplefinConn(null);
|
|
return;
|
|
}
|
|
const conns = Array.isArray(sources) ? sources.filter(source => source.provider === 'simplefin') : [];
|
|
setSimplefinConn(conns[0] || null);
|
|
} catch {
|
|
setSimplefinConn(null);
|
|
} finally {
|
|
setSyncLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadHistory();
|
|
loadSimplefinSummary();
|
|
}, [loadSimplefinSummary]);
|
|
|
|
const handleTransactionImportComplete = () => {
|
|
loadHistory();
|
|
setTransactionRefreshKey(key => key + 1);
|
|
};
|
|
|
|
// Called by BankSyncSection when connection state changes (connect/sync/disconnect)
|
|
const handleConnectionChange = useCallback((conn) => {
|
|
setSimplefinConn(conn || null);
|
|
setSyncLoading(false);
|
|
setTransactionRefreshKey(key => key + 1);
|
|
}, []);
|
|
|
|
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="text-sm text-muted-foreground mt-0.5">
|
|
Import, export, and review your user-owned bill tracker records.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
|
User data only
|
|
</div>
|
|
</div>
|
|
|
|
<DataStatusStrip history={history} historyLoading={historyLoading} simplefinConn={simplefinConn} syncLoading={syncLoading} />
|
|
|
|
<div className="rounded-lg border border-border/70 bg-card/70 p-1">
|
|
<div className="grid gap-1 md:grid-cols-3">
|
|
{DATA_TABS.map(tab => {
|
|
const active = activeTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={cn(
|
|
'rounded-md px-4 py-3 text-left transition-colors',
|
|
active ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
|
|
)}
|
|
>
|
|
<span className="block text-sm font-semibold">{tab.label}</span>
|
|
<span className={cn('mt-0.5 block text-xs', active ? 'text-primary-foreground/75' : 'text-muted-foreground')}>
|
|
{tab.description}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === 'sync' && (
|
|
<div className="space-y-5">
|
|
<BankSyncSection
|
|
onConnectionChange={handleConnectionChange}
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: true,
|
|
storageKey: 'billtracker:data.card.bankSync',
|
|
summary: simplefinConn ? `Connected as ${simplefinConn.name || 'SimpleFIN'}` : 'Connect or manage SimpleFIN bank sync.',
|
|
}}
|
|
/>
|
|
<TransactionMatchingSection
|
|
refreshKey={transactionRefreshKey}
|
|
simplefinConn={simplefinConn}
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: true,
|
|
storageKey: 'billtracker:data.card.transactions',
|
|
summary: 'Review transaction matches and create bill payments.',
|
|
}}
|
|
/>
|
|
<ImportTransactionCsvSection
|
|
onHistoryRefresh={handleTransactionImportComplete}
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: false,
|
|
storageKey: 'billtracker:data.card.transactionCsv',
|
|
summary: 'Upload bank or credit-card CSV transaction files.',
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'import' && (
|
|
<div className="space-y-5">
|
|
<ImportSpreadsheetSection
|
|
onHistoryRefresh={loadHistory}
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: false,
|
|
storageKey: 'billtracker:data.card.spreadsheet',
|
|
summary: 'Import bill/payment history from an XLSX workbook.',
|
|
}}
|
|
/>
|
|
<ImportMyDataSection
|
|
onHistoryRefresh={loadHistory}
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: false,
|
|
storageKey: 'billtracker:data.card.sqliteImport',
|
|
summary: 'Restore data from a user SQLite export.',
|
|
}}
|
|
/>
|
|
<SeedDemoDataSection
|
|
onSeeded={loadHistory}
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: false,
|
|
storageKey: 'billtracker:data.card.demo',
|
|
summary: 'Seed or clear demo data for testing.',
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'export' && (
|
|
<div className="space-y-5">
|
|
<DownloadMyDataSection
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: true,
|
|
storageKey: 'billtracker:data.card.download',
|
|
summary: 'Download SQLite or Excel exports of your records.',
|
|
}}
|
|
/>
|
|
<ImportHistorySection
|
|
history={history}
|
|
loading={historyLoading}
|
|
onRefresh={loadHistory}
|
|
cardProps={{
|
|
collapsible: true,
|
|
defaultOpen: false,
|
|
storageKey: 'billtracker:data.card.history',
|
|
summary: historyLoading ? 'Loading import activity…' : `${history?.length || 0} import record${(history?.length || 0) === 1 ? '' : 's'}.`,
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|