diff --git a/app/modules/expense/templates/expense/pending.html b/app/modules/expense/templates/expense/pending.html
index 1b986c5..9e21026 100644
--- a/app/modules/expense/templates/expense/pending.html
+++ b/app/modules/expense/templates/expense/pending.html
@@ -40,7 +40,13 @@
{{ "{:,}".format(it.amount) }} 원
- 첨부
+
|
@@ -68,6 +74,11 @@ document.getElementById("pend-tbody").addEventListener("click", async (e) => {
const tr = btn.closest("tr[data-id]");
const id = tr.dataset.id;
+ if (btn.classList.contains("js-attach")) {
+ const owner = tr.children[1]?.textContent?.trim() || "";
+ window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
+ return;
+ }
if (btn.classList.contains("js-approve")) {
if (!confirm("승인할까요?")) return;
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
diff --git a/app/static/erp-attach-viewer.css b/app/static/erp-attach-viewer.css
new file mode 100644
index 0000000..430363f
--- /dev/null
+++ b/app/static/erp-attach-viewer.css
@@ -0,0 +1,116 @@
+/* ════════════════════════════════════════════════════
+ 첨부 뷰어 모달 — 이미지 미리보기 + 펜 주석
+ ════════════════════════════════════════════════════ */
+
+.eav-overlay {
+ position: fixed; inset: 0; z-index: 1000;
+ background: rgba(0, 0, 0, 0.62);
+ display: none;
+ align-items: stretch; justify-content: center;
+ padding: 24px;
+}
+.eav-overlay[data-open="true"] { display: flex; }
+
+.eav-modal {
+ background: var(--color-canvas-white, #fff);
+ width: 100%;
+ max-width: 1180px;
+ border-radius: 14px;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ overflow: hidden;
+ box-shadow: 0 20px 60px rgba(0,0,0,0.35);
+}
+
+.eav-head {
+ 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; }
+.eav-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.eav-btn {
+ border: 1px solid var(--color-subtle-ash, #e5e5e5);
+ 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;
+}
+.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;
+ border: 0; background: transparent; cursor: pointer; font-size: 18px;
+}
+.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
+
+.eav-body {
+ display: grid; grid-template-columns: 200px 1fr; min-height: 0; height: 100%;
+ background: #1a1a1a;
+}
+.eav-list {
+ background: #fff; border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
+ overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px;
+}
+.eav-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 8px; border-radius: 8px; cursor: pointer;
+ border: 1px solid transparent; background: transparent;
+ text-align: left; font-family: inherit; font-size: 12px;
+}
+.eav-item:hover { background: var(--color-ghost-gray, #f2f2f2); }
+.eav-item.is-active { background: var(--color-ghost-gray, #f2f2f2); border-color: var(--color-subtle-ash, #e5e5e5); }
+.eav-item-thumb {
+ 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);
+ 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); }
+
+.eav-canvas-wrap {
+ position: relative;
+ overflow: auto;
+ display: flex; align-items: center; justify-content: center;
+ padding: 16px;
+}
+.eav-canvas-stack {
+ position: relative;
+ line-height: 0;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
+ background: #fff;
+}
+.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-fallback {
+ color: #fff; text-align: center; padding: 40px;
+}
+.eav-fallback a {
+ display: inline-block; margin-top: 12px;
+ background: #fff; color: #000; padding: 8px 16px; border-radius: 8px;
+}
+
+@media (max-width: 720px) {
+ .eav-body { grid-template-columns: 1fr; grid-template-rows: 80px 1fr; }
+ .eav-list { flex-direction: row; overflow-x: auto; }
+ .eav-item { flex-shrink: 0; min-width: 160px; }
+}
diff --git a/app/static/erp-attach-viewer.js b/app/static/erp-attach-viewer.js
new file mode 100644
index 0000000..d872978
--- /dev/null
+++ b/app/static/erp-attach-viewer.js
@@ -0,0 +1,320 @@
+// ──────────────────────────────────────────────────────────
+// ErpAttachViewer — 첨부 모달
+// 사용:
+// ErpAttachViewer.openFor(itemId, { title })
+// 동작:
+// - GET /expense/api/items/{itemId}/attachments
+// - 이미지: 캔버스 위 펜 주석, 다운로드(원본+주석 합성)
+// - 비이미지: 새 창 다운로드 링크
+// ──────────────────────────────────────────────────────────
+
+(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];
+
+ let overlay = null;
+ let state = {
+ items: [],
+ currentIdx: -1,
+ tool: "pen", // pen | erase
+ color: COLORS[0],
+ size: SIZES[1],
+ drawing: false,
+ last: null,
+ title: "",
+ };
+
+ 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 buildToolbar() {
+ const tb = overlay.querySelector('[data-role="toolbar"]');
+ tb.innerHTML = "";
+
+ 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);
+ });
+
+ 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");
+ dl.className = "eav-btn";
+ dl.textContent = "💾 저장";
+ dl.onclick = downloadComposite;
+ 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);
+ }
+
+ 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);
+ item.innerHTML = `
+ ${
+ isImg
+ ? `
`
+ : "📄"
+ }
+
+ ${escapeHtml(a.filename)}
+ ${a.kind === "receipt" ? "영수증" : "기타"} · ${fmtSize(a.size_bytes)}
+
+ `;
+ item.onclick = () => { state.currentIdx = i; renderCurrent(); buildList(); buildToolbar(); };
+ list.appendChild(item);
+ });
+ }
+
+ function renderCurrent() {
+ const stage = overlay.querySelector('[data-role="stage"]');
+ stage.innerHTML = "";
+ 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();
+ 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.src = `/expense/api/attachments/${cur.id}`;
+ wrap.appendChild(img);
+ stage.appendChild(wrap);
+ }
+
+ 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 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);
+ }
+
+ 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");
+ try {
+ octx.drawImage(state.image, 0, 0);
+ } catch (e) {
+ alert("이미지 합성 실패 (CORS): 원본만 다운로드하세요.");
+ return;
+ }
+ 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 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.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.canvas = null;
+ 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 };
+})();
diff --git a/app/templates/erp_base.html b/app/templates/erp_base.html
index f7e39cd..8db964d 100644
--- a/app/templates/erp_base.html
+++ b/app/templates/erp_base.html
@@ -6,6 +6,7 @@
{{ page_title or "ERP" }} — DBX Corporation
+
{% block head_extra %}{% endblock %}
@@ -159,6 +160,7 @@
})();
+
{% block scripts %}{% endblock %}