// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 편집 화면 — 셀 고르기(Shift 로 범위)·수식줄 편집·다시 계산(btnReCalc), // 셀 합(우·상·좌·하 btnSum*), 수량·품명·규격·단위로 지정(popSetMullang·popSetName·popSetSize·popSetUnite)·지정 해제(popSetFree), // 잘라내기·복사·붙여넣기·내용지우기, 행·열 삽입/삭제, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선(native-excel-edit.cjs). // 수량 지정 = 셀 ↔ 항목 연결(sheet.excelLinks) — 항목 수량이 그 셀 값(산출서 자리수)을 따른다. // 품명·규격·단위 지정은 그때 셀 글자를 항목에 적는다. 원본 파일 속 셀↔항목 대응은 찾지 못해, 가져올 때 값이 같은 셀 // 하나(합계 수식·아래쪽 우선)를 「추정」 연결로 둔다. 편집한 엑셀형 산출서는 .xls 로 다시 쓰지 못하므로 ESTX 저장을 막는다. // 되돌리기(actUndo)는 앱 전체 취소(↶)를 쓴다. const EstExcelView = (() => { const L = (n) => EstExcelSheet.letters(n); const DIAG = { left: "linear-gradient(to bottom right,transparent 49%,#555 50%,transparent 51%)", right: "linear-gradient(to top right,transparent 49%,#555 50%,transparent 51%)", }; const range = (sel) => ({ r1: Math.min(sel.row, sel.row2 ?? sel.row), r2: Math.max(sel.row, sel.row2 ?? sel.row), c1: Math.min(sel.col, sel.col2 ?? sel.col), c2: Math.max(sel.col, sel.col2 ?? sel.col), }); const btn = (action, label, extra = "") => ``; function toolbar(sheet) { const items = (sheet.items || []) .map( (i) => ``, ) .join(""); return [ btn("excel-recalc", "다시 계산"), btn("excel-print", "미리보기·인쇄"), btn("excel-cut", "잘라내기"), btn("excel-copy", "복사"), btn("excel-paste", "붙여넣기"), btn("excel-clear", "내용지우기"), btn("excel-insert", "행 삽입", 'data-axis="row"'), btn("excel-insert", "열 삽입", 'data-axis="col"'), btn("excel-delete", "행 삭제", 'data-axis="row"'), btn("excel-delete", "열 삭제", 'data-axis="col"'), btn("excel-style", "B", 'data-name="b" title="굵게"'), btn("excel-style", "I", 'data-name="i" title="기울임"'), btn("excel-style", "U", 'data-name="u" title="밑줄"'), btn("excel-style", "왼쪽", 'data-name="align" data-value="left"'), btn("excel-style", "가운데", 'data-name="align" data-value="center"'), btn("excel-style", "오른쪽", 'data-name="align" data-value="right"'), btn("excel-merge", "병합"), btn("excel-split", "분리"), ``, btn("excel-style", "왼쪽대각선", 'data-name="diag" data-value="left"'), btn("excel-style", "오른쪽대각선", 'data-name="diag" data-value="right"'), btn("excel-style", "대각선 삭제", 'data-name="diag" data-value=""'), btn("excel-sum", "우측 합", 'data-dir="right"'), btn("excel-sum", "위 합", 'data-dir="up"'), btn("excel-sum", "좌측 합", 'data-dir="left"'), btn("excel-sum", "아래 합", 'data-dir="down"'), ``, btn("excel-link", "수량으로 지정"), btn("excel-field", "품명으로 지정", 'data-field="Name"'), btn("excel-field", "규격으로 지정", 'data-field="Size"'), btn("excel-field", "단위로 지정", 'data-field="Unite"'), btn("excel-unlink", "지정 해제"), ].join(""); } function render(sheet, state) { if (!sheet?.excel?.length) return ""; const { esc, number } = EstApp, sel = state.excelCell?.sheet === sheet.Code ? state.excelCell : null, links = sheet.excelLinks || {}; const linked = new Map( Object.entries(links).map(([code, l]) => [`${l.n},${l.row},${l.col}`, { code, ...l }]), ); return sheet.excel .map(({ name, cells, merges = [] }, n) => { const keys = Object.keys(cells).map((k) => k.split(",").map(Number)); const rows = Math.max(20, ...keys.map(([r]) => r + 2), ...merges.map((m) => m.r2 + 2)), cols = Math.min( 40, Math.max(8, ...keys.map(([, c]) => c + 2), ...merges.map((m) => m.c2 + 2)), ); const here = sel && sel.n === n, box = here ? range(sel) : null; const raw = here ? cells[`${sel.row},${sel.col}`] : null; const bar = here ? `${L(sel.col)}${sel.row + 1}${box.r2 > box.r1 || box.c2 > box.c1 ? `:${L(box.c2)}${box.r2 + 1}` : ""}
${toolbar(sheet)}` : "셀을 누르면 수식줄에서 고칩니다. Shift 로 범위를 고릅니다."; const covered = new Set(), spans = new Map(); for (const m of merges) { spans.set(`${m.r1},${m.c1}`, m); for (let r = m.r1; r <= m.r2; r++) for (let c = m.c1; c <= m.c2; c++) if (r !== m.r1 || c !== m.c1) covered.add(`${r},${c}`); } const body = Array.from({ length: rows }, (_, r) => { const tds = Array.from({ length: cols }, (_, c) => { const k = `${r},${c}`; if (covered.has(k)) return ""; const cell = cells[k], link = linked.get(`${n},${r},${c}`), m = spans.get(k), chosen = box && r >= box.r1 && r <= box.r2 && c >= box.c1 && c <= box.c2, s = cell?.style || {}; const value = cell == null ? "" : typeof cell.value === "number" ? s.digits != null && s.digits !== "" ? cell.value.toLocaleString("ko-KR", { minimumFractionDigits: Number(s.digits), maximumFractionDigits: Number(s.digits), }) : number(cell.value) : esc(cell.value); const style = [ cell?.formula || s.b ? "font-weight:bold" : "", s.i ? "font-style:italic" : "", s.u ? "text-decoration:underline" : "", s.align ? `text-align:${s.align}` : "", s.diag ? `background-image:${DIAG[s.diag]}` : "", link ? "background-color:#e6f4ea" : chosen ? "background-color:#e8f0fb" : "", here && sel.row === r && sel.col === c ? "outline:2px solid #316ea5" : "", ] .filter(Boolean) .join(";"); const title = [ typeof cell?.formula === "string" ? `=${cell.formula}` : "", link ? `수량: ${link.code}${link.inferred ? " (추정 연결)" : ""}` : "", ] .filter(Boolean) .join(" · "); const span = m ? `${m.r2 > m.r1 ? ` rowspan="${m.r2 - m.r1 + 1}"` : ""}${m.c2 > m.c1 ? ` colspan="${m.c2 - m.c1 + 1}"` : ""}` : ""; return `${value}`; }).join(""); return `${r + 1}${tds}`; }).join(""); return `

엑셀형 산출서 · ${esc(name)}

${bar}
${Array.from({ length: cols }, (_, c) => ``).join("")}${body}
${L(c)}
`; }) .join(""); } function sheetOf(state) { return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); } function setCell(sheet, n, row, col, text) { const cells = sheet.excel[n].cells, k = `${row},${col}`, t = String(text ?? ""), style = cells[k]?.style; if (t === "" && !style) delete cells[k]; else if (t.startsWith("=")) cells[k] = { value: 0, formula: t.slice(1) }; else { const v = Number(t.replaceAll(",", "")); cells[k] = { value: t.trim() !== "" && Number.isFinite(v) ? v : t }; } if (style && cells[k]) cells[k].style = style; sheet.excelEdited = true; return EstExcelSheet.recalc(cells); } function linksOf(sheet, n) { const out = {}; for (const [code, l] of Object.entries(sheet.excelLinks || {})) if (l.n === n) out[code] = l; return out; } function install() { document.addEventListener("click", (event) => { const td = event.target.closest("[data-excel-pick]"); if (!td) return; const [n, row, col] = td.dataset.excelPick.split(",").map(Number), state = EstApp.state, old = state.excelCell; state.excelCell = event.shiftKey && old && old.sheet === state.quantitySheet && old.n === n ? { ...old, row2: row, col2: col } : { sheet: state.quantitySheet, n, row, col }; EstApp.render(); }); document.addEventListener("change", (event) => { if (event.target.dataset.excelDigits !== undefined) { const v = event.target.value, state = EstApp.state, sheet = sheetOf(state), sel = state.excelCell; if (!v || !sheet?.excel || !sel) return; EstApp.change(() => { EstExcelEdit.style(sheet.excel[sel.n], range(sel), "digits", v === "-" ? "" : v); sheet.excelEdited = true; }, false); return; } if (event.target.dataset.excelBar === undefined) return; const state = EstApp.state, sel = state.excelCell, value = event.target.value; let errors = []; EstApp.change(() => { errors = setCell(sheetOf(state), sel.n, sel.row, sel.col, value); }, false); if (errors.length) state.notice = errors.slice(0, 3).join(" / "); }); document.addEventListener("keydown", (event) => { const state = EstApp.state; if (!state.excelCell || event.target.closest?.("input,textarea,select")) return; const keyAction = event.ctrlKey ? { c: "excel-copy", x: "excel-cut", v: "excel-paste" }[event.key.toLowerCase()] : event.key === "Delete" ? "excel-clear" : null; if (!keyAction || !sheetOf(state)?.excel) return; event.preventDefault(); try { handle(keyAction, state, { dataset: {} }); } catch (error) { state.notice = error.message; EstApp.render(); } }); } const names = new Set([ "excel-sum", "excel-link", "excel-unlink", "excel-field", "excel-recalc", "excel-cut", "excel-copy", "excel-paste", "excel-clear", "excel-insert", "excel-delete", "excel-style", "excel-merge", "excel-split", "excel-print", ]); function handle(name, state, node) { if (!names.has(name)) return false; const sheet = sheetOf(state), sel = state.excelCell; if (!sheet?.excel || !sel) throw Error("엑셀형 산출서에서 셀을 고르세요."); const page = sheet.excel[sel.n], box = range(sel), edit = (fn) => EstApp.change(() => { const errors = fn() || []; sheet.excelEdited = true; if (errors.length) state.notice = errors.slice(0, 3).join(" / "); }, false); if (name === "excel-sum") { const formula = EstExcelSheet.sumFormula(page.cells, sel.row, sel.col, node.dataset.dir); EstApp.change(() => setCell(sheet, sel.n, sel.row, sel.col, `=${formula}`), false); } else if (name === "excel-recalc") edit(() => EstExcelSheet.recalc(page.cells)); else if (name === "excel-copy" || name === "excel-cut") { state.excelClip = EstExcelEdit.copyRange(page, box); if (name === "excel-cut") edit(() => EstExcelEdit.clearRange(page, box)); else state.notice = `${state.excelClip.rows}×${state.excelClip.cols} 칸을 복사했습니다.`; } else if (name === "excel-paste") { if (!state.excelClip) throw Error("복사한 셀이 없습니다."); edit(() => EstExcelEdit.pasteRange(page, state.excelClip, box.r1, box.c1)); } else if (name === "excel-clear") edit(() => EstExcelEdit.clearRange(page, box, true)); else if (name === "excel-insert" || name === "excel-delete") { const axis = node.dataset.axis, at = axis === "row" ? box.r1 : box.c1, count = axis === "row" ? box.r2 - box.r1 + 1 : box.c2 - box.c1 + 1; if ( name === "excel-delete" && !confirm(`${axis === "row" ? "행" : "열"} ${count}개를 삭제하시겠습니까?`) ) return true; edit(() => EstExcelEdit.shift( page, axis, at, name === "excel-insert" ? count : -count, linksOf(sheet, sel.n), ), ); state.excelCell = { sheet: sel.sheet, n: sel.n, row: box.r1, col: box.c1 }; } else if (name === "excel-style") edit(() => EstExcelEdit.style(page, box, node.dataset.name, node.dataset.value)); else if (name === "excel-merge") edit(() => EstExcelEdit.merge(page, box)); else if (name === "excel-split") edit(() => EstExcelEdit.split(page, box)); else if (name === "excel-print") { // 미리보기·인쇄(tbsPreView·tbsPrint): 고른 시트 격자만 새 창에 const html = render({ ...sheet, excel: [page], excelLinks: {} }, { excelCell: null }) .replace(/
[\s\S]*?<\/div>/, "") .replace(/ data-excel-pick="[^"]*"/g, ""); const win = open("", "_blank"); if (!win) throw Error("새 창을 열 수 없습니다. 팝업 차단을 확인하세요."); win.document.write( `${EstApp.esc(sheet.Name || page.name)}${html}