// ──────────────────────────────────────────────────────────
// 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 };
})();