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:
@@ -78,24 +78,39 @@
|
|||||||
<div class="erp-card-block">
|
<div class="erp-card-block">
|
||||||
<div class="erp-card-block-head">
|
<div class="erp-card-block-head">
|
||||||
<h2>경비 내역</h2>
|
<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>
|
||||||
<div class="erp-table-wrap">
|
<div class="erp-table-wrap">
|
||||||
<table class="erp-table" id="ex-table">
|
<table class="erp-table" id="ex-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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: 110px;">사용일</th>
|
||||||
<th style="width: 90px;">분류</th>
|
<th style="width: 90px;">분류</th>
|
||||||
<th style="width: 100px;">수단</th>
|
<th style="width: 100px;">수단</th>
|
||||||
<th>가맹점</th>
|
<th>가맹점</th>
|
||||||
<th style="width: 120px; text-align: right;">금액</th>
|
<th style="width: 120px; text-align: right;">금액</th>
|
||||||
<th style="width: 90px;">상태</th>
|
<th style="width: 90px;">상태</th>
|
||||||
<th style="width: 280px; text-align: right;">동작</th>
|
<th style="width: 240px; text-align: right;">동작</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="ex-tbody">
|
<tbody id="ex-tbody">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
|
<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.spent_at }}</td>
|
||||||
<td>{{ it.category }}</td>
|
<td>{{ it.category }}</td>
|
||||||
<td>{{ it.method }}</td>
|
<td>{{ it.method }}</td>
|
||||||
@@ -122,42 +137,24 @@
|
|||||||
</td>
|
</td>
|
||||||
<td style="text-align: right;" class="js-actions">
|
<td style="text-align: right;" class="js-actions">
|
||||||
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||||
<label class="erp-btn erp-btn-outline erp-btn-sm" title="영수증 첨부">
|
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||||
📎영수증<input type="file" class="js-upload" data-kind="receipt" hidden />
|
영수증<input type="file" class="js-upload" data-kind="receipt" hidden />
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-btn erp-btn-outline erp-btn-sm" title="기타파일 첨부">
|
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||||
📎기타<input type="file" class="js-upload" data-kind="other" hidden />
|
기타 파일<input type="file" class="js-upload" data-kind="other" hidden />
|
||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if it.status in ('작성중', '반려') %}
|
{% 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-edit">수정</button>
|
||||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</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 %}
|
{% elif it.status == '제출' and supports_workflow %}
|
||||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -170,22 +167,9 @@
|
|||||||
.erp-page-actions {
|
.erp-page-actions {
|
||||||
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
|
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
/* detail 행은 메인 행 바로 아래 — 위쪽 보더만 살짝, 좌측 들여쓰기 */
|
#ex-tbody td { vertical-align: middle; }
|
||||||
tr.ex-detail > td { padding: 6px var(--sp-16) var(--sp-12) 110px; background: var(--color-canvas-white); }
|
#ex-tbody .js-actions { white-space: nowrap; }
|
||||||
tr.ex-detail + tr[data-id] > td { border-top: 1px solid var(--color-subtle-ash); }
|
#ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; }
|
||||||
.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; }
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -197,16 +181,9 @@
|
|||||||
const submitBtn = document.getElementById("ex-submit");
|
const submitBtn = document.getElementById("ex-submit");
|
||||||
const resetBtn = document.getElementById("ex-reset");
|
const resetBtn = document.getElementById("ex-reset");
|
||||||
const tbody = document.getElementById("ex-tbody");
|
const tbody = document.getElementById("ex-tbody");
|
||||||
const countEl = document.getElementById("ex-count");
|
const selectAll = document.getElementById("ex-select-all");
|
||||||
const totalEl = document.getElementById("ex-total-amount");
|
const bulkBtn = document.getElementById("ex-bulk-submit");
|
||||||
|
const bulkCountEl = document.getElementById("ex-bulk-count");
|
||||||
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`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function setEditing(id, data) {
|
function setEditing(id, data) {
|
||||||
form.id.value = id || "";
|
form.id.value = id || "";
|
||||||
@@ -250,52 +227,59 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function postAction(id, action, body) {
|
// 체크박스 상태 갱신
|
||||||
const res = await fetch(`/expense/api/items/${id}/${action}`, {
|
function refreshSelection() {
|
||||||
method: "POST",
|
if (!bulkBtn) return;
|
||||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
const cbs = tbody.querySelectorAll("input.js-select");
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||||
});
|
bulkBtn.disabled = checked.length === 0;
|
||||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
if (bulkCountEl) bulkCountEl.textContent = checked.length;
|
||||||
return res.json();
|
if (selectAll && cbs.length > 0) {
|
||||||
}
|
selectAll.checked = checked.length === cbs.length;
|
||||||
|
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
tbody.addEventListener("click", async (e) => {
|
||||||
const btn = e.target.closest("button");
|
const btn = e.target.closest("button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
// 메인 행 또는 detail 행 모두 처리
|
const tr = btn.closest("tr[data-id]");
|
||||||
const mainTr = btn.closest("tr[data-id]");
|
if (!tr) return;
|
||||||
const detailTr = btn.closest("tr.ex-detail");
|
const id = tr.dataset.id;
|
||||||
const id = mainTr ? mainTr.dataset.id : (detailTr ? detailTr.dataset.detailFor : null);
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
if (btn.classList.contains("js-edit")) {
|
if (btn.classList.contains("js-edit")) {
|
||||||
const res = await fetch(`/expense/api/items`);
|
const res = await fetch(`/expense/api/items`);
|
||||||
@@ -308,29 +292,21 @@
|
|||||||
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
||||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
|
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")) {
|
} else if (btn.classList.contains("js-revert")) {
|
||||||
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
||||||
try { await postAction(id, "revert"); location.reload(); }
|
const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" });
|
||||||
catch (err) { alert(`취소 실패: ${err.message || err}`); }
|
if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패");
|
||||||
} else if (btn.classList.contains("js-del-att")) {
|
} else if (btn.classList.contains("js-view-att")) {
|
||||||
if (!confirm("첨부를 삭제할까요?")) return;
|
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` });
|
||||||
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 || "삭제 실패");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 첨부 업로드 (메인 행 동작셀 input)
|
// 첨부 업로드
|
||||||
tbody.addEventListener("change", async (e) => {
|
tbody.addEventListener("change", async (e) => {
|
||||||
const input = e.target.closest("input.js-upload");
|
const input = e.target.closest("input.js-upload");
|
||||||
if (!input || !input.files.length) return;
|
if (!input || !input.files.length) return;
|
||||||
const mainTr = input.closest("tr[data-id]");
|
const tr = input.closest("tr[data-id]");
|
||||||
if (!mainTr) return;
|
const itemId = tr.dataset.id;
|
||||||
const itemId = mainTr.dataset.id;
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", input.files[0]);
|
fd.append("file", input.files[0]);
|
||||||
fd.append("kind", input.dataset.kind || "other");
|
fd.append("kind", input.dataset.kind || "other");
|
||||||
@@ -340,19 +316,12 @@
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
loadAttachments(itemId);
|
alert("첨부 업로드 완료");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`업로드 실패: ${err.message || 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) {
|
if (!form.spent_at.value) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
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">
|
||||||
<div class="erp-card-block-head">
|
<div class="erp-card-block-head">
|
||||||
<h2>승인 대기 ({{ items | length }}건)</h2>
|
<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>
|
||||||
|
|
||||||
<div class="erp-table-wrap">
|
<div class="erp-table-wrap">
|
||||||
<table class="erp-table">
|
<table class="erp-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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: 110px;">사용일</th>
|
||||||
<th style="width: 200px;">소유자</th>
|
<th style="width: 200px;">소유자</th>
|
||||||
<th style="width: 90px;">분류</th>
|
<th style="width: 90px;">분류</th>
|
||||||
<th>가맹점/메모</th>
|
<th>가맹점/메모</th>
|
||||||
<th style="width: 120px; text-align: right;">금액</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pend-tbody">
|
<tbody id="pend-tbody">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
<tr data-id="{{ it.id }}">
|
<tr data-id="{{ it.id }}">
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<input type="checkbox" class="js-select" />
|
||||||
|
</td>
|
||||||
<td>{{ it.spent_at }}</td>
|
<td>{{ it.spent_at }}</td>
|
||||||
<td>{{ it.owner }}</td>
|
<td>{{ it.owner }}</td>
|
||||||
<td>{{ it.category }}</td>
|
<td>{{ it.category }}</td>
|
||||||
@@ -40,19 +51,17 @@
|
|||||||
{{ "{:,}".format(it.amount) }} 원
|
{{ "{:,}".format(it.amount) }} 원
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
<button class="erp-btn erp-btn-outline erp-btn-sm js-attach" title="첨부 보기"
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-attach" title="첨부 보기" aria-label="첨부 보기">
|
||||||
aria-label="첨부 보기">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="6" class="erp-empty">대기 항목이 없습니다.</td></tr>
|
<tr><td colspan="7" class="erp-empty">대기 항목이 없습니다.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -63,38 +72,81 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||||
|
#pend-tbody td { vertical-align: middle; }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.getElementById("pend-tbody").addEventListener("click", async (e) => {
|
(function () {
|
||||||
const btn = e.target.closest("button");
|
const tbody = document.getElementById("pend-tbody");
|
||||||
if (!btn) return;
|
const selectAll = document.getElementById("pend-select-all");
|
||||||
const tr = btn.closest("tr[data-id]");
|
const bulkBtn = document.getElementById("pend-bulk-approve");
|
||||||
const id = tr.dataset.id;
|
const bulkCountEl = document.getElementById("pend-bulk-count");
|
||||||
|
|
||||||
if (btn.classList.contains("js-attach")) {
|
function refreshSelection() {
|
||||||
const owner = tr.children[1]?.textContent?.trim() || "";
|
const cbs = tbody.querySelectorAll("input.js-select");
|
||||||
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
|
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||||
return;
|
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;
|
selectAll.addEventListener("change", () => {
|
||||||
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
|
tbody.querySelectorAll("input.js-select").forEach((cb) => {
|
||||||
if (res.ok) { tr.remove(); }
|
cb.checked = selectAll.checked;
|
||||||
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() }),
|
|
||||||
});
|
});
|
||||||
if (res.ok) { tr.remove(); }
|
refreshSelection();
|
||||||
else { alert((await res.json()).detail || "반려 실패"); }
|
});
|
||||||
}
|
|
||||||
});
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════
|
||||||
첨부 뷰어 모달 — 이미지 미리보기 + 펜 주석
|
첨부 뷰어 모달 — 이미지 패닝 + 엑셀 시트
|
||||||
════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.eav-overlay {
|
.eav-overlay {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
.eav-modal {
|
.eav-modal {
|
||||||
background: var(--color-canvas-white, #fff);
|
background: var(--color-canvas-white, #fff);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1180px;
|
max-width: 1280px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
background: var(--color-canvas-white, #fff);
|
|
||||||
}
|
}
|
||||||
.eav-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0;
|
.eav-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0;
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
@@ -36,16 +35,10 @@
|
|||||||
background: var(--color-canvas-white, #fff);
|
background: var(--color-canvas-white, #fff);
|
||||||
color: var(--color-rich-black, #0a0a0a);
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
padding: 5px 10px; font-size: 12px; border-radius: 8px; cursor: pointer;
|
padding: 5px 10px; font-size: 12px; border-radius: 8px; cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit; text-decoration: none; display: inline-block;
|
||||||
}
|
}
|
||||||
.eav-btn:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
.eav-btn:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||||
.eav-btn.is-active { background: var(--color-deep-black, #000); color: #fff; border-color: #000; }
|
.eav-btn.is-active { background: var(--color-deep-black, #000); color: #fff; border-color: #000; }
|
||||||
.eav-color {
|
|
||||||
width: 22px; height: 22px; border-radius: 50%; border: 2px solid #fff;
|
|
||||||
box-shadow: 0 0 0 1px var(--color-subtle-ash, #e5e5e5);
|
|
||||||
cursor: pointer; padding: 0;
|
|
||||||
}
|
|
||||||
.eav-color.is-active { box-shadow: 0 0 0 2px var(--color-deep-black, #000); }
|
|
||||||
.eav-close {
|
.eav-close {
|
||||||
width: 28px; height: 28px; border-radius: 8px;
|
width: 28px; height: 28px; border-radius: 8px;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
@@ -54,7 +47,7 @@
|
|||||||
.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||||
|
|
||||||
.eav-body {
|
.eav-body {
|
||||||
display: grid; grid-template-columns: 200px 1fr; min-height: 0; height: 100%;
|
display: grid; grid-template-columns: 220px 1fr; min-height: 0; height: 100%;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
}
|
}
|
||||||
.eav-list {
|
.eav-list {
|
||||||
@@ -73,33 +66,53 @@
|
|||||||
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
|
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
|
||||||
background: var(--color-ghost-gray, #f2f2f2);
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
font-size: 16px; color: var(--color-midtone-gray, #737373);
|
font-size: 18px; color: var(--color-midtone-gray, #737373);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.eav-item-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
.eav-item-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.eav-item-meta { flex: 1; min-width: 0; }
|
.eav-item-meta { flex: 1; min-width: 0; }
|
||||||
.eav-item-name { font-weight: 500; color: var(--color-rich-black, #0a0a0a);
|
.eav-item-name { font-weight: 500; color: var(--color-rich-black, #0a0a0a);
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||||
.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); }
|
.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); display: block; }
|
||||||
|
|
||||||
|
/* 이미지 패닝 스테이지 */
|
||||||
.eav-canvas-wrap {
|
.eav-canvas-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex; align-items: center; justify-content: center;
|
padding: 0;
|
||||||
padding: 16px;
|
background: #2a2a2a;
|
||||||
}
|
}
|
||||||
.eav-canvas-stack {
|
.eav-canvas-wrap.eav-pan { cursor: grab; }
|
||||||
position: relative;
|
.eav-canvas-wrap.eav-pan.is-panning { cursor: grabbing; }
|
||||||
line-height: 0;
|
.eav-img { display: block; user-select: none; -webkit-user-drag: none; margin: 0 auto; }
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
|
||||||
|
/* 엑셀/시트 */
|
||||||
|
.eav-sheet-wrap {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: grid; grid-template-rows: auto 1fr;
|
||||||
}
|
}
|
||||||
.eav-img { display: block; max-width: 100%; height: auto; user-select: none; }
|
.eav-sheet-tabs {
|
||||||
.eav-canvas {
|
display: flex; gap: 4px; padding: 8px;
|
||||||
position: absolute; left: 0; top: 0;
|
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
cursor: crosshair;
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
touch-action: none;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
.eav-sheet-body {
|
||||||
|
overflow: auto; padding: 12px;
|
||||||
|
}
|
||||||
|
.eav-sheet-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: 'Geist Mono', Menlo, monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.eav-sheet-body td, .eav-sheet-body th {
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 60px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.eav-sheet-body th { background: var(--color-ghost-gray, #f2f2f2); font-weight: 600; }
|
||||||
|
.eav-sheet-loading { color: #fff; text-align: center; padding: 40px; }
|
||||||
|
|
||||||
.eav-fallback {
|
.eav-fallback {
|
||||||
color: #fff; text-align: center; padding: 40px;
|
color: #fff; text-align: center; padding: 40px;
|
||||||
@@ -110,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.eav-body { grid-template-columns: 1fr; grid-template-rows: 80px 1fr; }
|
.eav-body { grid-template-columns: 1fr; grid-template-rows: 100px 1fr; }
|
||||||
.eav-list { flex-direction: row; overflow-x: auto; }
|
.eav-list { flex-direction: row; overflow-x: auto; }
|
||||||
.eav-item { flex-shrink: 0; min-width: 160px; }
|
.eav-item { flex-shrink: 0; min-width: 180px; }
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-179
@@ -1,31 +1,30 @@
|
|||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// ErpAttachViewer — 첨부 모달
|
// ErpAttachViewer — 첨부 모달
|
||||||
// 사용:
|
// - 이미지: 마우스 드래그로 패닝 (이미지 크면 스크롤). 확대/축소/맞춤.
|
||||||
// ErpAttachViewer.openFor(itemId, { title })
|
// - 엑셀 (xlsx/xls/csv): SheetJS 로 시트 표시 (CDN lazy load).
|
||||||
// 동작:
|
// - 기타: 다운로드 링크 표시.
|
||||||
// - GET /expense/api/items/{itemId}/attachments
|
// API: ErpAttachViewer.openFor(itemId, { title })
|
||||||
// - 이미지: 캔버스 위 펜 주석, 다운로드(원본+주석 합성)
|
|
||||||
// - 비이미지: 새 창 다운로드 링크
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
if (window.ErpAttachViewer) return;
|
if (window.ErpAttachViewer) return;
|
||||||
|
|
||||||
const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
||||||
const COLORS = ["#c22b10", "#0a0a0a", "#1d4ed8", "#10c22b"];
|
const SHEET_EXTS = new Set(["xlsx", "xls", "xlsm", "csv", "ods"]);
|
||||||
const SIZES = [2, 4, 8];
|
const XLSX_CDN = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js";
|
||||||
|
|
||||||
let overlay = null;
|
let overlay = null;
|
||||||
let state = {
|
let state = {
|
||||||
items: [],
|
items: [],
|
||||||
currentIdx: -1,
|
currentIdx: -1,
|
||||||
tool: "pen", // pen | erase
|
|
||||||
color: COLORS[0],
|
|
||||||
size: SIZES[1],
|
|
||||||
drawing: false,
|
|
||||||
last: null,
|
|
||||||
title: "",
|
title: "",
|
||||||
|
zoom: 1, // 이미지 줌 (1 = 100%)
|
||||||
|
// 패닝
|
||||||
|
panning: false,
|
||||||
|
panStartX: 0, panStartY: 0,
|
||||||
|
scrollStartLeft: 0, scrollStartTop: 0,
|
||||||
};
|
};
|
||||||
|
let xlsxLoading = null;
|
||||||
|
|
||||||
function ensureOverlay() {
|
function ensureOverlay() {
|
||||||
if (overlay) return overlay;
|
if (overlay) return overlay;
|
||||||
@@ -46,9 +45,7 @@
|
|||||||
`;
|
`;
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
overlay.addEventListener("click", (e) => {
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||||
if (e.target === overlay) close();
|
|
||||||
});
|
|
||||||
overlay.querySelector('[data-role="close"]').addEventListener("click", close);
|
overlay.querySelector('[data-role="close"]').addEventListener("click", close);
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (overlay.dataset.open === "true" && e.key === "Escape") close();
|
if (overlay.dataset.open === "true" && e.key === "Escape") close();
|
||||||
@@ -56,60 +53,55 @@
|
|||||||
return overlay;
|
return overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadXlsxLib() {
|
||||||
|
if (window.XLSX) return Promise.resolve(window.XLSX);
|
||||||
|
if (xlsxLoading) return xlsxLoading;
|
||||||
|
xlsxLoading = new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = XLSX_CDN;
|
||||||
|
s.onload = () => resolve(window.XLSX);
|
||||||
|
s.onerror = () => reject(new Error("XLSX 라이브러리 로드 실패"));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
return xlsxLoading;
|
||||||
|
}
|
||||||
|
|
||||||
function buildToolbar() {
|
function buildToolbar() {
|
||||||
const tb = overlay.querySelector('[data-role="toolbar"]');
|
const tb = overlay.querySelector('[data-role="toolbar"]');
|
||||||
tb.innerHTML = "";
|
tb.innerHTML = "";
|
||||||
|
const cur = state.items[state.currentIdx];
|
||||||
|
if (!cur) return;
|
||||||
|
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||||
|
|
||||||
const tools = [
|
if (IMG_EXTS.has(ext)) {
|
||||||
{ key: "pen", label: "✏️ 펜" },
|
[
|
||||||
{ key: "erase", label: "🩹 지우개" },
|
{ label: "-", title: "축소", fn: () => setZoom(state.zoom / 1.25) },
|
||||||
];
|
{ label: "100%", title: "원본", fn: () => setZoom(1) },
|
||||||
tools.forEach((t) => {
|
{ label: "+", title: "확대", fn: () => setZoom(state.zoom * 1.25) },
|
||||||
const b = document.createElement("button");
|
{ label: "맞춤", title: "화면에 맞춤", fn: () => setZoom("fit") },
|
||||||
b.className = "eav-btn" + (state.tool === t.key ? " is-active" : "");
|
].forEach((b) => {
|
||||||
b.textContent = t.label;
|
const btn = document.createElement("button");
|
||||||
b.onclick = () => { state.tool = t.key; buildToolbar(); };
|
btn.className = "eav-btn";
|
||||||
tb.appendChild(b);
|
btn.textContent = b.label;
|
||||||
});
|
btn.title = b.title;
|
||||||
|
btn.onclick = b.fn;
|
||||||
|
tb.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
COLORS.forEach((c) => {
|
const dl = document.createElement("a");
|
||||||
const b = document.createElement("button");
|
|
||||||
b.className = "eav-color" + (state.color === c ? " is-active" : "");
|
|
||||||
b.style.background = c;
|
|
||||||
b.title = c;
|
|
||||||
b.onclick = () => { state.color = c; buildToolbar(); };
|
|
||||||
tb.appendChild(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
SIZES.forEach((s) => {
|
|
||||||
const b = document.createElement("button");
|
|
||||||
b.className = "eav-btn" + (state.size === s ? " is-active" : "");
|
|
||||||
b.textContent = `${s}px`;
|
|
||||||
b.onclick = () => { state.size = s; buildToolbar(); };
|
|
||||||
tb.appendChild(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
const clear = document.createElement("button");
|
|
||||||
clear.className = "eav-btn";
|
|
||||||
clear.textContent = "전체 지우기";
|
|
||||||
clear.onclick = clearAnnot;
|
|
||||||
tb.appendChild(clear);
|
|
||||||
|
|
||||||
const dl = document.createElement("button");
|
|
||||||
dl.className = "eav-btn";
|
dl.className = "eav-btn";
|
||||||
dl.textContent = "💾 저장";
|
dl.textContent = "다운로드";
|
||||||
dl.onclick = downloadComposite;
|
dl.href = `/expense/api/attachments/${cur.id}`;
|
||||||
|
dl.setAttribute("download", cur.filename);
|
||||||
tb.appendChild(dl);
|
tb.appendChild(dl);
|
||||||
|
|
||||||
const original = document.createElement("a");
|
const open = document.createElement("a");
|
||||||
original.className = "eav-btn";
|
open.className = "eav-btn";
|
||||||
original.textContent = "원본";
|
open.textContent = "새 창";
|
||||||
const cur = state.items[state.currentIdx];
|
open.href = `/expense/api/attachments/${cur.id}`;
|
||||||
if (cur) {
|
open.target = "_blank";
|
||||||
original.href = `/expense/api/attachments/${cur.id}`;
|
tb.appendChild(open);
|
||||||
original.target = "_blank";
|
|
||||||
}
|
|
||||||
tb.appendChild(original);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildList() {
|
function buildList() {
|
||||||
@@ -120,18 +112,17 @@
|
|||||||
item.className = "eav-item" + (i === state.currentIdx ? " is-active" : "");
|
item.className = "eav-item" + (i === state.currentIdx ? " is-active" : "");
|
||||||
const ext = (a.filename.split(".").pop() || "").toLowerCase();
|
const ext = (a.filename.split(".").pop() || "").toLowerCase();
|
||||||
const isImg = IMG_EXTS.has(ext);
|
const isImg = IMG_EXTS.has(ext);
|
||||||
|
const isSheet = SHEET_EXTS.has(ext);
|
||||||
|
const icon = isImg ? `<img src="/expense/api/attachments/${a.id}" alt="" />`
|
||||||
|
: isSheet ? "📊" : "📄";
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<span class="eav-item-thumb">${
|
<span class="eav-item-thumb">${icon}</span>
|
||||||
isImg
|
|
||||||
? `<img src="/expense/api/attachments/${a.id}" alt="" />`
|
|
||||||
: "📄"
|
|
||||||
}</span>
|
|
||||||
<span class="eav-item-meta">
|
<span class="eav-item-meta">
|
||||||
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
|
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
|
||||||
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타"} · ${fmtSize(a.size_bytes)}</span>
|
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)}</span>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
item.onclick = () => { state.currentIdx = i; renderCurrent(); buildList(); buildToolbar(); };
|
item.onclick = () => { state.currentIdx = i; state.zoom = 1; renderCurrent(); buildList(); buildToolbar(); };
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -139,133 +130,133 @@
|
|||||||
function renderCurrent() {
|
function renderCurrent() {
|
||||||
const stage = overlay.querySelector('[data-role="stage"]');
|
const stage = overlay.querySelector('[data-role="stage"]');
|
||||||
stage.innerHTML = "";
|
stage.innerHTML = "";
|
||||||
|
stage.className = "eav-canvas-wrap";
|
||||||
const cur = state.items[state.currentIdx];
|
const cur = state.items[state.currentIdx];
|
||||||
if (!cur) {
|
if (!cur) {
|
||||||
stage.innerHTML = `<div class="eav-fallback">첨부가 없습니다.</div>`;
|
stage.innerHTML = `<div class="eav-fallback">첨부가 없습니다.</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||||
if (!IMG_EXTS.has(ext)) {
|
|
||||||
stage.innerHTML = `
|
|
||||||
<div class="eav-fallback">
|
|
||||||
이미지 파일이 아닙니다.<br/>
|
|
||||||
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrap = document.createElement("div");
|
if (IMG_EXTS.has(ext)) return renderImage(stage, cur);
|
||||||
wrap.className = "eav-canvas-stack";
|
if (SHEET_EXTS.has(ext)) return renderSheet(stage, cur, ext);
|
||||||
const img = new Image();
|
return renderFallback(stage, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImage(stage, cur) {
|
||||||
|
stage.classList.add("eav-pan");
|
||||||
|
const img = document.createElement("img");
|
||||||
img.className = "eav-img";
|
img.className = "eav-img";
|
||||||
img.crossOrigin = "anonymous";
|
img.alt = cur.filename;
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.className = "eav-canvas";
|
|
||||||
canvas.width = img.naturalWidth;
|
|
||||||
canvas.height = img.naturalHeight;
|
|
||||||
// 표시 크기는 img 의 렌더링 크기 따라감
|
|
||||||
canvas.style.width = img.clientWidth + "px";
|
|
||||||
canvas.style.height = img.clientHeight + "px";
|
|
||||||
wrap.appendChild(canvas);
|
|
||||||
attachDrawing(canvas, img);
|
|
||||||
// 창 리사이즈 시 캔버스 표시 크기 재조정
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
canvas.style.width = img.clientWidth + "px";
|
|
||||||
canvas.style.height = img.clientHeight + "px";
|
|
||||||
});
|
|
||||||
ro.observe(img);
|
|
||||||
state.canvas = canvas;
|
|
||||||
state.image = img;
|
|
||||||
};
|
|
||||||
img.onerror = () => {
|
|
||||||
stage.innerHTML = `<div class="eav-fallback">이미지 로드 실패</div>`;
|
|
||||||
};
|
|
||||||
img.src = `/expense/api/attachments/${cur.id}`;
|
img.src = `/expense/api/attachments/${cur.id}`;
|
||||||
wrap.appendChild(img);
|
img.draggable = false;
|
||||||
stage.appendChild(wrap);
|
img.onload = () => { applyZoom(); };
|
||||||
|
img.onerror = () => { stage.innerHTML = `<div class="eav-fallback">이미지 로드 실패</div>`; };
|
||||||
|
stage.appendChild(img);
|
||||||
|
state.image = img;
|
||||||
|
|
||||||
|
// 마우스 드래그 패닝
|
||||||
|
stage.addEventListener("pointerdown", (e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
state.panning = true;
|
||||||
|
state.panStartX = e.clientX;
|
||||||
|
state.panStartY = e.clientY;
|
||||||
|
state.scrollStartLeft = stage.scrollLeft;
|
||||||
|
state.scrollStartTop = stage.scrollTop;
|
||||||
|
stage.setPointerCapture(e.pointerId);
|
||||||
|
stage.classList.add("is-panning");
|
||||||
|
});
|
||||||
|
stage.addEventListener("pointermove", (e) => {
|
||||||
|
if (!state.panning) return;
|
||||||
|
stage.scrollLeft = state.scrollStartLeft - (e.clientX - state.panStartX);
|
||||||
|
stage.scrollTop = state.scrollStartTop - (e.clientY - state.panStartY);
|
||||||
|
});
|
||||||
|
const endPan = (e) => {
|
||||||
|
if (!state.panning) return;
|
||||||
|
state.panning = false;
|
||||||
|
try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
stage.classList.remove("is-panning");
|
||||||
|
};
|
||||||
|
stage.addEventListener("pointerup", endPan);
|
||||||
|
stage.addEventListener("pointercancel", endPan);
|
||||||
|
stage.addEventListener("pointerleave", endPan);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachDrawing(canvas, img) {
|
function applyZoom() {
|
||||||
const ctx = canvas.getContext("2d");
|
if (!state.image) return;
|
||||||
|
const stage = overlay.querySelector('[data-role="stage"]');
|
||||||
function posToCanvas(ev) {
|
const img = state.image;
|
||||||
const rect = canvas.getBoundingClientRect();
|
if (state.zoom === "fit") {
|
||||||
const x = (ev.clientX - rect.left) * (canvas.width / rect.width);
|
img.style.maxWidth = "100%";
|
||||||
const y = (ev.clientY - rect.top) * (canvas.height / rect.height);
|
img.style.maxHeight = "calc(100vh - 200px)";
|
||||||
return { x, y };
|
img.style.width = "";
|
||||||
|
img.style.height = "";
|
||||||
|
} else {
|
||||||
|
img.style.maxWidth = "none";
|
||||||
|
img.style.maxHeight = "none";
|
||||||
|
img.style.width = (img.naturalWidth * state.zoom) + "px";
|
||||||
|
img.style.height = "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
function start(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
state.drawing = true;
|
|
||||||
state.last = posToCanvas(ev);
|
|
||||||
}
|
|
||||||
function move(ev) {
|
|
||||||
if (!state.drawing) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
const p = posToCanvas(ev);
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
if (state.tool === "erase") {
|
|
||||||
ctx.globalCompositeOperation = "destination-out";
|
|
||||||
ctx.lineWidth = state.size * 4;
|
|
||||||
} else {
|
|
||||||
ctx.globalCompositeOperation = "source-over";
|
|
||||||
ctx.strokeStyle = state.color;
|
|
||||||
ctx.lineWidth = state.size;
|
|
||||||
}
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(state.last.x, state.last.y);
|
|
||||||
ctx.lineTo(p.x, p.y);
|
|
||||||
ctx.stroke();
|
|
||||||
state.last = p;
|
|
||||||
}
|
|
||||||
function end(ev) {
|
|
||||||
if (ev) ev.preventDefault();
|
|
||||||
state.drawing = false;
|
|
||||||
state.last = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", start);
|
|
||||||
canvas.addEventListener("pointermove", move);
|
|
||||||
canvas.addEventListener("pointerup", end);
|
|
||||||
canvas.addEventListener("pointerleave", end);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAnnot() {
|
function setZoom(z) {
|
||||||
if (!state.canvas) return;
|
state.zoom = z;
|
||||||
const ctx = state.canvas.getContext("2d");
|
applyZoom();
|
||||||
ctx.clearRect(0, 0, state.canvas.width, state.canvas.height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadComposite() {
|
async function renderSheet(stage, cur, ext) {
|
||||||
if (!state.canvas || !state.image) {
|
stage.classList.remove("eav-pan");
|
||||||
alert("이미지가 로드되지 않았습니다.");
|
stage.innerHTML = `<div class="eav-sheet-loading">시트 로드 중…</div>`;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cur = state.items[state.currentIdx];
|
|
||||||
const out = document.createElement("canvas");
|
|
||||||
out.width = state.canvas.width;
|
|
||||||
out.height = state.canvas.height;
|
|
||||||
const octx = out.getContext("2d");
|
|
||||||
try {
|
try {
|
||||||
octx.drawImage(state.image, 0, 0);
|
const XLSX = await loadXlsxLib();
|
||||||
} catch (e) {
|
const res = await fetch(`/expense/api/attachments/${cur.id}`);
|
||||||
alert("이미지 합성 실패 (CORS): 원본만 다운로드하세요.");
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return;
|
const buf = await res.arrayBuffer();
|
||||||
|
const wb = XLSX.read(buf, { type: "array" });
|
||||||
|
stage.innerHTML = "";
|
||||||
|
|
||||||
|
const sheetWrap = document.createElement("div");
|
||||||
|
sheetWrap.className = "eav-sheet-wrap";
|
||||||
|
|
||||||
|
const tabs = document.createElement("div");
|
||||||
|
tabs.className = "eav-sheet-tabs";
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "eav-sheet-body";
|
||||||
|
|
||||||
|
function showSheet(name) {
|
||||||
|
const ws = wb.Sheets[name];
|
||||||
|
const html = XLSX.utils.sheet_to_html(ws, { editable: false });
|
||||||
|
body.innerHTML = html;
|
||||||
|
tabs.querySelectorAll("button").forEach((b) => {
|
||||||
|
b.classList.toggle("is-active", b.dataset.sheet === name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wb.SheetNames.forEach((name) => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "eav-btn";
|
||||||
|
b.dataset.sheet = name;
|
||||||
|
b.textContent = name;
|
||||||
|
b.onclick = () => showSheet(name);
|
||||||
|
tabs.appendChild(b);
|
||||||
|
});
|
||||||
|
sheetWrap.appendChild(tabs);
|
||||||
|
sheetWrap.appendChild(body);
|
||||||
|
stage.appendChild(sheetWrap);
|
||||||
|
showSheet(wb.SheetNames[0]);
|
||||||
|
} catch (err) {
|
||||||
|
stage.innerHTML = `<div class="eav-fallback">시트 로드 실패: ${escapeHtml(err.message || String(err))}
|
||||||
|
<br/><a href="/expense/api/attachments/${cur.id}" target="_blank">원본 다운로드</a></div>`;
|
||||||
}
|
}
|
||||||
octx.drawImage(state.canvas, 0, 0);
|
}
|
||||||
out.toBlob((blob) => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
function renderFallback(stage, cur) {
|
||||||
const a = document.createElement("a");
|
stage.classList.remove("eav-pan");
|
||||||
a.href = url;
|
stage.innerHTML = `
|
||||||
const base = cur.filename.replace(/\.[^.]+$/, "");
|
<div class="eav-fallback">
|
||||||
a.download = `${base}_주석.png`;
|
미리보기를 지원하지 않는 파일입니다.<br/>
|
||||||
a.click();
|
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
</div>
|
||||||
}, "image/png");
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtSize(b) {
|
function fmtSize(b) {
|
||||||
@@ -284,6 +275,7 @@
|
|||||||
ensureOverlay();
|
ensureOverlay();
|
||||||
state.items = items || [];
|
state.items = items || [];
|
||||||
state.currentIdx = state.items.length ? 0 : -1;
|
state.currentIdx = state.items.length ? 0 : -1;
|
||||||
|
state.zoom = 1;
|
||||||
state.title = (opts && opts.title) || "첨부 보기";
|
state.title = (opts && opts.title) || "첨부 보기";
|
||||||
overlay.querySelector('[data-role="title"]').textContent = state.title;
|
overlay.querySelector('[data-role="title"]').textContent = state.title;
|
||||||
overlay.dataset.open = "true";
|
overlay.dataset.open = "true";
|
||||||
@@ -297,7 +289,6 @@
|
|||||||
overlay.dataset.open = "false";
|
overlay.dataset.open = "false";
|
||||||
state.items = [];
|
state.items = [];
|
||||||
state.currentIdx = -1;
|
state.currentIdx = -1;
|
||||||
state.canvas = null;
|
|
||||||
state.image = null;
|
state.image = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
|||||||
.erp-table tbody td {
|
.erp-table tbody td {
|
||||||
padding: 10px var(--sp-16);
|
padding: 10px var(--sp-16);
|
||||||
border-bottom: 1px solid var(--color-subtle-ash);
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
vertical-align: top;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
||||||
|
|||||||
Reference in New Issue
Block a user