feat(expense): 개인경비 모듈 + ERP 좌측메뉴 셸 + expense_db 연결

- ERP 셸: 좌측 사이드바 + 상단 헤더 + 콘텐츠 영역(erp_base.html)
- 분기: 슈퍼관리자 → 업무모듈 카드, 일반사용자 → ERP 메인(erp_home.html)
- 신규 라우트: /modules (슈퍼관리자 모듈 카드 재진입)
- 개인경비 모듈: app/modules/expense/ (라우터/저장소/템플릿)
  - JSON 저장소(ExpenseStore) + PostgreSQL 저장소(ExpenseDBStore)
  - 팩토리: EXPENSE_DB_URL 있으면 DB, 없으면 JSON 폴백
- DB: scripts/sql/expense_db_init.sql (멱등 DDL, postgres-db 컨테이너)
  - expense_db, 역할 expense_app, 테이블 expense_items, 인덱스/트리거
- 마이그레이션: scripts/migrate_expense_json_to_db.py (멱등, --dry-run)
- 의존성: psycopg[binary,pool]>=3.2
- 환경변수: EXPENSE_DB_URL 추가
- 문서: CLAUDE.md 작업 기준 + docs/{PROJECT_OVERVIEW,SERVER_ARCHITECTURE,DATABASES,DEPLOYMENT}.md
  - main-app 배포 경로 /opt/www/main 고정 명시
  - 위험 명령(DROP/TRUNCATE/rm -rf/volume 삭제) 사용자 승인 정책
This commit is contained in:
2026-05-29 02:21:03 +09:00
parent d7b6c7d029
commit 9c58b022e2
19 changed files with 2337 additions and 5 deletions
+35
View File
@@ -0,0 +1,35 @@
"""개인경비(expense) 모듈.
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/expense)
- 저장소: `store.py` (DATA_DIR/expense.json, 향후 expense_db 후보)
- 템플릿: `templates/expense/index.html`
"""
from pathlib import Path
from typing import Any
from .router import router
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
__all__ = [
"router",
"ExpenseStore",
"CATEGORIES",
"METHODS",
"STATUSES",
"build_expense_store",
]
def build_expense_store(*, dsn: str | None, json_path: Path) -> Any:
"""env 의 EXPENSE_DB_URL 이 있으면 DB 저장소, 없으면 JSON 저장소.
DB 저장소 실패(드라이버 미설치/접속 실패) 시 예외를 그대로 전파한다 —
의도치 않게 JSON 으로 폴백해 운영 데이터가 갈라지는 것을 막기 위함.
"""
if dsn:
from .db import ExpenseDBStore # 지연 import (개발 환경 deps 없을 수 있음)
return ExpenseDBStore(dsn)
return ExpenseStore(json_path)
+244
View File
@@ -0,0 +1,244 @@
"""expense_db PostgreSQL 저장소.
JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를
바꾸지 않고도 교체할 수 있다.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`)
- 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db)
- 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는
앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다.
"""
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from .store import CATEGORIES, METHODS, STATUSES
_DDL = """
CREATE TABLE IF NOT EXISTS expense_items (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
spent_at DATE NOT NULL,
category TEXT NOT NULL,
method TEXT NOT NULL,
merchant TEXT NOT NULL DEFAULT '',
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '작성중',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
"""
class ExpenseDBStore:
"""`ExpenseStore` 와 동일한 메서드 시그니처."""
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
self._pool = ConnectionPool(
conninfo=dsn,
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "autocommit": True},
open=True,
)
self._ensure_schema()
def close(self) -> None:
self._pool.close()
def _ensure_schema(self) -> None:
with self._pool.connection() as conn:
conn.execute(_DDL)
# ── 조회 ──
def list_for(self, email: str) -> list[dict[str, Any]]:
email = email.lower().strip()
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_items WHERE owner = %s "
"ORDER BY spent_at DESC, created_at DESC",
(email,),
).fetchall()
return [self._serialize(r) for r in rows]
def list_all(self) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM expense_items ORDER BY created_at DESC"
).fetchall()
return [self._serialize(r) for r in rows]
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
owner = owner.lower().strip()
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM expense_items WHERE id = %s AND owner = %s",
(item_id, owner),
).fetchone()
return self._serialize(row) if row else None
# ── 변경 ──
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
owner = owner.lower().strip()
norm = self._normalize(payload)
if not norm["spent_at"]:
raise ValueError("spent_at 필수")
status = (payload.get("status") or "작성중").strip()
item_id = uuid.uuid4().hex[:12]
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO expense_items
(id, owner, spent_at, category, method, merchant, amount, memo, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
item_id,
owner,
norm["spent_at"],
norm["category"],
norm["method"],
norm["merchant"],
norm["amount"],
norm["memo"],
status,
),
).fetchone()
return self._serialize(row)
def update(
self, *, item_id: str, owner: str, payload: dict[str, Any]
) -> dict[str, Any]:
owner = owner.lower().strip()
norm = self._normalize(payload)
status = payload.get("status")
with self._pool.connection() as conn:
if status:
row = conn.execute(
"""
UPDATE expense_items
SET spent_at = %s, category = %s, method = %s,
merchant = %s, amount = %s, memo = %s, status = %s
WHERE id = %s AND owner = %s
RETURNING *
""",
(
norm["spent_at"] or None,
norm["category"],
norm["method"],
norm["merchant"],
norm["amount"],
norm["memo"],
status,
item_id,
owner,
),
).fetchone()
else:
row = conn.execute(
"""
UPDATE expense_items
SET spent_at = %s, category = %s, method = %s,
merchant = %s, amount = %s, memo = %s
WHERE id = %s AND owner = %s
RETURNING *
""",
(
norm["spent_at"] or None,
norm["category"],
norm["method"],
norm["merchant"],
norm["amount"],
norm["memo"],
item_id,
owner,
),
).fetchone()
if not row:
raise KeyError(item_id)
return self._serialize(row)
def delete(self, *, item_id: str, owner: str) -> None:
owner = owner.lower().strip()
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM expense_items WHERE id = %s AND owner = %s",
(item_id, owner),
)
if cur.rowcount == 0:
raise KeyError(item_id)
# ── 요약 ──
def summary_for(self, email: str) -> dict[str, Any]:
email = email.lower().strip()
with self._pool.connection() as conn:
head = conn.execute(
"SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total "
"FROM expense_items WHERE owner = %s",
(email,),
).fetchone()
status_rows = conn.execute(
"SELECT status, COUNT(*) AS c FROM expense_items "
"WHERE owner = %s GROUP BY status",
(email,),
).fetchall()
cat_rows = conn.execute(
"SELECT category, COALESCE(SUM(amount), 0) AS s "
"FROM expense_items WHERE owner = %s GROUP BY category",
(email,),
).fetchall()
by_status = {s: 0 for s in STATUSES}
for r in status_rows:
by_status[r["status"]] = int(r["c"])
by_category = {c: 0 for c in CATEGORIES}
for r in cat_rows:
by_category[r["category"]] = int(r["s"])
return {
"count": int(head["count"]) if head else 0,
"total": int(head["total"]) if head else 0,
"by_status": by_status,
"by_category": by_category,
}
# ── helpers ──
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
if isinstance(out.get("spent_at"), date):
out["spent_at"] = out["spent_at"].isoformat()
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
out["amount"] = int(out.get("amount", 0))
return out
@staticmethod
def _normalize(
payload: dict[str, Any], base: dict[str, Any] | None = None
) -> dict[str, Any]:
b = dict(base or {})
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()
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
b["category"] = category if category in CATEGORIES else "기타"
b["method"] = method if method in METHODS else "법인카드"
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
try:
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
except (TypeError, ValueError):
b["amount"] = 0
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
return b
+150
View File
@@ -0,0 +1,150 @@
"""개인경비 모듈 라우터.
- 경로: /expense
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
router = APIRouter(prefix="/expense", tags=["expense"])
def _store(request: Request) -> ExpenseStore:
store = getattr(request.app.state, "expense_store", None)
if store is None:
raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.")
return store
def _require_user(request: Request) -> dict[str, Any]:
# 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다.
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not has_module(user, "expense"):
raise HTTPException(status_code=403, detail="개인경비 모듈 권한이 없습니다.")
return user
class ExpensePayload(BaseModel):
spent_at: str = Field(..., description="YYYY-MM-DD")
category: str
method: str
merchant: str = ""
amount: int = Field(ge=0)
memo: str = ""
status: str | None = None
@router.get("/", response_class=HTMLResponse)
async def expense_index(request: Request) -> HTMLResponse:
from app.main import ( # noqa: WPS433
build_erp_nav,
get_current_user_record,
render_template,
)
from app.store import has_module, is_admin # noqa: WPS433
user = get_current_user_record(request)
if user is None:
return RedirectResponse(url="/login", status_code=303)
if not has_module(user, "expense"):
return render_template(
request,
"denied.html",
{"reason": "개인경비 모듈 접근 권한이 없습니다."},
status_code=403,
)
store = _store(request)
items = sorted(
store.list_for(user["email"]),
key=lambda i: (i.get("spent_at", ""), i.get("created_at", "")),
reverse=True,
)
summary = store.summary_for(user["email"])
return render_template(
request,
"expense/index.html",
{
"user": user,
"is_admin": is_admin(user),
"items": items,
"summary": summary,
"categories": list(CATEGORIES),
"methods": list(METHODS),
"statuses": list(STATUSES),
"nav_items": build_erp_nav(user, active="expense"),
"page_title": "개인경비",
"page_subtitle": "법인카드 · 개인지출 · 정산 신청",
},
)
@router.get("/api/items")
async def list_items(
request: Request,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
return JSONResponse(
{
"items": _store(request).list_for(user["email"]),
"summary": _store(request).summary_for(user["email"]),
}
)
@router.post("/api/items")
async def create_item(
request: Request,
body: ExpensePayload,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
item = _store(request).create(owner=user["email"], payload=body.model_dump())
return JSONResponse({"item": item}, status_code=201)
@router.put("/api/items/{item_id}")
async def update_item(
request: Request,
item_id: str,
body: ExpensePayload,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
try:
item = _store(request).update(
item_id=item_id, owner=user["email"], payload=body.model_dump()
)
except KeyError:
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
return JSONResponse({"item": item})
@router.delete("/api/items/{item_id}")
async def delete_item(
request: Request,
item_id: str,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
try:
_store(request).delete(item_id=item_id, owner=user["email"])
except KeyError:
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
return JSONResponse({"ok": True})
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "expense"}
+159
View File
@@ -0,0 +1,159 @@
"""개인경비 항목 JSON 저장소.
- 저장 위치: DATA_DIR/expense.json
- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제.
- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용.
- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class ExpenseStore:
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({"items": []})
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 = {"items": []}
if not isinstance(data.get("items"), list):
data["items"] = []
return data
def _write_atomic(self, data: dict[str, Any]) -> None:
fd, tmp = tempfile.mkstemp(
prefix=".expense.", 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_for(self, email: str) -> list[dict[str, Any]]:
email = email.lower().strip()
with self._lock:
data = self._read()
return [it for it in data["items"] if it.get("owner") == email]
def list_all(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._read()["items"])
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
owner = owner.lower().strip()
with self._lock:
data = self._read()
for it in data["items"]:
if it["id"] == item_id and it.get("owner") == owner:
return dict(it)
return None
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
owner = owner.lower().strip()
item = self._normalize(payload)
item["id"] = uuid.uuid4().hex[:12]
item["owner"] = owner
item["status"] = payload.get("status") or "작성중"
item["created_at"] = _now_iso()
item["updated_at"] = item["created_at"]
with self._lock:
data = self._read()
data["items"].append(item)
self._write_atomic(data)
return item
def update(
self, *, item_id: str, owner: str, payload: dict[str, Any]
) -> dict[str, Any]:
owner = owner.lower().strip()
with self._lock:
data = self._read()
for idx, it in enumerate(data["items"]):
if it["id"] == item_id and it.get("owner") == owner:
new = self._normalize(payload, base=it)
new["id"] = it["id"]
new["owner"] = it["owner"]
new["created_at"] = it.get("created_at", _now_iso())
new["status"] = payload.get("status") or it.get("status", "작성중")
new["updated_at"] = _now_iso()
data["items"][idx] = new
self._write_atomic(data)
return new
raise KeyError(item_id)
def delete(self, *, item_id: str, owner: str) -> None:
owner = owner.lower().strip()
with self._lock:
data = self._read()
before = len(data["items"])
data["items"] = [
it
for it in data["items"]
if not (it["id"] == item_id and it.get("owner") == owner)
]
if len(data["items"]) == before:
raise KeyError(item_id)
self._write_atomic(data)
def summary_for(self, email: str) -> dict[str, Any]:
items = self.list_for(email)
total = sum(int(i.get("amount", 0)) for i in items)
by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES}
by_category = {
c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c)
for c in CATEGORIES
}
return {
"count": len(items),
"total": total,
"by_status": by_status,
"by_category": by_category,
}
@staticmethod
def _normalize(
payload: dict[str, Any], base: dict[str, Any] | None = None
) -> dict[str, Any]:
b = dict(base or {})
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()
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
b["category"] = category if category in CATEGORIES else "기타"
b["method"] = method if method in METHODS else "법인카드"
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
try:
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
except (TypeError, ValueError):
b["amount"] = 0
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
return b
@@ -0,0 +1,238 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-expense">
<!-- ── 요약 카드 ── -->
<div class="erp-summary-grid">
<div class="erp-summary-card">
<span class="erp-summary-label">총 건수</span>
<strong class="erp-summary-value">{{ summary.count }} 건</strong>
</div>
<div class="erp-summary-card">
<span class="erp-summary-label">총 금액</span>
<strong class="erp-summary-value" id="ex-total-amount">
{{ "{:,}".format(summary.total) }} 원
</strong>
</div>
{% for status in statuses %}
<div class="erp-summary-card erp-summary-card--mini">
<span class="erp-summary-label">{{ status }}</span>
<strong class="erp-summary-value erp-summary-value--sm">
{{ summary.by_status.get(status, 0) }}
</strong>
</div>
{% endfor %}
</div>
<!-- ── 입력 폼 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 등록</h2>
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
</div>
<form id="ex-form" class="erp-form-grid">
<input type="hidden" name="id" />
<label class="erp-field">
<span>사용일</span>
<input type="date" name="spent_at" required />
</label>
<label class="erp-field">
<span>분류</span>
<select name="category" required>
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
</select>
</label>
<label class="erp-field">
<span>결제수단</span>
<select name="method" required>
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
</select>
</label>
<label class="erp-field">
<span>금액</span>
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
</label>
<label class="erp-field erp-field-wide">
<span>가맹점/사용처</span>
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
</label>
<label class="erp-field erp-field-wide">
<span>메모</span>
<input type="text" name="memo" placeholder="비고" />
</label>
<div class="erp-form-actions">
<button type="reset" class="erp-btn erp-btn-outline" id="ex-reset">초기화</button>
<button type="submit" class="erp-btn erp-btn-primary" id="ex-submit">등록</button>
</div>
</form>
</div>
<!-- ── 항목 목록 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 내역</h2>
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="ex-table">
<thead>
<tr>
<th style="width: 110px;">사용일</th>
<th style="width: 90px;">분류</th>
<th style="width: 100px;">수단</th>
<th>가맹점</th>
<th style="width: 120px; text-align: right;">금액</th>
<th style="width: 90px;">상태</th>
<th style="width: 130px; text-align: right;">동작</th>
</tr>
</thead>
<tbody id="ex-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}">
<td>{{ it.spent_at }}</td>
<td>{{ it.category }}</td>
<td>{{ it.method }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
</td>
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(it.amount) }} 원
</td>
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
</td>
</tr>
{% else %}
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
const form = document.getElementById("ex-form");
const submitBtn = document.getElementById("ex-submit");
const resetBtn = document.getElementById("ex-reset");
const tbody = document.getElementById("ex-tbody");
const countEl = document.getElementById("ex-count");
const totalEl = document.getElementById("ex-total-amount");
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")}`;
function setEditing(id, data) {
form.id.value = id || "";
if (data) {
form.spent_at.value = data.spent_at || "";
form.category.value = data.category || "";
form.method.value = data.method || "";
form.amount.value = data.amount || 0;
form.merchant.value = data.merchant || "";
form.memo.value = data.memo || "";
submitBtn.textContent = "수정 저장";
} else {
submitBtn.textContent = "등록";
}
}
resetBtn.addEventListener("click", () => setEditing("", null));
form.addEventListener("submit", async (e) => {
e.preventDefault();
const id = form.id.value.trim();
const payload = {
spent_at: form.spent_at.value,
category: form.category.value,
method: form.method.value,
amount: parseInt(form.amount.value || "0", 10),
merchant: form.merchant.value,
memo: form.memo.value,
};
const url = id ? `/expense/api/items/${id}` : "/expense/api/items";
const method = id ? "PUT" : "POST";
try {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
await reload();
form.reset();
setEditing("", null);
} catch (err) {
alert(`저장 실패: ${err.message || err}`);
}
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr");
const id = tr && tr.dataset.id;
if (!id) return;
if (btn.classList.contains("js-delete")) {
if (!confirm("이 항목을 삭제할까요?")) return;
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
if (res.ok) reload(); else alert("삭제 실패");
} else if (btn.classList.contains("js-edit")) {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const j = await res.json();
const found = (j.items || []).find((x) => x.id === id);
if (found) setEditing(id, found);
window.scrollTo({ top: 0, behavior: "smooth" });
}
});
async function reload() {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const j = await res.json();
const items = j.items || [];
const summary = j.summary || { count: 0, total: 0 };
countEl.textContent = `${items.length}`;
if (totalEl) totalEl.textContent = fmt(summary.total);
tbody.innerHTML = "";
if (items.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
return;
}
items
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
.forEach((it) => {
const tr = document.createElement("tr");
tr.dataset.id = it.id;
tr.innerHTML = `
<td>${it.spent_at || ""}</td>
<td>${it.category || ""}</td>
<td>${it.method || ""}</td>
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
<td style="text-align:right;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
</td>`;
tbody.appendChild(tr);
});
}
// 사용일 기본값 = 오늘
if (!form.spent_at.value) {
const d = new Date();
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}
})();
</script>
{% endblock %}