From dfedb75e6d68fe91c09c7d2d9da1a5ab1a920073 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 21:33:25 -0500 Subject: [PATCH] refactor(ts): convert data/ shared + small sections to TypeScript dataShared (SectionCard/CountPill/importErrorState), DataNav, ConnectionHero, DownloadMyData/Erase/SeedDemo/ImportHistory/AutoMatchReview/ImportOfx sections. Branded Cents on OFX preview txns, Dollars on auto-match amounts; SectionCardProps exported for section cardProps passthrough. typecheck 0, build green. --- ...utoMatchReview.jsx => AutoMatchReview.tsx} | 34 +++++++---- ...{ConnectionHero.jsx => ConnectionHero.tsx} | 60 +++++++++++++------ .../data/{DataNav.jsx => DataNav.tsx} | 22 +++++-- ...aSection.jsx => DownloadMyDataSection.tsx} | 28 +++++---- ...seDataSection.jsx => EraseDataSection.tsx} | 15 +++-- ...rySection.jsx => ImportHistorySection.tsx} | 24 +++++++- ...ortOfxSection.jsx => ImportOfxSection.tsx} | 40 +++++++++---- ...ataSection.jsx => SeedDemoDataSection.tsx} | 25 ++++---- .../data/{dataShared.jsx => dataShared.tsx} | 54 ++++++++++++----- 9 files changed, 211 insertions(+), 91 deletions(-) rename client/components/data/{AutoMatchReview.jsx => AutoMatchReview.tsx} (82%) rename client/components/data/{ConnectionHero.jsx => ConnectionHero.tsx} (80%) rename client/components/data/{DataNav.jsx => DataNav.tsx} (84%) rename client/components/data/{DownloadMyDataSection.jsx => DownloadMyDataSection.tsx} (91%) rename client/components/data/{EraseDataSection.jsx => EraseDataSection.tsx} (90%) rename client/components/data/{ImportHistorySection.jsx => ImportHistorySection.tsx} (81%) rename client/components/data/{ImportOfxSection.jsx => ImportOfxSection.tsx} (82%) rename client/components/data/{SeedDemoDataSection.jsx => SeedDemoDataSection.tsx} (84%) rename client/components/data/{dataShared.jsx => dataShared.tsx} (67%) diff --git a/client/components/data/AutoMatchReview.jsx b/client/components/data/AutoMatchReview.tsx similarity index 82% rename from client/components/data/AutoMatchReview.jsx rename to client/components/data/AutoMatchReview.tsx index 38e39f0..1004bf2 100644 --- a/client/components/data/AutoMatchReview.jsx +++ b/client/components/data/AutoMatchReview.tsx @@ -1,36 +1,46 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Undo2, ChevronDown, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import { formatUSD } from '@/lib/money'; +import { cn, errMessage } from '@/lib/utils'; +import { formatUSD, type Dollars } from '@/lib/money'; -function fmtAmt(dollars) { +interface AutoMatchItem { + id: number; + transaction_id?: number; + payee?: string | null; + description?: string | null; + bill_name?: string; + paid_date?: string | null; + amount: Dollars; +} + +function fmtAmt(dollars: Dollars) { return formatUSD(dollars, { dash: true }); } -function fmtDate(iso) { +function fmtDate(iso: string | null | undefined) { if (!iso) return ''; const d = new Date(`${iso}T00:00:00`); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } -function payeeLabel(row) { +function payeeLabel(row: AutoMatchItem) { return row.payee || row.description || `Transaction #${row.transaction_id}`; } -export default function AutoMatchReview({ refreshKey }) { - const [items, setItems] = useState([]); +export default function AutoMatchReview({ refreshKey }: { refreshKey?: number | string }) { + const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(true); - const [undoing, setUndoing] = useState(null); + const [undoing, setUndoing] = useState(null); const load = useCallback(async () => { setLoading(true); try { const rows = await api.recentAutoMatched(); - setItems(Array.isArray(rows) ? rows : []); + setItems(Array.isArray(rows) ? rows as AutoMatchItem[] : []); } catch { // Non-blocking — don't surface errors for a review panel } finally { @@ -40,14 +50,14 @@ export default function AutoMatchReview({ refreshKey }) { useEffect(() => { load(); }, [load, refreshKey]); - async function handleUndo(item) { + async function handleUndo(item: AutoMatchItem) { setUndoing(item.id); try { await api.undoAutoMatch(item.id); setItems(prev => prev.filter(p => p.id !== item.id)); toast.success(`Undone — ${payeeLabel(item)} unlinked from "${item.bill_name}"`); } catch (err) { - toast.error(err.message || 'Failed to undo auto-match'); + toast.error(errMessage(err, 'Failed to undo auto-match')); } finally { setUndoing(null); } diff --git a/client/components/data/ConnectionHero.jsx b/client/components/data/ConnectionHero.tsx similarity index 80% rename from client/components/data/ConnectionHero.jsx rename to client/components/data/ConnectionHero.tsx index be03f67..5ecb2c1 100644 --- a/client/components/data/ConnectionHero.jsx +++ b/client/components/data/ConnectionHero.tsx @@ -1,12 +1,12 @@ -import { useState } from 'react'; +import { useState, type ComponentType, type ReactNode } from 'react'; import { toast } from 'sonner'; import { Landmark, RefreshCw, Loader2, AlertTriangle, RotateCcw, Upload, Plus } from 'lucide-react'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; // Relative "2h ago" / "3d ago"; returns null for empty input. -function relativeTime(iso) { +function relativeTime(iso: string | null | undefined): string | null { if (!iso) return null; const then = new Date(iso).getTime(); if (Number.isNaN(then)) return null; @@ -20,19 +20,23 @@ function relativeTime(iso) { return `${days}d ago`; } -function HeroShell({ tone = 'default', icon: Icon, children }) { - const toneRing = { +function HeroShell({ tone = 'default', icon: Icon, children }: { + tone?: string; + icon: ComponentType<{ className?: string }>; + children: ReactNode; +}) { + const toneRing = ({ default: 'border-border/60', good: 'border-emerald-500/30', warn: 'border-amber-500/40', error: 'border-rose-500/30', - }[tone]; - const toneChip = { + } as Record)[tone]; + const toneChip = ({ default: 'bg-muted/50 text-muted-foreground', good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', error: 'bg-rose-500/10 text-rose-600 dark:text-rose-400', - }[tone]; + } as Record)[tone]; return (
@@ -43,6 +47,24 @@ function HeroShell({ tone = 'default', icon: Icon, children }) { ); } +interface ConnData { + name?: string | null; + last_sync_at?: string | null; + last_error?: string | null; +} + +interface ConnectionHeroProps { + loading?: boolean; + error?: unknown; // truthy when the status/summary fetch failed + enabled?: boolean; // status.enabled (server feature flag) + hasConnections?: boolean; // status.has_connections + conn?: ConnData | null; // the simplefin data_source (name, last_sync_at, last_error) or null + txnTotal?: number | null; // total synced transactions (at-a-glance), or null + onRetry?: () => void; + onGoTo?: (sectionId: string) => void; // (sectionId) => void + onSynced?: () => void; // () => void — refresh after a successful sync +} + /** * The Data page's connection status hero. Five states, so a network blip is never * mistaken for "not connected", and a server without bank sync never gets a dead @@ -51,21 +73,21 @@ function HeroShell({ tone = 'default', icon: Icon, children }) { */ export default function ConnectionHero({ loading, - error, // truthy when the status/summary fetch failed - enabled, // status.enabled (server feature flag) - hasConnections, // status.has_connections - conn, // the simplefin data_source (name, last_sync_at, last_error) or null - txnTotal, // total synced transactions (at-a-glance), or null + error, + enabled, + hasConnections, + conn, + txnTotal, onRetry, - onGoTo, // (sectionId) => void - onSynced, // () => void — refresh after a successful sync -}) { + onGoTo, + onSynced, +}: ConnectionHeroProps) { const [syncing, setSyncing] = useState(false); async function handleSyncNow() { setSyncing(true); try { - const r = await api.syncAllSources(); + const r = await api.syncAllSources() as { errors?: unknown[]; transactions_new?: number }; const errs = Array.isArray(r?.errors) ? r.errors : []; if (errs.length) { toast.warning(`Synced, but ${errs.length} connection${errs.length === 1 ? '' : 's'} need attention.`); @@ -75,8 +97,8 @@ export default function ConnectionHero({ } onSynced?.(); } catch (err) { - if (err?.status === 429) toast.error('Please wait a moment before syncing again.'); - else toast.error(err?.message || 'Sync failed.'); + if ((err as { status?: number })?.status === 429) toast.error('Please wait a moment before syncing again.'); + else toast.error(errMessage(err, 'Sync failed.')); } finally { setSyncing(false); } diff --git a/client/components/data/DataNav.jsx b/client/components/data/DataNav.tsx similarity index 84% rename from client/components/data/DataNav.jsx rename to client/components/data/DataNav.tsx index 762bf6c..e537e6a 100644 --- a/client/components/data/DataNav.jsx +++ b/client/components/data/DataNav.tsx @@ -1,20 +1,34 @@ +import type { ComponentType } from 'react'; import { cn } from '@/lib/utils'; -const DOT_TONES = { +const DOT_TONES: Record = { green: 'bg-emerald-500', amber: 'bg-amber-500', red: 'bg-rose-500', gray: 'bg-muted-foreground/40', }; +export interface DataNavSection { + id: string; + label: string; + description?: string; + icon?: ComponentType<{ className?: string }>; + dot?: string; + badge?: number | null; +} + +interface DataNavProps { + sections: DataNavSection[]; + active: string; + onSelect: (id: string) => void; +} + /** * Goal-oriented navigation for the Data page. Desktop (≥ lg): a sticky vertical * list. Mobile: a horizontally-scrollable segmented control. One