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:
2026-06-02 17:22:01 +09:00
parent ec8c9edcc9
commit 0b04a05b26
8 changed files with 341 additions and 7 deletions
@@ -15,6 +15,9 @@
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
{% endif %}
{% if is_admin %}
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
{% endif %}
</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 %}