Files
Aislo/B08_Quantity/B08_Quantity_UI_Page.ts
T
eomsangdonandClaude Opus 5 c7d917864c feat(B08): 암 시공법 칸 · 관급구분 표 입력 · 산출 크기 요약
- 암 시공법(ripping/blasting)을 갈래 비율 칸 아래에 붙임. 갈래 이름만으로는
  품셈 공종(암절취 FP-09-04 / 발파암 FP-09-05)을 못 고르던 자리. 기본은
  「안 정함」이고, 안 정하면 인계에서 사유와 함께 드러남. 비율 미입력 상태의
  「암」 한 줄에도 칸을 냄.
- 관급/사급은 표 안에서 줄마다 고름. 관급 줄에만 설치주체가 열리고 사급으로
  되돌리면 잠기며 비워짐. 만진 줄은 표시가 남음.
- 산출 요약(최소·중앙·최대)을 원단위·자재총괄·인계에 붙임. 단위별로 갈라 냄 —
  값이 있기만 하면 시험이 못 잡는 자릿수 어긋남을 사람이 훑게 하는 장치.

화면에서 걸려 고친 것 둘
- 관급을 골라도 설치주체 칸이 잠긴 채 남던 것. 고른 즉시 열고 닫게 함.
- 한 번 고른 시공법을 되돌릴 길이 없던 것. 설정 저장이 병합이라 빈 값을 보내도
  옛 값이 남았음. `save_section(replace_keys=…)` 로 되돌릴 수 있어야 하는 칸만
  통째로 갈아 끼움. 나머지는 그대로 병합.

앞 커밋에서 온 타입 오류 2건도 고침 (`slopeColumnCount` 미사용,
`variant: "outlined"` 는 없는 값). 앞선 확인에서 npx 가 엉뚱한 패키지를 실행해
「오류 없음」으로 잘못 봤음 — tsc 는 config/node_modules 것으로 부를 것.

검증 — 새 시험 5건 + 인계 28건 통과, 전체 509 passed. tsc 오류 0.
화면 실조작으로 시공법 저장·되돌리기, 관급/설치주체 저장·되돌리기 확인 후
검증으로 바꾼 값은 원래대로 복원.

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

459 lines
18 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.
/* =============================================================================
* 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, renderSummaryGrid } 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,
}),
},
);
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;
}
/** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */
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>;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
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) {
// 「암」은 비율을 넣으면 사라지는 되메움 줄이라 비율 칸을 두지 않는다.
if (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 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_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>) },
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);
}