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:
@@ -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 %}
|
||||
Reference in New Issue
Block a user