From 151de492adc56fc08b06f4892bba49cac0e87fb5 Mon Sep 17 00:00:00 2001 From: king Date: Fri, 29 May 2026 03:40:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(expense):=20=EC=B2=A8=EB=B6=80=20=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20+=20?= =?UTF-8?q?=ED=8E=9C=20=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인 대기 페이지의 "첨부" 텍스트 링크를 클립 아이콘 버튼으로 교체. 클릭 시 모달 팝업으로 이미지 미리보기 + 마우스 펜 주석 + 다운로드. 뷰어 컴포넌트 (전역, 다른 페이지에서도 재사용 가능): - 좌측: 첨부 목록 (썸네일/메타) - 우측: 캔버스 스택 (img + 투명 canvas) - 도구: 펜/지우개, 색상 4종, 굵기 3종, 전체 지우기, 합성 PNG 저장, 원본 열기 - 이미지 외 파일은 다운로드 링크만 표시 - ESC/배경 클릭으로 닫기, pointer events 지원 전역 노출: window.ErpAttachViewer.{open, openFor, close} --- .../expense/templates/expense/pending.html | 13 +- app/static/erp-attach-viewer.css | 116 +++++++ app/static/erp-attach-viewer.js | 320 ++++++++++++++++++ app/templates/erp_base.html | 2 + 4 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 app/static/erp-attach-viewer.css create mode 100644 app/static/erp-attach-viewer.js 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 %}