refactor(ts): DataPage → TypeScript (section routing + data-section orchestration)
This commit is contained in:
parent
2f4f76db83
commit
42e67dfdeb
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from 'react';
|
import { useState, useEffect, useCallback, useRef, Suspense, lazy, type ComponentType, type ReactNode } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
|
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
|
||||||
|
|
@ -7,9 +7,10 @@ import {
|
||||||
FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History, ShieldAlert,
|
FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History, ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { errMessage } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import ConnectionHero from '@/components/data/ConnectionHero';
|
import ConnectionHero from '@/components/data/ConnectionHero';
|
||||||
import DataNav from '@/components/data/DataNav';
|
import DataNav, { type DataNavSection } from '@/components/data/DataNav';
|
||||||
import BankSyncSection from '@/components/data/BankSyncSection';
|
import BankSyncSection from '@/components/data/BankSyncSection';
|
||||||
import BillRulesManager from '@/components/BillRulesManager';
|
import BillRulesManager from '@/components/BillRulesManager';
|
||||||
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
||||||
|
|
@ -17,28 +18,44 @@ import ImportOfxSection from '@/components/data/ImportOfxSection';
|
||||||
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
|
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
|
||||||
import SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
|
import SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
|
||||||
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
|
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
|
||||||
import ImportHistorySection from '@/components/data/ImportHistorySection';
|
import ImportHistorySection, { type ImportHistoryRow } from '@/components/data/ImportHistorySection';
|
||||||
import EraseDataSection from '@/components/data/EraseDataSection';
|
import EraseDataSection from '@/components/data/EraseDataSection';
|
||||||
|
|
||||||
// Heavy panes (XLSX parsing / SQLite restore) — code-split, loaded on demand.
|
// Heavy panes (XLSX parsing / SQLite restore) — code-split, loaded on demand.
|
||||||
const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection'));
|
const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection'));
|
||||||
const ImportMyDataSection = lazy(() => import('@/components/data/ImportMyDataSection'));
|
const ImportMyDataSection = lazy(() => import('@/components/data/ImportMyDataSection'));
|
||||||
|
|
||||||
const SECTIONS = [
|
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: 'bank-sync', label: 'Bank sync', description: 'Connect & sync your bank', icon: Landmark },
|
||||||
{ id: 'transactions', label: 'Transactions', description: 'Review & match', icon: ArrowRightLeft },
|
{ id: 'transactions', label: 'Transactions', description: 'Review & match', icon: ArrowRightLeft },
|
||||||
{ id: 'import', label: 'Import', description: 'Bring in existing data', icon: Upload },
|
{ id: 'import', label: 'Import', description: 'Bring in existing data', icon: Upload },
|
||||||
{ id: 'export', label: 'Export & backups', description: 'Download & restore', icon: DatabaseBackup },
|
{ id: 'export', label: 'Export & backups', description: 'Download & restore', icon: DatabaseBackup },
|
||||||
];
|
];
|
||||||
const SECTION_IDS = SECTIONS.map(s => s.id);
|
const SECTION_IDS = SECTIONS.map(s => s.id);
|
||||||
const DEFAULT_SECTION = SECTIONS[0].id;
|
const DEFAULT_SECTION = SECTIONS[0]!.id;
|
||||||
const SECTION_KEY = 'billtracker:data.section';
|
const SECTION_KEY = 'billtracker:data.section';
|
||||||
const LEGACY_KEY = 'billtracker:data.activeTab'; // old 3-tab key → migrate
|
const LEGACY_KEY = 'billtracker:data.activeTab'; // old 3-tab key → migrate
|
||||||
|
|
||||||
function storedSection() {
|
function storedSection(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
const s = window.localStorage.getItem(SECTION_KEY);
|
const s = window.localStorage.getItem(SECTION_KEY);
|
||||||
return SECTION_IDS.includes(s) ? s : null;
|
return s && SECTION_IDS.includes(s) ? s : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaneSkeleton() {
|
function PaneSkeleton() {
|
||||||
|
|
@ -52,25 +69,25 @@ function PaneSkeleton() {
|
||||||
|
|
||||||
export default function DataPage() {
|
export default function DataPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [history, setHistory] = useState(null);
|
const [history, setHistory] = useState<ImportHistoryRow[] | null>(null);
|
||||||
const [historyLoading, setHistoryLoading] = useState(true);
|
const [historyLoading, setHistoryLoading] = useState(true);
|
||||||
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
|
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
|
||||||
const [simplefinConn, setSimplefinConn] = useState(null);
|
const [simplefinConn, setSimplefinConn] = useState<Conn | null>(null);
|
||||||
const [syncEnabled, setSyncEnabled] = useState(true);
|
const [syncEnabled, setSyncEnabled] = useState(true);
|
||||||
const [hasConnections, setHasConnections] = useState(false);
|
const [hasConnections, setHasConnections] = useState(false);
|
||||||
const [syncLoading, setSyncLoading] = useState(true);
|
const [syncLoading, setSyncLoading] = useState(true);
|
||||||
const [syncError, setSyncError] = useState(false);
|
const [syncError, setSyncError] = useState(false);
|
||||||
const [unmatchedCount, setUnmatchedCount] = useState(0);
|
const [unmatchedCount, setUnmatchedCount] = useState(0);
|
||||||
const [txnTotal, setTxnTotal] = useState(null);
|
const [txnTotal, setTxnTotal] = useState<number | null>(null);
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const paneRef = useRef(null);
|
const paneRef = useRef<HTMLDivElement>(null);
|
||||||
const firstRender = useRef(true);
|
const firstRender = useRef(true);
|
||||||
|
|
||||||
// Active section: URL ?section= is source of truth → localStorage → default.
|
// Active section: URL ?section= is source of truth → localStorage → default.
|
||||||
const urlSection = searchParams.get('section');
|
const urlSection = searchParams.get('section');
|
||||||
const activeSection = SECTION_IDS.includes(urlSection) ? urlSection : (storedSection() || DEFAULT_SECTION);
|
const activeSection = (urlSection && SECTION_IDS.includes(urlSection)) ? urlSection : (storedSection() || DEFAULT_SECTION);
|
||||||
|
|
||||||
const goTo = useCallback((id) => {
|
const goTo = useCallback((id: string) => {
|
||||||
if (!SECTION_IDS.includes(id)) return;
|
if (!SECTION_IDS.includes(id)) return;
|
||||||
window.localStorage.setItem(SECTION_KEY, id);
|
window.localStorage.setItem(SECTION_KEY, id);
|
||||||
setSearchParams(prev => {
|
setSearchParams(prev => {
|
||||||
|
|
@ -83,9 +100,9 @@ export default function DataPage() {
|
||||||
// Reflect the resolved section into the URL once, so refresh/back-button work
|
// 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.
|
// (and migrate the old 3-tab key). Runs only when the URL lacks a valid section.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SECTION_IDS.includes(urlSection)) return;
|
if (urlSection && SECTION_IDS.includes(urlSection)) return;
|
||||||
const legacy = window.localStorage.getItem(LEGACY_KEY);
|
const legacy = window.localStorage.getItem(LEGACY_KEY);
|
||||||
const migrated = { sync: 'bank-sync', import: 'import', export: 'export' }[legacy];
|
const migrated = legacy ? ({ sync: 'bank-sync', import: 'import', export: 'export' } as Record<string, string>)[legacy] : undefined;
|
||||||
const target = storedSection() || migrated || DEFAULT_SECTION;
|
const target = storedSection() || migrated || DEFAULT_SECTION;
|
||||||
setSearchParams(prev => {
|
setSearchParams(prev => {
|
||||||
const p = new URLSearchParams(prev);
|
const p = new URLSearchParams(prev);
|
||||||
|
|
@ -104,11 +121,11 @@ export default function DataPage() {
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
setHistoryLoading(true);
|
setHistoryLoading(true);
|
||||||
try {
|
try {
|
||||||
const { history } = await api.importHistory();
|
const res = await api.importHistory() as { history?: ImportHistoryRow[] };
|
||||||
setHistory(history);
|
setHistory(res.history ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
toast.error(err.message || 'Failed to load import history.');
|
toast.error(errMessage(err, 'Failed to load import history.'));
|
||||||
} finally {
|
} finally {
|
||||||
setHistoryLoading(false);
|
setHistoryLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -119,12 +136,12 @@ export default function DataPage() {
|
||||||
setSyncError(false);
|
setSyncError(false);
|
||||||
try {
|
try {
|
||||||
const [status, sources] = await Promise.all([
|
const [status, sources] = await Promise.all([
|
||||||
api.simplefinStatus(),
|
api.simplefinStatus() as Promise<{ enabled?: boolean; has_connections?: boolean }>,
|
||||||
api.dataSources({ type: 'provider_sync' }),
|
api.dataSources({ type: 'provider_sync' }),
|
||||||
]);
|
]);
|
||||||
setSyncEnabled(Boolean(status.enabled));
|
setSyncEnabled(Boolean(status.enabled));
|
||||||
setHasConnections(Boolean(status.has_connections));
|
setHasConnections(Boolean(status.has_connections));
|
||||||
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
|
const conns = Array.isArray(sources) ? (sources as Conn[]).filter(s => s.provider === 'simplefin') : [];
|
||||||
setSimplefinConn(conns[0] || null);
|
setSimplefinConn(conns[0] || null);
|
||||||
} catch {
|
} catch {
|
||||||
setSyncError(true);
|
setSyncError(true);
|
||||||
|
|
@ -137,7 +154,7 @@ export default function DataPage() {
|
||||||
// Cheap "N to review" + transaction count (summary is aggregate, so limit:1 is enough).
|
// Cheap "N to review" + transaction count (summary is aggregate, so limit:1 is enough).
|
||||||
const loadTxnStats = useCallback(async () => {
|
const loadTxnStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.bankTransactionsLedger({ limit: 1 });
|
const data = await api.bankTransactionsLedger({ limit: 1 }) as { summary?: { unmatched?: number; total?: number } };
|
||||||
setUnmatchedCount(data?.summary?.unmatched || 0);
|
setUnmatchedCount(data?.summary?.unmatched || 0);
|
||||||
setTxnTotal(data?.summary?.total ?? null);
|
setTxnTotal(data?.summary?.total ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -154,7 +171,7 @@ export default function DataPage() {
|
||||||
}, [loadHistory]);
|
}, [loadHistory]);
|
||||||
|
|
||||||
// BankSyncSection reports connect/sync/disconnect changes.
|
// BankSyncSection reports connect/sync/disconnect changes.
|
||||||
const handleConnectionChange = useCallback((conn) => {
|
const handleConnectionChange = useCallback((conn: Conn | null) => {
|
||||||
setSimplefinConn(conn || null);
|
setSimplefinConn(conn || null);
|
||||||
setHasConnections(Boolean(conn));
|
setHasConnections(Boolean(conn));
|
||||||
setSyncLoading(false);
|
setSyncLoading(false);
|
||||||
|
|
@ -172,7 +189,7 @@ export default function DataPage() {
|
||||||
setTransactionRefreshKey(k => k + 1);
|
setTransactionRefreshKey(k => k + 1);
|
||||||
}, [loadHistory, loadSimplefinSummary]);
|
}, [loadHistory, loadSimplefinSummary]);
|
||||||
|
|
||||||
const renderPane = () => {
|
const renderPane = (): ReactNode => {
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case 'bank-sync':
|
case 'bank-sync':
|
||||||
return (
|
return (
|
||||||
|
|
@ -250,7 +267,7 @@ export default function DataPage() {
|
||||||
|
|
||||||
// Health dot for Bank sync + "N to review" badge for Transactions.
|
// Health dot for Bank sync + "N to review" badge for Transactions.
|
||||||
const bankDot = (syncError || !syncEnabled || !hasConnections) ? 'gray' : simplefinConn?.last_error ? 'amber' : 'green';
|
const bankDot = (syncError || !syncEnabled || !hasConnections) ? 'gray' : simplefinConn?.last_error ? 'amber' : 'green';
|
||||||
const navSections = SECTIONS.map(s =>
|
const navSections: DataNavSection[] = SECTIONS.map(s =>
|
||||||
s.id === 'bank-sync' ? { ...s, dot: bankDot }
|
s.id === 'bank-sync' ? { ...s, dot: bankDot }
|
||||||
: s.id === 'transactions' ? { ...s, badge: unmatchedCount || undefined }
|
: s.id === 'transactions' ? { ...s, badge: unmatchedCount || undefined }
|
||||||
: s,
|
: s,
|
||||||
Loading…
Reference in New Issue