diff --git a/CHANGELOG.md b/CHANGELOG.md index 13b1186..6ce993a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,25 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## frontend:v2026.07.14.? & backend:v2026.07.14.? - 2026-07-14 +## 2026-07-31 -## Added +### Added +- Monthly totals in the transactions page. +- Click to load transactions in the budgets page. + +### Changed +- Category and input filter in the transactions page now show the sum of all matching transactions. + +### Fixed +- Missing cancel button when editing a budget value. + +--- + +## 2026-07-14 + +### Added - Scroll-linked header shrink effect in a progressive style. [Issue #4](https://lightroasted.vps-kinghost.net/rmcampos/ledger-finance/issues/4) - Page transition on route change. [Issue #5](https://lightroasted.vps-kinghost.net/rmcampos/ledger-finance/issues/5) - Sidebar active-link indicator now slide instead of snap. [Issue #6](https://lightroasted.vps-kinghost.net/rmcampos/ledger-finance/issues/6) - Today and Next two days transactions in the overview page. [Issue #1](https://lightroasted.vps-kinghost.net/rmcampos/ledger-finance/issues/1) -## Changed +### Changed - Headers in all pages to scroll smoothier. [Issue #3](https://lightroasted.vps-kinghost.net/rmcampos/ledger-finance/issues/3). -## Fixed +### Fixed - Apple and iOS PWA icon when installed at Home Screen. [Issue #2](https://lightroasted.vps-kinghost.net/rmcampos/ledger-finance/issues/2) -### Docker images -- `docker.io/rmcampos/ledger-backend:v2026.07.14.?` -- `docker.io/rmcampos/ledger-frontend:v2026.07.14.?` +```bash +# Docker images +docker.io/rmcampos/ledger-backend:v2026.07.14.? +docker.io/rmcampos/ledger-frontend:v2026.07.14.? +``` -## frontend:v2026.07.12.102 & backend:v2026.07.12.104 - 2026-07-12 +--- + +## 2026-07-12 ## Added - Predicted balances for the next three months in the transactions page. diff --git a/backend/src/main/java/com/ledger/resource/BudgetResource.java b/backend/src/main/java/com/ledger/resource/BudgetResource.java index 1c98f81..2a6f81f 100644 --- a/backend/src/main/java/com/ledger/resource/BudgetResource.java +++ b/backend/src/main/java/com/ledger/resource/BudgetResource.java @@ -3,8 +3,10 @@ package com.ledger.resource; import com.ledger.entity.Budget; import com.ledger.dto.response.CategorySpendResponse; import com.ledger.entity.Category; +import com.ledger.entity.Transaction; import com.ledger.security.CurrentUserService; import com.ledger.entity.User; +import io.quarkus.panache.common.Sort; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; @@ -40,6 +42,22 @@ public class BudgetResource { * Budget row — so it always reflects the live ledger. Only negative (expense) transactions are * summed; positive amounts (income, transfers in) are excluded from "spent". */ + @GET + @Path("/month/{yearMonth}/category/{categoryId}/transactions") + public List transactionsForCategory( + @PathParam("yearMonth") String yearMonth, @PathParam("categoryId") Long categoryId) { + User user = currentUser.require(); + LocalDate start = LocalDate.parse(yearMonth + "-01"); + LocalDate end = start.plusMonths(1).minusDays(1); + return Transaction.list( + "account.user.id = ?1 and category.id = ?2 and occurredOn between ?3 and ?4", + Sort.descending("occurredOn").and("id"), + user.id, + categoryId, + start, + end); + } + @GET @Path("/month/{yearMonth}/spend") public List spendForMonth(@PathParam("yearMonth") String yearMonth) { diff --git a/frontend/src/api/ledger.js b/frontend/src/api/ledger.js index e88f65d..b6d791e 100644 --- a/frontend/src/api/ledger.js +++ b/frontend/src/api/ledger.js @@ -56,6 +56,8 @@ export const BudgetsApi = { client.get(`/budgets/month/${yearMonth}`).then((r) => r.data), spendForMonth: (yearMonth) => client.get(`/budgets/month/${yearMonth}/spend`).then((r) => r.data), + transactionsForBudget: (yearMonth, categoryId) => + client.get(`/budgets/month/${yearMonth}/category/${categoryId}/transactions`).then((r) => r.data), upsert: (payload) => client.post('/budgets', payload).then((r) => r.data), remove: (id) => client.delete(`/budgets/${id}`), }; diff --git a/frontend/src/components/BudgetTransactionList.jsx b/frontend/src/components/BudgetTransactionList.jsx new file mode 100644 index 0000000..3901cf6 --- /dev/null +++ b/frontend/src/components/BudgetTransactionList.jsx @@ -0,0 +1,92 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { BudgetsApi } from '../api/ledger'; +import { parseLocalDate } from '../utils/date'; + +function money(amount) { + const value = Math.abs(Number(amount)).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + const sign = Number(amount) < 0 ? '-' : ''; + return `${sign}$${value}`; +} + +function dayLabel(dateStr) { + const date = parseLocalDate(dateStr); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + const sameDay = (a, b) => a.toDateString() === b.toDateString(); + if (sameDay(date, today)) return `Today · ${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`; + if (sameDay(date, yesterday)) + return `Yesterday · ${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export default function BudgetTransactionList({ yearMonth, categoryId }) { + const { data = [], isLoading } = useQuery({ + queryKey: ['budget-transactions', yearMonth, categoryId], + queryFn: () => BudgetsApi.transactionsForBudget(yearMonth, categoryId), + enabled: !!categoryId, + }); + + const grouped = useMemo(() => { + const map = new Map(); + for (const t of data) { + const label = dayLabel(t.occurredOn); + if (!map.has(label)) map.set(label, []); + map.get(label).push(t); + } + return Array.from(map.entries()); + }, [data]); + + if (isLoading) return
Loading…
; + if (grouped.length === 0) return
No transactions.
; + + return ( +
+ {grouped.map(([label, txns]) => ( +
+
+
{label}
+
+ {txns.map((t) => ( +
+
+
+ {t.description} + {t.seriesInfo && ( + + {t.seriesInfo} + + )} +
+
+ {t.account?.name || 'Unknown account'} + {t.occurredOn && ( + <> + + {t.occurredOn} + + )} +
+
+
+
+ {money(t.amount)} +
+ {t.runningBalance != null && ( +
{money(t.runningBalance)}
+ )} +
+
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/src/pages/Budgets.jsx b/frontend/src/pages/Budgets.jsx index 68ee95d..a750896 100644 --- a/frontend/src/pages/Budgets.jsx +++ b/frontend/src/pages/Budgets.jsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { BudgetsApi, CategoriesApi } from '../api/ledger'; import { localYearMonth } from '../utils/date'; import { useStickyHeader } from '../hooks/useStickyHeader'; +import BudgetTransactionList from '../components/BudgetTransactionList'; function shiftMonth(yearMonth, delta) { const [year, month] = yearMonth.split('-').map(Number); @@ -84,6 +85,7 @@ export default function Budgets() { }; const [confirmingId, setConfirmingId] = useState(null); + const [selectedBudgetId, setSelectedBudgetId] = useState(null); const deleteMutation = useMutation({ mutationFn: BudgetsApi.remove, @@ -91,6 +93,7 @@ export default function Budgets() { queryClient.invalidateQueries({ queryKey: ['budgets', yearMonth] }); queryClient.invalidateQueries({ queryKey: ['budgets-spend', yearMonth] }); setConfirmingId(null); + if (selectedBudgetId) setSelectedBudgetId(null); }, }); @@ -99,6 +102,8 @@ export default function Budgets() { deleteMutation.reset(); }; + const selectedBudget = budgets.find((b) => b.id === selectedBudgetId); + const monthLabel = new Date(`${yearMonth}-01T00:00:00`).toLocaleDateString('en-US', { month: 'long', year: 'numeric', @@ -180,6 +185,19 @@ export default function Budgets() { {upsertMutation.isPending ? 'Saving…' : 'Save'} +
+ +
@@ -235,7 +253,14 @@ export default function Budgets() { const color = b.category?.colorHex || categoryColors[b.category?.name] || '#8B92A0'; return (
-
+
{ + if (spent === 0) return; + setSelectedBudgetId(b.id); + }} + > {confirmingId === b.id ? (
Delete budget for “{b.category?.name}”?
@@ -268,13 +293,17 @@ export default function Budgets() { {b.category?.name}
- @@ -294,6 +323,26 @@ export default function Budgets() { ); })}
+ + {selectedBudget && ( +
+
+
{selectedBudget.category?.name} transactions
+ +
+ +
+ )}
); } diff --git a/frontend/src/pages/Transactions.jsx b/frontend/src/pages/Transactions.jsx index ad23840..7cd3cad 100644 --- a/frontend/src/pages/Transactions.jsx +++ b/frontend/src/pages/Transactions.jsx @@ -487,11 +487,16 @@ export default function Transactions() { }) : []; const showPredicted = predictedMonths.length > 0; - const balanceColClass = showPredicted - ? (filterCategoryId ? 'col-md-5' : 'col-md-8') - : (filterCategoryId ? 'col-md-7' : 'col-md-12'); - const predictedColClass = filterCategoryId ? 'col-md-3' : 'col-md-4'; - const categoryColClass = showPredicted ? 'col-md-4' : 'col-md-5'; + const hasActiveFilter = Boolean(filterCategoryId || search.trim()); + const showMonthlyTotals = filterRange === 'this-month'; + + const visibleCardCount = + 1 + (showMonthlyTotals ? 1 : 0) + (showPredicted ? 1 : 0) + (hasActiveFilter ? 1 : 0); + const topCardClass = + visibleCardCount === 4 ? 'col-md-3' : + visibleCardCount === 3 ? 'col-md-4' : + visibleCardCount === 2 ? 'col-md-6' : + 'col-md-12'; const filteredCategoryName = categories.find((c) => String(c.id) === filterCategoryId)?.name; @@ -525,7 +530,26 @@ export default function Transactions() { return Array.from(map.entries()); }, [filteredFlat]); - const filteredCategoryTotal = filteredFlat.reduce((sum, t) => sum + Number(t.amount), 0); + const filteredTotal = filteredFlat.reduce((sum, t) => sum + Number(t.amount), 0); + + const monthlyFlat = useMemo(() => { + const { start, end } = rangeBounds(filterRange, filterStartDate, filterEndDate, monthOffset); + return txnQueries + .flatMap((q) => q.data || []) + .filter((t) => !filterAccountId || String(t.account?.id) === filterAccountId) + .filter((t) => !start || parseLocalDate(t.occurredOn) >= start) + .filter((t) => !end || parseLocalDate(t.occurredOn) <= end) + .sort((a, b) => parseLocalDate(b.occurredOn) - parseLocalDate(a.occurredOn)); + }, [txnQueries, filterAccountId, filterRange, filterStartDate, filterEndDate, monthOffset]); + + const monthlyCredits = monthlyFlat.reduce( + (sum, t) => sum + (Number(t.amount) > 0 ? Number(t.amount) : 0), + 0 + ); + const monthlyDebits = monthlyFlat.reduce( + (sum, t) => sum + (Number(t.amount) < 0 ? Number(t.amount) : 0), + 0 + ); const handleExport = () => { downloadCsv(toCsv(filteredFlat), `transactions-${todayIso()}.csv`); @@ -696,7 +720,7 @@ export default function Transactions() {
-
+
Current balance @@ -712,8 +736,27 @@ export default function Transactions() {
)}
+ {showMonthlyTotals && ( +
+
Monthly totals
+
+
+ Credits + + {money(monthlyCredits, { signed: true })} + +
+
+ Debits + + {money(monthlyDebits, { signed: true })} + +
+
+
+ )} {showPredicted && ( -
+
Predicted
{predictedMonths.map((m) => ( @@ -727,14 +770,16 @@ export default function Transactions() {
)} - {filterCategoryId && ( -
-
{filteredCategoryName || 'Category'} total
+ {hasActiveFilter && ( +
+
+ {filterCategoryId ? `${filteredCategoryName} total` : 'Filtered total'} +
- {money(filteredCategoryTotal, { signed: true })} + {money(filteredTotal, { signed: true })}
Sum of currently filtered transactions.