@@ -63,38 +72,81 @@
{% endblock %}
{% block scripts %}
{% endblock %}
diff --git a/app/static/erp-attach-viewer.css b/app/static/erp-attach-viewer.css
index 430363f..73d3cb0 100644
--- a/app/static/erp-attach-viewer.css
+++ b/app/static/erp-attach-viewer.css
@@ -1,5 +1,5 @@
/* ════════════════════════════════════════════════════
- 첨부 뷰어 모달 — 이미지 미리보기 + 펜 주석
+ 첨부 뷰어 모달 — 이미지 패닝 + 엑셀 시트
════════════════════════════════════════════════════ */
.eav-overlay {
@@ -14,7 +14,7 @@
.eav-modal {
background: var(--color-canvas-white, #fff);
width: 100%;
- max-width: 1180px;
+ max-width: 1280px;
border-radius: 14px;
display: grid;
grid-template-rows: auto 1fr;
@@ -26,7 +26,6 @@
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
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;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -36,16 +35,10 @@
background: var(--color-canvas-white, #fff);
color: var(--color-rich-black, #0a0a0a);
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.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 {
width: 28px; height: 28px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
@@ -54,7 +47,7 @@
.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
.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;
}
.eav-list {
@@ -73,33 +66,53 @@
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
background: var(--color-ghost-gray, #f2f2f2);
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;
}
.eav-item-thumb img { width: 100%; height: 100%; object-fit: cover; }
.eav-item-meta { flex: 1; min-width: 0; }
.eav-item-name { font-weight: 500; color: var(--color-rich-black, #0a0a0a);
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); }
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
+.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); display: block; }
+/* 이미지 패닝 스테이지 */
.eav-canvas-wrap {
position: relative;
overflow: auto;
- display: flex; align-items: center; justify-content: center;
- padding: 16px;
+ padding: 0;
+ background: #2a2a2a;
}
-.eav-canvas-stack {
- position: relative;
- line-height: 0;
- box-shadow: 0 4px 20px rgba(0,0,0,0.4);
+.eav-canvas-wrap.eav-pan { cursor: grab; }
+.eav-canvas-wrap.eav-pan.is-panning { cursor: grabbing; }
+.eav-img { display: block; user-select: none; -webkit-user-drag: none; margin: 0 auto; }
+
+/* 엑셀/시트 */
+.eav-sheet-wrap {
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-canvas {
- position: absolute; left: 0; top: 0;
- cursor: crosshair;
- touch-action: none;
+.eav-sheet-tabs {
+ display: flex; gap: 4px; padding: 8px;
+ border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
+ background: var(--color-ghost-gray, #f2f2f2);
+ 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 {
color: #fff; text-align: center; padding: 40px;
@@ -110,7 +123,7 @@
}
@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-item { flex-shrink: 0; min-width: 160px; }
+ .eav-item { flex-shrink: 0; min-width: 180px; }
}
diff --git a/app/static/erp-attach-viewer.js b/app/static/erp-attach-viewer.js
index d872978..f8c62d1 100644
--- a/app/static/erp-attach-viewer.js
+++ b/app/static/erp-attach-viewer.js
@@ -1,31 +1,30 @@
// ──────────────────────────────────────────────────────────
// ErpAttachViewer — 첨부 모달
-// 사용:
-// ErpAttachViewer.openFor(itemId, { title })
-// 동작:
-// - GET /expense/api/items/{itemId}/attachments
-// - 이미지: 캔버스 위 펜 주석, 다운로드(원본+주석 합성)
-// - 비이미지: 새 창 다운로드 링크
+// - 이미지: 마우스 드래그로 패닝 (이미지 크면 스크롤). 확대/축소/맞춤.
+// - 엑셀 (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 COLORS = ["#c22b10", "#0a0a0a", "#1d4ed8", "#10c22b"];
- const SIZES = [2, 4, 8];
+ 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,
- tool: "pen", // pen | erase
- color: COLORS[0],
- size: SIZES[1],
- drawing: false,
- last: null,
title: "",
+ zoom: 1, // 이미지 줌 (1 = 100%)
+ // 패닝
+ panning: false,
+ panStartX: 0, panStartY: 0,
+ scrollStartLeft: 0, scrollStartTop: 0,
};
+ let xlsxLoading = null;
function ensureOverlay() {
if (overlay) return overlay;
@@ -46,9 +45,7 @@
`;
document.body.appendChild(overlay);
- overlay.addEventListener("click", (e) => {
- if (e.target === overlay) close();
- });
+ 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();
@@ -56,60 +53,55 @@
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();
- const tools = [
- { key: "pen", label: "✏️ 펜" },
- { key: "erase", label: "🩹 지우개" },
- ];
- tools.forEach((t) => {
- const b = document.createElement("button");
- b.className = "eav-btn" + (state.tool === t.key ? " is-active" : "");
- b.textContent = t.label;
- b.onclick = () => { state.tool = t.key; buildToolbar(); };
- tb.appendChild(b);
- });
+ 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);
+ });
+ }
- COLORS.forEach((c) => {
- 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");
+ const dl = document.createElement("a");
dl.className = "eav-btn";
- dl.textContent = "💾 저장";
- dl.onclick = downloadComposite;
+ dl.textContent = "다운로드";
+ dl.href = `/expense/api/attachments/${cur.id}`;
+ dl.setAttribute("download", cur.filename);
tb.appendChild(dl);
- const original = document.createElement("a");
- original.className = "eav-btn";
- original.textContent = "원본";
- const cur = state.items[state.currentIdx];
- if (cur) {
- original.href = `/expense/api/attachments/${cur.id}`;
- original.target = "_blank";
- }
- tb.appendChild(original);
+ 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() {
@@ -120,18 +112,17 @@
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 = `
-
${
- isImg
- ? `
`
- : "📄"
- }
+
${icon}
${escapeHtml(a.filename)}
- ${a.kind === "receipt" ? "영수증" : "기타"} · ${fmtSize(a.size_bytes)}
+ ${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)}
`;
- item.onclick = () => { state.currentIdx = i; renderCurrent(); buildList(); buildToolbar(); };
+ item.onclick = () => { state.currentIdx = i; state.zoom = 1; renderCurrent(); buildList(); buildToolbar(); };
list.appendChild(item);
});
}
@@ -139,133 +130,133 @@
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)) {
- stage.innerHTML = `
-
- `;
- return;
- }
- const wrap = document.createElement("div");
- wrap.className = "eav-canvas-stack";
- const img = new Image();
+ 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.crossOrigin = "anonymous";
- 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 = `
이미지 로드 실패
`;
- };
+ img.alt = cur.filename;
img.src = `/expense/api/attachments/${cur.id}`;
- wrap.appendChild(img);
- stage.appendChild(wrap);
+ 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 attachDrawing(canvas, img) {
- const ctx = canvas.getContext("2d");
-
- function posToCanvas(ev) {
- const rect = canvas.getBoundingClientRect();
- const x = (ev.clientX - rect.left) * (canvas.width / rect.width);
- const y = (ev.clientY - rect.top) * (canvas.height / rect.height);
- return { x, y };
+ 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 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() {
- if (!state.canvas) return;
- const ctx = state.canvas.getContext("2d");
- ctx.clearRect(0, 0, state.canvas.width, state.canvas.height);
+ function setZoom(z) {
+ state.zoom = z;
+ applyZoom();
}
- async function downloadComposite() {
- if (!state.canvas || !state.image) {
- alert("이미지가 로드되지 않았습니다.");
- 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");
+ async function renderSheet(stage, cur, ext) {
+ stage.classList.remove("eav-pan");
+ stage.innerHTML = `
시트 로드 중…
`;
try {
- octx.drawImage(state.image, 0, 0);
- } catch (e) {
- alert("이미지 합성 실패 (CORS): 원본만 다운로드하세요.");
- return;
+ 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))}
+
원본 다운로드`;
}
- octx.drawImage(state.canvas, 0, 0);
- out.toBlob((blob) => {
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- const base = cur.filename.replace(/\.[^.]+$/, "");
- a.download = `${base}_주석.png`;
- a.click();
- setTimeout(() => URL.revokeObjectURL(url), 1000);
- }, "image/png");
+ }
+
+ function renderFallback(stage, cur) {
+ stage.classList.remove("eav-pan");
+ stage.innerHTML = `
+
+ `;
}
function fmtSize(b) {
@@ -284,6 +275,7 @@
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";
@@ -297,7 +289,6 @@
overlay.dataset.open = "false";
state.items = [];
state.currentIdx = -1;
- state.canvas = null;
state.image = null;
}
diff --git a/app/static/erp-shell.css b/app/static/erp-shell.css
index f606323..cf9177f 100644
--- a/app/static/erp-shell.css
+++ b/app/static/erp-shell.css
@@ -259,7 +259,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
.erp-table tbody td {
padding: 10px var(--sp-16);
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:hover { background: var(--color-ghost-gray); }