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:
parent
a8496a9c64
commit
dfedb75e6d
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue