feat: advanced features — dashboard, budgets, history, export
Backend: - GET /api/v1/dashboard?month=YYYY-MM: KPIs, by_category, 6-month trend, budget alerts - GET/POST/PUT/DELETE /api/v1/budgets: budget envelopes with spent_cents/remaining_cents - POST /api/v1/budgets/rollover: copy budgets from M-1 to target month - GET /api/v1/history?year=YYYY: monthly summary for the year - GET /api/v1/export/csv|pdf?month=YYYY-MM: StreamingResponse exports (WeasyPrint PDF) - New schemas: dashboard, budget, history - Services: dashboard_service, budget_service - Routers mounted in main.py Frontend: - DashboardPage: 4 KPI cards, PieChart (expenses by category), BarChart (6-month trend), month navigation, budget alert badges, CSV/PDF export buttons - BudgetsPage: progress bars (green/orange/red), create/edit form, delete, rollover M-1 - HistoryPage: annual table with month click → dashboard, LineChart revenues/expenses - CategoriesPage: list by type with create/edit/delete (was missing from Phase 2) - TransactionsPage: added CSV/PDF export buttons - App.tsx: full routing with ProtectedRoute + Layout for all authenticated pages - New hooks: useDashboard, useBudgets (with mutations), useHistory - API types + client updated for all new endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.budget import Budget
|
||||
from app.models.transaction import Transaction, TransactionType
|
||||
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
|
||||
from app.utils import utcnow
|
||||
|
||||
|
||||
def _month_bounds(month: str) -> tuple[date, date]:
|
||||
year, mon = map(int, month.split("-"))
|
||||
start = date(year, mon, 1)
|
||||
if mon == 12:
|
||||
end = date(year + 1, 1, 1)
|
||||
else:
|
||||
end = date(year, mon + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _prev_month(month: str) -> str:
|
||||
year, mon = map(int, month.split("-"))
|
||||
if mon == 1:
|
||||
return f"{year - 1}-12"
|
||||
return f"{year}-{mon - 1:02d}"
|
||||
|
||||
|
||||
async def _spent_cents(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
category_id: uuid.UUID,
|
||||
month: str,
|
||||
) -> int:
|
||||
start, end = _month_bounds(month)
|
||||
result = await session.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.type == TransactionType.expense,
|
||||
Transaction.category_id == category_id,
|
||||
Transaction.transaction_date >= start,
|
||||
Transaction.transaction_date < end,
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def _to_response(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
budget: Budget,
|
||||
) -> BudgetResponse:
|
||||
spent = await _spent_cents(session, user_id, budget.category_id, budget.month)
|
||||
return BudgetResponse(
|
||||
id=budget.id,
|
||||
category_id=budget.category_id,
|
||||
category_name=budget.category.name,
|
||||
category_color=budget.category.color,
|
||||
month=budget.month,
|
||||
limit_cents=budget.limit_cents,
|
||||
spent_cents=spent,
|
||||
remaining_cents=max(0, budget.limit_cents - spent),
|
||||
created_at=budget.created_at,
|
||||
updated_at=budget.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def list_budgets(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
month: str,
|
||||
) -> list[BudgetResponse]:
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.user_id == user_id, Budget.month == month)
|
||||
.order_by(Budget.created_at)
|
||||
)
|
||||
budgets = result.scalars().all()
|
||||
return [await _to_response(session, user_id, b) for b in budgets]
|
||||
|
||||
|
||||
async def create_budget(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
data: BudgetCreate,
|
||||
) -> BudgetResponse:
|
||||
# Check for duplicate
|
||||
existing = await session.execute(
|
||||
select(Budget).where(
|
||||
Budget.user_id == user_id,
|
||||
Budget.category_id == data.category_id,
|
||||
Budget.month == data.month,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Budget already exists for this category and month",
|
||||
)
|
||||
|
||||
budget = Budget(
|
||||
user_id=user_id,
|
||||
category_id=data.category_id,
|
||||
month=data.month,
|
||||
limit_cents=data.limit_cents,
|
||||
)
|
||||
session.add(budget)
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == budget.id)
|
||||
)
|
||||
budget = result.scalar_one()
|
||||
return await _to_response(session, user_id, budget)
|
||||
|
||||
|
||||
async def update_budget(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
budget_id: uuid.UUID,
|
||||
data: BudgetUpdate,
|
||||
) -> BudgetResponse:
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == budget_id, Budget.user_id == user_id)
|
||||
)
|
||||
budget = result.scalar_one_or_none()
|
||||
if budget is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
|
||||
|
||||
budget.limit_cents = data.limit_cents
|
||||
budget.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == budget_id)
|
||||
)
|
||||
budget = result.scalar_one()
|
||||
return await _to_response(session, user_id, budget)
|
||||
|
||||
|
||||
async def delete_budget(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
budget_id: uuid.UUID,
|
||||
) -> None:
|
||||
result = await session.execute(
|
||||
select(Budget).where(Budget.id == budget_id, Budget.user_id == user_id)
|
||||
)
|
||||
budget = result.scalar_one_or_none()
|
||||
if budget is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
|
||||
await session.delete(budget)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def rollover_budgets(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
target_month: str,
|
||||
) -> list[BudgetResponse]:
|
||||
"""Copy budgets from previous month to target_month (skip existing)."""
|
||||
source_month = _prev_month(target_month)
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.user_id == user_id, Budget.month == source_month)
|
||||
)
|
||||
source_budgets = result.scalars().all()
|
||||
|
||||
created = []
|
||||
for src in source_budgets:
|
||||
existing = await session.execute(
|
||||
select(Budget).where(
|
||||
Budget.user_id == user_id,
|
||||
Budget.category_id == src.category_id,
|
||||
Budget.month == target_month,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
continue
|
||||
new_budget = Budget(
|
||||
user_id=user_id,
|
||||
category_id=src.category_id,
|
||||
month=target_month,
|
||||
limit_cents=src.limit_cents,
|
||||
)
|
||||
session.add(new_budget)
|
||||
created.append(new_budget)
|
||||
|
||||
await session.commit()
|
||||
|
||||
responses = []
|
||||
for b in created:
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == b.id)
|
||||
)
|
||||
b = result.scalar_one()
|
||||
responses.append(await _to_response(session, user_id, b))
|
||||
|
||||
return responses
|
||||
Reference in New Issue
Block a user