// 원본 품셈 보기(barbtnBook → ProductBook.exe · 같은 화면 TfrmBookViewer)와 품셈자료 붙여넣기(popupPasteBook 10d6e28). // 보기: 품셈 고르기 → 장 트리(펼치기·접기) → 본문(표는 칸 나눔), 검색(제목 · 제목+본문 · 키워드, 검색어 빈칸·'|' 지움), // 본문 검색(현재 쪽 강조), 복사하기(고른 글 또는 쪽 전체 → 클립보드, 붙여넣기 켬). 자료는 서버 book-store.cjs 가 원본 BookData 를 읽음. // 붙여넣기(원본은 품셈에서 복사한 뒤에만 켜짐): 단가산출은 첫 줄을 마지막 산출근거 칸 커서 자리에, 둘째 줄부터 그 줄 아래 새 줄로(다듬지 않음). // 내역서·일위대가·기계경비는 마지막으로 누른 칸 하나에 마지막 줄(글자 칸은 Trim, 수치 칸은 수치만). const EstBook = (() => { let lastCell = null; if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("focusin", (event) => { const t = event.target.closest?.("[data-table][data-field][data-row]"); if (t) lastCell = t; }); const api = (p) => EstApp.api(`/api/book${p}`); const st = (state) => (state.book ||= { name: "", open: new Set(), hits: null, pkid: null }); function treeHtml(b, nodes) { const { esc } = EstApp, kids = new Map(); for (const n of nodes) { const k = n.parent ?? "root"; if (!kids.has(k)) kids.set(k, []); kids.get(k).push(n); } for (const list of kids.values()) list.sort((a, c) => (a.order == null) - (c.order == null) || (a.order ?? 0) - (c.order ?? 0)); const walk = (key, depth) => (kids.get(key) || []) .map((n) => { const has = kids.has(n.pkid), open = b.open.has(n.pkid); return `
${has ? `` : ""}${esc(n.chapter)} ${esc(n.title)}
${has && open ? walk(n.pkid, depth + 1) : ""}`; }) .join(""); return walk("root", 0); } function pageHtml(page, mark) { const { esc } = EstApp; const hl = (t) => mark ? esc(t) .split(esc(mark)) .join(`${esc(mark)}`) : esc(t); const lines = String(page.text || "").split("\n"); let html = "", table = []; const flush = () => { if (table.length) html += `${table.map((cells) => `${cells.map((c) => ``).join("")}`).join("")}
${hl(c)}
`; table = []; }; for (const line of lines) { if (line.includes("\t")) table.push(line.replace(/\t$/, "").split("\t")); else { flush(); html += `
${hl(line) || " "}
`; } } flush(); return `

${esc(page.chapter)} ${esc(page.title)}

${html}`; } async function render(state) { const b = st(state), { esc } = EstApp; b.books ||= await api(""); b.name ||= b.books[0]?.name; b.tree = b.treeName === b.name ? b.tree : await api(`/${b.name}/tree`); b.treeName = b.name; const left = b.hits ? `

검색 결과 ${b.hits.length}건

${b.hits.map((h) => `
${esc(h.chapter)} ${esc(h.title)}
`).join("")}` : treeHtml(b, b.tree); EstApp.showDialog( "품셈", `
${left}
${b.page ? pageHtml(b.page, b.mark) : "

왼쪽에서 항목을 고르세요.

"}
`, ); } async function run(name, state, node) { const b = st(state), v = (sel) => document.querySelector(`#dialog-content ${sel}`)?.value; if (name === "book") return render(state); if (name === "book-fold") { const id = Number(node.dataset.pkid); if (b.open.has(id)) b.open.delete(id); else b.open.add(id); } else if (name === "book-expand") b.open = new Set(b.tree.map((n) => n.pkid)); else if (name === "book-collapse") b.open = new Set(); else if (name === "book-tree") b.hits = null; else if (name === "book-search") { b.word = v("[data-book-word]") || ""; b.hits = await api( `/${b.name}/search?q=${encodeURIComponent(b.word)}&mode=${node.dataset.mode}`, ); } else if (name === "book-mark") b.mark = (v("[data-book-mark]") || "").trim(); else if (name === "book-copy") { const sel = String(getSelection()).trim(), text = sel || b.page?.text || ""; if (!text) throw Error("복사할 품셈 내용을 고르세요."); state.bookClip = text; navigator.clipboard?.writeText(text).catch(() => {}); state.notice = "품셈 내용을 복사했습니다. 품셈자료 붙여넣기로 넣을 수 있습니다."; } else if (name === "book-paste") return paste(state); return render(state); } function paste(state) { if (!state.bookClip) throw Error("품셈에서 먼저 복사하기를 하세요."); const lines = state.bookClip.split(/\r?\n/); if (!lastCell) throw Error("붙여넣을 칸을 먼저 누르세요."); const table = lastCell.dataset.table, row = Number(lastCell.dataset.row), field = lastCell.dataset.field; if (table === "CostMainDat") { if (field !== "LineData") throw Error("단가산출 산출근거 칸을 누른 뒤 붙여넣으세요."); const caret = lastCell.selectionStart ?? lastCell.value.length, shown = lastCell.value, first = shown.slice(0, caret) + lines[0] + shown.slice(lastCell.selectionEnd ?? caret); EstApp.change((project) => { const rows = project.tables.CostMainDat; rows[row].LineData = EstText.encode(first); let pk = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0)); rows.splice( row + 1, 0, ...lines.slice(1).map((LineData) => ({ PkID: ++pk, Index: 0, LineData })), ); }); } else if (["EstMainDat", "HeadMainDat", "MachMainDat"].includes(table)) { const line = lines.at(-1).trim(); if (["Code", "SCheck"].includes(field)) throw Error("이 칸에는 붙여넣을 수 없습니다."); let value = line; if (EstModel.numeric.has(field)) { if (!/^[+-]?[\d,.]+$/.test(line)) throw Error("현재 칼럼에는 수치만 붙여넣을수 있습니다."); value = Number(line) || 0; } EstApp.change((project) => (project.tables[table][row][field] = value)); } else throw Error("내역서·일위대가·단가산출·기계경비 칸에 붙여넣으세요."); return true; } function install() { document.addEventListener("click", (event) => { const a = event.target.closest("[data-book-node]"); if (!a) return; event.preventDefault(); const state = EstApp.state, b = st(state); b.pkid = Number(a.dataset.bookNode); api(`/${b.name}/text/${b.pkid}`) .then((page) => { b.page = page; return render(state); }) .catch((error) => (state.notice = error.message)); }); document.addEventListener("change", (event) => { if (event.target.dataset.bookName === undefined) return; const b = st(EstApp.state); Object.assign(b, { name: event.target.value, open: new Set(), hits: null, page: null }); render(EstApp.state).catch((error) => (EstApp.state.notice = error.message)); }); } if (typeof document !== "undefined" && document.addEventListener) install(); const names = new Set([ "book", "book-fold", "book-expand", "book-collapse", "book-tree", "book-search", "book-mark", "book-copy", "book-paste", ]); function handle(name, state, node) { if (!names.has(name)) return false; run(name, state, node).catch((error) => { state.notice = error.message; EstApp.render(); }); return true; } return { handle }; })();