6918426264
[첨부 뷰어] - 펜 주석 기능 제거 (요구사항 정정). - 이미지: 마우스 드래그로 패닝, 축소/확대/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 포함 일관).
153 lines
5.7 KiB
HTML
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 %}
|