069d998c9d
- 상단 "월별 집계" 버튼 삭제 - 경비 내역: 월 선택(input month, 변경 시 자동 조회) 기본 이번 달 - 선택 월(사용일 기준)만 표시 + 해당 월 합계금액 표시 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
425 lines
17 KiB
HTML
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 %}
|