feat(est-test): 품셈 보기와 품셈자료 붙여넣기

- 원본 BookData 7권 해독(DBISAM 암호 표 Blowfish-CBC, zlib RTF) 서버 book-store.cjs, /api/book 경로
- 목차 트리·본문 표·제목/본문/키워드 검색·본문 강조·복사하기, 단가산출·내역·일위·기계 붙여넣기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 17:41:02 +09:00
co-authored by Claude Opus 5.5
parent b97ecf47de
commit db9bc2dee4
7 changed files with 530 additions and 1 deletions
+187
View File
@@ -0,0 +1,187 @@
// 원본 품셈 보기(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 `<div style="padding-left:${depth * 14}px">${has ? `<button class="tiny" data-action="book-fold" data-pkid="${n.pkid}">${open ? "−" : "+"}</button>` : "<span style='display:inline-block;width:18px'></span>"}<a href="#" data-book-node="${n.pkid}"${n.pkid === b.pkid ? ' style="font-weight:bold"' : ""}>${esc(n.chapter)} ${esc(n.title)}</a></div>${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(`<mark>${esc(mark)}</mark>`)
: esc(t);
const lines = String(page.text || "").split("\n");
let html = "",
table = [];
const flush = () => {
if (table.length)
html += `<table class="sheet" style="font-size:12px">${table.map((cells) => `<tr>${cells.map((c) => `<td style="text-align:left">${hl(c)}</td>`).join("")}</tr>`).join("")}</table>`;
table = [];
};
for (const line of lines) {
if (line.includes("\t")) table.push(line.replace(/\t$/, "").split("\t"));
else {
flush();
html += `<div style="white-space:pre-wrap">${hl(line) || "&nbsp;"}</div>`;
}
}
flush();
return `<h4>${esc(page.chapter)} ${esc(page.title)}</h4>${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
? `<p>검색 결과 ${b.hits.length}건 <button data-action="book-tree">목차로</button></p>${b.hits.map((h) => `<div><a href="#" data-book-node="${h.pkid}">${esc(h.chapter)} ${esc(h.title)}</a></div>`).join("")}`
: treeHtml(b, b.tree);
EstApp.showDialog(
"품셈",
`<div class="business-tools"><select data-book-name>${b.books.map((x) => `<option value="${x.name}"${x.name === b.name ? " selected" : ""}>${esc(x.desc)}</option>`).join("")}</select><input data-book-word value="${esc(b.word || "")}" placeholder="검색어"><button data-action="book-search" data-mode="title">검 색</button><button data-action="book-search" data-mode="body">전체검색(제목+본문)</button><button data-action="book-search" data-mode="keyword">키워드검색</button><button data-action="book-expand">펼치기</button><button data-action="book-collapse">접기</button><input data-book-mark value="${esc(b.mark || "")}" placeholder="본문 검색" size="10"><button data-action="book-mark">본문 검색</button><button data-action="book-copy">복사하기</button></div><div style="display:flex;gap:10px;height:62vh"><div style="flex:0 0 38%;overflow:auto;border:1px solid #ccc;padding:4px;font-size:12px">${left}</div><div id="book-page" style="flex:1;overflow:auto;border:1px solid #ccc;padding:6px">${b.page ? pageHtml(b.page, b.mark) : "<p>왼쪽에서 항목을 고르세요.</p>"}</div></div>`,
);
}
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 };
})();