feat: add year switcher to Dashboard, Transactions and Budget

Year was previously hardcoded to the current year on all three pages
(useState with no setter) — only the month could be changed via
MonthSelector, making it impossible to look at or compare older years
even though transaction history goes back further than the current
year.

Adds GET /transactions/years (contiguous range from the earliest
transaction through the current year) and a YearSelector component
matching MonthSelector's style, wired into all three pages alongside
a real year useState.
This commit is contained in:
ZProger
2026-08-16 22:10:49 +03:00
parent e9eae92975
commit f3fbe4d546
8 changed files with 99 additions and 14 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "aurum-frontend",
"private": true,
"version": "0.15.4",
"version": "0.16.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -24,6 +24,13 @@ export function fetchTransactions(filters: TransactionFilters = {}) {
return api.get<TransactionPage>(`/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<number[]>("/transactions/years");
}
export function createTransaction(input: TransactionInput) {
return api.post<Transaction>("/transactions", input);
}

View File

@@ -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 (
<div className="flex gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{years.map((value) => {
const active = value === year;
return (
<button
key={value}
type="button"
onClick={() => onChange(value)}
className={cn(
"shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
active
? "border-text-primary bg-text-primary text-surface-1"
: "border-border bg-surface-1 text-text-secondary hover:bg-surface-2"
)}
>
{value}
</button>
);
})}
</div>
);
}

View File

@@ -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({

View File

@@ -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() {
<div className="space-y-5">
<AlertBanner />
<MonthSelector month={month} onChange={setMonth} />
<div className="space-y-2">
<YearSelector years={years ?? [now.getFullYear()]} year={year} onChange={setYear} />
<MonthSelector month={month} onChange={setMonth} />
</div>
<Card>
<CardHeader>

View File

@@ -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() {
<div className="space-y-5">
<AlertBanner excludeKeys={["risky_allocation_exceeded"]} />
<MonthSelector month={month} onChange={setMonth} />
<div className="space-y-2">
<YearSelector years={years ?? [now.getFullYear()]} year={year} onChange={setYear} />
<MonthSelector month={month} onChange={setMonth} />
</div>
{isError && (
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">

View File

@@ -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<TransactionType | "">("");
const [categoryId, setCategoryId] = useState<string>("");
@@ -29,6 +30,7 @@ export function TransactionsPage() {
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const { data: categories } = useCategories();
const { data: years } = useTransactionYears();
const { data, isLoading, isError } = useTransactions({
year,
month,
@@ -61,13 +63,23 @@ export function TransactionsPage() {
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<MonthSelector
month={month}
onChange={(value) => {
setMonth(value);
setPage(1);
}}
/>
<div className="space-y-2">
<YearSelector
years={years ?? [now.getFullYear()]}
year={year}
onChange={(value) => {
setYear(value);
setPage(1);
}}
/>
<MonthSelector
month={month}
onChange={(value) => {
setMonth(value);
setPage(1);
}}
/>
</div>
<Button onClick={openCreateModal} className="w-full sm:w-auto">
<Plus size={16} />
{t("transactions.addButton")}