BillTracker/client/components/IncomeBreakdownModal.jsx

192 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
}
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) {
const [transactions, setTransactions] = useState([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState('');
const [showIgnored, setShowIgnored] = useState(false);
const [actionId, setActionId] = useState(null); // tx being acted on
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 });
setTransactions(d.transactions || []);
} catch (err) {
const msg = err.message || 'Failed to load income transactions';
setLoadError(msg);
toast.error(msg);
} finally {
setLoading(false);
}
}, [open, year, month, showIgnored]);
useEffect(() => { load(); }, [load]);
const handleIgnore = async (tx) => {
setActionId(tx.id);
try {
await api.ignoreTransaction(tx.id);
toast.success(`"${tx.payee}" marked as excluded`);
} catch (err) {
toast.error(err.message || '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) => {
setActionId(tx.id);
try {
await api.unignoreTransaction(tx.id);
toast.success(`"${tx.payee}" restored as income`);
} catch (err) {
toast.error(err.message || '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 = 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 && (
<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>
);
}