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
@@ -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 %}