BillTracker/client/components/IncomeBreakdownModal.tsx

262 lines
9.4 KiB
TypeScript
Raw Normal View History

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<typeof formatUSD>[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<IncomeTx[]>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState('');
const [showIgnored, setShowIgnored] = useState(false);
const [actionId, setActionId] = useState<number | string | null>(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 (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<TrendingUp className="h-4 w-4 text-emerald-500" />
Starting Balance Breakdown
</DialogTitle>
<DialogDescription className="sr-only">
How your starting balance was calculated from your bank account
</DialogDescription>
</DialogHeader>
{/* Balance calculation */}
{bt.enabled && (
<div className="rounded-xl border border-border/60 bg-muted/20 px-4 py-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{bt.org_name || bt.account_name || 'Bank'} balance
</span>
<span className="font-mono font-semibold">{fmt(bt.balance)}</span>
</div>
{(bt.pending_payments ?? 0) > 0 && (
<div className="flex items-center justify-between text-muted-foreground">
<span>Pending payments ({bt.pending_days}d window)</span>
<span className="font-mono text-destructive">{fmt(bt.pending_payments)}</span>
</div>
)}
<div className="flex items-center justify-between border-t border-border/50 pt-2 font-medium">
<span>Effective starting balance</span>
<span className="font-mono text-emerald-500">{fmt(bt.effective_balance)}</span>
</div>
</div>
)}
{/* Income transactions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Income this month
</p>
<div className="flex items-center gap-2">
{ignored.length > 0 || showIgnored ? (
<button
type="button"
onClick={() => setShowIgnored((v) => !v)}
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors flex items-center gap-1"
>
{showIgnored ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{showIgnored ? 'Hide excluded' : `Show excluded (${ignored.length})`}
</button>
) : null}
{active.length > 0 && (
<span className="text-xs font-semibold text-emerald-500 font-mono">
{fmt(total)}
</span>
)}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-6 gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : loadError ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<p className="text-sm text-destructive">{loadError}</p>
<Button variant="outline" size="sm" onClick={load}>
Retry
</Button>
</div>
) : transactions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No income transactions found for this month.
</p>
) : (
<div className="divide-y divide-border/30 rounded-xl border border-border/60 bg-muted/10 overflow-hidden max-h-72 overflow-y-auto">
{active.map((tx) => (
<div
key={tx.id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/20 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{tx.payee}</p>
<p className="text-xs text-muted-foreground">{tx.date}</p>
</div>
<span className="text-sm font-mono font-semibold text-emerald-500 shrink-0">
+{fmt(tx.amount)}
</span>
<button
type="button"
disabled={actionId === tx.id}
onClick={() => handleIgnore(tx)}
title="Exclude — mark as transfer or non-income"
className="shrink-0 text-xs text-muted-foreground hover:text-destructive transition-colors flex items-center gap-1 disabled:opacity-40"
>
<EyeOff className="h-3.5 w-3.5" />
</button>
</div>
))}
{showIgnored &&
ignored.map((tx) => (
<div
key={tx.id}
className="flex items-center gap-3 px-3 py-2.5 bg-muted/5 opacity-60"
>
<div className="min-w-0 flex-1">
<p className="text-sm line-through truncate text-muted-foreground">
{tx.payee}
</p>
<p className="text-xs text-muted-foreground">{tx.date}</p>
</div>
<span className="text-sm font-mono text-muted-foreground shrink-0">
+{fmt(tx.amount)}
</span>
<button
type="button"
disabled={actionId === tx.id}
onClick={() => handleUnignore(tx)}
title="Restore as income"
className="shrink-0 text-xs text-muted-foreground hover:text-emerald-500 transition-colors flex items-center gap-1 disabled:opacity-40"
>
<Eye className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
<p className="text-[10px] text-muted-foreground/60 text-center">
Showing positive unmatched transactions from your bank. Exclude transfers or non-income
deposits.
</p>
</DialogContent>
</Dialog>
);
}