6918426264
[첨부 뷰어] - 펜 주석 기능 제거 (요구사항 정정). - 이미지: 마우스 드래그로 패닝, 축소/확대/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 포함 일관).
312 lines
11 KiB
JavaScript
312 lines
11 KiB
JavaScript
// ──────────────────────────────────────────────────────────
|
||
// 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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 };
|
||
})();
|