feat(expense): 관리자 분류 설정 메뉴 추가 (동적 분류)
- CategoryStore: 분류 항목 JSON 저장소(DATA_DIR/expense_categories.json) - 관리자 설정 페이지(/expense/settings) + 분류 CRUD API - _normalize 분류 고정목록 검증 제거 — 추가 분류 즉시 등록 폼/집계 반영 - index/reports 분류를 동적 목록으로 교체 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ from fastapi.responses import (
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||
from .store import METHODS, STATUSES, ExpenseStore
|
||||
|
||||
router = APIRouter(prefix="/expense", tags=["expense"])
|
||||
|
||||
@@ -57,6 +57,18 @@ def _store(request: Request) -> Any:
|
||||
return store
|
||||
|
||||
|
||||
def _category_store(request: Request) -> Any:
|
||||
store = getattr(request.app.state, "expense_category_store", None)
|
||||
if store is None:
|
||||
raise RuntimeError("CategoryStore 가 app.state 에 등록되지 않았습니다.")
|
||||
return store
|
||||
|
||||
|
||||
def _categories(request: Request) -> list[str]:
|
||||
"""현재 활성 분류 목록 — 등록 폼/집계에서 사용."""
|
||||
return _category_store(request).list()
|
||||
|
||||
|
||||
def _upload_dir(request: Request) -> Path:
|
||||
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
|
||||
base = getattr(request.app.state, "data_dir", None)
|
||||
@@ -97,6 +109,18 @@ def _require_approver(request: Request) -> dict[str, Any]:
|
||||
return user
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _require_db_store(store: Any) -> None:
|
||||
"""워크플로/첨부/집계는 DB 모드 전용. JSON 폴백에서는 501."""
|
||||
if not hasattr(store, "submit"):
|
||||
@@ -124,6 +148,10 @@ class RejectBody(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
class CategoryBody(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 페이지
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@@ -169,7 +197,7 @@ async def expense_index(request: Request) -> HTMLResponse:
|
||||
"pending_count": pending_count,
|
||||
"items": items,
|
||||
"summary": summary,
|
||||
"categories": list(CATEGORIES),
|
||||
"categories": _categories(request),
|
||||
"methods": list(METHODS),
|
||||
"statuses": list(STATUSES),
|
||||
"nav_items": build_erp_nav(user, active="expense"),
|
||||
@@ -244,7 +272,13 @@ async def reports_page(request: Request) -> HTMLResponse:
|
||||
for r in rows:
|
||||
months.setdefault(r["month"], {})[r["category"]] = r["total"]
|
||||
month_keys = sorted(months.keys())
|
||||
cat_totals = {c: sum(months[m].get(c, 0) for m in month_keys) for c in CATEGORIES}
|
||||
# 활성 분류 + 데이터에만 있는(삭제된) 분류 모두 포함
|
||||
categories = list(_categories(request))
|
||||
for m in month_keys:
|
||||
for c in months[m]:
|
||||
if c not in categories:
|
||||
categories.append(c)
|
||||
cat_totals = {c: sum(months[m].get(c, 0) for m in month_keys) for c in categories}
|
||||
grand_total = sum(cat_totals.values())
|
||||
|
||||
return render_template(
|
||||
@@ -256,7 +290,7 @@ async def reports_page(request: Request) -> HTMLResponse:
|
||||
"year": year,
|
||||
"months": month_keys,
|
||||
"pivot": months,
|
||||
"categories": list(CATEGORIES),
|
||||
"categories": categories,
|
||||
"cat_totals": cat_totals,
|
||||
"grand_total": grand_total,
|
||||
"nav_items": build_erp_nav(user, active="expense"),
|
||||
@@ -266,6 +300,79 @@ async def reports_page(request: Request) -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 관리자 설정 — 분류 관리
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request) -> HTMLResponse:
|
||||
"""관리자 전용 설정 — 분류 추가/삭제."""
|
||||
from app.main import ( # noqa: WPS433
|
||||
build_erp_nav,
|
||||
get_current_user_record,
|
||||
render_template,
|
||||
)
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not is_admin(user):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "개인경비 설정은 관리자만 접근할 수 있습니다."},
|
||||
status_code=403,
|
||||
)
|
||||
return render_template(
|
||||
request,
|
||||
"expense/settings.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": True,
|
||||
"categories": _categories(request),
|
||||
"nav_items": build_erp_nav(user, active="expense"),
|
||||
"page_title": "개인경비 — 설정",
|
||||
"page_subtitle": "분류 항목 관리",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/categories")
|
||||
async def list_categories(
|
||||
request: Request,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
return JSONResponse({"categories": _categories(request)})
|
||||
|
||||
|
||||
@router.post("/api/categories")
|
||||
async def add_category(
|
||||
request: Request,
|
||||
body: CategoryBody,
|
||||
user: dict[str, Any] = Depends(_require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
categories = _category_store(request).add(body.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"categories": categories}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/categories/{name}")
|
||||
async def delete_category(
|
||||
request: Request,
|
||||
name: str,
|
||||
user: dict[str, Any] = Depends(_require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
categories = _category_store(request).delete(name)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다.")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"categories": categories})
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 항목 CRUD API
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user