Files
Aislo/B08_Quantity/B08_Quantity_UI_Page.ts
T
eomsangdonandClaude Opus 5 01578eaed6 feat(B08): 타설 방식 단가 차이 표시 + 값 자리 식 깃발
타설 방식 — 「무엇을 정해야 하는지」만으로는 부족하고 「정하면 얼마나 달라지는지」가
보여야 사용자가 판단함. 세 방식 단가 차이가 6배라 조용히 기본값을 쓰면 총액이 갈림.
- 방식 이름을 늘 값 옆에 보임(「적용 중: 레디믹스트 (기본값 — 확인 필요)」).
  코드(12-01-01)만으로는 무엇을 쓰는지 모름.
- 참고 단가 세 줄을 안내로 띄움 — 레디믹스트 65,826 · 기계비빔 163,508 ·
  인력비빔 408,327 원/㎥. ⚠ **표시 전용**이며 B08 의 어떤 계산에도 안 들어감
  (금액은 B09 몫). 출처·산출일을 데이터에 함께 적음.

품셈 마스터 — 값 자리에 식이 적힌 칸에 깃발(`expression_cells`).
`0.2 × 30%`(기초잡석 소할)처럼 계산이 그대로 적힌 칸은 값이 숫자로 안 읽혀
그 성분이 조용히 빠짐. 형태 판정은 통과하고 배분율 딱지도 없어 아무 검사에도
안 걸리던 자리(서브 창이 실물에서 부딪힘 — 기초잡석이 부설다짐 0.6인만으로 섬).
⚠ 식을 계산하지 않고 드러내기만 함 — 뜻을 잘못 읽으면 조용히 틀림.
31표에서 잡힘(9-12·9-13 터파기 계열 · 12-24 뒷채움 · 12-25 기초잡석 등).

검증 — 품셈 34건 통과, 전체 588 passed, tsc 오류 0.
화면에서 방식 이름·기본값 표시·단가 세 줄 확인.

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

542 lines
21 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;
}
/** 타설 방식 표기 — 코드가 아니라 사람이 읽는 이름으로 보인다. */
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;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
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;
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) ?? "",
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);
}