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 포함 일관).
This commit is contained in:
2026-05-29 03:57:18 +09:00
parent 151de492ad
commit 6918426264
5 changed files with 378 additions and 353 deletions
+84 -115
View File
@@ -78,24 +78,39 @@
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 내역</h2>
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
<div style="display: flex; align-items: center; gap: var(--sp-12);">
<span class="erp-muted" id="ex-count">{{ items | length }}건</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; text-align: right;">금액</th>
<th style="width: 90px;">상태</th>
<th style="width: 280px; text-align: right;">동작</th>
<th style="width: 240px; text-align: right;">동작</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>
@@ -122,42 +137,24 @@
</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" title="영수증 첨부">
📎영수증<input type="file" class="js-upload" data-kind="receipt" hidden />
<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" title="기타파일 첨부">
📎기타<input type="file" class="js-upload" data-kind="other" hidden />
<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>
{% 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 %}
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
</td>
</tr>
{% if supports_workflow %}
<tr class="ex-detail" data-detail-for="{{ it.id }}">
<td colspan="7">
<div class="ex-detail-box">
<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>
@@ -170,22 +167,9 @@
.erp-page-actions {
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
}
/* detail 행은 메인 행 바로 아래 — 위쪽 보더만 살짝, 좌측 들여쓰기 */
tr.ex-detail > td { padding: 6px var(--sp-16) var(--sp-12) 110px; background: var(--color-canvas-white); }
tr.ex-detail + tr[data-id] > td { border-top: 1px solid var(--color-subtle-ash); }
.ex-detail-box { background: transparent; padding: 0; }
.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; }
#ex-tbody td { vertical-align: middle; }
#ex-tbody .js-actions { white-space: nowrap; }
#ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; }
</style>
{% endblock %}
@@ -197,16 +181,9 @@
const submitBtn = document.getElementById("ex-submit");
const resetBtn = document.getElementById("ex-reset");
const tbody = document.getElementById("ex-tbody");
const countEl = document.getElementById("ex-count");
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`;
};
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 || "";
@@ -250,52 +227,59 @@
}
});
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>`;
// 체크박스 상태 갱신
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;
// 메인 행 또는 detail 행 모두 처리
const mainTr = btn.closest("tr[data-id]");
const detailTr = btn.closest("tr.ex-detail");
const id = mainTr ? mainTr.dataset.id : (detailTr ? detailTr.dataset.detailFor : null);
if (!id) 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`);
@@ -308,29 +292,21 @@
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 || "삭제 실패");
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()}` });
}
});
// 첨부 업로드 (메인 행 동작셀 input)
// 첨부 업로드
tbody.addEventListener("change", async (e) => {
const input = e.target.closest("input.js-upload");
if (!input || !input.files.length) return;
const mainTr = input.closest("tr[data-id]");
if (!mainTr) return;
const itemId = mainTr.dataset.id;
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");
@@ -340,19 +316,12 @@
});
if (!res.ok) throw new Error((await res.json()).detail || res.status);
input.value = "";
loadAttachments(itemId);
alert("첨부 업로드 완료");
} catch (err) {
alert(`업로드 실패: ${err.message || err}`);
}
});
// 페이지 로드 시 모든 detail 행의 첨부 자동 로드
if (supportsWorkflow) {
tbody.querySelectorAll("tr.ex-detail[data-detail-for]").forEach((tr) => {
loadAttachments(tr.dataset.detailFor);
});
}
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")}`;
@@ -11,24 +11,35 @@
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>승인 대기 ({{ items | length }}건)</h2>
<span class="erp-muted">제출 상태 항목 — 승인/반려 처리</span>
<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: 260px; 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>
@@ -40,19 +51,17 @@
{{ "{:,}".format(it.amount) }} 원
</td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-outline erp-btn-sm js-attach" title="첨부 보기"
aria-label="첨부 보기">
<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>
<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>
<tr><td colspan="7" class="erp-empty">대기 항목이 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -63,38 +72,81 @@
<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>
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;
(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");
if (btn.classList.contains("js-attach")) {
const owner = tr.children[1]?.textContent?.trim() || "";
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
return;
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;
}
}
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() }),
selectAll.addEventListener("change", () => {
tbody.querySelectorAll("input.js-select").forEach((cb) => {
cb.checked = selectAll.checked;
});
if (res.ok) { tr.remove(); }
else { alert((await res.json()).detail || "반려 실패"); }
}
});
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 %}