diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index 7a5ae0f..8664d59 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -98,6 +98,19 @@ async def list_transactions( return TransactionPage(items=items, total=total, page=page, page_size=page_size) +@router.get("/years", response_model=list[int]) +async def list_transaction_years(session: AsyncSession = Depends(get_session)) -> list[int]: + """Full range of years to offer in the year picker — from the earliest + transaction through the current year, so a gap year with no activity + still shows up (as zero) instead of silently disappearing from the UI.""" + bounds = await session.execute(select(func.min(Transaction.date), func.max(Transaction.date))) + min_date, max_date = bounds.one() + current_year = date_.today().year + if min_date is None: + return [current_year] + return list(range(min_date.year, max(max_date.year, current_year) + 1)) + + @router.post("", response_model=TransactionRead, status_code=201) async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction: await _ensure_category_matches_type(session, payload.category_id, payload.type) diff --git a/frontend/package.json b/frontend/package.json index f048f38..1e302af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.15.4", + "version": "0.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index d81bdc3..542ccfe 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -24,6 +24,13 @@ export function fetchTransactions(filters: TransactionFilters = {}) { return api.get(`/transactions?${params.toString()}`); } +/** Full range of years to offer in the year picker, from the earliest + * transaction through the current year (see backend for the "gap year" + * rationale). */ +export function fetchTransactionYears() { + return api.get("/transactions/years"); +} + export function createTransaction(input: TransactionInput) { return api.post("/transactions", input); } diff --git a/frontend/src/components/layout/YearSelector.tsx b/frontend/src/components/layout/YearSelector.tsx new file mode 100644 index 0000000..3d8858f --- /dev/null +++ b/frontend/src/components/layout/YearSelector.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils"; + +interface YearSelectorProps { + years: number[]; + year: number; + onChange: (year: number) => void; +} + +export function YearSelector({ years, year, onChange }: YearSelectorProps) { + return ( +
+ {years.map((value) => { + const active = value === year; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useTransactions.ts b/frontend/src/hooks/useTransactions.ts index d606551..1600084 100644 --- a/frontend/src/hooks/useTransactions.ts +++ b/frontend/src/hooks/useTransactions.ts @@ -3,6 +3,7 @@ import { createTransaction, deleteTransaction, fetchTransactions, + fetchTransactionYears, updateTransaction, type TransactionFilters, } from "@/api/transactions"; @@ -12,6 +13,7 @@ function useInvalidateAfterTransactionChange() { const queryClient = useQueryClient(); return () => { queryClient.invalidateQueries({ queryKey: ["transactions"] }); + queryClient.invalidateQueries({ queryKey: ["transaction-years"] }); queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] }); queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] }); queryClient.invalidateQueries({ queryKey: ["category-spending-report"] }); @@ -30,6 +32,13 @@ export function useTransactions(filters: TransactionFilters) { }); } +export function useTransactionYears() { + return useQuery({ + queryKey: ["transaction-years"], + queryFn: fetchTransactionYears, + }); +} + export function useCreateTransaction() { const invalidate = useInvalidateAfterTransactionChange(); return useMutation({ diff --git a/frontend/src/pages/BudgetPage.tsx b/frontend/src/pages/BudgetPage.tsx index 2be16d7..77754d0 100644 --- a/frontend/src/pages/BudgetPage.tsx +++ b/frontend/src/pages/BudgetPage.tsx @@ -3,10 +3,12 @@ import { Plus } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { MonthSelector } from "@/components/layout/MonthSelector"; +import { YearSelector } from "@/components/layout/YearSelector"; import { AlertBanner } from "@/components/insights/AlertBanner"; import { BudgetList } from "@/components/budget/BudgetList"; import { BudgetFormModal } from "@/components/budget/BudgetFormModal"; import { useBudgets, useBudgetStatus, useDeleteBudget } from "@/hooks/useBudgets"; +import { useTransactionYears } from "@/hooks/useTransactions"; import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; import type { Budget, BudgetStatus } from "@/types"; @@ -14,8 +16,9 @@ import type { Budget, BudgetStatus } from "@/types"; export function BudgetPage() { const { t } = useTranslation(); const now = new Date(); - const [year] = useState(now.getFullYear()); + const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); + const { data: years } = useTransactionYears(); const { data: budgets } = useBudgets(); const { data: status, isLoading } = useBudgetStatus(year, month); @@ -51,7 +54,10 @@ export function BudgetPage() {
- +
+ + +
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 643d1ce..a0c41a8 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; import { MonthSelector } from "@/components/layout/MonthSelector"; +import { YearSelector } from "@/components/layout/YearSelector"; import { StatCard } from "@/components/dashboard/StatCard"; import { SpendingByCategoryCard } from "@/components/dashboard/SpendingByCategoryCard"; import { RecentTransactionsCard } from "@/components/dashboard/RecentTransactionsCard"; import { AlertBanner } from "@/components/insights/AlertBanner"; import { useDashboardSummary } from "@/hooks/useDashboard"; +import { useTransactionYears } from "@/hooks/useTransactions"; import { formatCurrency, formatSignedCurrency } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; @@ -22,8 +24,9 @@ function formatPercent(value: number): string { export function DashboardPage() { const { t } = useTranslation(); const now = new Date(); - const [year] = useState(now.getFullYear()); + const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); + const { data: years } = useTransactionYears(); const { data, isLoading, isError } = useDashboardSummary(year, month); const rate = data ? savingsRate(Number(data.real_income), Number(data.net)) : null; @@ -32,7 +35,10 @@ export function DashboardPage() {
- +
+ + +
{isError && (

diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx index ad406b5..c2dd370 100644 --- a/frontend/src/pages/TransactionsPage.tsx +++ b/frontend/src/pages/TransactionsPage.tsx @@ -4,9 +4,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Select } from "@/components/ui/Input"; import { MonthSelector } from "@/components/layout/MonthSelector"; +import { YearSelector } from "@/components/layout/YearSelector"; import { TransactionsTable } from "@/components/transactions/TransactionsTable"; import { TransactionFormModal } from "@/components/transactions/TransactionFormModal"; -import { useTransactions, useDeleteTransaction } from "@/hooks/useTransactions"; +import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions"; import { useCategories } from "@/hooks/useCategories"; import type { TransactionSort } from "@/api/transactions"; import { useTranslation } from "@/lib/i18n"; @@ -18,7 +19,7 @@ const PAGE_SIZE = 20; export function TransactionsPage() { const { t } = useTranslation(); const now = new Date(); - const [year] = useState(now.getFullYear()); + const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); const [type, setType] = useState(""); const [categoryId, setCategoryId] = useState(""); @@ -29,6 +30,7 @@ export function TransactionsPage() { const [editingTransaction, setEditingTransaction] = useState(null); const { data: categories } = useCategories(); + const { data: years } = useTransactionYears(); const { data, isLoading, isError } = useTransactions({ year, month, @@ -61,13 +63,23 @@ export function TransactionsPage() { return (

- { - setMonth(value); - setPage(1); - }} - /> +
+ { + setYear(value); + setPage(1); + }} + /> + { + setMonth(value); + setPage(1); + }} + /> +