// 원본 수량산출(TFRMMULLANG) 공종 단위 조작 — 공종복사하기·붙여넣기(mnuCopy·mnuPaste)와 순서 이동. // 복사본은 현재 산출서 바로 아래에 넣고, 내역 이중 반영을 막기 위해 내역 연결을 비운다. // 산출 줄의 수량으로 지정·지정 해제(TFRMMULWORD popSetMulLang·popSetFree)는 화면 표기(⟦결과⟧) 위치로 다룬다. const EstQuantitySheets = (() => { function copy(project, code, uid) { const sheets = project.quantitySheets || [], index = sheets.findIndex((sheet) => sheet.Code === code); if (index < 0) throw Error("복사할 수량산출서를 고르세요."); const used = new Set(sheets.map((sheet) => sheet.Code)); let next = `Q${uid}`; for (let n = 2; used.has(next); n++) next = `Q${uid}_${n}`; const sheet = structuredClone(sheets[index]); sheet.Code = next; sheet.Name = `${sheets[index].Name || sheets[index].Code} 복사본`; for (const item of sheet.items || []) { item.TargetCode = ""; item.TargetKey = ""; delete item.LinkCode; delete item.LinkIndex; } sheets.splice(index + 1, 0, sheet); return next; } function move(project, code, step) { const sheets = project.quantitySheets || [], index = sheets.findIndex((sheet) => sheet.Code === code), next = index + step; if (index < 0 || next < 0 || next >= sheets.length) return false; [sheets[index], sheets[next]] = [sheets[next], sheets[index]]; return true; } const RESULT = "⟦결과⟧"; function tagged(shown, label) { const match = shown.match(new RegExp(`⟦${label}⟧([^⟦]*)⟦${label}⟧`)); return match ? match[1].trim() : ""; } function count(text) { return text.split(RESULT).length - 1; } function mark(sheet, rowIndex, start, end, code, text) { const row = sheet.rows[rowIndex]; if (!row) throw Error("산출 줄을 고르세요."); const shown = text ?? EstText.display(row.LineData); let from = Math.min(start, end), to = Math.max(start, end); while (from < to && /\s/.test(shown[from])) from++; while (to > from && /\s/.test(shown[to - 1])) to--; const picked = shown.slice(from, to); if (!/^-?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$/.test(picked)) throw Error("산출 줄에서 수량으로 지정할 숫자만 드래그해 고르세요."); if (count(shown.slice(0, from)) % 2) throw Error("이미 수량으로 지정된 숫자입니다."); const position = count(shown.slice(0, from)) / 2; row.LineData = EstText.encode( `${shown.slice(0, from)}${RESULT}${picked}${RESULT}${shown.slice(to)}`, ); row.markers = [...(row.markers || [])]; row.markers.splice(position, 0, code); sheet.markerEdited = true; sheet.items.push({ Code: code, Name: tagged(shown, "품명") || "새 항목", Size: tagged(shown, "규격"), Unite: tagged(shown, "단위"), GN: 0, TargetCode: "", }); return code; } function free(sheet, rowIndex, caret, text) { const row = sheet.rows[rowIndex]; if (!row) throw Error("산출 줄을 고르세요."); const shown = text ?? EstText.display(row.LineData), pairs = []; for (let at = shown.indexOf(RESULT), open = -1; at >= 0; at = shown.indexOf(RESULT, at + 1)) if (open < 0) open = at; else { pairs.push([open, at + RESULT.length]); open = -1; } const position = pairs.findIndex(([open, close]) => caret >= open && caret <= close); if (position < 0) throw Error("해제할 수량(⟦결과⟧ 안쪽)에 커서를 두세요."); const code = row.markers?.[position], item = sheet.items.find((next) => next.Code === code); if (item?.TargetCode || item?.TargetKey) throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요."); const [open, close] = pairs[position]; row.LineData = EstText.encode( shown.slice(0, open) + shown.slice(open + RESULT.length, close - RESULT.length) + shown.slice(close), ); row.markers = row.markers.filter((_, i) => i !== position); sheet.markerEdited = true; if (!sheet.rows.some((other) => (other.markers || []).includes(code))) sheet.items = sheet.items.filter((next) => next.Code !== code); return code; } function lineEditor(index) { const editor = document.querySelector(`[data-quantity-line="${index}"]`); if (!editor) throw Error("산출 줄을 찾을 수 없습니다."); return editor; } // 원본 수량산출 검색(TFRMMULLANG btnSerch·btnAllDisplay): 명칭·규격에서 빈칸·|·괄호를 빼고 포함 검색. const loose = (text) => String(text ?? "") .replace(/[\s|()[\]{}]/g, "") .toLowerCase(); function matches(sheet, query) { const q = loose(query); if (!q) return true; return [sheet.Name, sheet.Code, ...(sheet.items || []).flatMap((i) => [i.Name, i.Size])].some( (t) => loose(t).includes(q), ); } // 수량산출서 고르기 줄(원본 공종 목록 + 검색·전체보기). function selector(state) { const { esc } = EstApp; const options = (state.project.quantitySheets || []) .filter((sheet) => matches(sheet, state.quantityQuery) || sheet.Code === state.quantitySheet) .map( (sheet) => ``, ) .join(""); return `
`; } function handle(name, state, node) { const code = state.quantitySheet; if (name === "quantity-sheet-search" || name === "quantity-sheet-all") { state.quantityQuery = name === "quantity-sheet-all" ? "" : (document.querySelector("[data-quantity-query]")?.value ?? ""); const first = (state.project.quantitySheets || []).find((s) => matches(s, state.quantityQuery), ); if (state.quantityQuery && !first) throw Error("찾는 수량산출서가 없습니다."); if ( first && !matches( state.project.quantitySheets.find((s) => s.Code === code) || {}, state.quantityQuery, ) ) state.quantitySheet = first.Code; return true; } if (name === "quantity-sheet-insert") { // 삽입(TFRMMULLANG btnInsert): 현재 산출서 앞에 새 산출서, 공종명칭(품명·규격) 입력 const title = prompt("공종명칭", "새 수량산출서"); if (title == null) return true; const size = prompt("규격", "") ?? ""; EstApp.change((project) => { const sheets = (project.quantitySheets ||= []), at = Math.max( 0, sheets.findIndex((s) => s.Code === code), ), next = `Q${EstApp.uid()}`; sheets.splice(at, 0, { Code: next, Name: title.trim() || "새 수량산출서", Size: size.trim(), rows: [], items: [], options: { digits: 2, roundMode: 2 }, }); state.quantitySheet = next; }, false); return true; } if (name === "quantity-sheet-multi-delete") { // 선택공종 삭제(btnSelectDel): 고른 산출서들을 한 번에 지움 const { esc } = EstApp; EstApp.showDialog( "선택공종 삭제", `
${(state.project.quantitySheets || []).map((s) => ``).join("")}
`, ); return true; } if (name === "quantity-sheet-multi-delete-ok") { const codes = new Set( [...document.querySelectorAll('#dialog-content [name="qd-sheet"]:checked')].map( (n) => n.value, ), ); if (!codes.size) throw Error("지울 산출서를 고르세요."); if (!confirm(`산출서 ${codes.size}개를 삭제하시겠습니까?`)) return true; EstApp.byId("dialog").close(); EstApp.change((project) => { project.quantitySheets = project.quantitySheets.filter((s) => !codes.has(s.Code)); if (codes.has(state.quantitySheet)) state.quantitySheet = project.quantitySheets[0]?.Code || "manual"; }, false); return true; } if (name === "quantity-sheet-copy") { EstApp.change((project) => { state.quantitySheet = copy(project, code, EstApp.uid()); }, false); state.notice = "산출서를 복사했습니다. 복사본의 내역 연결은 비워 두었습니다."; } else if (name === "quantity-sheet-up" || name === "quantity-sheet-down") { const step = name === "quantity-sheet-up" ? -1 : 1, sheets = state.project.quantitySheets || [], index = sheets.findIndex((sheet) => sheet.Code === code); if (index < 0 || index + step < 0 || index + step >= sheets.length) throw Error("더 옮길 수 없습니다."); EstApp.change((project) => move(project, code, step), false); } else if (name === "quantity-total") { const sheet = state.project.quantitySheets?.find((next) => next.Code === code), index = Number(node?.dataset.index), editor = lineEditor(index); if (!sheet) throw Error("수량산출서를 고르세요."); EstApp.change( () => EstQuantityTotal.insert(sheet, index, editor.selectionStart ?? 0, editor.value), false, ); } else if (name === "quantity-mark" || name === "quantity-free") { const sheet = state.project.quantitySheets?.find((next) => next.Code === code), index = Number(node?.dataset.index), editor = lineEditor(index); if (!sheet) throw Error("수량산출서를 고르세요."); const { selectionStart: start, selectionEnd: end } = editor; if (name === "quantity-mark" && start === end) throw Error("산출 줄에서 수량으로 지정할 숫자를 드래그해 고른 뒤 누르세요."); EstApp.change(() => { if (name === "quantity-mark") mark(sheet, index, start, end, EstQuantity.nextItemCode(sheet), editor.value); else free(sheet, index, start, editor.value); }, false); } else if (name === "excel-mode") excelMode(state); else if (["quantity-row-copy", "quantity-row-cut", "quantity-row-paste"].includes(name)) { // 산출 줄 잘라내기·복사·붙여넣기(TFRMMULWORD popCut·popCopy·popPaste) — 붙일 때 수량 지정 표지는 떼고 글만 const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet), index = Number(node.dataset.index); if (!sheet?.rows?.[index]) throw Error("산출 줄을 고르세요."); if (name === "quantity-row-paste") { if (!state.quantityClip?.length) throw Error("복사한 산출 줄이 없습니다."); EstApp.change( () => sheet.rows.splice(index + 1, 0, ...rowsToPaste(state.quantityClip)), false, ); } else { if (name === "quantity-row-cut" && sheet.rows[index].markers?.length) throw Error("수량으로 지정된 줄은 먼저 지정 해제하세요."); state.quantityClip = [structuredClone(sheet.rows[index])]; if (name === "quantity-row-cut") EstApp.change(() => sheet.rows.splice(index, 1), false); else state.notice = "산출 줄을 복사했습니다."; } } else return false; return true; } function rowsToPaste(rows) { return rows.map((row) => { const copy = structuredClone(row); copy.LineData = EstText.encode(EstText.display(copy.LineData).split(RESULT).join("")); delete copy.markers; delete copy.PkID; return copy; }); } // 엑셀형 수량산출서(원본 TFRMMULEXCEL) — 편집 화면은 native-excel-view.js. // 엑셀형식(popSetMode): 원본은 DataKind 를 바꿔 편집기를 갈아끼움 — 웹은 빈 엑셀 시트를 만들거나 끄고 켬(내용은 보관). function excelView(sheet) { if (typeof EstExcelView === "undefined") return ""; const on = sheet.excel?.length && !sheet.excelOff; return `${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.banner(sheet) : ""}

${on ? EstExcelView.render(sheet, EstApp.state) : ""}`; } function excelMode(state) { const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); if (!sheet) throw Error("수량산출서를 고르세요."); const on = sheet.excel?.length && !sheet.excelOff; if ( !confirm( on ? "일반형식으로 바꾸시겠습니까? 엑셀 내용은 보관됩니다." : "엑셀형식으로 바꾸시겠습니까?", ) ) return; EstApp.change(() => { if (on) sheet.excelOff = true; else { sheet.excel ||= [{ name: "Sheet1", cells: {} }]; sheet.excelLinks ||= {}; delete sheet.excelOff; } sheet.excelEdited = true; }, false); } return { copy, move, mark, free, rowsToPaste, excelView, matches, selector, handle }; })();