Files
dbx-main/app/modules/expense/templates/expense/pending.html
T
king 6918426264 feat(expense): 첨부 뷰어 패닝/엑셀 + 체크박스 일괄작업 + 정렬
[첨부 뷰어]
- 펜 주석 기능 제거 (요구사항 정정).
- 이미지: 마우스 드래그로 패닝, 축소/확대/100%/맞춤 줌 도구.
- 엑셀 (xlsx/xls/xlsm/csv/ods): SheetJS lazy load(CDN) 후 시트 탭으로 표시.
- 기타 파일: 다운로드/새 창 링크.

[경비 내역 / 승인 대기 공통]
- 행 단위 첫 컬럼 체크박스 + thead 전체선택 체크박스.
- 표 제목 상단에 일괄 동작 버튼 (선택 0이면 disabled).
- 카드 헤더와 행 모두 vertical-align: middle 로 일직선 정렬.

[경비 내역]
- 첨부 추가 버튼의 📎 아이콘 제거. "기타" → "기타 파일".
- 행 단위 "제출" 버튼 제거 → 상단 "선택 항목 제출" 단일화.
- 행 단위 첨부 보기 아이콘(📎) 추가 — 클릭 시 ErpAttachViewer 호출.

[승인 대기]
- 일괄 승인 (체크 후 상단 버튼).
- 반려는 항목별 유지 (사유 필수라 일괄 부적합).

[CSS]
- erp-table tbody td vertical-align top → middle (admin 포함 일관).
2026-05-29 03:57:18 +09:00

153 lines
5.7 KiB
HTML

{% 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>
<div style="display: flex; align-items: center; gap: var(--sp-12);">
<span class="erp-muted">제출 상태 항목 — 일괄 승인 / 항목별 반려</span>
<button id="pend-bulk-approve" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
선택 일괄 승인 (<span id="pend-bulk-count">0</span>)
</button>
</div>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th style="width: 36px; text-align: center;">
<input type="checkbox" id="pend-select-all" title="전체 선택" />
</th>
<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: 200px; text-align: right;">동작</th>
</tr>
</thead>
<tbody id="pend-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}">
<td style="text-align: center;">
<input type="checkbox" class="js-select" />
</td>
<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;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-attach" title="첨부 보기" aria-label="첨부 보기">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
</td>
</tr>
{% else %}
<tr><td colspan="7" 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); }
#pend-tbody td { vertical-align: middle; }
</style>
{% endblock %}
{% block scripts %}
<script>
(function () {
const tbody = document.getElementById("pend-tbody");
const selectAll = document.getElementById("pend-select-all");
const bulkBtn = document.getElementById("pend-bulk-approve");
const bulkCountEl = document.getElementById("pend-bulk-count");
function refreshSelection() {
const cbs = tbody.querySelectorAll("input.js-select");
const checked = Array.from(cbs).filter((cb) => cb.checked);
bulkBtn.disabled = checked.length === 0;
bulkCountEl.textContent = checked.length;
if (cbs.length > 0) {
selectAll.checked = checked.length === cbs.length;
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
}
}
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();
});
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}/approve`, { 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]");
const id = tr.dataset.id;
if (btn.classList.contains("js-attach")) {
const owner = tr.children[2]?.textContent?.trim() || "";
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
return;
}
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(); refreshSelection(); }
else { alert((await res.json()).detail || "반려 실패"); }
}
});
})();
</script>
{% endblock %}