const EstActions = (() => { let pending = null, clipboard = [], clipboardTable = "", restoring = false; function form(title, fields, callback) { const { esc, showDialog } = EstApp; showDialog( title, `
${fields.map((f) => ``).join("")}
`, ); pending = () => callback( Object.fromEntries( fields.map((f) => { const node = document.querySelector(`#dialog-content [name="${f.name}"]`); return [f.name, f.type === "number" ? Number(node.value) : node.value]; }), ), ); } function selected() { return [...EstApp.state.selection].sort((a, b) => a - b); } function activeRows() { const { state } = EstApp; const rows = state.project.tables[state.view]; if (!rows) throw Error("작업 표를 먼저 선택하세요."); return rows; } async function undo(redo = false) { const { state } = EstApp, source = redo ? state.future : state.history, target = redo ? state.history : state.future; if (!source.length || restoring) return; const entry = source.at(-1), revision = state.revision, documentKey = state.documentKey; restoring = true; try { const project = typeof entry === "string" ? JSON.parse(entry) : await EstApp.restore(entry); if (revision !== state.revision || documentKey !== state.documentKey) return; target.push(EstApp.snapshot()); if (target.length > 8) target.shift(); source.pop(); const id = state.project.id; state.project = project; if (id) state.project.id = id; state.revision++; state.selection.clear(); EstApp.cache(); EstApp.render(); } finally { restoring = false; } } function insertRow() { const rows = activeRows(), { state } = EstApp; EstApp.change(() => { const row = EstModel.newRow(state.view, rows, state.project); if (state.view === "MachMainDat") row.Index = -3; const group = EstNative.groups(rows).get(state.group); const at = selected().at(-1) ?? (group ? group.start + group.rows.length - 1 : rows.length - 1); rows.splice(at + 1, 0, row); state.selection = new Set([at + 1]); }); } function insertGroup() { const { state } = EstApp, rows = activeRows(); form( "새 묶음 만들기", [ { name: "code", label: "코드", value: `U${Date.now().toString(36)}` }, { name: "name", label: "품명" }, { name: "size", label: "규격" }, { name: "unit", label: "단위", value: "식" }, ], (values) => { if (!values.code || rows.some((r) => r.Code === values.code && Number(r.Index) > 0)) throw Error("코드를 입력하거나 중복 코드를 바꾸세요."); EstApp.change(() => { const index = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)) + 1; const row = { ...EstModel.newRow(state.view, rows, state.project), Index: index, Code: values.code, Name: values.name, Size: values.size, Unite: values.unit, GN: 0, }; if (state.view === "CostMainDat") row.LineData = `\x0e${values.name}\x0e \x13${values.size}\x13 \x15${values.unit}\x15`; rows.push(row); state.group = values.code; }); }, ); } function deleteRows() { const { state } = EstApp, rows = activeRows(), indexes = new Set(selected()); if (!indexes.size) throw Error("삭제할 행을 선택하세요."); for (const group of ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view) ? EstNative.groups(rows).values() : []) if (indexes.has(group.start)) for (let i = group.start; i < group.start + group.rows.length; i++) indexes.add(i); const codes = [...indexes].map((i) => rows[i].Code).filter(Boolean), kind = Number( Object.entries(EstNative.sourceTables).find(([, table]) => table === state.view)?.[0] || { HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[state.view], ); if ( kind && Object.entries(state.project.tables).some(([table, list]) => list.some((row, i) => table !== state.view || !indexes.has(i) ? row.LinkIndex === kind && codes.includes(row.Code) : false, ), ) ) throw Error("다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."); if (!confirm(`${indexes.size}개 행을 삭제하시겠습니까?`)) return; EstApp.change(() => { [...indexes].sort((a, b) => b - a).forEach((i) => rows.splice(i, 1)); state.selection.clear(); }); } async function action(name, node) { const { state, api, esc, number, showDialog } = EstApp, project = state.project; state.notice = ""; if (name === "new") { if (confirm("저장하지 않은 입력을 비우고 새 내역서를 만드시겠습니까?")) { EstApp.setProject(EstNative.empty()); if (typeof EstDefaults !== "undefined") await EstDefaults.applyNew(); if (typeof EstFormsLibrary !== "undefined") await EstFormsLibrary.applyDefault(); } } else if (name === "sample") { if (confirm("현재 작업을 REA 샘플로 바꾸시겠습니까?")) EstApp.setProject(await api("/api/sample")); } else if (name === "open") EstApp.byId("open-file").click(); else if (name === "save") await EstApp.save(); else if (name === "save-as") form( "다른 이름으로 저장", [{ name: "name", label: "작업 이름", value: project.name }], async (values) => { const nextName = values.name.trim(); const input = document.querySelector('#dialog-content [name="name"]'); input.setCustomValidity(nextName ? "" : "작업 이름을 입력하세요."); if (!input.reportValidity()) throw Error("작업 이름을 입력하세요."); EstApp.change(() => (project.name = nextName), false); await EstApp.save(true); }, ); else if (name === "undo") await undo(); else if (name === "redo") await undo(true); else if (name === "add-row") insertRow(); else if (name === "add-group") insertGroup(); else if (name === "delete-row") deleteRows(); else if (name === "copy-row" || name === "cut-row") { const rows = activeRows(), indices = new Set(selected()); if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)) for (const group of EstNative.groups(rows).values()) if (indices.has(group.start)) for (let i = group.start; i < group.start + group.rows.length; i++) indices.add(i); clipboard = [...indices].sort((a, b) => a - b).map((i) => structuredClone(rows[i])); if (!clipboard.length) throw Error("복사할 행을 선택하세요."); clipboardTable = state.view; if (name === "cut-row") deleteRows(); } else if (name === "paste-row") { if (!clipboard.length) throw Error("복사한 행이 없습니다."); if (clipboardTable !== state.view) throw Error("복사한 행과 같은 종류의 자료에 붙여넣으세요."); const rows = activeRows(); EstApp.change(() => { let at = (selected().at(-1) ?? rows.length - 1) + 1; const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view); if (grouped && clipboard.some((r) => Number(r.Index) > 0)) for (const group of EstNative.groups(rows).values()) if (at > group.start && at <= group.start + group.rows.length) { at = group.start + group.rows.length; break; } const copies = structuredClone(clipboard); let next = EstModel.newRow(state.view, rows, state.project).PkID - 1; let groupCode = "", nextIndex = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)); copies.forEach((r) => { r.PkID = ++next; r.NewItem = true; delete r.PreviousCodes; if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(state.view)) { const base = r.Code || "U"; let suffix = next; do { r.Code = `${base}_${suffix++}`; } while ( rows.some((row) => row.Code === r.Code) || copies.some((row) => row !== r && row.Code === r.Code) ); } if (grouped && Number(r.Index) > 0 && r.Code) { const base = r.Code; let suffix = next; do { r.Code = `${base}_${suffix++}`; } while ( rows.some((row) => row.Code === r.Code) || copies.some((row) => row !== r && Number(row.Index) > 0 && row.Code === r.Code) ); r.Index = ++nextIndex; groupCode = r.Code; } if (grouped && groupCode) r.TitleCode = groupCode; }); rows.splice(at, 0, ...copies); }); } else if (name === "recalculate") { if (project.options.AutoCalc === false) throw Error("내역서 자동계산이 꺼져 있어 전체 재계산을 실행할 수 없습니다."); EstApp.calculate(); EstApp.cache(); EstApp.render(); } else if (name === "find") EstFind.handle(name, EstApp.state); else if (name === "level") { if (!selected().length) throw Error("레벨을 바꿀 행을 선택하세요."); const level = Number(node.dataset.level); EstApp.change(() => selected().forEach((i) => (activeRows()[i].Level = level))); } else if (name === "export-xlsx") await EstExchange.exportExcel(); else if (name === "export-report-xlsx") await EstReport.exportExcel(); else if (name === "export-estx") await EstExchange.exportNative(); else if (name === "export-jdl") await EstExchange.exportJdl(); else if (name === "import-jdl") EstApp.byId("jdl-file").click(); else if (name === "export-xml") form( "조달청 XML 작성정보", [ { name: "company", label: "작성업체명", value: project.exportMeta?.company || "" }, { name: "kind", label: "공사구분", value: project.exportMeta?.kind || "", choices: [ ["", "선택하세요"], ...[ "토목공사", "건축공사", "설비공사", "전기공사", "통신공사", "조경공사", "소방공사", "문화재수리공사", "국가유산수리공사", ].map((v) => [v, v]), ], }, { name: "number", label: "공사관리번호", value: project.exportMeta?.number || "" }, ], (meta) => { const output = EstC3r.output(project, meta); if ( output.warnings.length && !confirm( output.warnings.slice(0, 15).join("\n") + `\n총 ${output.warnings.length}건. 단가 전개 결과를 XML로 저장할까요?`, ) ) return; EstApp.change(() => (project.exportMeta = meta), false); EstApp.download(`${project.name}.xml`, output.xml, "application/xml;charset=utf-8"); }, ); else if (name === "import-xml") EstApp.byId("xml-file").click(); else if (name === "import-xlsx") EstApp.byId("excel-file").click(); else if (name === "print") await EstReport.open(); else if (name === "print-document") EstReport.print(); else if (name === "print-default-save") await EstReport.saveDefault(); else if (name === "print-default-open") await EstReport.openDefault(); else if (name === "csv") { const rows = activeRows(), keys = EstModel.columns(state.view); const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const csv = [ keys.map((key) => EstModel.labels[key] || key), ...rows.map((row) => keys.map((key) => typeof row[key] === "number" ? row[key] : /^[=+\-@]/.test(String(row[key] || "")) ? `'${row[key]}` : EstText.clean(row[key]), ), ), ] .map((row) => row.map(quote).join(",")) .join("\r\n"); EstApp.download( `${state.project.name}_${state.view}.csv`, "\uFEFF" + csv, "text/csv;charset=utf-8", ); } else if (name === "projects") { const projects = await api("/api/projects"); showDialog( "저장한 작업", projects.length ? projects .map( (p) => `

${esc(p.modified)}

`, ) .join("") : "저장된 작업이 없습니다.", ); } else if (name === "load-project") { if (confirm("현재 작업을 저장한 작업으로 바꾸시겠습니까?")) { EstApp.setProject(await api(`/api/projects/${node.dataset.id}`)); EstApp.byId("dialog").close(); } } else if (name === "parity") { const result = await api("/api/parity"); showDialog( "REA 샘플 대조", `

${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개

보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.

${result.errors.length ? `

${esc(result.errors.join(" / "))}

` : ""}${[...new Set(result.results.map((r) => r.group))].map((group) => ``).join("")}
영역검사 수일치
${esc(group)}${result.results.filter((r) => r.group === group).length}${result.results.filter((r) => r.group === group && r.pass).length}
`, ); } else if (name === "settings") EstSettings.show(); else if (name === "raw-line") { const row = activeRows()[Number(node.dataset.row)]; showDialog( "산출근거 편집", `

표지는 짝으로 지정합니다. 구간지정 A~Z는 결과 앞에, 구간합계는 합산할 같은 문자를 감쌉니다.

${["품명", "규격", "단위", "단가", "변수", "다른줄", "결과", "합계", "구간지정", "구간합계"].map((m) => ``).join("")}
`, ); } else if (name === "line-apply") { const text = EstApp.byId("line-editor").value; EstApp.change(() => (activeRows()[Number(node.dataset.row)].LineData = EstText.encode(text))); EstApp.byId("dialog").close(); } else if (name === "template") { const templates = project.wongaTemplates || []; showDialog( "원가 서식 선택", `

현재 원가 표를 바꾸므로 작업을 저장한 뒤 선택하세요.

${templates.map((t, i) => ``).join("")}`, ); } else if (name === "use-template") { if (confirm("현재 원가 표를 선택한 서식으로 바꾸시겠습니까?")) EstApp.change( () => (project.tables.Wonga = structuredClone( project.wongaTemplates[Number(node.dataset.row)].rows, )), ); EstApp.byId("dialog").close(); } else if (name === "basic-wonga") { if (confirm("현재 원가 표를 기본 뼈대로 바꾸시겠습니까?")) EstApp.change(() => { project.options.TotalFix = false; project.tables.Wonga = [ { Gubun: "N", Name: "직접 노무비", BaseField: "NC", Cost: 0 }, { Gubun: "G", Name: "직접 재료비", BaseField: "GC", Cost: 0 }, { Gubun: "J", Name: "직접 경비", BaseField: "JC", Cost: 0 }, { Gubun: "S", Name: "표준시장단가", BaseField: "SC", Cost: 0 }, { Gubun: "T", Name: "순공사비", VSusic: "N+G+J+S" }, { Gubun: "R", Name: "도급액", VSusic: "T", LinkIndex: 55 }, ]; }); EstApp.byId("dialog").close(); } else if (name === "capture-baseline") { if ( confirm( "현재 계산값을 당초·계약 내역으로 보존하시겠습니까? 기존 당초 사본이 있으면 교체됩니다.", ) ) EstApp.change(() => (state.project = EstBusiness.baseline(project))); } else if (name === "capture-bid") { if (confirm("현재 단가를 입찰 당시 단가로 기록하시겠습니까?")) EstApp.change(() => project.tables.EstMainDat.forEach((row) => EstMath.units.forEach((key) => (row[`A${key}`] = EstMath.num(row[key]))), ), ); } else if (name === "recalculate-original") form( "당초분 재계산", [ { name: "method", value: String(project.options.OldRecalcMethod || 0), label: "계산 방법", choices: [ ["0", "신규공종만 계산"], ["1", "변경된 단가만"], ["2", "당초분 전체"], ], }, ], (values) => EstApp.change(() => { project.baseline = EstBusiness.original(project, Number(values.method)); project.options.OldRecalcMethod = Number(values.method); }), ); else if (name === "restore-bid") { if (confirm("당초단가(100%)로 복원하시겠습니까?")) EstApp.change(() => { state.project = EstBusiness.restoreBid(project); }); } else if (name === "add-quantity") EstApp.change(() => { project.quantities.push({ Code: EstQuantity.newCode(project.quantities), Name: "새 수량", TargetCode: "", formula: "1", digits: 2, }); }, false); else if (name === "apply-quantity") { const totals = EstQuantity.totals(project, state.quantitySheet); if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?")) EstApp.change(() => EstQuantity.applyTotals(project, totals)); } else if (name === "add-round") { const next = { id: EstApp.uid(), name: EstBusiness.newProgressName(project.progress), inputs: {}, approved: false, locked: false, }; EstApp.change(() => { project.progress.push(next); state.roundId = next.id; }, false); } else if (name === "lock-round") { if ( confirm("이 회차를 확정하고 저장하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.") ) { EstApp.change(() => (state.project = EstProgress.lock(project, state.roundId)), false); await EstApp.save(); } } else if (name === "add-annual") EstApp.change( () => project.annual.push({ name: EstBusiness.newAnnualName(project.annual), formula: "[기준]", }), false, ); else if (name === "move-annual") { const index = Number(node.dataset.row), next = index + Number(node.dataset.direction); if (index >= 0 && index < project.annual.length && next >= 0 && next < project.annual.length) EstApp.change(() => { [project.annual[index], project.annual[next]] = [ project.annual[next], project.annual[index], ]; }, false); } else if (name === "open-annual") { const result = EstBusiness.annualCopy(project, Number(node.dataset.row)); if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) { EstApp.setProject(result); EstApp.navigate("EstMainDat"); } } else if (name === "delete-extra") { if (confirm("이 항목을 삭제하시겠습니까?")) EstApp.change( () => project[node.dataset.section].splice(Number(node.dataset.row), 1), false, ); } else if (name === "dialog-apply" && pending) { await pending(); pending = null; EstApp.byId("dialog").close(); } else if (name === "close-dialog") EstApp.byId("dialog").close(); else await EstBusinessActions.handle(name, node); } function install() { EstSchedule.install(); EstExchange.install(); EstBusinessActions.install(); EstIssues.install(); document.addEventListener("click", async (event) => { try { const marker = event.target.closest("[data-marker]"); if (marker) { const editor = EstApp.byId("line-editor"), start = editor.selectionStart, end = editor.selectionEnd, tag = `⟦${marker.dataset.marker}⟧`; editor.setRangeText(tag + editor.value.slice(start, end) + tag, start, end, "select"); editor.focus(); return; } const view = event.target.closest("[data-view]"); if (view) { EstApp.navigate(view.dataset.view); return; } const group = event.target.closest("[data-group]"); if (group) { EstApp.navigate(EstApp.state.view, group.dataset.group); return; } const node = event.target.closest("[data-action]"); if (node) { await action(node.dataset.action, node); node.closest("details")?.removeAttribute("open"); } } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } }); document.addEventListener("change", (event) => { const node = event.target, { state } = EstApp, project = state.project; state.notice = ""; try { if (EstBusinessActions.change(node)) return; if (node.matches("[data-select]")) { const index = Number(node.dataset.select); node.checked ? state.selection.add(index) : state.selection.delete(index); EstApp.render(); return; } if (node.id === "project-name") { EstApp.change(() => (project.name = node.value), false); return; } if (node.id === "automatic" || node.id === "refresh-sources") { EstApp.change( () => (project.options[node.id === "automatic" ? "AutoCalc" : "SourceRefresh"] = node.checked), ); return; } if (node.id === "round-choice") { state.roundId = node.value; EstApp.render(); return; } if (node.id === "escalation-component") { state.escalationComponent = node.value; EstApp.render(); return; } if (node.id === "use-approved") { const round = project.progress.find((r) => r.id === state.roundId); if (round.locked) throw Error("확정 회차입니다."); EstApp.change(() => (round.approved = node.checked), false); return; } if (node.matches("[data-progress]")) { const round = project.progress.find((r) => r.id === state.roundId); if (round.locked) throw Error("확정 회차입니다."); if (EstProgress.ambiguousKeys(project.tables.EstMainDat).has(node.dataset.key)) throw Error("기성 내역 식별자가 중복되어 입력할 수 없습니다."); const value = node.type === "number" ? Number(node.value) : node.value; if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류"); EstApp.change(() => { round.inputs[node.dataset.key] ||= {}; round.inputs[node.dataset.key][node.dataset.progress] = value; }, false); return; } if (node.matches("[data-extra]")) { const value = node.type === "number" ? Number(node.value) : node.value; if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류"); EstApp.change(() => { const row = project[node.dataset.extra][Number(node.dataset.row)]; row[node.dataset.field] = value; if (node.dataset.extra === "quantities" && node.dataset.field === "TargetCode") row.TargetKey = ""; }, false); return; } if (node.matches("[data-table][data-field]")) { const row = project.tables[node.dataset.table][Number(node.dataset.row)], key = node.dataset.field; const numeric = EstModel.numeric.has(key), value = numeric ? node.value === "" ? null : Number(node.value) : key === "LineData" ? EstText.encode(node.value) : node.value; if ( numeric && value !== null && (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) ) throw Error("유효한 수치를 입력하세요."); if (key === "Level" && (value < 0 || value > 9 || !Number.isInteger(value))) throw Error("레벨은 0~9 정수입니다."); if (key === "Precision" && (value < 0 || value > 6 || !Number.isInteger(value))) throw Error("소수 자릿수는 0~6 정수입니다."); EstApp.remember(); if (key === "Code") { const previous = row.Code; EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value); if (state.group === previous && Number(row.Index) > 0) state.group = row.Code; } else if (key === "SCheck") EstBusinessActions.setClassification(project, row, node, value); else row[key] = value; if (state.view === "escalation" && EstMath.units.includes(key)) project.options.SourceRefresh = false; if (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) { if (key === "TC") { row.InputTC = value; row.ValueIndex = 4; } if (["NC", "GC", "JC", "SC"].includes(key)) row[`Input${key}`] = value; project.options.SourceRefresh = true; } if (project.options.AutoCalc !== false) EstApp.calculate(); EstApp.cache(); setTimeout(() => EstApp.render(), 0); } } catch (error) { state.notice = error.message; EstApp.render(); } }); EstApp.byId("open-file").addEventListener("change", async (event) => { try { await EstApp.open(event.target.files[0]); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } finally { event.target.value = ""; } }); EstApp.byId("search").addEventListener("input", (event) => { EstApp.state.query = event.target.value; EstApp.state.page = 0; EstApp.render(); }); EstApp.byId("previous-page").addEventListener("click", () => { EstApp.state.page--; EstApp.render(); }); EstApp.byId("next-page").addEventListener("click", () => { EstApp.state.page++; EstApp.render(); }); document.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); EstApp.save().catch((error) => { EstApp.state.notice = error.message; EstApp.render(); }); } }); document.addEventListener("dblclick", (event) => { const input = event.target.closest('[data-field="Code"]'); if (!input) return; const row = EstApp.state.project.tables[input.dataset.table][Number(input.dataset.row)], kind = Number(row.LinkIndex); const table = { 103: "HeadMainDat", 104: "CostMainDat", 105: "MachMainDat" }[kind] || EstNative.sourceTables[kind]; if (table) { EstApp.navigate(table, row.Code); if (![103, 104, 105].includes(kind)) { EstApp.state.query = row.Code; EstApp.render(); } } }); EstAttachments.install(); EstStandard.install(); } const clip = () => ({ table: clipboardTable, rows: clipboard }); const setClip = (table, rows) => ((clipboardTable = table), (clipboard = structuredClone(rows))); return { install, form, undo, clip, setClip }; })();