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 { Undo2, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { formatUSD } from '@/lib/money'; 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 }); return formatUSD(dollars, { dash: true });
} }
function fmtDate(iso) { function fmtDate(iso: string | null | undefined) {
if (!iso) return ''; if (!iso) return '';
const d = new Date(`${iso}T00:00:00`); const d = new Date(`${iso}T00:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
function payeeLabel(row) { function payeeLabel(row: AutoMatchItem) {
return row.payee || row.description || `Transaction #${row.transaction_id}`; return row.payee || row.description || `Transaction #${row.transaction_id}`;
} }
export default function AutoMatchReview({ refreshKey }) { export default function AutoMatchReview({ refreshKey }: { refreshKey?: number | string }) {
const [items, setItems] = useState([]); const [items, setItems] = useState<AutoMatchItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [undoing, setUndoing] = useState(null); const [undoing, setUndoing] = useState<number | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const rows = await api.recentAutoMatched(); const rows = await api.recentAutoMatched();
setItems(Array.isArray(rows) ? rows : []); setItems(Array.isArray(rows) ? rows as AutoMatchItem[] : []);
} catch { } catch {
// Non-blocking — don't surface errors for a review panel // Non-blocking — don't surface errors for a review panel
} finally { } finally {
@ -40,14 +50,14 @@ export default function AutoMatchReview({ refreshKey }) {
useEffect(() => { load(); }, [load, refreshKey]); useEffect(() => { load(); }, [load, refreshKey]);
async function handleUndo(item) { async function handleUndo(item: AutoMatchItem) {
setUndoing(item.id); setUndoing(item.id);
try { try {
await api.undoAutoMatch(item.id); await api.undoAutoMatch(item.id);
setItems(prev => prev.filter(p => p.id !== item.id)); setItems(prev => prev.filter(p => p.id !== item.id));
toast.success(`Undone — ${payeeLabel(item)} unlinked from "${item.bill_name}"`); toast.success(`Undone — ${payeeLabel(item)} unlinked from "${item.bill_name}"`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to undo auto-match'); toast.error(errMessage(err, 'Failed to undo auto-match'));
} finally { } finally {
setUndoing(null); 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 { toast } from 'sonner';
import { Landmark, RefreshCw, Loader2, AlertTriangle, RotateCcw, Upload, Plus } from 'lucide-react'; import { Landmark, RefreshCw, Loader2, AlertTriangle, RotateCcw, Upload, Plus } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
// Relative "2h ago" / "3d ago"; returns null for empty input. // 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; if (!iso) return null;
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
if (Number.isNaN(then)) return null; if (Number.isNaN(then)) return null;
@ -20,19 +20,23 @@ function relativeTime(iso) {
return `${days}d ago`; return `${days}d ago`;
} }
function HeroShell({ tone = 'default', icon: Icon, children }) { function HeroShell({ tone = 'default', icon: Icon, children }: {
const toneRing = { tone?: string;
icon: ComponentType<{ className?: string }>;
children: ReactNode;
}) {
const toneRing = ({
default: 'border-border/60', default: 'border-border/60',
good: 'border-emerald-500/30', good: 'border-emerald-500/30',
warn: 'border-amber-500/40', warn: 'border-amber-500/40',
error: 'border-rose-500/30', error: 'border-rose-500/30',
}[tone]; } as Record<string, string>)[tone];
const toneChip = { const toneChip = ({
default: 'bg-muted/50 text-muted-foreground', default: 'bg-muted/50 text-muted-foreground',
good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-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', error: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
}[tone]; } as Record<string, string>)[tone];
return ( return (
<div className={cn('surface flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:gap-4', toneRing)}> <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)}> <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 * 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 * 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({ export default function ConnectionHero({
loading, loading,
error, // truthy when the status/summary fetch failed error,
enabled, // status.enabled (server feature flag) enabled,
hasConnections, // status.has_connections hasConnections,
conn, // the simplefin data_source (name, last_sync_at, last_error) or null conn,
txnTotal, // total synced transactions (at-a-glance), or null txnTotal,
onRetry, onRetry,
onGoTo, // (sectionId) => void onGoTo,
onSynced, // () => void — refresh after a successful sync onSynced,
}) { }: ConnectionHeroProps) {
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
async function handleSyncNow() { async function handleSyncNow() {
setSyncing(true); setSyncing(true);
try { 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 : []; const errs = Array.isArray(r?.errors) ? r.errors : [];
if (errs.length) { if (errs.length) {
toast.warning(`Synced, but ${errs.length} connection${errs.length === 1 ? '' : 's'} need attention.`); toast.warning(`Synced, but ${errs.length} connection${errs.length === 1 ? '' : 's'} need attention.`);
@ -75,8 +97,8 @@ export default function ConnectionHero({
} }
onSynced?.(); onSynced?.();
} catch (err) { } catch (err) {
if (err?.status === 429) toast.error('Please wait a moment before syncing again.'); if ((err as { status?: number })?.status === 429) toast.error('Please wait a moment before syncing again.');
else toast.error(err?.message || 'Sync failed.'); else toast.error(errMessage(err, 'Sync failed.'));
} finally { } finally {
setSyncing(false); setSyncing(false);
} }

View File

@ -1,20 +1,34 @@
import type { ComponentType } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const DOT_TONES = { const DOT_TONES: Record<string, string> = {
green: 'bg-emerald-500', green: 'bg-emerald-500',
amber: 'bg-amber-500', amber: 'bg-amber-500',
red: 'bg-rose-500', red: 'bg-rose-500',
gray: 'bg-muted-foreground/40', 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 * Goal-oriented navigation for the Data page. Desktop ( lg): a sticky vertical
* list. Mobile: a horizontally-scrollable segmented control. One <nav> landmark, * list. Mobile: a horizontally-scrollable segmented control. One <nav> landmark,
* aria-current on the active item, fully keyboard-operable (native buttons). * 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 ( return (
<nav aria-label="Data sections" className="lg:sticky lg:top-20"> <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"> <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 { toast } from 'sonner';
import { Database, Download, FileSpreadsheet, FileJson, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react'; import { Database, Download, FileSpreadsheet, FileJson, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { SectionCard } from './dataShared'; import { SectionCard, type SectionCardProps } from './dataShared';
// Shared blob download: works whether or not the response sets Content-Disposition // Shared blob download: works whether or not the response sets Content-Disposition
// (the JSON payments export doesn't), falling back to the given name. // (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' }); const res = await fetch(endpoint, { credentials: 'include' });
if (!res.ok) { if (!res.ok) {
let data = {}; let data: { message?: string; error?: string } = {};
try { data = await res.json(); } catch {} try { data = await res.json(); } catch { /* ignore */ }
throw new Error(data.message || data.error || `HTTP ${res.status}`); throw new Error(data.message || data.error || `HTTP ${res.status}`);
} }
const disposition = res.headers.get('Content-Disposition'); const disposition = res.headers.get('Content-Disposition');
const match = disposition?.match(/filename="?([^"]+)"?/i); const match = disposition?.match(/filename="?([^"]+)"?/i);
const name = match ? match[1] : fallbackName; const name = match?.[1] || fallbackName;
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -26,7 +26,13 @@ async function downloadFile(endpoint, fallbackName) {
URL.revokeObjectURL(url); 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 [loading, setLoading] = useState(false);
const handleDownload = async () => { const handleDownload = async () => {
setLoading(true); setLoading(true);
@ -34,7 +40,7 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
await downloadFile(endpoint, filename); await downloadFile(endpoint, filename);
toast.success(`${title} downloaded.`); toast.success(`${title} downloaded.`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Download failed.'); toast.error(errMessage(err, 'Download failed.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -77,7 +83,7 @@ function PaymentsExport() {
await downloadFile(`/api/export?${params.toString()}`, `bills.${format === 'json' ? 'json' : 'csv'}`); await downloadFile(`/api/export?${params.toString()}`, `bills.${format === 'json' ? 'json' : 'csv'}`);
toast.success('Payments exported.'); toast.success('Payments exported.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Export failed.'); toast.error(errMessage(err, 'Export failed.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -124,7 +130,7 @@ function PaymentsExport() {
); );
} }
export default function DownloadMyDataSection({ cardProps = {} }) { export default function DownloadMyDataSection({ cardProps = {} }: { cardProps?: Partial<SectionCardProps> }) {
return ( return (
<SectionCard <SectionCard
title="Download My Data" title="Download My Data"

View File

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

View File

@ -1,9 +1,27 @@
import React from 'react';
import { RefreshCw, Clock } from 'lucide-react'; import { RefreshCw, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button'; 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) { if (loading) {
return ( return (
<SectionCard title="Import History" {...cardProps}> <SectionCard title="Import History" {...cardProps}>

View File

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

View File

@ -1,27 +1,32 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } 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 [loading, setLoading] = useState(false);
const [seeded, setSeeded] = 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 [clearing, setClearing] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
const [statusLoading, setStatusLoading] = useState(true); const [statusLoading, setStatusLoading] = useState(true);
useEffect(() => { useEffect(() => {
api.seededStatus() api.seededStatus()
.then(data => { .then(raw => {
setSeeded(data.seeded); 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 }); if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
}) })
.catch(err => console.error('Failed to check seeded status:', err)) .catch(err => console.error('Failed to check seeded status:', err))
@ -31,7 +36,7 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
const handleSeed = async () => { const handleSeed = async () => {
setLoading(true); setLoading(true);
try { 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'); if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 }); setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
setSeeded(true); setSeeded(true);
@ -39,7 +44,7 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
setTimeout(() => onSeeded?.(), 100); setTimeout(() => onSeeded?.(), 100);
} catch (err) { } catch (err) {
console.error('Seed error:', 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 { } finally {
setLoading(false); setLoading(false);
} }
@ -48,14 +53,14 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
const handleClearDemoData = async () => { const handleClearDemoData = async () => {
setClearing(true); setClearing(true);
try { try {
const data = await api.clearDemoData(); const data = await api.clearDemoData() as { billsDeleted?: number; categoriesDeleted?: number };
setSeeded(false); setSeeded(false);
setCounts({ bills: 0, categories: 0 }); setCounts({ bills: 0, categories: 0 });
setShowClearConfirm(false); setShowClearConfirm(false);
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`); toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
onSeeded?.(); onSeeded?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to clear demo data.'); toast.error(errMessage(err, 'Failed to clear demo data.'));
} finally { } finally {
setClearing(false); 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 { cn } from '@/lib/utils';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
// At-a-glance health tones for the optional SectionCard status dot. // At-a-glance health tones for the optional SectionCard status dot.
const DOT_TONES = { const DOT_TONES: Record<string, string> = {
green: 'bg-emerald-500', green: 'bg-emerald-500',
amber: 'bg-amber-500', amber: 'bg-amber-500',
red: 'bg-rose-500', red: 'bg-rose-500',
gray: 'bg-muted-foreground/40', gray: 'bg-muted-foreground/40',
}; };
export function fmt(isoStr) { export function fmt(isoStr: string | null | undefined): string {
if (!isoStr) return '—'; if (!isoStr) return '—';
const d = new Date(isoStr); const d = new Date(isoStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
export function importErrorState(err, fallback) { export interface ImportErrorState {
const data = err?.data || {}; 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 { return {
message: err?.message || data.message || data.error || fallback, message: e.message || (data.message as string) || (data.error as string) || fallback,
error: data.error || fallback, error: (data.error as string) || fallback,
code: data.code || err?.code || null, code: (data.code as string) || e.code || null,
details: Array.isArray(data.details) ? data.details : (Array.isArray(err?.details) ? err.details : []), details: Array.isArray(data.details) ? data.details : (Array.isArray(e.details) ? e.details : []),
error_id: data.error_id || null, 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({ export function SectionCard({
title, title,
subtitle, subtitle,
icon: Icon, // optional lucide icon component, shown in a soft chip icon: Icon,
statusDot, // optional 'green' | 'amber' | 'red' | 'gray' health dot statusDot,
badge, // optional node rendered next to the title (e.g. a count pill) badge,
children, children,
className, className,
collapsible = false, collapsible = false,
@ -41,7 +65,7 @@ export function SectionCard({
storageKey, storageKey,
summary, summary,
actions, actions,
}) { }: SectionCardProps) {
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(() => {
if (!collapsible || !storageKey || typeof window === 'undefined') return defaultOpen; if (!collapsible || !storageKey || typeof window === 'undefined') return defaultOpen;
const stored = window.localStorage.getItem(storageKey); 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 ( return (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2"> <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> <p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</p>