Files
dbx-main/app/static/erp-attach-viewer.js
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

312 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ──────────────────────────────────────────────────────────
// ErpAttachViewer — 첨부 모달
// - 이미지: 마우스 드래그로 패닝 (이미지 크면 스크롤). 확대/축소/맞춤.
// - 엑셀 (xlsx/xls/csv): SheetJS 로 시트 표시 (CDN lazy load).
// - 기타: 다운로드 링크 표시.
// API: ErpAttachViewer.openFor(itemId, { title })
// ──────────────────────────────────────────────────────────
(function () {
if (window.ErpAttachViewer) return;
const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
const SHEET_EXTS = new Set(["xlsx", "xls", "xlsm", "csv", "ods"]);
const XLSX_CDN = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js";
let overlay = null;
let state = {
items: [],
currentIdx: -1,
title: "",
zoom: 1, // 이미지 줌 (1 = 100%)
// 패닝
panning: false,
panStartX: 0, panStartY: 0,
scrollStartLeft: 0, scrollStartTop: 0,
};
let xlsxLoading = null;
function ensureOverlay() {
if (overlay) return overlay;
overlay = document.createElement("div");
overlay.className = "eav-overlay";
overlay.innerHTML = `
<div class="eav-modal" role="dialog" aria-modal="true">
<div class="eav-head">
<div class="eav-title" data-role="title">첨부 보기</div>
<div class="eav-toolbar" data-role="toolbar"></div>
<button class="eav-close" data-role="close" aria-label="닫기">✕</button>
</div>
<div class="eav-body">
<div class="eav-list" data-role="list"></div>
<div class="eav-canvas-wrap" data-role="stage"></div>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-role="close"]').addEventListener("click", close);
document.addEventListener("keydown", (e) => {
if (overlay.dataset.open === "true" && e.key === "Escape") close();
});
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() {
const tb = overlay.querySelector('[data-role="toolbar"]');
tb.innerHTML = "";
const cur = state.items[state.currentIdx];
if (!cur) return;
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
if (IMG_EXTS.has(ext)) {
[
{ label: "", title: "축소", fn: () => setZoom(state.zoom / 1.25) },
{ label: "100%", title: "원본", fn: () => setZoom(1) },
{ label: "", title: "확대", fn: () => setZoom(state.zoom * 1.25) },
{ label: "맞춤", title: "화면에 맞춤", fn: () => setZoom("fit") },
].forEach((b) => {
const btn = document.createElement("button");
btn.className = "eav-btn";
btn.textContent = b.label;
btn.title = b.title;
btn.onclick = b.fn;
tb.appendChild(btn);
});
}
const dl = document.createElement("a");
dl.className = "eav-btn";
dl.textContent = "다운로드";
dl.href = `/expense/api/attachments/${cur.id}`;
dl.setAttribute("download", cur.filename);
tb.appendChild(dl);
const open = document.createElement("a");
open.className = "eav-btn";
open.textContent = "새 창";
open.href = `/expense/api/attachments/${cur.id}`;
open.target = "_blank";
tb.appendChild(open);
}
function buildList() {
const list = overlay.querySelector('[data-role="list"]');
list.innerHTML = "";
state.items.forEach((a, i) => {
const item = document.createElement("button");
item.className = "eav-item" + (i === state.currentIdx ? " is-active" : "");
const ext = (a.filename.split(".").pop() || "").toLowerCase();
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 = `
<span class="eav-item-thumb">${icon}</span>
<span class="eav-item-meta">
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)}</span>
</span>
`;
item.onclick = () => { state.currentIdx = i; state.zoom = 1; renderCurrent(); buildList(); buildToolbar(); };
list.appendChild(item);
});
}
function renderCurrent() {
const stage = overlay.querySelector('[data-role="stage"]');
stage.innerHTML = "";
stage.className = "eav-canvas-wrap";
const cur = state.items[state.currentIdx];
if (!cur) {
stage.innerHTML = `<div class="eav-fallback">첨부가 없습니다.</div>`;
return;
}
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
if (IMG_EXTS.has(ext)) return renderImage(stage, cur);
if (SHEET_EXTS.has(ext)) return renderSheet(stage, cur, ext);
return renderFallback(stage, cur);
}
function renderImage(stage, cur) {
stage.classList.add("eav-pan");
const img = document.createElement("img");
img.className = "eav-img";
img.alt = cur.filename;
img.src = `/expense/api/attachments/${cur.id}`;
img.draggable = false;
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 applyZoom() {
if (!state.image) return;
const stage = overlay.querySelector('[data-role="stage"]');
const img = state.image;
if (state.zoom === "fit") {
img.style.maxWidth = "100%";
img.style.maxHeight = "calc(100vh - 200px)";
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 setZoom(z) {
state.zoom = z;
applyZoom();
}
async function renderSheet(stage, cur, ext) {
stage.classList.remove("eav-pan");
stage.innerHTML = `<div class="eav-sheet-loading">시트 로드 중…</div>`;
try {
const XLSX = await loadXlsxLib();
const res = await fetch(`/expense/api/attachments/${cur.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
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>`;
}
}
function renderFallback(stage, cur) {
stage.classList.remove("eav-pan");
stage.innerHTML = `
<div class="eav-fallback">
미리보기를 지원하지 않는 파일입니다.<br/>
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
</div>
`;
}
function 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 escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
function open(items, opts) {
ensureOverlay();
state.items = items || [];
state.currentIdx = state.items.length ? 0 : -1;
state.zoom = 1;
state.title = (opts && opts.title) || "첨부 보기";
overlay.querySelector('[data-role="title"]').textContent = state.title;
overlay.dataset.open = "true";
buildList();
buildToolbar();
renderCurrent();
}
function close() {
if (!overlay) return;
overlay.dataset.open = "false";
state.items = [];
state.currentIdx = -1;
state.image = null;
}
async function openFor(itemId, opts) {
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 || !attachments.length) {
alert("첨부 파일이 없습니다.");
return;
}
open(attachments, opts);
} catch (err) {
alert(`첨부 로드 실패: ${err.message || err}`);
}
}
window.ErpAttachViewer = { open, openFor, close };
})();