feat: income breakdown modal with ignore/restore, summary chart click, includeIgnored query param
This commit is contained in:
parent
59d32f4686
commit
3623cadcf6
|
|
@ -0,0 +1,191 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt } from '@/lib/utils';
|
||||||
import { moveInArray, reorderPayload } from '@/lib/reorder';
|
import { moveInArray, reorderPayload } from '@/lib/reorder';
|
||||||
|
import IncomeBreakdownModal from '@/components/IncomeBreakdownModal';
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
'January',
|
'January',
|
||||||
|
|
@ -81,7 +82,7 @@ function StatusMark({ expense }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryChart({ rows = [] }) {
|
function SummaryChart({ rows = [], onStartingClick }) {
|
||||||
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
|
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
|
||||||
const chartRows = rows.map((row, index) => ({
|
const chartRows = rows.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
|
|
@ -100,21 +101,37 @@ function SummaryChart({ rows = [] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{chartRows.map(row => (
|
{chartRows.map(row => {
|
||||||
<div key={row.type} className="grid gap-2 sm:grid-cols-[5.75rem_minmax(0,1fr)_7rem] sm:items-center">
|
const isStarting = row.type === 'Starting';
|
||||||
<div className="text-sm font-medium text-foreground">{row.label}</div>
|
const clickable = isStarting && !!onStartingClick;
|
||||||
<div className="h-7 rounded-full bg-muted/70 p-1">
|
return (
|
||||||
<div
|
<div key={row.type} className="grid gap-2 sm:grid-cols-[5.75rem_minmax(0,1fr)_7rem] sm:items-center">
|
||||||
className="h-full rounded-full transition-[width]"
|
{clickable ? (
|
||||||
style={{ width: `${Math.min(row.width, 100)}%`, backgroundColor: row.color }}
|
<button
|
||||||
title={`${row.label}: ${fmt(row.amount)}`}
|
type="button"
|
||||||
/>
|
onClick={onStartingClick}
|
||||||
|
className="text-sm font-medium text-foreground text-left underline underline-offset-2 decoration-dashed decoration-muted-foreground/50 hover:text-primary transition-colors"
|
||||||
|
title="Click to see income breakdown"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-medium text-foreground">{row.label}</div>
|
||||||
|
)}
|
||||||
|
<div className="h-7 rounded-full bg-muted/70 p-1">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-[width]', clickable && 'cursor-pointer')}
|
||||||
|
style={{ width: `${Math.min(row.width, 100)}%`, backgroundColor: row.color }}
|
||||||
|
title={`${row.label}: ${fmt(row.amount)}`}
|
||||||
|
onClick={clickable ? onStartingClick : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
|
||||||
|
{fmt(row.amount)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
|
);
|
||||||
{fmt(row.amount)}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +205,7 @@ export default function SummaryPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
|
||||||
const [startingFirst, setStartingFirst] = useState('0');
|
const [startingFirst, setStartingFirst] = useState('0');
|
||||||
const [startingFifteenth, setStartingFifteenth] = useState('0');
|
const [startingFifteenth, setStartingFifteenth] = useState('0');
|
||||||
const [startingOther, setStartingOther] = useState('0');
|
const [startingOther, setStartingOther] = useState('0');
|
||||||
|
|
@ -550,7 +568,10 @@ export default function SummaryPage() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<SummaryChart rows={data.chart || []} />
|
<SummaryChart
|
||||||
|
rows={data.chart || []}
|
||||||
|
onStartingClick={data.bank_tracking?.enabled ? () => setIncomeModalOpen(true) : undefined}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -559,6 +580,16 @@ export default function SummaryPage() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{data?.bank_tracking?.enabled && (
|
||||||
|
<IncomeBreakdownModal
|
||||||
|
open={incomeModalOpen}
|
||||||
|
onClose={() => setIncomeModalOpen(false)}
|
||||||
|
year={data.year}
|
||||||
|
month={data.month}
|
||||||
|
bankTracking={data.bank_tracking}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,9 @@ router.get('/income', (req, res) => {
|
||||||
if (ym.error) return res.status(400).json({ error: ym.error });
|
if (ym.error) return res.status(400).json({ error: ym.error });
|
||||||
try {
|
try {
|
||||||
res.json(getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
res.json(getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
||||||
page: parseInt(req.query.page || '1', 10),
|
page: parseInt(req.query.page || '1', 10),
|
||||||
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
|
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
|
||||||
|
includeIgnored: req.query.include_ignored === 'true',
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[spending/income]', err.message);
|
console.error('[spending/income]', err.message);
|
||||||
|
|
|
||||||
|
|
@ -273,32 +273,34 @@ function deleteSpendingCategoryRule(db, userId, ruleId) {
|
||||||
|
|
||||||
// ── Income ───────────────────────────────────────────────────────────────────
|
// ── Income ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50 } = {}) {
|
function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50, includeIgnored = false } = {}) {
|
||||||
const { start, end } = monthRange(year, month);
|
const { start, end } = monthRange(year, month);
|
||||||
const offset = (Math.max(1, page) - 1) * limit;
|
const offset = (Math.max(1, page) - 1) * limit;
|
||||||
|
const ignoredFilter = includeIgnored ? '' : 'AND ignored = 0';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT id, amount, payee, description, memo, posted_date, transacted_at
|
SELECT id, amount, payee, description, memo, posted_date, transacted_at, ignored
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE user_id = ? AND amount > 0 AND ignored = 0 AND match_status != 'matched'
|
WHERE user_id = ? AND amount > 0 ${ignoredFilter} AND match_status != 'matched'
|
||||||
AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
|
AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
|
||||||
ORDER BY COALESCE(posted_date, DATE(transacted_at)) DESC, id DESC
|
ORDER BY ignored ASC, COALESCE(posted_date, DATE(transacted_at)) DESC, id DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59', limit, offset);
|
`).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59', limit, offset);
|
||||||
|
|
||||||
const total = db.prepare(`
|
const total = db.prepare(`
|
||||||
SELECT COUNT(*) AS n FROM transactions
|
SELECT COUNT(*) AS n FROM transactions
|
||||||
WHERE user_id = ? AND amount > 0 AND ignored = 0 AND match_status != 'matched'
|
WHERE user_id = ? AND amount > 0 ${ignoredFilter} AND match_status != 'matched'
|
||||||
AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
|
AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
|
||||||
`).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n;
|
`).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transactions: rows.map(r => ({
|
transactions: rows.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
amount: Math.abs(Number(r.amount)) / 100,
|
amount: Math.abs(Number(r.amount)) / 100,
|
||||||
payee: r.payee || r.description || r.memo || '(Unknown)',
|
payee: r.payee || r.description || r.memo || '(Unknown)',
|
||||||
date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null),
|
date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null),
|
||||||
|
ignored: r.ignored === 1,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue