import { useState, useEffect, useCallback } from 'react'; import { toast } from 'sonner'; import { formatUSD, asDollars, type Dollars } from '@/lib/money'; import { TrendingUp, EyeOff, Eye, Loader2 } from 'lucide-react'; import { api } from '@/api'; import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; function fmt(n: Parameters[0]): string { return formatUSD(n); } interface IncomeTx { id: number | string; payee?: string | null; date?: string | null; amount: Dollars; ignored?: boolean; } interface IncomeBt { enabled?: boolean; org_name?: string | null; account_name?: string | null; balance?: Dollars; pending_payments?: Dollars; pending_days?: number; effective_balance?: Dollars; } interface IncomeBreakdownModalProps { open: boolean; onClose: () => void; year: number; month: number; bankTracking?: IncomeBt | null; } export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }: IncomeBreakdownModalProps) { const [transactions, setTransactions] = useState([]); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(''); const [showIgnored, setShowIgnored] = useState(false); const [actionId, setActionId] = useState(null); const load = useCallback(async () => { if (!open) return; setLoading(true); setLoadError(''); try { const d = await api.spendingIncome({ year, month, include_ignored: showIgnored ? 'true' : undefined, limit: 100 }) as { transactions?: IncomeTx[] }; setTransactions(d.transactions || []); } catch (err) { const msg = errMessage(err, 'Failed to load income transactions'); setLoadError(msg); toast.error(msg); } finally { setLoading(false); } }, [open, year, month, showIgnored]); useEffect(() => { load(); }, [load]); const handleIgnore = async (tx: IncomeTx) => { setActionId(tx.id); try { await api.ignoreTransaction(tx.id); toast.success(`"${tx.payee}" marked as excluded`); } catch (err) { toast.error(errMessage(err, 'Failed to exclude transaction')); setActionId(null); return; // don't reload if the action itself failed } setActionId(null); await load(); // reload outside the action try so load errors are surfaced separately }; const handleUnignore = async (tx: IncomeTx) => { setActionId(tx.id); try { await api.unignoreTransaction(tx.id); toast.success(`"${tx.payee}" restored as income`); } catch (err) { toast.error(errMessage(err, 'Failed to restore transaction')); setActionId(null); return; } setActionId(null); await load(); }; const active = transactions.filter(t => !t.ignored); const ignored = transactions.filter(t => t.ignored); const total = asDollars(active.reduce((s, t) => s + t.amount, 0)); const bt = bankTracking || {}; return ( { if (!v) onClose(); }}> Starting Balance Breakdown How your starting balance was calculated from your bank account {/* Balance calculation */} {bt.enabled && (
{bt.org_name || bt.account_name || 'Bank'} balance {fmt(bt.balance)}
{(bt.pending_payments ?? 0) > 0 && (
Pending payments ({bt.pending_days}d window) −{fmt(bt.pending_payments)}
)}
Effective starting balance {fmt(bt.effective_balance)}
)} {/* Income transactions */}

Income this month

{ignored.length > 0 || showIgnored ? ( ) : null} {active.length > 0 && ( {fmt(total)} )}
{loading ? (
Loading…
) : loadError ? (

{loadError}

) : transactions.length === 0 ? (

No income transactions found for this month.

) : (
{active.map(tx => (

{tx.payee}

{tx.date}

+{fmt(tx.amount)}
))} {showIgnored && ignored.map(tx => (

{tx.payee}

{tx.date}

+{fmt(tx.amount)}
))}
)}

Showing positive unmatched transactions from your bank. Exclude transfers or non-income deposits.

); }