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 (
+
+ );
+}
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,