Files
Aislo/B08_Quantity/B08_Quantity_UI_Page.ts
T
eomsangdonandClaude Opus 5 7d856f37a3 feat(b08,b09): 폐기물처리비 — 경비 비목(일반관리비·이윤 밑수) · 임목폐기물 톤 · 분리발주 칸
- 원가계산서: 폐기물처리비를 경비 줄로(예정가격작성기준 제19조③18호) · 법정경비 뒤라 그 밑수엔 안 섞임
- 분리발주 칸(기본 아님) — 켜면 총원가 밖 · 총공사비에만 더함
- 준비공 「임목폐기물 처리」 톤: WA = 0.5·π·(B/2)²·h·1.3·W1·N · WR = WA × 15/85 (한국건설기술연구원 2012)
- 조사값 넷(1,000㎡당 본수·흉고직경·수고·단위체적중량) 칸 · 기본값 없음 · 5톤·100톤 경계 알림
- 수동 처리단가 빨간 테두리 + 「미확정 N건」 · 내역엔 안 서고 제외 사유 「경비」
- 시험: 승률 구조(경비·일반관리비·이윤 밑수 증가 · 법정경비 불변 · 분리발주) · 실정보고 부피 대조

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 00:55:41 +09:00

1073 lines
46 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** stage 5(QUANTITY) 완료 요청 — 본문 미구현 상태의 유일한 백엔드 연동. */
async function confirmQuantityStage(projectId: string): Promise<void> {
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<EarthworkTable> {
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<MaterialResponse> {
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<void> {
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,
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,
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,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
// 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길).
dozer_haul_limit_m: draft.dozer_haul_limit_m,
}),
},
);
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<readonly [string, string, string]> = [
["각재 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<string, number> = {
"각재 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<string, keyof typeof ui_locales> = {
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<string, string> = {
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 {
rock_class_set?: string;
rock_ratios_pct: Record<string, number>;
application_ratios_pct: Record<string, number>;
// 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다.
rock_methods: Record<string, string>;
// 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다.
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<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string;
// 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점.
frame_material: Record<string, number | null>;
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
wood_chipping_enabled: boolean;
wood_chipping_volume_m3: number | null;
// 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량).
subgrade_compaction_enabled: boolean;
// 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님).
tree_waste: Record<string, number | null>;
tree_waste_unit_price_krw_per_ton: number | null;
waste_separate_order: boolean;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
conversion_factors: Record<string, FactorDraft>;
// 도쟈 한계거리(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;
}),
);
}
}
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × 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"), ""));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_BenchCut_Label"),
draft.bench_cut_depth_m,
"0.01",
(value) => {
draft.bench_cut_depth_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
// 층따기 관행값 — **제안값**이지 확정이 아니다(원본 그림 확인 전 · 10장 판정). 칸에 넣지 않고 보이기만.
const benchSuggest = hintRow(L("B08_Quantity_Side_BenchCut_Suggest"));
benchSuggest.classList.add("b08-quantity__hint--warn");
panel.append(benchSuggest);
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")));
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 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-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<string, number> };
};
} | 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,
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<void> {
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<string, string>) },
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) ?? "",
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
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<string, number | null>) },
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),
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
// ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다.
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,
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<ReturnType<typeof fetchWorkflowState>> | 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);
}