Files
Aislo/B08_Quantity/B08_Quantity_UI_Page.ts
eomsangdonandClaude Opus 5 962bbfe971 feat(B08): 임목파쇄 켜는 칸 (확정 5차 5번) — 기본 꺼짐 · 근주이식은 칸 없음
- 「기본 안 셈」이되 「현장에 따라 파쇄가 적용될 필요 있음」이라 **임목파쇄만** 켤 수 있게 둠
- ⚠ 안 켠 프로젝트에는 **줄 자체가 없음** — 빈 칸이 「세야 함」으로 읽히지 않게
  (부대시설은 법정 의무라 늘 서는 것과 다른 자리)
- 켜도 부피는 지어내지 않음 — 넣어야 값이 서고 FP-08-11(이동식 임목 파쇄)이 붙음
- 근주이식(FP-14-02)은 **칸도 안 만듦** — 켤 자리가 없으면 물을 일도 없음
- 설정 `wood_chipping_enabled`·`wood_chipping_volume_m3` + 화면 칸 둘
- tmp/tests/test_b08_wood_chipping.py 신설(5건)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:16:09 +09:00

928 lines
38 KiB
TypeScript
Raw Permalink 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 { 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 { 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";
/** 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,
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,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
}),
},
);
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;
}
/** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */
const GROUND_LABELS: Record<string, string> = {
soil: "토사",
ripping_rock: "리핑암",
blasting_rock: "발파암",
};
function groundLabel(kind: string): string {
return GROUND_LABELS[kind] ?? kind;
}
/** 자재 한 줄의 관급/사급. `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;
// 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 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;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
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");
buttons.className = "b08-quantity__actions ui-sidebar-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")));
const entries = Object.entries(table?.conversion_factors ?? {});
if (entries.length) {
panel.append(field(L("B08_Quantity_Side_Factors"), ""));
for (const [kind, value] of entries) {
// ⚠ 서버 키(`soil`·`ripping_rock`·`blasting_rock`)를 그대로 내보내지 않는다 —
// 2026-09-08 ㉕ 화면 통과에서 좌측 세 줄이 개발자 키로 떠 있었다(`soil_guard` 와 같은 병).
// 모르는 키는 **지어내지 않고** 그대로 보인다.
panel.append(field(groundLabel(kind), String((value as { compacted: number }).compacted)));
}
}
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, 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;
}),
);
}
}
// ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
// 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리.
panel.append(field(L("B08_Quantity_Side_Topsoil"), ""));
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")));
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")));
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
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;
},
),
);
const placing = (
table as unknown as {
concrete_placing?: {
method: string;
is_default: boolean;
price_hint?: { basis?: string; values?: Record<string, number> };
};
}
).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);
return panel;
}
/** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */
function buildQuantityBody(
table: EarthworkTable | null,
failed: boolean,
material: MaterialResponse | null,
draft: DraftSettings,
): 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;
};
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) : message(L("B08_Quantity_Grid_Empty")),
},
{
label: L("B08_Quantity_Tab_Haul"),
build: () =>
table.haul
? renderHaulGrid(table.haul, Boolean(table.haul_available))
: 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)
: message(L("B08_Quantity_Grid_Empty"));
},
},
{
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;
},
})
: 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);
});
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,
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,
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
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),
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);
}