refactor(ts): convert BankSyncSection to TypeScript
SimpleFIN connect/sync/backfill/accounts + bank-budget-tracking + auto-categorize. Branded Cents on account/tx amounts, Dollars on financial-account balances. Dropped unused sourceId/bills props on AccountRow. typecheck 0, build green.
This commit is contained in:
parent
5a2e37fd61
commit
0cc5cbd957
|
|
@ -1,12 +1,12 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
||||||
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
|
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, errMessage } from '@/lib/utils';
|
||||||
import { formatUSD, formatCentsUSD } from '@/lib/money';
|
import { formatUSD, formatCentsUSD, type Cents, type Dollars } from '@/lib/money';
|
||||||
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 { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
@ -19,10 +19,62 @@ import {
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SectionCard } from './dataShared';
|
import { SectionCard, type SectionCardProps } from './dataShared';
|
||||||
import AutoMatchReview from './AutoMatchReview';
|
import AutoMatchReview from './AutoMatchReview';
|
||||||
|
import type { Bill } from '@/types';
|
||||||
|
|
||||||
function TokenInput({ value, onChange, disabled }) {
|
interface BankTx {
|
||||||
|
id: number | string;
|
||||||
|
posted_date?: string | null;
|
||||||
|
transacted_at?: string | null;
|
||||||
|
payee?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
amount: Cents;
|
||||||
|
match_status?: string | null;
|
||||||
|
matched_bill_id?: number | null;
|
||||||
|
matched_bill_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BankAccount {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
org_name?: string | null;
|
||||||
|
monitored?: boolean;
|
||||||
|
balance: Cents;
|
||||||
|
transaction_count?: number;
|
||||||
|
transactions: BankTx[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Connection {
|
||||||
|
id: number | string;
|
||||||
|
name?: string;
|
||||||
|
provider?: string;
|
||||||
|
status?: string;
|
||||||
|
last_error?: string | null;
|
||||||
|
last_sync_at?: string | null;
|
||||||
|
account_count?: number;
|
||||||
|
transaction_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinAccount {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
org_name?: string | null;
|
||||||
|
balance_dollars?: Dollars | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BtPatch {
|
||||||
|
enabled?: boolean;
|
||||||
|
accountId?: string;
|
||||||
|
pendingDays?: number;
|
||||||
|
lateGraceDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokenInput({ value, onChange, disabled }: {
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const tail = value.slice(-4);
|
const tail = value.slice(-4);
|
||||||
return (
|
return (
|
||||||
|
|
@ -57,31 +109,32 @@ function TokenInput({ value, onChange, disabled }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUtc(str) {
|
function parseUtc(str: string | null | undefined): Date | null {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
||||||
return new Date(normalized);
|
return new Date(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '—';
|
const d = parseUtc(iso);
|
||||||
return parseUtc(iso).toLocaleString(undefined, {
|
if (!d) return '—';
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
month: 'short', day: 'numeric', year: 'numeric',
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit',
|
hour: '2-digit', minute: '2-digit',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtShortDate(date) {
|
function fmtShortDate(date: string | null | undefined): string {
|
||||||
if (!date) return '—';
|
if (!date) return '—';
|
||||||
const d = new Date(`${date}T00:00:00`);
|
const d = new Date(`${date}T00:00:00`);
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDollars(cents) {
|
function fmtDollars(cents: Cents): string {
|
||||||
return formatCentsUSD(cents, { dash: true });
|
return formatCentsUSD(cents, { dash: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchBadge({ status, billName }) {
|
function MatchBadge({ status, billName }: { status: string; billName?: string | null }) {
|
||||||
if (status === 'matched') {
|
if (status === 'matched') {
|
||||||
return (
|
return (
|
||||||
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded-full max-w-[120px] truncate inline-block" title={billName || 'matched'}>
|
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded-full max-w-[120px] truncate inline-block" title={billName || 'matched'}>
|
||||||
|
|
@ -95,9 +148,16 @@ function MatchBadge({ status, billName }) {
|
||||||
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</span>;
|
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }) {
|
function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
transaction?: BankTx | null;
|
||||||
|
bills: Bill[];
|
||||||
|
onConfirm: (billId: number | string | null) => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId, setSelectedId] = useState<number | string | null>(null);
|
||||||
|
|
||||||
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
|
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
|
||||||
|
|
||||||
|
|
@ -165,7 +225,18 @@ function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) {
|
interface AccountRowProps {
|
||||||
|
account: BankAccount;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onToggleMonitored: (accountId: number | string, monitored: boolean) => void;
|
||||||
|
toggling: boolean;
|
||||||
|
onMatch: (tx: BankTx) => void;
|
||||||
|
onUnmatch: (tx: BankTx) => void;
|
||||||
|
matchingTxId: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRow({ account, expanded, onToggleExpand, onToggleMonitored, toggling, onMatch, onUnmatch, matchingTxId }: AccountRowProps) {
|
||||||
const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10);
|
const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -183,7 +254,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
||||||
{/* Monitored toggle */}
|
{/* Monitored toggle */}
|
||||||
<div onClick={e => e.stopPropagation()}>
|
<div onClick={e => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={account.monitored}
|
checked={!!account.monitored}
|
||||||
onCheckedChange={v => onToggleMonitored(account.id, v)}
|
onCheckedChange={v => onToggleMonitored(account.id, v)}
|
||||||
disabled={toggling}
|
disabled={toggling}
|
||||||
aria-label={`Monitor ${account.name}`}
|
aria-label={`Monitor ${account.name}`}
|
||||||
|
|
@ -292,27 +363,30 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BankSyncSection({ onConnectionChange, cardProps = {} }) {
|
export default function BankSyncSection({ onConnectionChange, cardProps = {} }: {
|
||||||
const [enabled, setEnabled] = useState(null);
|
onConnectionChange?: (conn: Connection | null) => void;
|
||||||
|
cardProps?: Partial<SectionCardProps>;
|
||||||
|
}) {
|
||||||
|
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||||
const [syncDays, setSyncDays] = useState(30);
|
const [syncDays, setSyncDays] = useState(30);
|
||||||
const [seedDays, setSeedDays] = useState(44);
|
const [seedDays, setSeedDays] = useState(44);
|
||||||
const [serverTz, setServerTz] = useState(null);
|
const [serverTz, setServerTz] = useState<string | null>(null);
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState<Connection[]>([]);
|
||||||
const [accountsBySource, setAccountsBySource] = useState({});
|
const [accountsBySource, setAccountsBySource] = useState<Record<string, BankAccount[]>>({});
|
||||||
const [accountsLoading, setAccountsLoading] = useState({});
|
const [accountsLoading, setAccountsLoading] = useState<Record<string, boolean>>({});
|
||||||
const [accountsErrorBySource, setAccountsError] = useState({});
|
const [accountsErrorBySource, setAccountsError] = useState<Record<string, string>>({});
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [setupToken, setSetupToken] = useState('');
|
const [setupToken, setSetupToken] = useState('');
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(null);
|
const [syncing, setSyncing] = useState<number | string | null>(null);
|
||||||
const [backfilling, setBackfilling] = useState(null);
|
const [backfilling, setBackfilling] = useState<number | string | null>(null);
|
||||||
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
const [disconnectTarget, setDisconnectTarget] = useState<Connection | null>(null);
|
||||||
const [disconnecting, setDisconnecting] = useState(false);
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
const [expandedAccount, setExpandedAccount] = useState(null);
|
const [expandedAccount, setExpandedAccount] = useState<number | string | null>(null);
|
||||||
const [togglingAccount, setTogglingAccount] = useState(null);
|
const [togglingAccount, setTogglingAccount] = useState<number | string | null>(null);
|
||||||
const [bills, setBills] = useState([]);
|
const [bills, setBills] = useState<Bill[]>([]);
|
||||||
const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx }
|
const [matchTarget, setMatchTarget] = useState<{ sourceId: number | string; tx: BankTx } | null>(null);
|
||||||
const [matchingTxId, setMatchingTxId] = useState(null);
|
const [matchingTxId, setMatchingTxId] = useState<number | string | null>(null);
|
||||||
const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0);
|
const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0);
|
||||||
|
|
||||||
// Bank tracking state
|
// Bank tracking state
|
||||||
|
|
@ -320,22 +394,22 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
const [btAccountId, setBtAccountId] = useState('');
|
const [btAccountId, setBtAccountId] = useState('');
|
||||||
const [btPendingDays, setBtPendingDays] = useState(3);
|
const [btPendingDays, setBtPendingDays] = useState(3);
|
||||||
const [btLateGraceDays, setBtLateGraceDays] = useState(0);
|
const [btLateGraceDays, setBtLateGraceDays] = useState(0);
|
||||||
const [btAccounts, setBtAccounts] = useState([]);
|
const [btAccounts, setBtAccounts] = useState<FinAccount[]>([]);
|
||||||
const [btSaving, setBtSaving] = useState(false);
|
const [btSaving, setBtSaving] = useState(false);
|
||||||
|
|
||||||
// Auto-categorization state
|
// Auto-categorization state
|
||||||
const [acmEnabled, setAcmEnabled] = useState(true);
|
const [acmEnabled, setAcmEnabled] = useState(true);
|
||||||
const [acmSaving, setAcmSaving] = useState(false);
|
const [acmSaving, setAcmSaving] = useState(false);
|
||||||
|
|
||||||
const loadAccounts = useCallback(async (conns) => {
|
const loadAccounts = useCallback(async (conns: Connection[]) => {
|
||||||
for (const conn of conns) {
|
for (const conn of conns) {
|
||||||
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
|
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
|
||||||
setAccountsError(prev => ({ ...prev, [conn.id]: '' }));
|
setAccountsError(prev => ({ ...prev, [conn.id]: '' }));
|
||||||
try {
|
try {
|
||||||
const accounts = await api.dataSourceAccounts(conn.id);
|
const accounts = await api.dataSourceAccounts(conn.id) as BankAccount[];
|
||||||
setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts }));
|
setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAccountsError(prev => ({ ...prev, [conn.id]: err.message || 'Failed to load accounts' }));
|
setAccountsError(prev => ({ ...prev, [conn.id]: errMessage(err, 'Failed to load accounts') }));
|
||||||
} finally {
|
} finally {
|
||||||
setAccountsLoading(prev => ({ ...prev, [conn.id]: false }));
|
setAccountsLoading(prev => ({ ...prev, [conn.id]: false }));
|
||||||
}
|
}
|
||||||
|
|
@ -346,21 +420,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
const loadBankTracking = useCallback(async () => {
|
const loadBankTracking = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [settings, accounts] = await Promise.all([
|
const [settings, accounts] = await Promise.all([
|
||||||
api.settings(),
|
api.settings() as Promise<Record<string, string>>,
|
||||||
api.allFinancialAccounts().catch(() => []),
|
api.allFinancialAccounts().catch(() => []),
|
||||||
]);
|
]);
|
||||||
setBtEnabled(settings.bank_tracking_enabled === 'true');
|
setBtEnabled(settings.bank_tracking_enabled === 'true');
|
||||||
setBtAccountId(settings.bank_tracking_account_id || '');
|
setBtAccountId(settings.bank_tracking_account_id || '');
|
||||||
setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3);
|
setBtPendingDays(parseInt(settings.bank_tracking_pending_days || '', 10) || 3);
|
||||||
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0);
|
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days || '', 10) || 0);
|
||||||
setBtAccounts(Array.isArray(accounts) ? accounts : []);
|
setBtAccounts(Array.isArray(accounts) ? accounts as FinAccount[] : []);
|
||||||
setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false');
|
setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false');
|
||||||
} catch {
|
} catch {
|
||||||
// non-fatal — bank tracking section just won't populate
|
// non-fatal — bank tracking section just won't populate
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBtSave = useCallback(async (patch) => {
|
const handleBtSave = useCallback(async (patch: BtPatch) => {
|
||||||
setBtSaving(true);
|
setBtSaving(true);
|
||||||
try {
|
try {
|
||||||
const next = {
|
const next = {
|
||||||
|
|
@ -376,20 +450,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays);
|
if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays);
|
||||||
toast.success('Bank tracking settings saved');
|
toast.success('Bank tracking settings saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save bank tracking settings');
|
toast.error(errMessage(err, 'Failed to save bank tracking settings'));
|
||||||
} finally {
|
} finally {
|
||||||
setBtSaving(false);
|
setBtSaving(false);
|
||||||
}
|
}
|
||||||
}, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]);
|
}, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]);
|
||||||
|
|
||||||
const handleAcmSave = useCallback(async (next) => {
|
const handleAcmSave = useCallback(async (next: boolean) => {
|
||||||
setAcmSaving(true);
|
setAcmSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.saveSettings({ bank_auto_categorize_merchants: String(next) });
|
await api.saveSettings({ bank_auto_categorize_merchants: String(next) });
|
||||||
setAcmEnabled(next);
|
setAcmEnabled(next);
|
||||||
toast.success('Auto-categorization setting saved');
|
toast.success('Auto-categorization setting saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save auto-categorization setting');
|
toast.error(errMessage(err, 'Failed to save auto-categorization setting'));
|
||||||
} finally {
|
} finally {
|
||||||
setAcmSaving(false);
|
setAcmSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -399,20 +473,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
try {
|
try {
|
||||||
const [status, sources] = await Promise.all([
|
const [status, sources] = await Promise.all([
|
||||||
api.simplefinStatus(),
|
api.simplefinStatus() as Promise<{ enabled?: boolean; sync_days?: number; seed_days?: number; timezone?: string | null }>,
|
||||||
api.dataSources({ type: 'provider_sync' }),
|
api.dataSources({ type: 'provider_sync' }),
|
||||||
]);
|
]);
|
||||||
setEnabled(status.enabled);
|
setEnabled(!!status.enabled);
|
||||||
setSyncDays(status.sync_days ?? 30);
|
setSyncDays(status.sync_days ?? 30);
|
||||||
setSeedDays(status.seed_days ?? 44);
|
setSeedDays(status.seed_days ?? 44);
|
||||||
setServerTz(status.timezone || null);
|
setServerTz(status.timezone || null);
|
||||||
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
|
const conns = Array.isArray(sources) ? (sources as Connection[]).filter(s => s.provider === 'simplefin') : [];
|
||||||
setConnections(conns);
|
setConnections(conns);
|
||||||
onConnectionChange?.(conns[0] || null);
|
onConnectionChange?.(conns[0] || null);
|
||||||
if (conns.length > 0) loadAccounts(conns);
|
if (conns.length > 0) loadAccounts(conns);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEnabled(false);
|
setEnabled(false);
|
||||||
setLoadError(err.message || 'Failed to load bank sync status');
|
setLoadError(errMessage(err, 'Failed to load bank sync status'));
|
||||||
}
|
}
|
||||||
}, [onConnectionChange, loadAccounts]);
|
}, [onConnectionChange, loadAccounts]);
|
||||||
|
|
||||||
|
|
@ -426,7 +500,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
}
|
}
|
||||||
}, [connections, bills.length]);
|
}, [connections, bills.length]);
|
||||||
|
|
||||||
function updateTxInState(sourceId, txId, updates) {
|
function updateTxInState(sourceId: number | string, txId: number | string, updates: Partial<BankTx>) {
|
||||||
setAccountsBySource(prev => ({
|
setAccountsBySource(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[sourceId]: (prev[sourceId] || []).map(acc => ({
|
[sourceId]: (prev[sourceId] || []).map(acc => ({
|
||||||
|
|
@ -436,14 +510,14 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMatch = (sourceId, tx) => setMatchTarget({ sourceId, tx });
|
const handleMatch = (sourceId: number | string, tx: BankTx) => setMatchTarget({ sourceId, tx });
|
||||||
|
|
||||||
const handleConfirmMatch = async (billId) => {
|
const handleConfirmMatch = async (billId: number | string | null) => {
|
||||||
if (!matchTarget || !billId) return;
|
if (!matchTarget || !billId) return;
|
||||||
const { sourceId, tx } = matchTarget;
|
const { sourceId, tx } = matchTarget;
|
||||||
setMatchingTxId(tx.id);
|
setMatchingTxId(tx.id);
|
||||||
try {
|
try {
|
||||||
const { transaction } = await api.confirmTransactionMatch(tx.id, billId);
|
const { transaction } = await api.confirmTransactionMatch(tx.id, billId) as { transaction: BankTx };
|
||||||
updateTxInState(sourceId, tx.id, {
|
updateTxInState(sourceId, tx.id, {
|
||||||
match_status: transaction.match_status,
|
match_status: transaction.match_status,
|
||||||
matched_bill_id: transaction.matched_bill_id,
|
matched_bill_id: transaction.matched_bill_id,
|
||||||
|
|
@ -452,20 +526,20 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
setMatchTarget(null);
|
setMatchTarget(null);
|
||||||
toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`);
|
toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to match transaction.');
|
toast.error(errMessage(err, 'Failed to match transaction.'));
|
||||||
} finally {
|
} finally {
|
||||||
setMatchingTxId(null);
|
setMatchingTxId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnmatch = async (sourceId, tx) => {
|
const handleUnmatch = async (sourceId: number | string, tx: BankTx) => {
|
||||||
setMatchingTxId(tx.id);
|
setMatchingTxId(tx.id);
|
||||||
try {
|
try {
|
||||||
await api.unmatchTransaction(tx.id);
|
await api.unmatchTransaction(tx.id);
|
||||||
updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null });
|
updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null });
|
||||||
toast.success('Match removed.');
|
toast.success('Match removed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to remove match.');
|
toast.error(errMessage(err, 'Failed to remove match.'));
|
||||||
} finally {
|
} finally {
|
||||||
setMatchingTxId(null);
|
setMatchingTxId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -476,21 +550,21 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
|
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.connectSimplefin(token);
|
const result = await api.connectSimplefin(token) as { accountsUpserted?: number; transactionsNew?: number };
|
||||||
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
|
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
|
||||||
setSetupToken('');
|
setSetupToken('');
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to connect SimpleFIN');
|
toast.error(errMessage(err, 'Failed to connect SimpleFIN'));
|
||||||
} finally {
|
} finally {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSync = async (id) => {
|
const handleSync = async (id: number | string) => {
|
||||||
setSyncing(id);
|
setSyncing(id);
|
||||||
try {
|
try {
|
||||||
const result = await api.syncDataSource(id);
|
const result = await api.syncDataSource(id) as { errlist?: string; transactionsNew?: number };
|
||||||
if (result.errlist) {
|
if (result.errlist) {
|
||||||
toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`);
|
toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -499,17 +573,17 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
setAutoMatchRefreshKey(k => k + 1);
|
setAutoMatchRefreshKey(k => k + 1);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Sync failed');
|
toast.error(errMessage(err, 'Sync failed'));
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing(null);
|
setSyncing(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackfill = async (id) => {
|
const handleBackfill = async (id: number | string) => {
|
||||||
setBackfilling(id);
|
setBackfilling(id);
|
||||||
try {
|
try {
|
||||||
const result = await api.backfillDataSource(id);
|
const result = await api.backfillDataSource(id) as { errlist?: string; transactionsNew?: number };
|
||||||
if (result.errlist) {
|
if (result.errlist) {
|
||||||
toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`);
|
toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -518,7 +592,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
setAutoMatchRefreshKey(k => k + 1);
|
setAutoMatchRefreshKey(k => k + 1);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Backfill failed');
|
toast.error(errMessage(err, 'Backfill failed'));
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
setBackfilling(null);
|
setBackfilling(null);
|
||||||
|
|
@ -534,13 +608,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
setDisconnectTarget(null);
|
setDisconnectTarget(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to disconnect');
|
toast.error(errMessage(err, 'Failed to disconnect'));
|
||||||
} finally {
|
} finally {
|
||||||
setDisconnecting(false);
|
setDisconnecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleMonitored = async (sourceId, accountId, monitored) => {
|
const handleToggleMonitored = async (sourceId: number | string, accountId: number | string, monitored: boolean) => {
|
||||||
setTogglingAccount(accountId);
|
setTogglingAccount(accountId);
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
setAccountsBySource(prev => ({
|
setAccountsBySource(prev => ({
|
||||||
|
|
@ -559,13 +633,13 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
a.id === accountId ? { ...a, monitored: !monitored } : a
|
a.id === accountId ? { ...a, monitored: !monitored } : a
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
toast.error(err.message || 'Failed to update account');
|
toast.error(errMessage(err, 'Failed to update account'));
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingAccount(null);
|
setTogglingAccount(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function connWarning(conn) {
|
function connWarning(conn: Connection): { kind: string; label: string } | null {
|
||||||
if (!conn.last_error) return null;
|
if (!conn.last_error) return null;
|
||||||
if (conn.status === 'error') return { kind: 'error', label: 'Sync error' };
|
if (conn.status === 'error') return { kind: 'error', label: 'Sync error' };
|
||||||
// Partial errlist: sync succeeded but some bank connections need attention
|
// Partial errlist: sync succeeded but some bank connections need attention
|
||||||
|
|
@ -729,12 +803,10 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
<AccountRow
|
<AccountRow
|
||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
sourceId={conn.id}
|
|
||||||
expanded={expandedAccount === account.id}
|
expanded={expandedAccount === account.id}
|
||||||
onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)}
|
onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)}
|
||||||
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
|
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
|
||||||
toggling={togglingAccount === account.id}
|
toggling={togglingAccount === account.id}
|
||||||
bills={bills}
|
|
||||||
onMatch={tx => handleMatch(conn.id, tx)}
|
onMatch={tx => handleMatch(conn.id, tx)}
|
||||||
onUnmatch={tx => handleUnmatch(conn.id, tx)}
|
onUnmatch={tx => handleUnmatch(conn.id, tx)}
|
||||||
matchingTxId={matchingTxId}
|
matchingTxId={matchingTxId}
|
||||||
|
|
@ -863,7 +935,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Landmark className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Landmark className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span>{acc.org_name ? `${acc.org_name} — ` : ''}{acc.name}</span>
|
<span>{acc.org_name ? `${acc.org_name} — ` : ''}{acc.name}</span>
|
||||||
{acc.balance_dollars !== null && (
|
{acc.balance_dollars != null && (
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{formatUSD(acc.balance_dollars)}
|
{formatUSD(acc.balance_dollars)}
|
||||||
</span>
|
</span>
|
||||||
Loading…
Reference in New Issue