diff --git a/app/modules/expense/templates/expense/index.html b/app/modules/expense/templates/expense/index.html index 18ce68a..829f50e 100644 --- a/app/modules/expense/templates/expense/index.html +++ b/app/modules/expense/templates/expense/index.html @@ -78,24 +78,39 @@

경비 내역

- {{ items | length }}건 +
+ {{ items | length }}건 + {% if supports_workflow %} + + {% endif %} +
+ - + {% for it in items %} + @@ -122,42 +137,24 @@ - {% if supports_workflow %} - - - - {% endif %} {% else %} - + {% endfor %}
+ + 사용일 분류 수단 가맹점 금액 상태동작동작
+ {% if it.status in ('작성중', '반려') and supports_workflow %} + + {% endif %} + {{ it.spent_at }} {{ it.category }} {{ it.method }} {% if it.status in ('작성중', '반려') and supports_workflow %} -
-
-
    -
  • 불러오는 중…
  • -
- {% if it.approver_email %} -
- 결재: {{ it.approver_email }} · {{ (it.decided_at or '')[:16].replace('T',' ') }} -
- {% endif %} -
-
등록된 경비가 없습니다.
등록된 경비가 없습니다.
@@ -170,22 +167,9 @@ .erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap; } - /* detail 행은 메인 행 바로 아래 — 위쪽 보더만 살짝, 좌측 들여쓰기 */ - tr.ex-detail > td { padding: 6px var(--sp-16) var(--sp-12) 110px; background: var(--color-canvas-white); } - tr.ex-detail + tr[data-id] > td { border-top: 1px solid var(--color-subtle-ash); } - .ex-detail-box { background: transparent; padding: 0; } - .ex-attach-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } - .ex-attach-list li { - display: flex; align-items: center; gap: 8px; padding: 6px 8px; - background: var(--color-canvas-white); border: 1px solid var(--color-subtle-ash); border-radius: 6px; - font-size: 13px; - } - .ex-attach-list li .ex-kind { - padding: 1px 6px; border-radius: 8px; font-size: 11px; - background: var(--color-ghost-gray); color: var(--color-midtone-gray); - } - .ex-attach-list li .ex-name { flex: 1; } - .ex-attach-list li .ex-size { color: var(--color-midtone-gray); font-size: 12px; } + #ex-tbody td { vertical-align: middle; } + #ex-tbody .js-actions { white-space: nowrap; } + #ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; } {% endblock %} @@ -197,16 +181,9 @@ const submitBtn = document.getElementById("ex-submit"); const resetBtn = document.getElementById("ex-reset"); const tbody = document.getElementById("ex-tbody"); - const countEl = document.getElementById("ex-count"); - const totalEl = document.getElementById("ex-total-amount"); - - const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`; - const 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`; - }; + const selectAll = document.getElementById("ex-select-all"); + const bulkBtn = document.getElementById("ex-bulk-submit"); + const bulkCountEl = document.getElementById("ex-bulk-count"); function setEditing(id, data) { form.id.value = id || ""; @@ -250,52 +227,59 @@ } }); - async function postAction(id, action, body) { - const res = await fetch(`/expense/api/items/${id}/${action}`, { - method: "POST", - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - if (!res.ok) throw new Error((await res.json()).detail || res.status); - return res.json(); - } - - async function loadAttachments(itemId) { - const ul = tbody.querySelector(`.ex-attach-list[data-list="${itemId}"]`); - if (!ul) return; - ul.innerHTML = `
  • 불러오는 중…
  • `; - 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.length) { - ul.innerHTML = `
  • 첨부 없음
  • `; - return; - } - ul.innerHTML = ""; - attachments.forEach((a) => { - const li = document.createElement("li"); - li.innerHTML = ` - ${a.kind === 'receipt' ? '영수증' : '기타'} - ${a.filename} - ${fmtSize(a.size_bytes)} - - `; - ul.appendChild(li); - }); - } catch (err) { - ul.innerHTML = `
  • 로드 실패: ${err.message || err}
  • `; + // 체크박스 상태 갱신 + function refreshSelection() { + if (!bulkBtn) return; + const cbs = tbody.querySelectorAll("input.js-select"); + const checked = Array.from(cbs).filter((cb) => cb.checked); + bulkBtn.disabled = checked.length === 0; + if (bulkCountEl) bulkCountEl.textContent = checked.length; + if (selectAll && cbs.length > 0) { + selectAll.checked = checked.length === cbs.length; + selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length; } } + if (selectAll) { + selectAll.addEventListener("change", () => { + tbody.querySelectorAll("input.js-select").forEach((cb) => { + cb.checked = selectAll.checked; + }); + refreshSelection(); + }); + } + + tbody.addEventListener("change", (e) => { + if (e.target.classList.contains("js-select")) refreshSelection(); + }); + + if (bulkBtn) { + bulkBtn.addEventListener("click", async () => { + const ids = Array.from(tbody.querySelectorAll("input.js-select:checked")) + .map((cb) => cb.closest("tr").dataset.id); + if (!ids.length) return; + if (!confirm(`${ids.length}건을 결재 제출할까요? 제출 후에는 수정 불가.`)) return; + bulkBtn.disabled = true; + bulkBtn.textContent = "제출 중…"; + const fails = []; + for (const id of ids) { + try { + const res = await fetch(`/expense/api/items/${id}/submit`, { method: "POST" }); + if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`); + } catch (err) { fails.push(`${id}: ${err.message || err}`); } + } + if (fails.length) alert("일부 실패:\n" + fails.join("\n")); + location.reload(); + }); + } + + // 행 동작 (수정/삭제/취소/첨부보기 + 업로드) tbody.addEventListener("click", async (e) => { const btn = e.target.closest("button"); if (!btn) return; - // 메인 행 또는 detail 행 모두 처리 - const mainTr = btn.closest("tr[data-id]"); - const detailTr = btn.closest("tr.ex-detail"); - const id = mainTr ? mainTr.dataset.id : (detailTr ? detailTr.dataset.detailFor : null); - if (!id) return; + const tr = btn.closest("tr[data-id]"); + if (!tr) return; + const id = tr.dataset.id; if (btn.classList.contains("js-edit")) { const res = await fetch(`/expense/api/items`); @@ -308,29 +292,21 @@ if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return; const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" }); if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패"); - } else if (btn.classList.contains("js-submit")) { - if (!confirm("결재 제출할까요? 제출 후에는 수정할 수 없습니다.")) return; - try { await postAction(id, "submit"); location.reload(); } - catch (err) { alert(`제출 실패: ${err.message || err}`); } } else if (btn.classList.contains("js-revert")) { if (!confirm("작성중 상태로 되돌릴까요?")) return; - try { await postAction(id, "revert"); location.reload(); } - catch (err) { alert(`취소 실패: ${err.message || err}`); } - } else if (btn.classList.contains("js-del-att")) { - if (!confirm("첨부를 삭제할까요?")) return; - const attId = btn.dataset.att; - const res = await fetch(`/expense/api/attachments/${attId}`, { method: "DELETE" }); - if (res.ok) loadAttachments(id); else alert((await res.json()).detail || "삭제 실패"); + const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" }); + if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패"); + } else if (btn.classList.contains("js-view-att")) { + window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` }); } }); - // 첨부 업로드 (메인 행 동작셀 input) + // 첨부 업로드 tbody.addEventListener("change", async (e) => { const input = e.target.closest("input.js-upload"); if (!input || !input.files.length) return; - const mainTr = input.closest("tr[data-id]"); - if (!mainTr) return; - const itemId = mainTr.dataset.id; + const tr = input.closest("tr[data-id]"); + const itemId = tr.dataset.id; const fd = new FormData(); fd.append("file", input.files[0]); fd.append("kind", input.dataset.kind || "other"); @@ -340,19 +316,12 @@ }); if (!res.ok) throw new Error((await res.json()).detail || res.status); input.value = ""; - loadAttachments(itemId); + alert("첨부 업로드 완료"); } catch (err) { alert(`업로드 실패: ${err.message || err}`); } }); - // 페이지 로드 시 모든 detail 행의 첨부 자동 로드 - if (supportsWorkflow) { - tbody.querySelectorAll("tr.ex-detail[data-detail-for]").forEach((tr) => { - loadAttachments(tr.dataset.detailFor); - }); - } - if (!form.spent_at.value) { const d = new Date(); form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; diff --git a/app/modules/expense/templates/expense/pending.html b/app/modules/expense/templates/expense/pending.html index 9e21026..4af8f80 100644 --- a/app/modules/expense/templates/expense/pending.html +++ b/app/modules/expense/templates/expense/pending.html @@ -11,24 +11,35 @@

    승인 대기 ({{ items | length }}건)

    - 제출 상태 항목 — 승인/반려 처리 +
    + 제출 상태 항목 — 일괄 승인 / 항목별 반려 + +
    + - + {% for it in items %} + @@ -40,19 +51,17 @@ {{ "{:,}".format(it.amount) }} 원 {% else %} - + {% endfor %}
    + + 사용일 소유자 분류 가맹점/메모 금액결재동작
    + + {{ it.spent_at }} {{ it.owner }} {{ it.category }} - -
    대기 항목이 없습니다.
    대기 항목이 없습니다.
    @@ -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); }