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:
+3
-1
@@ -15,7 +15,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
|
|
||||||
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
||||||
from .modules.cupang import router as cupang_router
|
from .modules.cupang import router as cupang_router
|
||||||
from .modules.expense import build_expense_store
|
from .modules.expense import CategoryStore, build_expense_store
|
||||||
from .modules.expense import router as expense_router
|
from .modules.expense import router as expense_router
|
||||||
from .modules.vacation import build_vacation_store
|
from .modules.vacation import build_vacation_store
|
||||||
from .modules.vacation import router as vacation_router
|
from .modules.vacation import router as vacation_router
|
||||||
@@ -127,6 +127,8 @@ app.state.expense_store = build_expense_store(
|
|||||||
dsn=env("EXPENSE_DB_URL") or None,
|
dsn=env("EXPENSE_DB_URL") or None,
|
||||||
json_path=DATA_DIR / "expense.json",
|
json_path=DATA_DIR / "expense.json",
|
||||||
)
|
)
|
||||||
|
# 분류(category) 설정 — 관리자가 추가/삭제, 저장 즉시 반영. 항상 JSON 파일.
|
||||||
|
app.state.expense_category_store = CategoryStore(DATA_DIR / "expense_categories.json")
|
||||||
# 쿠팡 밀크런: CUPANG_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
# 쿠팡 밀크런: CUPANG_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
|
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
|
||||||
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
|
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
|
||||||
|
|||||||
@@ -9,12 +9,15 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .categories import DEFAULT_CATEGORIES, CategoryStore
|
||||||
from .router import router
|
from .router import router
|
||||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"router",
|
"router",
|
||||||
"ExpenseStore",
|
"ExpenseStore",
|
||||||
|
"CategoryStore",
|
||||||
|
"DEFAULT_CATEGORIES",
|
||||||
"CATEGORIES",
|
"CATEGORIES",
|
||||||
"METHODS",
|
"METHODS",
|
||||||
"STATUSES",
|
"STATUSES",
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""개인경비 분류(category) 설정 저장소.
|
||||||
|
|
||||||
|
- 저장 위치: DATA_DIR/expense_categories.json
|
||||||
|
- 관리자만 추가/삭제. 저장 즉시 사용자 등록 폼/집계에 반영.
|
||||||
|
- JSON 파일 기반 — expense_db(PostgreSQL) 모드와 무관하게 동작(설정값이라
|
||||||
|
트랜잭션 데이터와 분리). DB 스키마 변경(superuser SQL) 불필요.
|
||||||
|
- 동시성: ExpenseStore 와 동일 패턴(threading.Lock + temp→rename 원자적 쓰기).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# 최초 1회 시드. 기존 하드코딩 CATEGORIES 와 동일.
|
||||||
|
DEFAULT_CATEGORIES: tuple[str, ...] = (
|
||||||
|
"식대", "교통", "숙박", "비품", "접대", "통신", "기타",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryStore:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self._path = path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self._path.exists():
|
||||||
|
self._write_atomic({"categories": list(DEFAULT_CATEGORIES)})
|
||||||
|
|
||||||
|
def _read(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with self._path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
cats = data.get("categories")
|
||||||
|
if not isinstance(cats, list) or not cats:
|
||||||
|
data["categories"] = list(DEFAULT_CATEGORIES)
|
||||||
|
else:
|
||||||
|
# 문자열만, 공백 제거, 중복 제거(순서 유지)
|
||||||
|
seen: set[str] = set()
|
||||||
|
clean: list[str] = []
|
||||||
|
for c in cats:
|
||||||
|
name = str(c).strip()
|
||||||
|
if name and name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
clean.append(name)
|
||||||
|
data["categories"] = clean or list(DEFAULT_CATEGORIES)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||||
|
fd, tmp = tempfile.mkstemp(
|
||||||
|
prefix=".expense_categories.", suffix=".json.tmp",
|
||||||
|
dir=str(self._path.parent),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, self._path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list(self) -> list[str]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._read()["categories"])
|
||||||
|
|
||||||
|
def add(self, name: str) -> list[str]:
|
||||||
|
name = str(name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("분류명을 입력하세요.")
|
||||||
|
if len(name) > 30:
|
||||||
|
raise ValueError("분류명은 30자 이하로 입력하세요.")
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
if name in data["categories"]:
|
||||||
|
raise ValueError(f"이미 존재하는 분류입니다: {name}")
|
||||||
|
data["categories"].append(name)
|
||||||
|
self._write_atomic(data)
|
||||||
|
return list(data["categories"])
|
||||||
|
|
||||||
|
def delete(self, name: str) -> list[str]:
|
||||||
|
name = str(name or "").strip()
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
if name not in data["categories"]:
|
||||||
|
raise KeyError(name)
|
||||||
|
if len(data["categories"]) <= 1:
|
||||||
|
raise ValueError("분류는 최소 1개 이상이어야 합니다.")
|
||||||
|
data["categories"] = [c for c in data["categories"] if c != name]
|
||||||
|
self._write_atomic(data)
|
||||||
|
return list(data["categories"])
|
||||||
@@ -482,7 +482,8 @@ class ExpenseDBStore:
|
|||||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||||
b["category"] = category if category in CATEGORIES else "기타"
|
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||||
|
b["category"] = category or "기타"
|
||||||
b["method"] = method if method in METHODS else "법인카드"
|
b["method"] = method if method in METHODS else "법인카드"
|
||||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from fastapi.responses import (
|
|||||||
)
|
)
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
from .store import METHODS, STATUSES, ExpenseStore
|
||||||
|
|
||||||
router = APIRouter(prefix="/expense", tags=["expense"])
|
router = APIRouter(prefix="/expense", tags=["expense"])
|
||||||
|
|
||||||
@@ -57,6 +57,18 @@ def _store(request: Request) -> Any:
|
|||||||
return store
|
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:
|
def _upload_dir(request: Request) -> Path:
|
||||||
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
|
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
|
||||||
base = getattr(request.app.state, "data_dir", None)
|
base = getattr(request.app.state, "data_dir", None)
|
||||||
@@ -97,6 +109,18 @@ def _require_approver(request: Request) -> dict[str, Any]:
|
|||||||
return user
|
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:
|
def _require_db_store(store: Any) -> None:
|
||||||
"""워크플로/첨부/집계는 DB 모드 전용. JSON 폴백에서는 501."""
|
"""워크플로/첨부/집계는 DB 모드 전용. JSON 폴백에서는 501."""
|
||||||
if not hasattr(store, "submit"):
|
if not hasattr(store, "submit"):
|
||||||
@@ -124,6 +148,10 @@ class RejectBody(BaseModel):
|
|||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
# 페이지
|
# 페이지
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
@@ -169,7 +197,7 @@ async def expense_index(request: Request) -> HTMLResponse:
|
|||||||
"pending_count": pending_count,
|
"pending_count": pending_count,
|
||||||
"items": items,
|
"items": items,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"categories": list(CATEGORIES),
|
"categories": _categories(request),
|
||||||
"methods": list(METHODS),
|
"methods": list(METHODS),
|
||||||
"statuses": list(STATUSES),
|
"statuses": list(STATUSES),
|
||||||
"nav_items": build_erp_nav(user, active="expense"),
|
"nav_items": build_erp_nav(user, active="expense"),
|
||||||
@@ -244,7 +272,13 @@ async def reports_page(request: Request) -> HTMLResponse:
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
months.setdefault(r["month"], {})[r["category"]] = r["total"]
|
months.setdefault(r["month"], {})[r["category"]] = r["total"]
|
||||||
month_keys = sorted(months.keys())
|
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())
|
grand_total = sum(cat_totals.values())
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -256,7 +290,7 @@ async def reports_page(request: Request) -> HTMLResponse:
|
|||||||
"year": year,
|
"year": year,
|
||||||
"months": month_keys,
|
"months": month_keys,
|
||||||
"pivot": months,
|
"pivot": months,
|
||||||
"categories": list(CATEGORIES),
|
"categories": categories,
|
||||||
"cat_totals": cat_totals,
|
"cat_totals": cat_totals,
|
||||||
"grand_total": grand_total,
|
"grand_total": grand_total,
|
||||||
"nav_items": build_erp_nav(user, active="expense"),
|
"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
|
# 항목 CRUD API
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ class ExpenseStore:
|
|||||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||||
b["category"] = category if category in CATEGORIES else "기타"
|
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||||
|
b["category"] = category or "기타"
|
||||||
b["method"] = method if method in METHODS else "법인카드"
|
b["method"] = method if method in METHODS else "법인카드"
|
||||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
{% if is_approver %}
|
{% if is_approver %}
|
||||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── 요약 카드 ── -->
|
<!-- ── 요약 카드 ── -->
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-ex-settings">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-card-block" style="max-width: 640px;">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>분류 항목 관리</h2>
|
||||||
|
<span class="erp-muted">추가/삭제 즉시 사용자 등록 폼에 반영됩니다.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="cat-form" class="erp-form-grid" style="grid-template-columns: 1fr auto; align-items: end; gap: var(--sp-12);">
|
||||||
|
<label class="erp-field"><span>새 분류명</span>
|
||||||
|
<input type="text" name="name" maxlength="30" placeholder="예) 마케팅" required />
|
||||||
|
</label>
|
||||||
|
<div class="erp-form-actions" style="margin: 0;">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap" style="margin-top: var(--sp-16);">
|
||||||
|
<table class="erp-table" id="cat-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>분류명</th>
|
||||||
|
<th style="width: 100px; text-align: right;">동작</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cat-tbody">
|
||||||
|
{% for c in categories %}
|
||||||
|
<tr data-name="{{ c }}">
|
||||||
|
<td>{{ c }}</td>
|
||||||
|
<td style="text-align: right;">
|
||||||
|
<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="erp-muted" style="margin-top: var(--sp-12);">
|
||||||
|
삭제해도 이미 등록된 경비 항목의 분류는 그대로 유지됩니다. 분류는 최소 1개 이상이어야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||||
|
#cat-tbody td { vertical-align: middle; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById("cat-form");
|
||||||
|
const tbody = document.getElementById("cat-tbody");
|
||||||
|
|
||||||
|
function rowFor(name) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.name = name;
|
||||||
|
tr.innerHTML =
|
||||||
|
`<td></td>` +
|
||||||
|
`<td style="text-align: right;">` +
|
||||||
|
`<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button></td>`;
|
||||||
|
tr.children[0].textContent = name;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(categories) {
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
categories.forEach((c) => tbody.appendChild(rowFor(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = form.name.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/expense/api/categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || res.status);
|
||||||
|
render(data.categories);
|
||||||
|
form.reset();
|
||||||
|
form.name.focus();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`추가 실패: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.target.closest("button.js-del");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-name]");
|
||||||
|
const name = tr.dataset.name;
|
||||||
|
if (!confirm(`분류 "${name}" 을(를) 삭제할까요?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/expense/api/categories/${encodeURIComponent(name)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || res.status);
|
||||||
|
render(data.categories);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`삭제 실패: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user