Files
Aislo/B08_Quantity/B08_Quantity_UI_Page.ts
T
eomsangdonandClaude Opus 5 24fc6ec090 feat(B08): 화면에 안 보이던 확정 대기 셋을 드러냄
목록에만 있고 화면에 없으면 사용자는 그것이 잠정인 줄도 모름. 셋 다
「지금 어떤 값으로 돌고 있는지 + 왜 잠정인지」를 함께 보임.

- 콘크리트 타설 방식을 좌측 패널 칸으로 냄. ⚠ 금액에 바로 걸리는 값이라
  기본값으로 돌고 있으면 「기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값」
  안내를 띄움. 설정 기본을 None 으로 바꿔 「안 정함」과 「일부러 레디믹스트를
  고른 것」을 가름 — 값을 미리 넣으면 그 구별이 사라짐. 되돌리기도 됨.
- 물구멍 근거에 잠정값을 적음 — 「관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·
  간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)」. 「미확정」만으로는 무엇을
  정해야 하는지 모름.
- 준비공의 벌목 줄에 공종 미확정 사유와 후보를 함께 적음(수확베기·단목베기·
  위험목 베기 중 어느 것인지 원본이 말하지 않음).

검증 — 전체 580 passed, tsc 오류 0. 화면에서 셋 다 뜨는 것과 타설 방식
저장·되돌리기까지 확인 후 검증으로 바꾼 값은 원래대로 복원.

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

506 lines
20 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,
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,
}),
},
);
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>;
// 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다.
concrete_placing_method: 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;
}),
);
}
}
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
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 } }
).concrete_placing;
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) ?? "",
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);
}