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 { 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);
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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}>
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue