diff --git a/client/components/IncomeBreakdownModal.jsx b/client/components/IncomeBreakdownModal.jsx new file mode 100644 index 0000000..e96b7d6 --- /dev/null +++ b/client/components/IncomeBreakdownModal.jsx @@ -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 ( + { 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 && ( +
+ 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. +

+
+
+ ); +} diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.jsx index 202d9a4..c4e10df 100644 --- a/client/pages/SummaryPage.jsx +++ b/client/pages/SummaryPage.jsx @@ -21,6 +21,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Input } from '@/components/ui/input'; import { cn, fmt } from '@/lib/utils'; import { moveInArray, reorderPayload } from '@/lib/reorder'; +import IncomeBreakdownModal from '@/components/IncomeBreakdownModal'; const MONTHS = [ '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 chartRows = rows.map((row, index) => ({ ...row, @@ -100,21 +101,37 @@ function SummaryChart({ rows = [] }) { return (
- {chartRows.map(row => ( -
-
{row.label}
-
-
+ {chartRows.map(row => { + const isStarting = row.type === 'Starting'; + const clickable = isStarting && !!onStartingClick; + return ( +
+ {clickable ? ( + + ) : ( +
{row.label}
+ )} +
+
+
+
+ {fmt(row.amount)} +
-
- {fmt(row.amount)} -
-
- ))} + ); + })}
); } @@ -188,6 +205,7 @@ export default function SummaryPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const [incomeModalOpen, setIncomeModalOpen] = useState(false); const [startingFirst, setStartingFirst] = useState('0'); const [startingFifteenth, setStartingFifteenth] = useState('0'); const [startingOther, setStartingOther] = useState('0'); @@ -550,7 +568,10 @@ export default function SummaryPage() { - + setIncomeModalOpen(true) : undefined} + /> @@ -559,6 +580,16 @@ export default function SummaryPage() {
)} + + {data?.bank_tracking?.enabled && ( + setIncomeModalOpen(false)} + year={data.year} + month={data.month} + bankTracking={data.bank_tracking} + /> + )}
); } diff --git a/routes/spending.js b/routes/spending.js index d0feca5..a95b05b 100644 --- a/routes/spending.js +++ b/routes/spending.js @@ -138,8 +138,9 @@ router.get('/income', (req, res) => { if (ym.error) return res.status(400).json({ error: ym.error }); try { res.json(getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, { - page: parseInt(req.query.page || '1', 10), - limit: Math.min(parseInt(req.query.limit || '50', 10), 200), + page: parseInt(req.query.page || '1', 10), + limit: Math.min(parseInt(req.query.limit || '50', 10), 200), + includeIgnored: req.query.include_ignored === 'true', })); } catch (err) { console.error('[spending/income]', err.message); diff --git a/services/spendingService.js b/services/spendingService.js index 2bdc4eb..f245253 100644 --- a/services/spendingService.js +++ b/services/spendingService.js @@ -273,32 +273,34 @@ function deleteSpendingCategoryRule(db, userId, ruleId) { // ── 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 offset = (Math.max(1, page) - 1) * limit; + const ignoredFilter = includeIgnored ? '' : 'AND ignored = 0'; try { 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 - 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 ?)) - 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 ? `).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59', limit, offset); const total = db.prepare(` 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 ?)) `).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n; return { transactions: rows.map(r => ({ - id: r.id, - amount: Math.abs(Number(r.amount)) / 100, - payee: r.payee || r.description || r.memo || '(Unknown)', - date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null), + id: r.id, + amount: Math.abs(Number(r.amount)) / 100, + payee: r.payee || r.description || r.memo || '(Unknown)', + date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null), + ignored: r.ignored === 1, })), total, page,