Files
dbx-main/app/modules/expense/templates/expense/index.html
T
king 069d998c9d feat(expense): 경비 내역 월 선택 필터 + 월 합계, 월별 집계 버튼 제거
- 상단 "월별 집계" 버튼 삭제
- 경비 내역: 월 선택(input month, 변경 시 자동 조회) 기본 이번 달
- 선택 월(사용일 기준)만 표시 + 해당 월 합계금액 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:22:58 +09:00

425 lines
17 KiB
HTML

{% extends "erp_base.html" %}
{% block content %}
<section class="erp-expense">
<!-- ── 페이지 액션 ── -->
<div class="erp-page-actions">
{% 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>
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</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 %}
{% if is_approver %}
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
{% endif %}
</div>
<!-- ── 요약 카드 ── -->
<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>
<!-- ── 등록(좌) / 내역(우) 2단 ── -->
<div class="ex-two-col">
<!-- ── 입력 폼 ── -->
<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="예) 스타벅스 강남점" required />
</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>
<div style="display: flex; align-items: center; gap: var(--sp-12); flex-wrap: wrap;">
<form method="get" action="/expense/" id="ex-month-form" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
<input type="month" name="month" value="{{ month }}" />
</form>
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
<span class="erp-muted">합계 <strong>{{ "{:,}".format(month_total) }}</strong></span>
{% if supports_workflow %}
<button id="ex-bulk-submit" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
선택 항목 제출 (<span id="ex-bulk-count">0</span>)
</button>
{% endif %}
</div>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="ex-table">
<thead>
<tr>
<th style="width: 36px; text-align: center;">
<input type="checkbox" id="ex-select-all" title="전체 선택" />
</th>
<th style="width: 110px;">사용일</th>
<th style="width: 90px;">분류</th>
<th style="width: 100px;">수단</th>
<th>가맹점</th>
<th style="width: 120px;">금액</th>
<th style="width: 90px;">상태</th>
<th style="width: 240px;"></th>
</tr>
</thead>
<tbody id="ex-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
<td style="text-align: center;">
{% if it.status in ('작성중', '반려') and supports_workflow %}
<input type="checkbox" class="js-select" />
{% 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>
{% 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 ('작성중', '반려') and supports_workflow %}
<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>
{% endif %}
{% 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>
{% elif it.status == '제출' and supports_workflow %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
{% endif %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
</td>
</tr>
{% else %}
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div><!-- /ex-two-col -->
</section>
<style>
.erp-page-actions {
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
}
#ex-tbody td { vertical-align: middle; }
#ex-tbody .js-actions { white-space: nowrap; }
#ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; }
/* 상단 요약 카드: 세로 70px 고정 (grid 행 높이 고정 → stretch 무력화) */
.erp-expense .erp-summary-grid {
grid-auto-rows: 70px !important;
margin-bottom: var(--sp-20); /* 등록 폼과 간격 */
}
.erp-expense .erp-summary-card {
height: 70px !important; min-height: 0 !important; max-height: 70px;
padding: 8px 14px; justify-content: center; gap: 2px; overflow: hidden;
}
.erp-expense .erp-summary-card--mini { padding: 8px 14px; }
/* 요약줄과 2단 블록 사이 간격 */
.ex-two-col { margin-top: var(--sp-20); }
/* 경비 내역 테이블: 전부 가운데 정렬 + 한 줄(2줄 방지) + 너비 자동 */
#ex-table { table-layout: auto; }
#ex-table th, #ex-table td {
text-align: center !important;
white-space: nowrap;
width: auto !important;
vertical-align: middle;
}
#ex-table th:first-child, #ex-table td:first-child { width: 36px !important; }
#ex-table td > div { white-space: nowrap; } /* 가맹점/메모 줄바꿈 방지 */
#ex-tbody .js-actions { text-align: center !important; }
#ex-tbody .js-actions > * { margin: 0 2px; }
/* 첫 행(헤더) 모서리 라운드 제거 — 라운드는 .erp-table-wrap 에 걸려 있음 */
.erp-expense .erp-table-wrap { border-radius: 0 !important; box-shadow: none; }
/* 첨부(클립) 아이콘 크게 */
#ex-tbody .js-view-att { font-size: 22px !important; line-height: 1; padding: 2px 8px; }
.erp-expense .erp-summary-value { font-size: 20px; line-height: 1.1; }
.erp-expense .erp-summary-value--sm { font-size: 18px; }
/* 경비 등록(좌) / 경비 내역(우) 2단 */
.ex-two-col {
display: grid; grid-template-columns: 520px minmax(0, 1fr);
gap: var(--sp-16); align-items: start;
}
.ex-two-col > .erp-card-block { margin: 0; }
/* 좌측 폼은 2열로 (가맹점/메모는 전체폭) */
.ex-two-col .erp-form-grid { grid-template-columns: 1fr 1fr; }
.ex-two-col .erp-form-grid .erp-field-wide,
.ex-two-col .erp-form-grid .erp-form-actions { grid-column: 1 / -1; }
@media (max-width: 1100px) {
.ex-two-col { grid-template-columns: 1fr; }
}
</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");
const tbody = document.getElementById("ex-tbody");
const selectAll = document.getElementById("ex-select-all");
const bulkBtn = document.getElementById("ex-bulk-submit");
const bulkCountEl = document.getElementById("ex-bulk-count");
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));
// 필수 입력 검증 — 누락 시 경고창
function validateRequired() {
const required = [
["spent_at", "사용일"],
["category", "분류"],
["method", "결제수단"],
["amount", "금액"],
["merchant", "가맹점/사용처"],
];
const missing = [];
for (const [field, label] of required) {
const val = (form[field].value || "").trim();
if (!val) missing.push([form[field], label]);
}
// 금액은 0 이하도 미입력 취급
if (!missing.some(([f]) => f === form.amount)) {
if (!(parseInt(form.amount.value, 10) > 0)) {
missing.push([form.amount, "금액(1원 이상)"]);
}
}
if (missing.length) {
alert("다음 항목을 입력하세요:\n- " + missing.map(([, l]) => l).join("\n- "));
missing[0][0].focus();
return false;
}
return true;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!validateRequired()) return;
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.json()).detail || res.status);
location.reload();
} catch (err) {
alert(`저장 실패: ${err.message || err}`);
}
});
// 체크박스 상태 갱신
function refreshSelection() {
if (!bulkBtn) return;
const cbs = tbody.querySelectorAll("input.js-select");
const checked = Array.from(cbs).filter((cb) => cb.checked);
bulkBtn.disabled = checked.length === 0;
if (bulkCountEl) bulkCountEl.textContent = checked.length;
if (selectAll && cbs.length > 0) {
selectAll.checked = checked.length === cbs.length;
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
}
}
if (selectAll) {
selectAll.addEventListener("change", () => {
tbody.querySelectorAll("input.js-select").forEach((cb) => {
cb.checked = selectAll.checked;
});
refreshSelection();
});
}
tbody.addEventListener("change", (e) => {
if (e.target.classList.contains("js-select")) refreshSelection();
});
if (bulkBtn) {
bulkBtn.addEventListener("click", async () => {
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
.map((cb) => cb.closest("tr").dataset.id);
if (!ids.length) return;
if (!confirm(`${ids.length}건을 결재 제출할까요? 제출 후에는 수정 불가.`)) return;
bulkBtn.disabled = true;
bulkBtn.textContent = "제출 중…";
const fails = [];
for (const id of ids) {
try {
const res = await fetch(`/expense/api/items/${id}/submit`, { method: "POST" });
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
}
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
location.reload();
});
}
// 행 동작 (수정/삭제/취소/첨부보기 + 업로드)
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr[data-id]");
if (!tr) return;
const id = tr.dataset.id;
if (btn.classList.contains("js-edit")) {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
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-revert")) {
if (!confirm("작성중 상태로 되돌릴까요?")) return;
const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" });
if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패");
} else if (btn.classList.contains("js-view-att")) {
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` });
}
});
// 첨부 업로드
tbody.addEventListener("change", async (e) => {
const input = e.target.closest("input.js-upload");
if (!input || !input.files.length) return;
const tr = input.closest("tr[data-id]");
const itemId = tr.dataset.id;
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 = "";
alert("첨부 업로드 완료");
} 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")}`;
}
// 월 선택 변경 시 자동 조회
const monthInput = document.querySelector('#ex-month-form input[name="month"]');
if (monthInput) monthInput.addEventListener("change", () => monthInput.form.submit());
})();
</script>
{% endblock %}