feat(expense+admin): 결재 워크플로 + 첨부 + 집계 + 엑셀 + 사용자 직접 등록
[admin]
- POST /api/users 사용자 직접 등록 (이메일만으로). UserStore.create_user 추가
- admin.html: 등록 폼 + 삭제 버튼 + 한글 라벨 + 신규 권한 표시
- 신규 권한 키: expense_approver, vacation_approver (APPROVER_KEYS)
- is_approver(user_rec, kind) 헬퍼
[expense 워크플로]
- 상태 전이: 작성중 → 제출 → 승인/반려 → 정산완료
- API: /api/items/{id}/{submit,revert,approve,reject,settle}
- 권한: submit/revert=owner, approve/reject/settle=expense_approver 또는 admin
- expense_items 컬럼: approver_email, decided_at, reject_reason
- 작성중/반려 상태에서만 수정/삭제/첨부 가능
[expense 첨부]
- 영수증/기타 (kind: receipt|other). 최대 20MB, 확장자 화이트리스트
- 저장: DATA_DIR/uploads/expense/{item_id}/{att_id}_{filename}
- expense_attachments 테이블 (CASCADE on item delete)
- API: 업로드/목록/다운로드/삭제
[expense 집계]
- /expense/reports: 연도별 월×카테고리 피벗 표
[expense 엑셀]
- GET /expense/api/export.xlsx?from&to&scope=mine|all
- scope=all 은 승인자/관리자만
[DDL]
- expense_db_init.sql: 신규 컬럼/테이블 포함 (멱등)
- expense_db_002_workflow_attachments.sql: 운영 DB 마이그레이션용
[deps]
- openpyxl>=3.1, python-multipart>=0.0.20
[docs]
- DATABASES.md: 워크플로 다이어그램, attachments 테이블, 마이그레이션
- PROJECT_OVERVIEW.md: 권한 키 표
- DEPLOYMENT.md: DATA_DIR/uploads 안내
This commit is contained in:
@@ -3,6 +3,20 @@
|
||||
{% block content %}
|
||||
<section class="erp-expense">
|
||||
|
||||
<!-- ── 페이지 액션 ── -->
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/reports">월별 집계</a>
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
||||
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── 요약 카드 ── -->
|
||||
<div class="erp-summary-grid">
|
||||
<div class="erp-summary-card">
|
||||
@@ -29,36 +43,28 @@
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 등록</h2>
|
||||
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<label class="erp-field erp-field-wide"><span>메모</span>
|
||||
<input type="text" name="memo" placeholder="비고" />
|
||||
</label>
|
||||
<div class="erp-form-actions">
|
||||
@@ -78,36 +84,93 @@
|
||||
<table class="erp-table" id="ex-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 28px;"></th>
|
||||
<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>
|
||||
<th style="width: 100px;">상태</th>
|
||||
<th style="width: 220px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ex-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
|
||||
<td>
|
||||
{% if supports_workflow %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-toggle" title="첨부/상세">▸</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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 %}
|
||||
{% if it.reject_reason %}
|
||||
<div class="erp-row-sub" style="color: var(--color-callout-red);">반려: {{ it.reject_reason }}</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>
|
||||
{% if it.status == '작성중' or it.status == '반려' %}
|
||||
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
|
||||
{% elif it.status == '제출' %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% elif it.status == '승인' %}
|
||||
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||
{% else %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right;" class="js-actions">
|
||||
{% if it.status in ('작성중', '반려') %}
|
||||
<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>
|
||||
{% if supports_workflow %}
|
||||
<button class="erp-btn erp-btn-primary erp-btn-sm js-submit">제출</button>
|
||||
{% endif %}
|
||||
{% elif it.status == '제출' and supports_workflow %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if supports_workflow %}
|
||||
<tr class="ex-detail" data-detail-for="{{ it.id }}" hidden>
|
||||
<td colspan="8">
|
||||
<div class="ex-detail-box">
|
||||
<div class="ex-attach-head">
|
||||
<strong>첨부파일</strong>
|
||||
{% if it.status in ('작성중', '반려') %}
|
||||
<div class="ex-upload">
|
||||
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||
영수증 추가
|
||||
<input type="file" class="js-upload" data-kind="receipt" hidden />
|
||||
</label>
|
||||
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||
기타파일 추가
|
||||
<input type="file" class="js-upload" data-kind="other" hidden />
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul class="ex-attach-list" data-list="{{ it.id }}">
|
||||
<li class="erp-muted">불러오는 중…</li>
|
||||
</ul>
|
||||
{% if it.approver_email %}
|
||||
<div class="erp-muted" style="margin-top: 6px;">
|
||||
결재: {{ it.approver_email }} · {{ (it.decided_at or '')[:16].replace('T',' ') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -115,11 +178,40 @@
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions {
|
||||
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
|
||||
}
|
||||
.ex-detail-box {
|
||||
background: var(--color-ghost-gray);
|
||||
padding: var(--sp-12) var(--sp-16);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ex-attach-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-8);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ex-upload { display: flex; gap: 6px; }
|
||||
.ex-attach-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.ex-attach-list li {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
||||
background: var(--color-canvas-white); border: 1px solid var(--color-subtle-ash); border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ex-attach-list li .ex-kind {
|
||||
padding: 1px 6px; border-radius: 8px; font-size: 11px;
|
||||
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||
}
|
||||
.ex-attach-list li .ex-name { flex: 1; }
|
||||
.ex-attach-list li .ex-size { color: var(--color-midtone-gray); font-size: 12px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
|
||||
const form = document.getElementById("ex-form");
|
||||
const submitBtn = document.getElementById("ex-submit");
|
||||
const resetBtn = document.getElementById("ex-reset");
|
||||
@@ -128,6 +220,12 @@
|
||||
const totalEl = document.getElementById("ex-total-amount");
|
||||
|
||||
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
|
||||
const fmtSize = (b) => {
|
||||
b = Number(b || 0);
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
|
||||
return `${(b/1024/1024).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
function setEditing(id, data) {
|
||||
form.id.value = id || "";
|
||||
@@ -143,7 +241,6 @@
|
||||
submitBtn.textContent = "등록";
|
||||
}
|
||||
}
|
||||
|
||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
@@ -165,70 +262,119 @@
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await reload();
|
||||
form.reset();
|
||||
setEditing("", null);
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
alert(`저장 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function postAction(id, action, body) {
|
||||
const res = await fetch(`/expense/api/items/${id}/${action}`, {
|
||||
method: "POST",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadAttachments(itemId) {
|
||||
const ul = tbody.querySelector(`.ex-attach-list[data-list="${itemId}"]`);
|
||||
if (!ul) return;
|
||||
ul.innerHTML = `<li class="erp-muted">불러오는 중…</li>`;
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${itemId}/attachments`);
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
const { attachments } = await res.json();
|
||||
if (!attachments.length) {
|
||||
ul.innerHTML = `<li class="erp-muted">첨부 없음</li>`;
|
||||
return;
|
||||
}
|
||||
ul.innerHTML = "";
|
||||
attachments.forEach((a) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `
|
||||
<span class="ex-kind">${a.kind === 'receipt' ? '영수증' : '기타'}</span>
|
||||
<a class="ex-name" href="/expense/api/attachments/${a.id}" target="_blank">${a.filename}</a>
|
||||
<span class="ex-size">${fmtSize(a.size_bytes)}</span>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-del-att" data-att="${a.id}">삭제</button>
|
||||
`;
|
||||
ul.appendChild(li);
|
||||
});
|
||||
} catch (err) {
|
||||
ul.innerHTML = `<li style="color: var(--color-callout-red);">로드 실패: ${err.message || err}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
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("삭제 실패");
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
if (!tr) return;
|
||||
const id = tr.dataset.id;
|
||||
|
||||
if (btn.classList.contains("js-toggle")) {
|
||||
const detail = tbody.querySelector(`tr.ex-detail[data-detail-for="${id}"]`);
|
||||
if (!detail) return;
|
||||
const opening = detail.hasAttribute("hidden");
|
||||
if (opening) {
|
||||
detail.removeAttribute("hidden");
|
||||
btn.textContent = "▾";
|
||||
loadAttachments(id);
|
||||
} else {
|
||||
detail.setAttribute("hidden", "");
|
||||
btn.textContent = "▸";
|
||||
}
|
||||
} 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);
|
||||
const { items } = await res.json();
|
||||
const found = items.find((x) => x.id === id);
|
||||
if (found) setEditing(id, found);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} else if (btn.classList.contains("js-delete")) {
|
||||
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
|
||||
} else if (btn.classList.contains("js-submit")) {
|
||||
if (!confirm("결재 제출할까요? 제출 후에는 수정할 수 없습니다.")) return;
|
||||
try { await postAction(id, "submit"); location.reload(); }
|
||||
catch (err) { alert(`제출 실패: ${err.message || err}`); }
|
||||
} else if (btn.classList.contains("js-revert")) {
|
||||
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
||||
try { await postAction(id, "revert"); location.reload(); }
|
||||
catch (err) { alert(`취소 실패: ${err.message || err}`); }
|
||||
} else if (btn.classList.contains("js-del-att")) {
|
||||
if (!confirm("첨부를 삭제할까요?")) return;
|
||||
const attId = btn.dataset.att;
|
||||
const res = await fetch(`/expense/api/attachments/${attId}`, { method: "DELETE" });
|
||||
if (res.ok) loadAttachments(id); else alert((await res.json()).detail || "삭제 실패");
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
// 첨부 업로드
|
||||
tbody.addEventListener("change", async (e) => {
|
||||
const input = e.target.closest("input.js-upload");
|
||||
if (!input || !input.files.length) return;
|
||||
const tr = input.closest("tr.ex-detail");
|
||||
if (!tr) return;
|
||||
const itemId = tr.dataset.detailFor;
|
||||
const fd = new FormData();
|
||||
fd.append("file", input.files[0]);
|
||||
fd.append("kind", input.dataset.kind || "other");
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
|
||||
method: "POST", body: fd,
|
||||
});
|
||||
}
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
input.value = "";
|
||||
loadAttachments(itemId);
|
||||
} catch (err) {
|
||||
alert(`업로드 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 사용일 기본값 = 오늘
|
||||
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")}`;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-pending">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>승인 대기 ({{ items | length }}건)</h2>
|
||||
<span class="erp-muted">제출 상태 항목 — 승인/반려 처리</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px;">사용일</th>
|
||||
<th style="width: 200px;">소유자</th>
|
||||
<th style="width: 90px;">분류</th>
|
||||
<th>가맹점/메모</th>
|
||||
<th style="width: 120px; text-align: right;">금액</th>
|
||||
<th style="width: 260px; text-align: right;">결재</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pend-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.owner }}</td>
|
||||
<td>{{ it.category }}</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 style="text-align: right;">
|
||||
<a class="erp-btn erp-btn-ghost erp-btn-sm" href="/expense/api/items/{{ it.id }}/attachments" target="_blank">첨부</a>
|
||||
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
|
||||
<button class="erp-btn erp-btn-primary erp-btn-sm js-approve">승인</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="erp-empty">대기 항목이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById("pend-tbody").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
const id = tr.dataset.id;
|
||||
|
||||
if (btn.classList.contains("js-approve")) {
|
||||
if (!confirm("승인할까요?")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
|
||||
if (res.ok) { tr.remove(); }
|
||||
else { alert((await res.json()).detail || "승인 실패"); }
|
||||
} else if (btn.classList.contains("js-reject")) {
|
||||
const reason = prompt("반려 사유를 입력하세요:");
|
||||
if (!reason || !reason.trim()) return;
|
||||
const res = await fetch(`/expense/api/items/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
if (res.ok) { tr.remove(); }
|
||||
else { alert((await res.json()).detail || "반려 실패"); }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-reports">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||
<form method="get" style="display: inline-flex; gap: 6px; align-items: center;">
|
||||
<label class="erp-muted">연도</label>
|
||||
<input type="number" name="year" value="{{ year }}" min="2000" max="2100"
|
||||
style="width: 100px; padding: 6px 10px; border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input); font-family: inherit;" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
<a class="erp-btn erp-btn-outline"
|
||||
href="/expense/api/export.xlsx?from={{ year }}-01-01&to={{ year }}-12-31">엑셀</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>{{ year }}년 월별 / 카테고리별 합계</h2>
|
||||
<span class="erp-muted">총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px;">월</th>
|
||||
{% for c in categories %}
|
||||
<th style="text-align: right;">{{ c }}</th>
|
||||
{% endfor %}
|
||||
<th style="text-align: right; background: var(--color-ghost-gray);">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in months %}
|
||||
{% set row_total = pivot[m].values() | sum %}
|
||||
<tr>
|
||||
<td><strong>{{ m }}</strong></td>
|
||||
{% for c in categories %}
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums; background: var(--color-ghost-gray);">
|
||||
<strong>{{ "{:,}".format(row_total) }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ categories | length + 2 }}" class="erp-empty">데이터 없음</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if months %}
|
||||
<tfoot>
|
||||
<tr style="background: var(--color-ghost-gray);">
|
||||
<th>합계</th>
|
||||
{% for c in categories %}
|
||||
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(cat_totals[c]) }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(grand_total) }}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); align-items: center; flex-wrap: wrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user