// ────────────────────────────────────────────────────────── // 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 = ` `; 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 ? `` : isSheet ? "📊" : "📄"; item.innerHTML = ` ${icon} ${escapeHtml(a.filename)} ${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)} `; 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 = `
첨부가 없습니다.
`; 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 = `
이미지 로드 실패
`; }; 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 = `
시트 로드 중…
`; 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 = `
시트 로드 실패: ${escapeHtml(err.message || String(err))}
원본 다운로드
`; } } function renderFallback(stage, cur) { stage.classList.remove("eav-pan"); stage.innerHTML = `
미리보기를 지원하지 않는 파일입니다.
다운로드 / 새 창 열기
`; } 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 }; })();