mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -98,6 +98,19 @@ async def list_transactions(
|
|||||||
return TransactionPage(items=items, total=total, page=page, page_size=page_size)
|
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)
|
@router.post("", response_model=TransactionRead, status_code=201)
|
||||||
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
||||||
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.4",
|
"version": "0.16.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export function fetchTransactions(filters: TransactionFilters = {}) {
|
|||||||
return api.get<TransactionPage>(`/transactions?${params.toString()}`);
|
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) {
|
export function createTransaction(input: TransactionInput) {
|
||||||
return api.post<Transaction>("/transactions", input);
|
return api.post<Transaction>("/transactions", input);
|
||||||
}
|
}
|
||||||
|
|||||||
32
frontend/src/components/layout/YearSelector.tsx
Normal file
32
frontend/src/components/layout/YearSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
createTransaction,
|
createTransaction,
|
||||||
deleteTransaction,
|
deleteTransaction,
|
||||||
fetchTransactions,
|
fetchTransactions,
|
||||||
|
fetchTransactionYears,
|
||||||
updateTransaction,
|
updateTransaction,
|
||||||
type TransactionFilters,
|
type TransactionFilters,
|
||||||
} from "@/api/transactions";
|
} from "@/api/transactions";
|
||||||
@@ -12,6 +13,7 @@ function useInvalidateAfterTransactionChange() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return () => {
|
return () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["transaction-years"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["category-spending-report"] });
|
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() {
|
export function useCreateTransaction() {
|
||||||
const invalidate = useInvalidateAfterTransactionChange();
|
const invalidate = useInvalidateAfterTransactionChange();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { Plus } from "lucide-react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||||
|
import { YearSelector } from "@/components/layout/YearSelector";
|
||||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||||
import { BudgetList } from "@/components/budget/BudgetList";
|
import { BudgetList } from "@/components/budget/BudgetList";
|
||||||
import { BudgetFormModal } from "@/components/budget/BudgetFormModal";
|
import { BudgetFormModal } from "@/components/budget/BudgetFormModal";
|
||||||
import { useBudgets, useBudgetStatus, useDeleteBudget } from "@/hooks/useBudgets";
|
import { useBudgets, useBudgetStatus, useDeleteBudget } from "@/hooks/useBudgets";
|
||||||
|
import { useTransactionYears } from "@/hooks/useTransactions";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
import type { Budget, BudgetStatus } from "@/types";
|
import type { Budget, BudgetStatus } from "@/types";
|
||||||
@@ -14,8 +16,9 @@ import type { Budget, BudgetStatus } from "@/types";
|
|||||||
export function BudgetPage() {
|
export function BudgetPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [year] = useState(now.getFullYear());
|
const [year, setYear] = useState(now.getFullYear());
|
||||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||||
|
const { data: years } = useTransactionYears();
|
||||||
|
|
||||||
const { data: budgets } = useBudgets();
|
const { data: budgets } = useBudgets();
|
||||||
const { data: status, isLoading } = useBudgetStatus(year, month);
|
const { data: status, isLoading } = useBudgetStatus(year, month);
|
||||||
@@ -51,7 +54,10 @@ export function BudgetPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<AlertBanner />
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||||
|
import { YearSelector } from "@/components/layout/YearSelector";
|
||||||
import { StatCard } from "@/components/dashboard/StatCard";
|
import { StatCard } from "@/components/dashboard/StatCard";
|
||||||
import { SpendingByCategoryCard } from "@/components/dashboard/SpendingByCategoryCard";
|
import { SpendingByCategoryCard } from "@/components/dashboard/SpendingByCategoryCard";
|
||||||
import { RecentTransactionsCard } from "@/components/dashboard/RecentTransactionsCard";
|
import { RecentTransactionsCard } from "@/components/dashboard/RecentTransactionsCard";
|
||||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||||
import { useDashboardSummary } from "@/hooks/useDashboard";
|
import { useDashboardSummary } from "@/hooks/useDashboard";
|
||||||
|
import { useTransactionYears } from "@/hooks/useTransactions";
|
||||||
import { formatCurrency, formatSignedCurrency } from "@/lib/format";
|
import { formatCurrency, formatSignedCurrency } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
@@ -22,8 +24,9 @@ function formatPercent(value: number): string {
|
|||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [year] = useState(now.getFullYear());
|
const [year, setYear] = useState(now.getFullYear());
|
||||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||||
|
const { data: years } = useTransactionYears();
|
||||||
|
|
||||||
const { data, isLoading, isError } = useDashboardSummary(year, month);
|
const { data, isLoading, isError } = useDashboardSummary(year, month);
|
||||||
const rate = data ? savingsRate(Number(data.real_income), Number(data.net)) : null;
|
const rate = data ? savingsRate(Number(data.real_income), Number(data.net)) : null;
|
||||||
@@ -32,7 +35,10 @@ export function DashboardPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<AlertBanner excludeKeys={["risky_allocation_exceeded"]} />
|
<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 && (
|
{isError && (
|
||||||
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
|
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Select } from "@/components/ui/Input";
|
import { Select } from "@/components/ui/Input";
|
||||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||||
|
import { YearSelector } from "@/components/layout/YearSelector";
|
||||||
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
||||||
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
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 { useCategories } from "@/hooks/useCategories";
|
||||||
import type { TransactionSort } from "@/api/transactions";
|
import type { TransactionSort } from "@/api/transactions";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
@@ -18,7 +19,7 @@ const PAGE_SIZE = 20;
|
|||||||
export function TransactionsPage() {
|
export function TransactionsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [year] = useState(now.getFullYear());
|
const [year, setYear] = useState(now.getFullYear());
|
||||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||||
const [type, setType] = useState<TransactionType | "">("");
|
const [type, setType] = useState<TransactionType | "">("");
|
||||||
const [categoryId, setCategoryId] = useState<string>("");
|
const [categoryId, setCategoryId] = useState<string>("");
|
||||||
@@ -29,6 +30,7 @@ export function TransactionsPage() {
|
|||||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
|
const { data: years } = useTransactionYears();
|
||||||
const { data, isLoading, isError } = useTransactions({
|
const { data, isLoading, isError } = useTransactions({
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
@@ -61,13 +63,23 @@ export function TransactionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<MonthSelector
|
<div className="space-y-2">
|
||||||
month={month}
|
<YearSelector
|
||||||
onChange={(value) => {
|
years={years ?? [now.getFullYear()]}
|
||||||
setMonth(value);
|
year={year}
|
||||||
setPage(1);
|
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">
|
<Button onClick={openCreateModal} className="w-full sm:w-auto">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t("transactions.addButton")}
|
{t("transactions.addButton")}
|
||||||
|
|||||||
Reference in New Issue
Block a user