feat(expense): 첨부 뷰어 모달 — 이미지 미리보기 + 펜 주석

승인 대기 페이지의 "첨부" 텍스트 링크를 클립 아이콘 버튼으로 교체.
클릭 시 모달 팝업으로 이미지 미리보기 + 마우스 펜 주석 + 다운로드.

뷰어 컴포넌트 (전역, 다른 페이지에서도 재사용 가능):
- 좌측: 첨부 목록 (썸네일/메타)
- 우측: 캔버스 스택 (img + 투명 canvas)
- 도구: 펜/지우개, 색상 4종, 굵기 3종, 전체 지우기, 합성 PNG 저장, 원본 열기
- 이미지 외 파일은 다운로드 링크만 표시
- ESC/배경 클릭으로 닫기, pointer events 지원

전역 노출: window.ErpAttachViewer.{open, openFor, close}
This commit is contained in:
2026-05-29 03:40:03 +09:00
parent d4af2dc064
commit 151de492ad
4 changed files with 450 additions and 1 deletions
@@ -40,7 +40,13 @@
{{ "{:,}".format(it.amount) }} 원
</td>
<td style="text-align: right;">
<a class="erp-btn erp-btn-ghost erp-btn-sm" href="/expense/api/items/{{ it.id }}/attachments" target="_blank">첨부</a>
<button class="erp-btn erp-btn-outline erp-btn-sm js-attach" title="첨부 보기"
aria-label="첨부 보기">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
<button class="erp-btn erp-btn-primary erp-btn-sm js-approve">승인</button>
</td>
@@ -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" });
+116
View File
@@ -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; }
}
+320
View File
@@ -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 = `
<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 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 = `
<span class="eav-item-thumb">${
isImg
? `<img src="/expense/api/attachments/${a.id}" alt="" />`
: "📄"
}</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; 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 = `<div class="eav-fallback">첨부가 없습니다.</div>`;
return;
}
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
if (!IMG_EXTS.has(ext)) {
stage.innerHTML = `
<div class="eav-fallback">
이미지 파일이 아닙니다.<br/>
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
</div>
`;
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 = `<div class="eav-fallback">이미지 로드 실패</div>`;
};
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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 };
})();
+2
View File
@@ -6,6 +6,7 @@
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
<link rel="stylesheet" href="/static/erp.css" />
<link rel="stylesheet" href="/static/erp-shell.css" />
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
{% block head_extra %}{% endblock %}
</head>
<body class="erp-body erp-app-body">
@@ -159,6 +160,7 @@
})();
</script>
<script src="/static/erp-attach-viewer.js" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>