Add category management UI and service layer

Users can now create, edit, and delete custom categories from Settings → Categories. System categories (45 built-in) are shown read-only. Backend adds update_category() and delete_category() service functions; frontend has a new categories API module and a full CRUD section in SettingsPage with filter tabs, colour picker, and delete confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 11:50:28 +00:00
parent 8ef3bb2965
commit 6111424f47
5 changed files with 455 additions and 22 deletions

View file

@ -1,14 +1,28 @@
import uuid
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.services.category_service import create_category, list_categories
from app.services.category_service import create_category, list_categories, update_category, delete_category
router = APIRouter()
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
type: str = Field(..., pattern="^(income|expense|transfer)$")
color: str | None = Field(default=None, max_length=7)
icon: str | None = Field(default=None, max_length=50)
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
color: str | None = None
icon: str | None = None
@router.get("")
async def get_categories(
db: AsyncSession = Depends(get_db),
@ -19,18 +33,43 @@ async def get_categories(
@router.post("", status_code=201)
async def create(
body: dict,
body: CategoryCreate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
result = await create_category(
db,
user_id=user.id,
name=body["name"],
type_=body["type"],
icon=body.get("icon"),
color=body.get("color"),
parent_id=uuid.UUID(body["parent_id"]) if body.get("parent_id") else None,
name=body.name,
type_=body.type,
icon=body.icon,
color=body.color,
)
await db.commit()
return result
@router.put("/{category_id}", status_code=200)
async def update(
category_id: uuid.UUID,
body: CategoryUpdate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
result = await update_category(db, user.id, category_id, body.name, body.color, body.icon)
if result is None:
raise HTTPException(status_code=404, detail="Category not found or is a system category")
await db.commit()
return result
@router.delete("/{category_id}", status_code=204)
async def delete(
category_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
ok = await delete_category(db, user.id, category_id)
if not ok:
raise HTTPException(status_code=404, detail="Category not found or is a system category")
await db.commit()

View file

@ -133,3 +133,51 @@ async def create_category(
db.add(cat)
await db.flush()
return {"id": str(cat.id), "name": cat.name, "type": cat.type, "icon": cat.icon, "color": cat.color, "is_system": False}
async def update_category(
db: AsyncSession,
user_id: uuid.UUID,
category_id: uuid.UUID,
name: str | None,
color: str | None,
icon: str | None,
) -> dict | None:
result = await db.execute(
select(Category).where(
Category.id == category_id,
Category.user_id == user_id,
Category.is_system == False,
)
)
cat = result.scalar_one_or_none()
if cat is None:
return None
if name is not None:
cat.name = name
if color is not None:
cat.color = color
if icon is not None:
cat.icon = icon
await db.flush()
return {"id": str(cat.id), "name": cat.name, "type": cat.type, "icon": cat.icon, "color": cat.color, "is_system": False}
async def delete_category(
db: AsyncSession,
user_id: uuid.UUID,
category_id: uuid.UUID,
) -> bool:
result = await db.execute(
select(Category).where(
Category.id == category_id,
Category.user_id == user_id,
Category.is_system == False,
)
)
cat = result.scalar_one_or_none()
if cat is None:
return False
await db.delete(cat)
await db.flush()
return True