/* ============================================================================= * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) * * 우측 = 실무 수량산출서의 시트를 탭으로 옮긴 것. 지금은 **토적표** 한 장이 서 있고 * 나머지(토적집계·구조물위치·수량집계표·총괄집계·수리계산·운반거리)는 차례로 붙인다. * 확정 = 워크플로 stage 5(QUANTITY) 완료 처리 후 B09 설계도서로 이동. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, showToast } from "@ui/ui_template_elements"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections"; import { createProvenanceToggle } from "@ui/ui_template_provenance"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid"; import { conversionOverridePayload, renderConversionFactorFields, type FactorDraft, } from "./B08_Quantity_UI_ConversionFactors"; import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; import { renderHaulGrid, renderPreparationGrid, renderSummaryGrid, type PreparationTable, } from "./B08_Quantity_UI_SummaryGrid"; import { renderMaterialGrid, renderUnitQuantityGrid, type MaterialResponse, } from "./B08_Quantity_UI_MaterialGrid"; import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet"; import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary"; import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste"; import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing"; import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval"; import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray"; import type { GravelDraft } from "./B08_Quantity_UI_Side_Gravel"; import { appendGravelFields, gravelDraftFrom, gravelPayload } from "./B08_Quantity_UI_Side_Gravel"; import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** stage 5(QUANTITY) 완료 요청 — 본문 미구현 상태의 유일한 백엔드 연동. */ async function confirmQuantityStage(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/confirm`, { method: "POST", credentials: "include" }, ); if (!response.ok) { throw new Error(`quantity confirm failed: ${response.status}`); } } /** 토적표를 받아 온다. 노선을 안 주면 워크플로가 보고 있는 최신 노선으로 나온다. */ async function fetchEarthworkTable(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/earthwork-table`, { credentials: "include" }, ); if (!response.ok) throw new Error(`earthwork table failed: ${response.status}`); return (await response.json()) as EarthworkTable; } /** 구조물 원단위·자재총괄을 받아 온다. 한 번에 받는 까닭은 자재총괄이 원단위의 부분집합이라서다. */ async function fetchMaterialSummary(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/material-summary`, { credentials: "include" }, ); if (!response.ok) throw new Error(`material summary failed: ${response.status}`); return (await response.json()) as MaterialResponse; } /** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/settings`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rock_class_set: draft.rock_class_set ?? null, rock_ratios_pct: draft.rock_ratios_pct, application_ratios_pct: draft.application_ratios_pct, // ⚠ 「안 정함」으로 되돌린 갈래까지 **통째로** 보낸다. 정한 것만 보내면 서버가 // 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다. rock_methods: draft.rock_methods, material_supply: draft.material_supply, material_surcharge: draft.material_surcharge, concrete_placing_method: draft.concrete_placing_method, // ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙). topsoil_thickness_m: draft.topsoil_thickness_m, // 확정 2차 ① · 3차 ②③④ — 값이 화면 칸에서 오고, 비면 그 줄이 막힌다. bench_cut_depth_m: draft.bench_cut_depth_m, rubble_base_thickness_m: draft.rubble_base_thickness_m, spoil_site_distance_m: draft.spoil_site_distance_m, structure_trench_water: draft.structure_trench_water, topsoil_haul_distance_m: draft.topsoil_haul_distance_m, // `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다. topsoil_target: draft.topsoil_target, stand_volume_class: draft.stand_volume_class, root_removal_excavator_m3: draft.root_removal_excavator_m3, seed_spray_ground: draft.seed_spray_ground, // 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길). face_dressing_cut_class: draft.face_dressing_cut_class, face_dressing_fill_class: draft.face_dressing_fill_class, face_dressing_fill_area_m2: draft.face_dressing_fill_area_m2, face_dressing_cut_area_m2: draft.face_dressing_cut_area_m2, frame_material: draft.frame_material, wood_chipping_enabled: draft.wood_chipping_enabled, wood_chipping_volume_m3: draft.wood_chipping_volume_m3, subgrade_compaction_enabled: draft.subgrade_compaction_enabled, // 조사값은 **통째로** — 지운 칸까지 가야 되돌릴 길이 있다. tree_waste: draft.tree_waste, tree_waste_unit_price_krw_per_ton: draft.tree_waste_unit_price_krw_per_ton, waste_separate_order: draft.waste_separate_order, tree_waste_root_method: draft.tree_waste_root_method, // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. ancillary_counts: draft.ancillary_counts, // 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다. conversion_factors_override: conversionOverridePayload(draft.conversion_factors), // 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길). dozer_haul_limit_m: draft.dozer_haul_limit_m, ...gravelPayload(draft), // 혼합석 부설 — 칸은 `_Side_Gravel` }), }, ); if (!response.ok) throw new Error(`quantity settings save failed: ${response.status}`); } /** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */ function field(label: string, value: string): HTMLElement { const row = document.createElement("div"); row.className = "b08-quantity__field"; const name = document.createElement("span"); name.textContent = label; const amount = document.createElement("span"); amount.className = "b08-quantity__field-value"; amount.textContent = value; row.append(name, amount); return row; } /** 규준틀 재료 칸 — 이름은 **서버 `FRAME_MATERIAL_SUGGESTED` 와 같은 낱말**이라야 한다. */ const FRAME_MATERIAL_FIELDS: ReadonlyArray = [ ["각재 50×50", "B08_Quantity_Frame_Square", "0.0001"], ["판재 T12", "B08_Quantity_Frame_Board", "0.0001"], ["못", "B08_Quantity_Frame_Nail", "0.01"], ]; /** 채워 보이는 **제안값**(실무 관측). ⚠ 법정 기준이 아니라 「고치라고 보이는 값」이다. */ const FRAME_MATERIAL_SUGGESTED: Record = { "각재 50×50": 0.0044, "판재 T12": 0.0029, 못: 0.03, }; /** 부대시설 항목 키 — 서버 `ANCILLARY_ITEMS` 와 **같은 차례·같은 낱말**이라야 한다. */ const ANCILLARY_KEYS = [ "national_point_sign", "guide_sign", "gate", "site_container", "flood_supplies", ] as const; /** 칸 밑에 붙는 **근거 한 줄** — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */ function hintRow(text: string): HTMLElement { const row = document.createElement("p"); row.className = "b08-quantity__hint"; row.textContent = text; return row; } /** 반영률 키 → 사람이 읽는 이름. 서버 키를 그대로 보이면 설계자가 못 읽는다. */ const RATIO_LABEL_KEYS: Record = { fill_slope_compaction: "B08_Quantity_Ratio_FillCompaction", seed_spray_fill: "B08_Quantity_Ratio_SeedFill", seed_spray_cut: "B08_Quantity_Ratio_SeedCut", obstacle_removal: "B08_Quantity_Ratio_TreeRemoval", }; function ratioLabel(key: string): string { const localeKey = RATIO_LABEL_KEYS[key]; return localeKey ? L(localeKey) : key; } /** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(5장). */ function numberField(label: string, value: number, onInput: (value: number) => void): HTMLElement { const row = document.createElement("label"); row.className = "b08-quantity__field"; const name = document.createElement("span"); name.textContent = label; const input = document.createElement("input"); input.type = "number"; input.className = "b08-quantity__input"; input.min = "0"; input.step = "1"; input.value = String(value); // 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장). input.addEventListener("input", () => onInput(Number(input.value))); row.append(name, input); return row; } /** 비워 둘 수 있는 숫자 칸 — **빈 값은 「안 정함」**이고 0 과 다르다. * * ⚠ `numberField` 로 두면 빈 칸이 0 으로 읽혀 「두께 0m」와 「안 정함」이 같아진다. * 표토제거처럼 **안 정하면 줄이 아예 안 서는** 값은 그 둘을 갈라야 한다. */ function optionalNumberField( label: string, value: number | null, step: string, onInput: (value: number | null) => void, ): HTMLElement { const row = document.createElement("label"); row.className = "b08-quantity__field"; const name = document.createElement("span"); name.textContent = label; const input = document.createElement("input"); input.type = "number"; input.className = "b08-quantity__input"; input.min = "0"; input.step = step; input.placeholder = L("B08_Quantity_Unset_Placeholder"); input.value = value === null || value === undefined ? "" : String(value); // 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장). input.addEventListener("input", () => { const text = input.value.trim(); onInput(text === "" ? null : Number(text)); }); row.append(name, input); return row; } /** 타설 방식 표기 — 코드가 아니라 사람이 읽는 이름으로 보인다. */ const PLACING_LABELS: Record = { ready_mixed: "레디믹스트", machine_mixed: "기계비빔", hand_mixed: "인력비빔", }; /** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */ function selectField( label: string, value: string, options: { value: string; label: string }[], onChange: (value: string) => void, ): HTMLElement { const row = document.createElement("label"); row.className = "b08-quantity__field"; const name = document.createElement("span"); name.textContent = label; const select = document.createElement("select"); select.className = "b08-quantity__input"; for (const option of options) { const element = document.createElement("option"); element.value = option.value; element.textContent = option.label; select.append(element); } select.value = value; // 자동저장은 만들지 않는다 — 고른 값은 캐시에만 남는다(CLAUDE.md 5장). select.addEventListener("change", () => onChange(select.value)); row.append(name, select); return row; } /** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */ /** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */ export interface SupplyChoice { supply: string; install_by: string | null; } /** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */ interface DraftSettings extends GravelDraft { rock_class_set?: string; rock_ratios_pct: Record; application_ratios_pct: Record; // 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다. rock_methods: Record; // 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다. concrete_placing_method: string; // 표토 두께(m) — `null` 은 「안 정함」. 정해야 표토제거 줄이 선다(품셈 9-15 [주]② 의 T). topsoil_thickness_m: number | null; // 층따기 길이(m) — 면적 × 이 값 = ㎥ (확정 2차 ①). `null` 이면 층따기 줄이 막힌다. bench_cut_depth_m: number | null; // 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05). rubble_base_thickness_m: number | null; // 사토장까지 거리(m) — 현장값. `null` 이면 사토 운반 줄이 막힌다(확정 3차 ③ 는 미정). spoil_site_distance_m: number | null; // 구조물터파기 용수 — "육상"·"용수". ⚠ 「육상」은 **통상값**이지 사용자 확정이 아니다. structure_trench_water: string; // 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」. topsoil_haul_distance_m: number | null; // 표토제거 대상 — `""` 기본(노면 + 절토, 별표2 문언) · "road_only" 노면만(설계자 선택). topsoil_target: string; // 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). ancillary_counts: Record; // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①). stand_volume_class: string; // 제근 굴착기 크기 — "0.2"·"0.7"(서버 선택지) · `""` 는 「안 정함」. 칸은 `_Side_RootRemoval`. root_removal_excavator_m3: string; // 초류종자살포 비탈면 토질 — "일반"·"마사토"(5-24 잎) · `""` 는 「안 정함」. 칸은 `_Side_SeedSpray`. seed_spray_ground: string; // 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`. face_dressing_cut_class: string; face_dressing_fill_class: string; face_dressing_fill_area_m2: number | null; face_dressing_cut_area_m2: number | null; // 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점. frame_material: Record; // 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다. wood_chipping_enabled: boolean; wood_chipping_volume_m3: number | null; // 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량). subgrade_compaction_enabled: boolean; // 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님). tree_waste: Record; tree_waste_unit_price_krw_per_ton: number | null; waste_separate_order: boolean; tree_waste_root_method: string; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; // 자재별 할증률(%) — 표값 이내에서 고른 값만 · `null` 은 표값. material_surcharge: Record; // 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다. conversion_factors: Record; // 도쟈 한계거리(m) — `null` 은 기본값(서버 정본 60 m)이 선다. dozer_haul_limit_m: number | null; dirty: boolean; } /** 개발 전용 「확정 없이 다음으로」 한 줄 — 단추 둘 + 지금 상태 안내. * * ⚠ **조용히 넘어가지 않는다.** 우회로 열린 상태면 「확정을 건너뛴 상태입니다」를 띄운다 — * 안 그러면 다음 사람이 「왜 값이 없나」로 헤맨다. * ⚠ **되돌리는 단추를 같은 줄에 둔다.** 되돌릴 길이 안 보이면 검증용 프로젝트가 굳는다. */ function devUnlockRow(projectId: string, reload: () => void): HTMLElement { const row = document.createElement("div"); row.className = "b08-quantity__dev"; const title = document.createElement("p"); title.className = "b08-quantity__note"; title.textContent = L("B08_Quantity_Dev_Title"); row.append(title); const state = document.createElement("p"); state.className = "b08-quantity__note"; row.append(state); const paint = (): void => { fetch(`/api/projects/${projectId}/dev/unlock`, { credentials: "include" }) .then((response) => (response.ok ? response.json() : null)) .then((body: { bypassed_stages?: number[] } | null) => { const stages = body?.bypassed_stages ?? []; state.textContent = stages.length ? `${L("B08_Quantity_Dev_Bypassed")} (${stages.join(", ")})` : L("B08_Quantity_Dev_Normal"); }) .catch(() => { state.textContent = L("B08_Quantity_Dev_Normal"); }); }; paint(); const call = (method: "POST" | "DELETE", button: HTMLButtonElement): void => { button.disabled = true; fetch(`/api/projects/${projectId}/dev/unlock`, { method, credentials: "include" }) .then((response) => { if (!response.ok) throw new Error(String(response.status)); showToast(L("B08_Quantity_Dev_Done"), "success"); paint(); reload(); }) .catch(() => showToast(L("B08_Quantity_Dev_Failed"), "error")) .finally(() => { button.disabled = false; }); }; const unlock = createButton({ label: L("B08_Quantity_Dev_Unlock"), variant: "ghost", onClick: () => call("POST", unlock), }); const relock = createButton({ label: L("B08_Quantity_Dev_Relock"), variant: "ghost", onClick: () => call("DELETE", relock), }); const buttons = document.createElement("div"); // ⚠ 공용 `ui-sidebar-actions` 를 쓰지 않는다 — 공용 코드가 **첫 번째** 그 클래스를 // 패널 바닥 액션 줄로 집어(`ui_template_overlay.ts` splitSidebarActions), // 스크롤 영역이 이 줄 안에 갇히고 아래 칸들이 통째로 잘린다(2026-09-12). buttons.className = "b08-quantity__dev-actions"; buttons.append(unlock, relock); row.append(buttons); return row; } /** 좌측 패널: 산출 조건 + 하단 [저장]·[확정] 액션 행. * `reload` 는 저장 뒤 표를 다시 그리는 손잡이다 — 조건이 바뀌면 집계·운반 값이 달라진다. */ function buildQuantitySidePanel( projectId: string | null, table: EarthworkTable | null, draft: DraftSettings, reload: () => void, ): HTMLElement { const panel = document.createElement("div"); panel.className = "b08-quantity__panel"; // ── 개발 전용 「확정 없이 다음으로」 ──────────────────────────────────────── // ⚠ **맨 위에 둔다.** 패널이 `overflow: hidden` 이라 아래에 붙이면 화면 밖으로 // 밀려 **눌리지 않는다**(2026-09-08 화면에서 실제로 그랬다 — 단추 y=772, // 패널 높이 740). 상태 경고이기도 하니 자리도 여기가 맞다. // ⚠ **이것은 보조 문일 뿐이다.** 진짜 문은 서버가 `ENVIRONMENT` 로 막는다 // (`common_util_dev_unlock`). 화면만 숨기면 API 는 그대로 뚫려 있다. // ⚠ **계산을 대신 돌리지 않는다** — 워크플로 잠금만 푼다. 값이 비어 보이는 것은 // 정상이고, 그것을 「미확보」로 보이는 것이 이 화면이 이미 하는 일이다. if (import.meta.env.DEV && projectId) { panel.append(devUnlockRow(projectId, reload)); } panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value"))); // 토량환산계수 — **고를 수 있는 값**이다(오솔길 대조 06절 3번). 기본값 정의처는 서버 한 곳이고, // 화면은 고른 값만 보낸다. 유토곡선·운반표·기초단가가 같이 읽는다는 안내도 그 칸이 낸다. const factorFields = renderConversionFactorFields( table?.conversion_factor_choices, table?.conversion_factor_pumsem_ranges, draft.conversion_factors, () => { draft.dirty = true; }, ); if (factorFields) panel.append(factorFields); // ── 운반장비 거리 경계 — 도쟈 한계거리는 설계 조건이라 칸으로(2026-09-13 판정) ── // ⚠ 기본값과 근거를 **서버가 준 그대로** 옆에 보인다 — 「이 값이 어디서 왔나」가 화면에 있어야 함. const haul = table?.haul_limit_choice; if (haul) { panel.append(field(L("B08_Quantity_Side_HaulLimits"), "")); const dozer = optionalNumberField( L("B08_Quantity_Side_DozerLimit_Label"), draft.dozer_haul_limit_m, "5", (value) => { draft.dozer_haul_limit_m = value; draft.dirty = true; }, ); const input = dozer.querySelector("input"); if (input) input.placeholder = String(haul.default); panel.append(dozer); panel.append(hintRow(`${L("B08_Quantity_Factor_Default")} ${haul.default} m — ${haul.basis}`)); panel.append( hintRow(`${L("B08_Quantity_Side_FreeHaul")} ${haul.free_haul_m} m — ${haul.free_haul_basis}`), ); panel.append(hintRow(L("B08_Quantity_Side_HaulLimits_Reach"))); } // ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ── const classes = [...(table?.summary?.rock_classes ?? [])]; // ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도 // 시공법을 정할 수 있어야 공종이 선다 — 그때만 칸을 하나 더 낸다. const hasRockFallback = (table?.summary?.rows ?? []).some((row) => row.item === "암"); if (hasRockFallback && !classes.includes("암")) classes.push("암"); if (classes.length) { panel.append(field(L("B08_Quantity_Side_RockRatios"), "")); for (const name of classes) { // 「암」은 비율을 넣으면 사라지는 되메움 줄이라 비율 칸을 두지 않는다. // 「토사」도 비율 칸을 두지 않는다 — 토사 물량은 토적표의 흙깎기 값이 그대로 서고, // 비율은 **암 총량을 갈래로 나누는 데만** 쓰인다(집계 엔진 `_rock_split`). // 칸을 두면 넣은 값이 조용히 버려져 「입력 합 60 %」 같은 안내가 뜬다(2026-09-08 통과). if (name !== "암" && name !== "토사") { panel.append( numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => { draft.rock_ratios_pct[name] = value; draft.dirty = true; }), ); } // ⚠ 암 갈래는 **시공법까지 정해야** 공종이 갈린다 — 품셈이 긁어내기(암절취)와 // 터뜨리기(발파암)를 다른 공종으로 두기 때문이다. 「토사」에는 안 붙인다. if (name !== "토사") { panel.append( selectField( ` ${name} ${L("B08_Quantity_Side_Method_Label")}`, draft.rock_methods[name] ?? "", [ { value: "", label: L("B08_Quantity_Method_Unset") }, { value: "ripping", label: L("B08_Quantity_Method_Ripping") }, { value: "blasting", label: L("B08_Quantity_Method_Blasting") }, ], (value) => { draft.rock_methods[name] = value; draft.dirty = true; }, ), ); } } } // ── 반영률 — 기본 100 %. 실무 관측 80/50/80 은 기본값이 아니다(PLAN 8-11) ── const ratios = table?.settings?.application_ratios_pct ?? {}; if (Object.keys(ratios).length) { panel.append(field(L("B08_Quantity_Side_Ratios"), "")); for (const key of Object.keys(ratios)) { panel.append( numberField(ratioLabel(key), draft.application_ratios_pct[key] ?? 100, (value) => { draft.application_ratios_pct[key] = value; draft.dirty = true; }), ); } } // ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로. const sideHelpers = { field, optionalNumberField, selectField, hintRow }; appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers); appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers); // ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ── // ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.** // 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리. panel.append(field(L("B08_Quantity_Side_Topsoil"), "")); // 대상 — 기본은 법 문언(노면 + 절토). 영월 한 건이 노면만이라 3배 차이 → 설계자가 고름(2026-09-14). panel.append( selectField( L("B08_Quantity_Side_TopsoilTarget_Label"), draft.topsoil_target, [ { value: "", label: L("B08_Quantity_TopsoilTarget_RoadAndCut") }, { value: "road_only", label: L("B08_Quantity_TopsoilTarget_RoadOnly") }, ], (value) => { draft.topsoil_target = value; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_TopsoilTarget_Hint"))); panel.append( optionalNumberField( L("B08_Quantity_Side_Topsoil_Label"), draft.topsoil_thickness_m, "0.01", (value) => { draft.topsoil_thickness_m = value; draft.dirty = true; }, ), ); // ── 구조물·사토 — 확정 2차 ① · 3차 ②③④ 의 값들 ────────────────────── // ⚠ 사용자 지시(2026-09-09): **값을 코드에 박고 끝내지 말고 화면에 칸으로 세우고 // 지금 값과 근거를 보이고 바꿀 수 있게 할 것.** 정한 값이 화면에 안 보이면 다음 사람이 // 왜 그 값인지 모른다. panel.append(field(L("B08_Quantity_Side_Structure"), "")); // 층따기 칸 · KDS 근거 줄 · [제안값 넣기] — 누른 때만 채움(교본 6-4 「설계도서에 명시」). appendBenchCutFields(panel, draft, { field, optionalNumberField, selectField, hintRow }); panel.append( optionalNumberField( L("B08_Quantity_Side_Rubble_Label"), draft.rubble_base_thickness_m, "0.05", (value) => { draft.rubble_base_thickness_m = value; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_Rubble_Hint"))); panel.append( optionalNumberField( L("B08_Quantity_Side_SpoilDistance_Label"), draft.spoil_site_distance_m, "10", (value) => { draft.spoil_site_distance_m = value; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_SpoilDistance_Hint"))); panel.append( selectField( L("B08_Quantity_Side_Water_Label"), draft.structure_trench_water, [ { value: "", label: L("B08_Quantity_Water_Unset") }, { value: "육상", label: L("B08_Quantity_Water_Dry") }, { value: "용수", label: L("B08_Quantity_Water_Wet") }, ], (value) => { draft.structure_trench_water = value; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_Water_Hint"))); panel.append( optionalNumberField( L("B08_Quantity_Side_TopsoilHaul_Label"), draft.topsoil_haul_distance_m, "10", (value) => { draft.topsoil_haul_distance_m = value; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_TopsoilHaul_Hint"))); panel.append( selectField( L("B08_Quantity_Side_StandVolume_Label"), draft.stand_volume_class, [ { value: "", label: L("B08_Quantity_StandVolume_Unset") }, { value: "소림", label: L("B08_Quantity_StandVolume_Low") }, { value: "중림", label: L("B08_Quantity_StandVolume_Mid") }, { value: "밀림", label: L("B08_Quantity_StandVolume_High") }, ], (value) => { draft.stand_volume_class = value; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint"))); appendRootRemovalFields(panel, draft, table?.root_removal_excavator_choices, { field, optionalNumberField, selectField, hintRow, }); // ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ── panel.append(field(L("B08_Quantity_Side_Ancillary"), "")); for (const key of ANCILLARY_KEYS) { panel.append( optionalNumberField( L(`B08_Quantity_Ancillary_${key}` as keyof typeof ui_locales), draft.ancillary_counts[key] ?? null, "1", (value) => { draft.ancillary_counts[key] = value; draft.dirty = true; }, ), ); } panel.append(hintRow(L("B08_Quantity_Side_Ancillary_Hint"))); // ── 규준틀 재료 — ⚠ **세는 것은 확정이고 수량만 모르던 자리**(품셈 [주]④ 「설계수량에 따른다」). // 그래서 **제안값을 채워 보이고 고칠 수 있게** 둔다(확정 ⑨·⑩ 과 같은 틀). // ⚠ 값만 박고 근거를 안 보이면 사용자 지시(「대신 페이지에 남길 것」)를 어기는 것이라 // 칸 밑에 **관측값임**과 **손율 원문값**을 함께 적는다. panel.append(field(L("B08_Quantity_Side_Frame"), "")); for (const [key, label, step] of FRAME_MATERIAL_FIELDS) { panel.append( optionalNumberField( L(label as keyof typeof ui_locales), draft.frame_material[key] ?? FRAME_MATERIAL_SUGGESTED[key], step, (value) => { draft.frame_material[key] = value; draft.dirty = true; }, ), ); } panel.append(hintRow(L("B08_Quantity_Side_Frame_Hint"))); // ── 임목파쇄 — ⚠ **기본 꺼짐**(확정 5차 5번). 「셀지 말지가 설계 판단」이라 켜야 줄이 선다. // ⚠ 근주이식은 **칸 자체를 안 만든다** — 켤 자리가 없으면 물을 일도 없다. panel.append(field(L("B08_Quantity_Side_Chipping"), "")); panel.append( selectField( L("B08_Quantity_Chipping_Label"), draft.wood_chipping_enabled ? "on" : "", [ { value: "", label: L("B08_Quantity_Chipping_Off") }, { value: "on", label: L("B08_Quantity_Chipping_On") }, ], (value) => { draft.wood_chipping_enabled = value === "on"; draft.dirty = true; }, ), ); panel.append( optionalNumberField( L("B08_Quantity_Chipping_Volume"), draft.wood_chipping_volume_m3, "1", (value) => { draft.wood_chipping_volume_m3 = value; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint"))); // ── 노체다짐 — ⚠ **기본 꺼짐**(2026-09-13 판정). 9-16-2 [주]⑤ 조건부라 켜야 토공집계에 줄이 선다. panel.append(field(L("B08_Quantity_Side_SubgradeCompaction"), "")); panel.append( selectField( L("B08_Quantity_SubgradeCompaction_Label"), draft.subgrade_compaction_enabled ? "on" : "", [ { value: "", label: L("B08_Quantity_Chipping_Off") }, { value: "on", label: L("B08_Quantity_Chipping_On") }, ], (value) => { draft.subgrade_compaction_enabled = value === "on"; draft.dirty = true; }, ), ); panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint"))); // ── 혼합석 부설 — 법령 조건 자동 · 연약·습윤 칸 · 전 구간 고르개(2026-09-15 브레인 ⓑ). appendGravelFields(panel, draft, table?.gravel, { field, optionalNumberField, selectField, hintRow, }); // ── 임목폐기물 — 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14). 칸은 따로 뺀 파일에서. appendTreeWasteFields(panel, draft, { field, optionalNumberField, selectField, hintRow }); // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── panel.append(field(L("B08_Quantity_Side_Placing"), "")); panel.append( selectField( L("B08_Quantity_Side_Placing_Label"), draft.concrete_placing_method, [ { value: "", label: L("B08_Quantity_Placing_Unset") }, { value: "ready_mixed", label: L("B08_Quantity_Placing_Ready") }, { value: "machine_mixed", label: L("B08_Quantity_Placing_Machine") }, { value: "hand_mixed", label: L("B08_Quantity_Placing_Hand") }, ], (value) => { draft.concrete_placing_method = value; draft.dirty = true; }, ), ); // ⚠ `?.` 이 빠지면 표를 못 받은 때(`table === null`) 여기서 터져 **페이지가 통째로 // 백지**가 된다 — 정작 보여야 할 「표를 못 불렀다」 안내까지 같이 사라진다(2026-09-12 실측). const placing = ( table as unknown as { concrete_placing?: { method: string; is_default: boolean; price_hint?: { basis?: string; values?: Record }; }; } | null )?.concrete_placing; if (placing) { // ⚠ 방식 이름을 **늘** 값 옆에 보인다 — 코드(`12-01-01`)만으로는 무엇을 쓰는지 모른다. const label = PLACING_LABELS[placing.method] ?? placing.method; panel.append( field( L("B08_Quantity_Placing_Current"), placing.is_default ? `${label} (${L("B08_Quantity_Placing_Default_Tag")})` : label, ), ); // 2026-09-08 ㉙: 인계가 **타설 공종 줄을 실제로 세운다.** 겹치지 않는 것이 확인됐다 — // 품셈 12-1-1 표는 직종·품만 주고 재료를 안 줘서, 품은 이 줄 · 재료는 자재 쪽이다. // ⚠ 돌쌓기 뒤채움(채움콘크리트)은 뺐다 — 그 공종 품에 이미 들어 있을 수 있다. // ⚠ **기본값일 때는 싣지 않는다** — 바로 아래 「기본값으로 계산 중」 경고가 더 많은 것을 // 말하는데, 둘을 겹쳐 실으면 긴 문구가 그 경고를 화면 밖으로 밀어낸다 // (2026-09-08 ㉕ 화면 통과에서 실제로 잘려 있었다). if (!placing.is_default) { const applied = document.createElement("p"); applied.className = "b08-quantity__note"; applied.textContent = L("B08_Quantity_Placing_NotApplied"); panel.append(applied); } } // ⚠ 「정하면 얼마나 달라지는지」까지 보여야 사용자가 판단한다. 이 값은 **참고 표시 전용**이고 // B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫). const hint = placing?.price_hint; if (hint?.values) { const line = document.createElement("p"); line.className = "b08-quantity__notice"; const parts = Object.entries(hint.values).map( ([key, value]) => `${PLACING_LABELS[key] ?? key} ${Math.round(value).toLocaleString("ko-KR")}원`, ); line.textContent = `${L("B08_Quantity_Placing_Hint")} ${hint.basis ?? ""} — ${parts.join(" · ")}`; panel.append(line); } if (placing?.is_default) { // 「확인 필요」만 있으면 무엇을 정해야 하는지 모른다 — **지금 무엇으로 돌고 있는지**를 함께 적는다. const notice = document.createElement("p"); notice.className = "b08-quantity__notice"; notice.textContent = L("B08_Quantity_Placing_Default_Notice"); panel.append(notice); } const saveButton = createButton({ label: L("B08_Quantity_Btn_Save"), variant: "ghost", onClick: () => { if (!projectId) { showToast(L("B08_Quantity_Save_Failed"), "error"); return; } saveButton.disabled = true; saveQuantitySettings(projectId, draft) .then(() => { draft.dirty = false; showToast(L("B08_Quantity_Save_Success"), "success"); // 조건이 바뀌면 집계·운반 값이 달라진다 — 표를 다시 받아 그린다. reload(); }) .catch(() => { showToast(L("B08_Quantity_Save_Failed"), "error"); saveButton.disabled = false; }); }, }); const confirmButton = createButton({ label: L("B08_Quantity_Btn_Confirm"), variant: "filled", onClick: () => { if (!projectId) { showToast(L("B08_Quantity_Confirm_Failed"), "error"); return; } confirmButton.disabled = true; confirmQuantityStage(projectId) .then(() => { showToast(L("B08_Quantity_Confirm_Success"), "success"); goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]); }) .catch(() => { showToast(L("B08_Quantity_Confirm_Failed"), "error"); confirmButton.disabled = false; }); }, }); const actions = document.createElement("div"); actions.className = "b08-quantity__actions ui-sidebar-actions"; // [초기화]는 이번에 달지 않는다 — 5장의 [초기화]는 초기값(`initial_snapshot/`)을 작업본에 // 덮어쓰는 것인데 설정에는 대응하는 초기값이 아직 없다. 재계산 단추로 오해될 자리다. // TODO(미결) — 설정의 초기값을 무엇으로 볼지 사용자 확인 뒤에 붙인다. actions.append(saveButton, confirmButton); panel.append(actions); // 조건 칸을 B03~B07 공통 상자로 묶고 제목 클릭으로 접히게 한다. groupPanelSections(panel); attachCollapsible(panel); return panel; } /** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */ function buildQuantityBody( table: EarthworkTable | null, failed: boolean, material: MaterialResponse | null, draft: DraftSettings, projectId: string | null, ): HTMLElement { const body = document.createElement("div"); body.className = "b08-quantity__body"; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs"; const pane = document.createElement("div"); pane.className = "b08-quantity__pane"; const message = (text: string): HTMLElement => { const element = document.createElement("p"); element.className = "b08-quantity__message"; element.textContent = text; return element; }; // 등급색 토글 — ⚠ **사전이 왔을 때만** 만든다(개발환경). 배포 빌드에서는 단추 자체가 없다. // ⚠ 만들기만 하고 **붙이는 것은 탭 단추를 다 세운 뒤**이다 — 여기서 바로 붙이면 // 탭 줄 **맨 앞**에 서서 「토적표」 앞에 탭 하나가 더 있는 것처럼 보인다(2026-09-12 실측: x=8). const provenanceToggle = (table as unknown as { provenance?: unknown } | null)?.provenance || (material as unknown as { provenance?: unknown } | null)?.provenance ? createProvenanceToggle(body) : null; if (failed) { body.append(tabs, message(L("B08_Quantity_Grid_Failed"))); return body; } if (!table || !table.rows?.length) { body.append(tabs, message(L("B08_Quantity_Grid_Empty"))); return body; } const sheets: { label: string; build: () => HTMLElement }[] = [ { label: L("B08_Quantity_Tab_Earthwork"), build: () => renderEarthworkGrid(table) }, { label: L("B08_Quantity_Tab_Summary"), build: () => table.summary ? renderSummaryGrid(table.summary, table.provenance?.sheets?.summary) : message(L("B08_Quantity_Grid_Empty")), }, { label: L("B08_Quantity_Tab_Haul"), build: () => table.haul ? renderHaulGrid( table.haul, Boolean(table.haul_available), table.provenance?.sheets?.haul, ) : message(L("B08_Quantity_Haul_Missing")), }, { label: L("B08_Quantity_Tab_Preparation"), build: () => { const preparation = (table as unknown as { preparation?: PreparationTable }).preparation; return preparation ? renderPreparationGrid(preparation, table.provenance?.sheets?.preparation) : message(L("B08_Quantity_Grid_Empty")); }, }, // 구조물도 — 제원 조합 하나 = 한 장(PLAN 3장). 스스로 받아 오므로 표를 넘기지 않음. // ⚠ 탭 차례 바꾸기·원단위 탭 흡수는 PLAN 5장 몫 — 여기서는 원단위 앞에 붙이기만 함. // 구조물 집계표 — 측점별 한 줄(PLAN 2장). 구조물도의 앞 사슬이라 그 바로 앞에 둠. { label: L("B08_Quantity_Tab_StructureSummary"), build: () => renderStructureSummary(projectId), }, { label: L("B08_Quantity_Tab_StructureSheet"), build: () => renderStructureSheets(projectId) }, { label: L("B08_Quantity_Tab_UnitQuantity"), build: () => material ? renderUnitQuantityGrid(material) : message(L("B08_Quantity_Material_Failed")), }, { label: L("B08_Quantity_Tab_Material"), build: () => material ? renderMaterialGrid( material.material, { choices: draft.material_supply, surcharge: draft.material_surcharge, onChange: () => { draft.dirty = true; }, }, material.provenance?.sheets?.material, ) : message(L("B08_Quantity_Material_Failed")), }, ]; const buttons: HTMLButtonElement[] = []; const show = (index: number): void => { buttons.forEach((button, i) => button.classList.toggle("is-active", i === index)); pane.replaceChildren(sheets[index].build()); }; sheets.forEach((sheet, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "b08-quantity__tab"; button.textContent = sheet.label; button.addEventListener("click", () => show(index)); buttons.push(button); tabs.append(button); }); // 토글은 탭 줄 **끝**에 둔다 — 표를 고르는 단추가 아니라 보기 방식을 바꾸는 단추라 섞이면 안 된다. if (provenanceToggle) tabs.append(provenanceToggle); body.append(tabs, pane); show(0); return body; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export async function renderB08Quantity(root: HTMLElement): Promise { injectEarthworkGridStyles(); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); // 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다. let table: EarthworkTable | null = null; let material: MaterialResponse | null = null; let failed = false; if (projectId) { try { table = await fetchEarthworkTable(projectId); } catch { failed = true; } // 자재총괄은 따로 받는다 — 구조물이 없어도 토적표는 서야 하므로 실패를 옮기지 않는다. try { material = await fetchMaterialSummary(projectId); } catch { material = null; } } // 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장). const stored = table?.settings ?? {}; const draft: DraftSettings = { rock_class_set: stored.rock_class_set, rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) }, application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) }, rock_methods: { ...((stored.rock_methods ?? {}) as Record) }, concrete_placing_method: (stored.concrete_placing_method as string) ?? "", topsoil_thickness_m: (stored.topsoil_thickness_m as number | null) ?? null, bench_cut_depth_m: (stored.bench_cut_depth_m as number | null) ?? null, rubble_base_thickness_m: (stored.rubble_base_thickness_m as number | null) ?? null, spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null, structure_trench_water: (stored.structure_trench_water as string) ?? "", topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, topsoil_target: (stored.topsoil_target as string) ?? "", stand_volume_class: (stored.stand_volume_class as string) ?? "", root_removal_excavator_m3: (stored.root_removal_excavator_m3 as string) ?? "", seed_spray_ground: (stored.seed_spray_ground as string) ?? "", face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "", face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "", face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null, face_dressing_cut_area_m2: (stored.face_dressing_cut_area_m2 as number | null) ?? null, frame_material: { ...((stored.frame_material ?? {}) as Record) }, wood_chipping_enabled: Boolean(stored.wood_chipping_enabled), wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null, subgrade_compaction_enabled: Boolean(stored.subgrade_compaction_enabled), tree_waste: { ...((stored.tree_waste ?? {}) as Record) }, tree_waste_unit_price_krw_per_ton: (stored.tree_waste_unit_price_krw_per_ton as number | null) ?? null, waste_separate_order: Boolean(stored.waste_separate_order), tree_waste_root_method: (stored.tree_waste_root_method as string) ?? "", ancillary_counts: { ...((stored.ancillary_counts ?? {}) as Record), }, material_supply: { ...((stored.material_supply ?? {}) as Record) }, material_surcharge: { ...((stored.material_surcharge ?? {}) as Record), }, // ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다. conversion_factors: Object.fromEntries( Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [ kind, { compacted: typeof entry?.compacted === "number" ? entry.compacted : null, reason: typeof entry?.reason === "string" ? entry.reason : "", }, ]), ), dozer_haul_limit_m: (stored.dozer_haul_limit_m as number | null) ?? null, ...gravelDraftFrom(stored as Record), dirty: false, }; const reload = (): void => { root.replaceChildren(); void renderB08Quantity(root); }; // 저장 안 한 값이 조용히 사라지지 않게 나갈 때 알린다 — 이 구조의 대가다. const warnUnsaved = (event: BeforeUnloadEvent): void => { if (!draft.dirty) return; event.preventDefault(); event.returnValue = L("B08_Quantity_Unsaved"); }; window.addEventListener("beforeunload", warnUnsaved); let workflowState: Awaited> | undefined; if (projectId) { try { workflowState = await fetchWorkflowState(projectId); } catch { /* 조회 실패 시 stages 미전달 → 전체 이동 허용 (다른 워크플로 페이지와 같음) */ } } const layout = createWorkflowLayout({ title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, leftPanel: buildQuantitySidePanel(projectId, table, draft, reload), mainContent: buildQuantityBody(table, failed, material, draft, projectId), stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); }