const EstExtra = (() => { function field(section, index, key, value, type = "number") { return ``; } function button(action, text, extra = "") { return ``; } function table(headers, rows) { return `${headers.map((h) => ``).join("")}${rows.map((row) => `${row.map((cell) => ``).join("")}`).join("")}
${h}
${cell}
`; } function box(title, tools, body, note = "") { return `
${title}${tools}
${body}${note ? `

${note}

` : ""}`; } function render(state) { if (state.view === "review") { EstReview.render(state); return; } if (state.view === "schedule") { EstSchedule.render(); return; } if (state.view === "issues") { EstIssues.render(); return; } if (state.view === "attachments") { EstAttachments.render(); return; } if (["standard", "standard-change"].includes(state.view)) { EstStandard.render(); return; } if (["estimate-usage", "estimate-change"].includes(state.view)) { EstEstimate.render(); return; } if (["material-usage", "material-change", "labor-usage", "labor-change"].includes(state.view)) { EstBasicPrices.render(); return; } if (["machine-usage", "machine-change"].includes(state.view)) { EstMachine.render(); return; } const { project, view } = state, { esc, number, byId } = EstApp; let html = ""; if (view === "quantity") { if (project.quantitySheets?.length && state.quantitySheet !== "manual") { EstQuantity.render(state); return; } const rows = EstQuantity.manual(project.quantities).rows.map((row, index) => { return [ field("quantities", index, "Code", row.Code, "text"), field("quantities", index, "Name", row.Name, "text"), field("quantities", index, "TargetCode", row.TargetCode, "text") + EstQuantity.targetSelect(project, row, "manual", index), field("quantities", index, "formula", row.formula, "text"), field("quantities", index, "digits", row.digits ?? 2), esc(row.Error || number(row.GN)), button( "move-quantity", "↑", `data-row="${index}" data-direction="-1" ${index === 0 ? "disabled" : ""}`, ) + button( "move-quantity", "↓", `data-row="${index}" data-direction="1" ${index === project.quantities.length - 1 ? "disabled" : ""}`, ) + button("delete-extra", "삭제", `data-section="quantities" data-row="${index}"`), ]; }); html = EstQuantity.selector(state) + box( "수량산출서", button("add-quantity", "산출 항목 추가") + button("apply-quantity", "내역에 수량 반영"), table(["산출코드", "품명", "연결 내역코드", "수식", "소수자리", "결과", ""], rows), "앞선 산출 항목을 [산출코드]로 참조합니다. 내역 반영은 연결된 코드의 산출값을 합산하여 수량에 반영합니다.", ); } else if (view === "change") { const changes = EstBusiness.changes(project); const page = EstBlockActions.changePage(state, changes); html = box( "설계변경", button("capture-baseline", "현재 내역을 당초로 확정") + button("load-contract", "당초·계약 내역 불러오기") + button("recalculate-original", "당초 단가 재계산") + button("bid-rates", "낙찰률 적용") + button("restore-bid", "당초단가(100%) 복원") + button("change-prev", "이전", state.changePage === 0 ? "disabled" : "") + `${state.changePage + 1} / ${page.pages}쪽 · ${page.total}행` + button("change-next", "다음", state.changePage + 1 >= page.pages ? "disabled" : ""), table( ["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"], page.rows.map(({ row }) => [ esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]), esc(row.Code), esc(row.Name), number(row.originalGN), number(row.GN), number(row.originalTC), number(row.TC), number(row.difference), ]), ) + table( ["원가 구분", "비목", "당초 금액", "변경 금액", "증감"], EstBusiness.costChanges(project).map((row) => [ esc(row.Gubun), esc(row.Name), number(row.originalCost), number(row.changedCost), number(row.difference), ]), ), project.baseline ? `당초: ${esc(project.baseline.name)}. 당초 자료는 별도 사본으로 보존합니다.` : changes.some((row) => row.originalSource === "file") ? "원본 파일에 저장된 당초 수량·금액과 비교합니다." : "현재 작업을 저장한 뒤 당초 내역을 확정하세요.", ); } else if (view === "progress") { state.roundId ||= project.progress[0]?.id; const round = project.progress.find((r) => r.id === state.roundId); let body = '

회차를 추가하세요.

', tools = button("add-round", "기성 회차 추가") + (project.progress.length ? button("clear-progress", "기성 전체 초기화") : ""); if (round) { tools += `` + button( round.locked ? "unlock-round" : "lock-round", round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장", ) + button("fill-progress", "전량·비율 일괄 입력") + button("rename-round", "회차 이름") + button("delete-round", "회차 삭제") + button("reset-round-inputs", "회차 입력 초기화") + `` + ``; let output; try { output = round.locked && round.result ? round.result : EstBusiness.progress(project, round.id); } catch (e) { output = { rows: [], errors: [e.message] }; } state.business = output; const page = EstBlockActions.progressPage(state, output.rows); tools += button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") + `${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행` + button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : ""); body = table( [ "코드", "공종", "도급수량", "전회수량", "금회 입력", "입력방법", "사정 입력", "입력방법", "금회금액", "사정금액", "누계수량", "누계금액", ], page.rows.map(({ row, index }) => { const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)); const key = EstBusiness.rowKey(row, index), input = round.inputs?.[key] || {}; const edit = (prefix) => `` + (input[prefix + "Mode"] === "components" ? EstMath.components .map( (field, i) => ``, ) .join("") : ""); const method = (prefix) => !editable ? "자동계산" : ``; return [ esc(row.Code), esc(row.Name), number(row.GN), number(row.JunGN), edit("Gum"), method("Gum"), edit("Sa"), method("Sa"), number(row.GumTC), number(row.SaTC), number(row.TotalGN), number(row.TotalTC), ]; }), ) + `

${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%

`; body += table( ["원가 비목", "도급금액", "전회", "금회", "사정", "누계"], (output.costs || []).map((row) => [ esc(row.Name || row.Gubun), number(row.ContractCost), number(row.JunCost), number(row.GumCost), number(row.SaCost), number(row.TotalCost), ]), ); } html = box( "기성고", tools, body, "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.", ); } else if (view === "escalation") { const output = EstBusiness.escalation(project); state.business = output; const comp = state.escalationComponent || "NU"; const choices = EstMath.units .map( (key, i) => ``, ) .join(""); const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat); const rows = page.rows.map(({ row, index }) => { const result = output.rows[index]; return [ esc(row.Code), esc(row.Name), EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]), number(result[`Contract${comp}`]), EstViews.cell("EstMainDat", index, comp, row[comp]), number(result[`D${comp}`]), number(result[`E${comp}`]), number(result[`F${comp}`]), EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0), EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0), EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0), number(result.EscExcluded), number(result.EscApplied), ``, EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0), EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0), EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0), EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0), ]; }); html = box( "물가변동 품목조정", `` + button("capture-bid", "현재 단가를 입찰단가로 기록") + button("load-bid", "입찰단가 불러오기") + button("load-contract", "계약 내역 불러오기") + button("load-progress-exclusion", "기성 누계수량 불러오기") + button("reset-escalation", "물가변동 입력 초기화") + button("capture-baseline", "계약 내역 확정") + button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") + `${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행` + button( "escalation-next", "다음", state.escalationPage + 1 >= page.pages ? "disabled" : "", ) + ``, table( [ "코드", "품명", "입찰 당시", "계약 단가", "변동 당시", "등락률 %", "등락폭", "조정단가", "예정공정수량", "실행공정수량", "기성수량", "적용제외", "적용수량", "제외 자동", "수동 제외수량", "시공중 수량(참고)", "PS 수량(참고)", "선시공 수량(참고)", ], rows, ), `조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`, ); } else if (view === "annual") { const output = EstBusiness.annual(project, false); state.business = output; html = box( "연차공사", button("add-annual", "차수 추가") + button("reset-annual", "연차 차수 초기화(웹보조)"), table( ["차수", "수량·금액 배분 수식", "순공사비", "도급액", "작업", ""], project.annual.map((year, index) => [ field("annual", index, "name", year.name, "text"), field("annual", index, "formula", year.formula, "text"), number(output.results[index]?.project.calculation.direct.TC), number(output.results[index]?.project.calculation.total), button("open-annual", "차수 내역 열기", `data-row="${index}"`), button( "move-annual", "↑", `data-row="${index}" data-direction="-1" ${index === 0 ? "disabled" : ""}`, ) + button( "move-annual", "↓", `data-row="${index}" data-direction="1" ${index === project.annual.length - 1 ? "disabled" : ""}`, ) + button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`), ]), ), `수식 예: [기준]*0.5, [기준]-[1차]. 같은 수식을 수량과 각 성분 금액에 따로 적용하고 금액은 원 미만 절사합니다. ${esc(output.errors.join(" / "))}`, ); } else if (view === "resources") { state.resourceKind = state.resourceKind || "all"; state.resourceByWork = state.resourceByWork ?? false; const byWork = Boolean(state.resourceByWork); const output = EstResources.calculate(project, byWork); const filtered = output.rows.filter( (r) => state.resourceKind === "all" || String(r.kind) === String(state.resourceKind), ); const kinds = [ ["all", "전체"], ["6", "노임"], ["5", "자재"], ["105", "기계"], ["21", "표준"], ["101", "견적"], ]; const kindSelect = ``; const byWorkCheck = ``; const headers = byWork ? [ "공종코드", "공종명", "종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액", ] : ["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"]; const rows = filtered.map((row) => { const baseCells = [ esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]), esc(row.Code), esc(row.Name), esc(row.Size), esc(row.Unite), number(row.quantity), number(row.amount), ]; return byWork ? [esc(row.WorkCode), esc(row.WorkName), ...baseCells] : baseCells; }); html = box( "동원인원·자재·장비", `${kindSelect} ${byWorkCheck} (총 ${filtered.length}건)`, table(headers, rows), esc(output.errors.join(" / ")), ); } else if (view === "drawing") { const rows = project.tables.CadMainDat || []; html = box( "원본 도면·문서 자료", "", table( ["번호", "코드", "이름", "보존 필드"], rows.map((row, index) => [ index + 1, esc(row.Code), esc(row.Name), esc( Object.keys(row) .filter((key) => row[key]?.binary) .join(", "), ), ]), ), "가져온 도면 바이너리는 프로젝트에 보존됩니다. VEC0650/CAD 편집·출력의 재현 근거는 현재 자료에 없어 동작을 구현하지 않았습니다.", ); } byId("work-content").innerHTML = html; } function findDataUsages(project, target) { if (!project || !target) return []; const code = target.Code ? String(target.Code).trim() : ""; const name = target.Name ? EstNative.normalize(target.Name) : ""; const size = target.Size ? EstNative.normalize(target.Size) : ""; if (!code && !name) return []; const results = []; const tables = [ { id: "EstMainDat", label: "내역서" }, { id: "HeadMainDat", label: "일위대가" }, { id: "CostMainDat", label: "단가산출" }, { id: "MachMainDat", label: "기계경비" }, ]; for (const info of tables) { const rows = project.tables[info.id] || []; const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(info.id); let curGroup = ""; for (let i = 0; i < rows.length; i++) { const r = rows[i]; if (grouped && Number(r.Index) > 0 && r.Code) curGroup = r.Code; // 대상 자기 자신의 대표 행은 검색 결과에서 제외 if (r === target) continue; let matched = false; if (code && r.Code === code) { matched = true; } else if (!code && name) { const rName = r.Name ? EstNative.normalize(r.Name) : ""; const rSize = r.Size ? EstNative.normalize(r.Size) : ""; if (rName === name && (!size || rSize === size)) { matched = true; } } if (matched) { const rowName = r.Name || (r.LineData ? EstText.clean(r.LineData) : ""); results.push({ table: info.id, tableLabel: info.label, index: i, group: curGroup, code: r.Code || "", name: rowName, size: r.Size || "", unite: r.Unite || "", quantity: r.GN != null ? Number(r.GN) : null, price: r.TC != null ? Number(r.TC) : null, }); } } } return results; } function showUsesSearchDialog(targetRow) { const { state, esc, number, showDialog, byId } = EstApp; if (!targetRow) throw Error("사용위치를 검색할 행을 먼저 선택하세요."); const rowName = targetRow.Name || (targetRow.LineData ? EstText.clean(targetRow.LineData) : ""); const results = findDataUsages(state.project, targetRow); const title = `데이터 사용위치 검색: ${targetRow.Code ? `[${targetRow.Code}] ` : ""}${rowName || "선택 항목"}`; let bodyHtml = ""; if (!results.length) { bodyHtml = '

내역서, 일위대가, 단가산출, 기계경비에서 사용된 위치를 찾지 못했습니다.

'; } else { bodyHtml = `
${results .map( (res) => ` `, ) .join("")}
구분코드품명 / 산출근거규격단위수량금액이동
${esc(res.tableLabel)} ${esc(res.code)} ${esc(res.name)} ${esc(res.size)} ${esc(res.unite)} ${res.quantity != null ? number(res.quantity) : "-"} ${res.price != null ? number(res.price) : "-"}
총 ${results.length}곳에서 사용 중입니다. [이동] 버튼을 누르면 해당 화면과 행으로 이동합니다.
`; } showDialog(title, bodyHtml); const dialogContent = byId("dialog-content"); const clickHandler = (e) => { const btn = e.target.closest('[data-action="goto-use-location"]'); if (!btn) return; const targetTable = btn.dataset.table; const targetIndex = Number(btn.dataset.index); const targetGroup = btn.dataset.group || ""; byId("dialog").close(); dialogContent.removeEventListener("click", clickHandler); EstApp.navigate(targetTable, targetGroup); state.page = Math.floor(targetIndex / 100); state.selection = new Set([targetIndex]); EstApp.render(); }; dialogContent.addEventListener("click", clickHandler); } function install() { // 필요한 경우 이벤트 리스너 등록 } function toggleJcBimog(state) { // REA ActionSetJCBimog (0xf17c10 / 0x10dba14): 경비성단가 지정/해제 -> SCheck 3 토글 const rows = state.project.tables[state.view], indices = [...state.selection].sort((a, b) => a - b); if (!rows || !["EstMainDat", "HeadMainDat"].includes(state.view)) throw Error("내역서 또는 일위대가 화면에서만 경비성단가를 지정/해제할 수 있습니다."); if (!indices.length || indices.some((index) => !rows[index])) throw Error("경비성단가를 지정/해제할 행을 선택하세요."); // 원자적 검증: 한 행이라도 복원 정보가 없거나 거부되면 앞선 행도 변경되지 않도록 사전 검사 const plans = []; for (const index of indices) { const row = rows[index]; if (Number(row.SCheck) === 3) { // 경비성 해제 -> 명시적 0/1/2 이력(_prevSCheck) 확인 let restoreVal = row._prevSCheck !== undefined && [0, 1, 2].includes(Number(row._prevSCheck)) ? Number(row._prevSCheck) : null; if ( restoreVal == null && row.SCheck_ !== undefined && [0, 1, 2].includes(Number(row.SCheck_)) ) { restoreVal = Number(row.SCheck_); } if (restoreVal == null) { throw Error( `행 ${index + 1}: 이전 비목 이력(0:비목별, 1:재료, 2:노무)이 없는 기존 경비성(3) 행은 임의 변환할 수 없습니다.`, ); } plans.push({ index, row, isToggleOn: false, restoreVal }); } else { // 경비성 지정 -> 현재 SCheck(0, 1, 2)를 이력으로 보존 const cur = Number(row.SCheck) || 0; plans.push({ index, row, isToggleOn: true, cur }); } } // 전수 검증 통과 후 일괄 적용 EstApp.change(() => { for (const p of plans) { if (p.isToggleOn) { p.row._prevSCheck = p.cur; EstBusinessActions.setEstimateClassification( state.project, p.row, p.index, 3, state.view, ); } else { delete p.row._prevSCheck; EstBusinessActions.setEstimateClassification( state.project, p.row, p.index, p.restoreVal, state.view, ); } } }); } function toggleSunGongsa(state) { // REA popMainSetTotalSumLine: 순직공비 계산끝(순공사비) 지정/해제 -> LinkIndex 54 토글 const rows = state.project.tables.EstMainDat, indices = [...state.selection].sort((a, b) => a - b); if (state.view !== "EstMainDat" || !rows) throw Error("내역서 화면에서만 순공사비 경계 행을 지정/해제할 수 있습니다."); if (indices.length !== 1 || !rows[indices[0]]) throw Error("순공사비 경계 행으로 지정하거나 해제할 행 1개를 선택하세요."); const index = indices[0], row = rows[index]; // 연결 행 보호: 원천 연결(자재/노임/일위/단가/기계 등 LinkIndex 5,6,21,101~105) 또는 // 수량이 있는 행, 코드가 있는 상세 행은 순공사비 경계선으로 지정 불가 (원천/연결 파괴 차단) const curLink = Number(row.LinkIndex) || 0; if (curLink !== 54) { if ( row.Code || Number(row.GN) > 0 || [5, 6, 21, 51, 52, 55, 57, 58, 61, 62, 67, 68, 101, 103, 104, 105].includes(curLink) ) { throw Error( "단가·수량 연결 행이나 기존 특수 공종 행은 순공사비 경계선으로 지정할 수 없습니다. 빈 제목/구분 행을 선택하세요.", ); } } EstApp.change(() => { if (curLink === 54) { // 기존 54 해제 -> 보존된 원래 LinkIndex 또는 원래 유형이 확인되는 경우에만 복원 let origLink = row._origLinkIndex !== undefined ? Number(row._origLinkIndex) : null; if (origLink == null && row.LinkIndex_ !== undefined && Number(row.LinkIndex_) !== 54) { origLink = Number(row.LinkIndex_); } if (origLink == null) { // 복원 정보가 없는 기존 원본 54 행: 원래 유형을 알 수 없으므로 임의 0 변환 차단 throw Error( "기존 파일에 저장된 원본 순공사비(54) 행이거나 원래 유형을 확인할 수 없어 임의 0으로 변환할 수 없습니다.", ); } row.LinkIndex = origLink; delete row._origLinkIndex; if (row._origName !== undefined) { row.Name = row._origName; delete row._origName; } else if (row.Name === "순공사비") { row.Name = "공종"; } } else { // 기존 다른 54 행이 있으면 원래 상태로 안전 복원 for (const r of rows) { if (Number(r.LinkIndex) === 54) { let origL = r._origLinkIndex !== undefined ? Number(r._origLinkIndex) : null; if (origL == null && r.LinkIndex_ !== undefined && Number(r.LinkIndex_) !== 54) { origL = Number(r.LinkIndex_); } if (origL == null) { throw Error( "기존 원본 순공사비(54) 행의 원래 유형을 알 수 없어 다른 행으로 대체할 수 없습니다.", ); } r.LinkIndex = origL; delete r._origLinkIndex; if (r._origName !== undefined) { r.Name = r._origName; delete r._origName; } else if (r.Name === "순공사비") { r.Name = "공종"; } } } row._origLinkIndex = curLink; row._origName = row.Name ?? ""; row.LinkIndex = 54; if (!row.Name || row.Name === "공종") row.Name = "순공사비"; } }); } return { render, findDataUsages, showUsesSearchDialog, install, toggleJcBimog, toggleSunGongsa }; })();