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.
This commit is contained in:
null 2026-07-04 21:33:25 -05:00
parent a8496a9c64
commit dfedb75e6d
9 changed files with 211 additions and 91 deletions

View File

@ -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<AutoMatchItem[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(true);
const [undoing, setUndoing] = useState(null);
const [undoing, setUndoing] = useState<number | null>(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);
}

View File

@ -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<string, string>)[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<string, string>)[tone];
return (
<div className={cn('surface flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:gap-4', toneRing)}>
<span className={cn('grid h-11 w-11 shrink-0 place-items-center rounded-xl', toneChip)}>
@ -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);
}

View File

@ -1,20 +1,34 @@
import type { ComponentType } from 'react';
import { cn } from '@/lib/utils';
const DOT_TONES = {
const DOT_TONES: Record<string, string> = {
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 <nav> landmark,
* aria-current on the active item, fully keyboard-operable (native buttons).
*
* sections: [{ id, label, description?, icon, dot?, badge? }]
*/
export default function DataNav({ sections, active, onSelect }) {
export default function DataNav({ sections, active, onSelect }: DataNavProps) {
return (
<nav aria-label="Data sections" className="lg:sticky lg:top-20">
<ul className="flex gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">

View File

@ -1,24 +1,24 @@
import React, { useState } from 'react';
import { useState, type ComponentType } from 'react';
import { toast } from 'sonner';
import { Database, Download, FileSpreadsheet, FileJson, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { SectionCard } from './dataShared';
import { cn, errMessage } from '@/lib/utils';
import { SectionCard, type SectionCardProps } from './dataShared';
// Shared blob download: works whether or not the response sets Content-Disposition
// (the JSON payments export doesn't), falling back to the given name.
async function downloadFile(endpoint, fallbackName) {
async function downloadFile(endpoint: string, fallbackName: string): Promise<void> {
const res = await fetch(endpoint, { credentials: 'include' });
if (!res.ok) {
let data = {};
try { data = await res.json(); } catch {}
let data: { message?: string; error?: string } = {};
try { data = await res.json(); } catch { /* ignore */ }
throw new Error(data.message || data.error || `HTTP ${res.status}`);
}
const disposition = res.headers.get('Content-Disposition');
const match = disposition?.match(/filename="?([^"]+)"?/i);
const name = match ? match[1] : fallbackName;
const name = match?.[1] || fallbackName;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@ -26,7 +26,13 @@ async function downloadFile(endpoint, fallbackName) {
URL.revokeObjectURL(url);
}
function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
function ExportCard({ icon: Icon, title, description, filename, endpoint }: {
icon: ComponentType<{ className?: string }>;
title: string;
description: string;
filename: string;
endpoint: string;
}) {
const [loading, setLoading] = useState(false);
const handleDownload = async () => {
setLoading(true);
@ -34,7 +40,7 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
await downloadFile(endpoint, filename);
toast.success(`${title} downloaded.`);
} catch (err) {
toast.error(err.message || 'Download failed.');
toast.error(errMessage(err, 'Download failed.'));
} finally {
setLoading(false);
}
@ -77,7 +83,7 @@ function PaymentsExport() {
await downloadFile(`/api/export?${params.toString()}`, `bills.${format === 'json' ? 'json' : 'csv'}`);
toast.success('Payments exported.');
} catch (err) {
toast.error(err.message || 'Export failed.');
toast.error(errMessage(err, 'Export failed.'));
} finally {
setLoading(false);
}
@ -124,7 +130,7 @@ function PaymentsExport() {
);
}
export default function DownloadMyDataSection({ cardProps = {} }) {
export default function DownloadMyDataSection({ cardProps = {} }: { cardProps?: Partial<SectionCardProps> }) {
return (
<SectionCard
title="Download My Data"

View File

@ -2,20 +2,24 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { ShieldAlert, Loader2, Trash2 } from 'lucide-react';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { SectionCard } from './dataShared';
import { SectionCard, type SectionCardProps } from './dataShared';
/**
* Danger zone: permanently erase the user's financial data. Type-to-confirm, and
* makes clear that the account/login/2FA are preserved. Reuses POST
* /api/user/erase-data (transactional, audited, re-seeds default categories).
*/
export default function EraseDataSection({ onErased, cardProps = {} }) {
export default function EraseDataSection({ onErased, cardProps = {} }: {
onErased?: () => void;
cardProps?: Partial<SectionCardProps>;
}) {
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
@ -25,13 +29,14 @@ export default function EraseDataSection({ onErased, cardProps = {} }) {
if (!canErase) return;
setBusy(true);
try {
const r = await api.eraseMyData(confirm.trim());
toast.success(`Your data was erased — ${r.erased} record${r.erased === 1 ? '' : 's'} removed.`);
const r = await api.eraseMyData(confirm.trim()) as { erased?: number };
const n = r.erased ?? 0;
toast.success(`Your data was erased — ${n} record${n === 1 ? '' : 's'} removed.`);
setOpen(false);
setConfirm('');
onErased?.();
} catch (err) {
toast.error(err.message || 'Erase failed.');
toast.error(errMessage(err, 'Erase failed.'));
} finally {
setBusy(false);
}

View File

@ -1,9 +1,27 @@
import React from 'react';
import { RefreshCw, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SectionCard, fmt } from './dataShared';
import { SectionCard, fmt, type SectionCardProps } from './dataShared';
export default function ImportHistorySection({ history, loading, onRefresh, cardProps = {} }) {
export interface ImportHistoryRow {
id: number | string;
imported_at?: string | null;
source_filename?: string | null;
sheet_name?: string | null;
rows_parsed?: number;
rows_created?: number;
rows_updated?: number;
rows_skipped?: number;
rows_errored?: number;
}
interface ImportHistorySectionProps {
history?: ImportHistoryRow[] | null;
loading?: boolean;
onRefresh?: () => void;
cardProps?: Partial<SectionCardProps>;
}
export default function ImportHistorySection({ history, loading, onRefresh, cardProps = {} }: ImportHistorySectionProps) {
if (loading) {
return (
<SectionCard title="Import History" {...cardProps}>

View File

@ -2,28 +2,44 @@ import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { FileText, Upload, Loader2, CheckCircle2, X } from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { cn, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { formatCentsUSD } from '@/lib/money';
import { SectionCard, importErrorState } from './dataShared';
import { formatCentsUSD, type Cents } from '@/lib/money';
import { SectionCard, importErrorState, type SectionCardProps } from './dataShared';
interface OfxTx {
payee?: string | null;
description?: string | null;
posted_date?: string | null;
amount: Cents;
}
interface OfxPreview {
import_session_id?: string;
count: number;
sample?: OfxTx[];
}
/**
* Import bank transactions from an OFX / QFX file. Unlike CSV, the file is
* structured, so there is no column-mapping step: upload preview import.
* Duplicates are skipped by the server (same dedupe scope as CSV/SimpleFIN).
*/
export default function ImportOfxSection({ onHistoryRefresh, cardProps = {} }) {
const fileRef = useRef(null);
const [preview, setPreview] = useState(null); // { import_session_id, count, sample }
const [busy, setBusy] = useState(null); // 'preview' | 'commit' | null
export default function ImportOfxSection({ onHistoryRefresh, cardProps = {} }: {
onHistoryRefresh?: () => void;
cardProps?: Partial<SectionCardProps>;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<OfxPreview | null>(null);
const [busy, setBusy] = useState<'preview' | 'commit' | null>(null);
async function handleFile(e) {
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setBusy('preview');
setPreview(null);
try {
setPreview(await api.previewOfxTransactionImport(file));
setPreview(await api.previewOfxTransactionImport(file) as OfxPreview);
} catch (err) {
toast.error(importErrorState(err, 'Could not read that OFX/QFX file.').message);
} finally {
@ -36,15 +52,15 @@ export default function ImportOfxSection({ onHistoryRefresh, cardProps = {} }) {
if (!preview?.import_session_id) return;
setBusy('commit');
try {
const r = await api.commitOfxTransactionImport({ import_session_id: preview.import_session_id });
const parts = [`${r.imported} imported`];
const r = await api.commitOfxTransactionImport({ import_session_id: preview.import_session_id }) as { imported?: number; skipped?: number; failed?: number };
const parts = [`${r.imported ?? 0} imported`];
if (r.skipped) parts.push(`${r.skipped} already present`);
if (r.failed) parts.push(`${r.failed} failed`);
toast.success(`OFX import complete — ${parts.join(', ')}.`);
setPreview(null);
onHistoryRefresh?.();
} catch (err) {
toast.error(err.message || 'OFX import failed.');
toast.error(errMessage(err, 'OFX import failed.'));
} finally {
setBusy(null);
}

View File

@ -1,27 +1,32 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { SectionCard } from './dataShared';
import { SectionCard, type SectionCardProps } from './dataShared';
export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
onSeeded?: () => void;
cardProps?: Partial<SectionCardProps>;
}) {
const [loading, setLoading] = useState(false);
const [seeded, setSeeded] = useState(false);
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
const [counts, setCounts] = useState<{ bills: number; categories: number }>({ bills: 0, categories: 0 });
const [clearing, setClearing] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
useEffect(() => {
api.seededStatus()
.then(data => {
setSeeded(data.seeded);
.then(raw => {
const data = raw as { seeded?: boolean; seededBills?: number; seededCategories?: number };
setSeeded(!!data.seeded);
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
})
.catch(err => console.error('Failed to check seeded status:', err))
@ -31,7 +36,7 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
const handleSeed = async () => {
setLoading(true);
try {
const data = await api.seedDemoData();
const data = await api.seedDemoData() as { billsCreated?: number; categoriesCreated?: number } | null;
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
setSeeded(true);
@ -39,7 +44,7 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
setTimeout(() => onSeeded?.(), 100);
} catch (err) {
console.error('Seed error:', err);
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
toast.error(errMessage(err, 'Failed to seed demo data.'));
} finally {
setLoading(false);
}
@ -48,14 +53,14 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
const handleClearDemoData = async () => {
setClearing(true);
try {
const data = await api.clearDemoData();
const data = await api.clearDemoData() as { billsDeleted?: number; categoriesDeleted?: number };
setSeeded(false);
setCounts({ bills: 0, categories: 0 });
setShowClearConfirm(false);
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
onSeeded?.();
} catch (err) {
toast.error(err.message || 'Failed to clear demo data.');
toast.error(errMessage(err, 'Failed to clear demo data.'));
} finally {
setClearing(false);
}

View File

@ -1,39 +1,63 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState, type ReactNode, type ComponentType } from 'react';
import { cn } from '@/lib/utils';
import { ChevronDown } from 'lucide-react';
// At-a-glance health tones for the optional SectionCard status dot.
const DOT_TONES = {
const DOT_TONES: Record<string, string> = {
green: 'bg-emerald-500',
amber: 'bg-amber-500',
red: 'bg-rose-500',
gray: 'bg-muted-foreground/40',
};
export function fmt(isoStr) {
export function fmt(isoStr: string | null | undefined): string {
if (!isoStr) return '—';
const d = new Date(isoStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function importErrorState(err, fallback) {
const data = err?.data || {};
export interface ImportErrorState {
message: string;
error: string;
code: string | null;
details: unknown[];
error_id: string | null;
}
export function importErrorState(err: unknown, fallback: string): ImportErrorState {
const e = (err ?? {}) as { data?: Record<string, unknown>; message?: string; code?: string; details?: unknown };
const data = e.data || {};
return {
message: err?.message || data.message || data.error || fallback,
error: data.error || fallback,
code: data.code || err?.code || null,
details: Array.isArray(data.details) ? data.details : (Array.isArray(err?.details) ? err.details : []),
error_id: data.error_id || null,
message: e.message || (data.message as string) || (data.error as string) || fallback,
error: (data.error as string) || fallback,
code: (data.code as string) || e.code || null,
details: Array.isArray(data.details) ? data.details : (Array.isArray(e.details) ? e.details : []),
error_id: (data.error_id as string) || null,
};
}
export interface SectionCardProps {
title?: ReactNode;
subtitle?: ReactNode;
icon?: ComponentType<{ className?: string }>; // optional lucide icon component, shown in a soft chip
statusDot?: string; // optional 'green' | 'amber' | 'red' | 'gray' health dot
badge?: ReactNode; // optional node rendered next to the title (e.g. a count pill)
children?: ReactNode;
className?: string;
collapsible?: boolean;
defaultOpen?: boolean;
storageKey?: string;
summary?: ReactNode;
actions?: ReactNode;
}
export function SectionCard({
title,
subtitle,
icon: Icon, // optional lucide icon component, shown in a soft chip
statusDot, // optional 'green' | 'amber' | 'red' | 'gray' health dot
badge, // optional node rendered next to the title (e.g. a count pill)
icon: Icon,
statusDot,
badge,
children,
className,
collapsible = false,
@ -41,7 +65,7 @@ export function SectionCard({
storageKey,
summary,
actions,
}) {
}: SectionCardProps) {
const [open, setOpen] = useState(() => {
if (!collapsible || !storageKey || typeof window === 'undefined') return defaultOpen;
const stored = window.localStorage.getItem(storageKey);
@ -101,7 +125,7 @@ export function SectionCard({
);
}
export function CountPill({ label, value }) {
export function CountPill({ label, value }: { label: ReactNode; value?: ReactNode }) {
return (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</p>