㉘ 자기 감사. B09 에 겨눈 네 축을 내 것에 그대로 겨눔. 안 부르던 검사 — 시험에서만 불리고 있던 것들을 실제 경로에 이음 - verify_no_code_on_materials · verify_bill_flags → build_handoff 결과에 실음 - check_against_plan → 운반 표 옆에 haul_check 로 실음 (계획이 있을 때만) - 바로 위 주석이 「검사는 실제로 부른다」였는데 형제 둘이 놀고 있었음 표토 두께 — 서버·엔진은 받고 있었는데 화면에 넣을 칸이 없었음 - optionalNumberField 신설 (빈 값 = 「안 정함」, 0 과 구별) - null 을 저장에서 버리던 것 수정 (NULLABLE_SETTING_KEYS) — 한 번 넣으면 「안 정함」으로 못 되돌리던 자리 짝 시험 — 규칙은 있는데 시험이 없던 둘 - REFERENCE_MARKS (41건을 지웠던 그 규칙): 기준표는 잡고 「단 위」는 안 잡는 것을 양쪽으로 박음 - attachments_of: 칸이 있을 때만 줄이 서고 부모와 id 가 갈리는 것 시험: 662 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관) · tsc 오류 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
597 lines
24 KiB
TypeScript
597 lines
24 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
}),
|
||
},
|
||
);
|
||
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;
|
||
}
|
||
|
||
/** 반영률 키 → 사람이 읽는 이름. 서버 키를 그대로 보이면 설계자가 못 읽는다. */
|
||
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;
|
||
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
|
||
material_supply: Record<string, SupplyChoice>;
|
||
dirty: boolean;
|
||
}
|
||
|
||
/** 좌측 패널: 산출 조건 + 하단 [저장]·[확정] 액션 행.
|
||
* `reload` 는 저장 뒤 표를 다시 그리는 손잡이다 — 조건이 바뀌면 집계·운반 값이 달라진다. */
|
||
function buildQuantitySidePanel(
|
||
projectId: string | null,
|
||
table: EarthworkTable | null,
|
||
draft: DraftSettings,
|
||
reload: () => void,
|
||
): HTMLElement {
|
||
const panel = document.createElement("div");
|
||
panel.className = "b08-quantity__panel";
|
||
|
||
// 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다.
|
||
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) {
|
||
panel.append(field(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;
|
||
},
|
||
),
|
||
);
|
||
|
||
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
// ⚠ 「정하면 얼마나 달라지는지」까지 보여야 사용자가 판단한다. 이 값은 **참고 표시 전용**이고
|
||
// 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,
|
||
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);
|
||
}
|