구조물 원단위의 `destination == "material"` 성분만 모아 자재별 합산 후 할증률을 한 번만 적용. 열은 순수량·할증률·합계 + 관급구분·설치주체·비고이며 금액은 없음(B09 경계). - 할증률은 코드가 아니라 데이터 — `resources/data_material_surcharge/` (품셈 1-3-1 재료 할증률 19종 + sha256 매니페스트). 실무 관측값은 `observed_practice` 로 분리(법대로 원칙). - 표에 없는 자재는 0 % 로 넘기지 않고 「할증률 미확보」로 표시. 이름 조회는 정확 일치 — 부분일치면 `막자갈` 이 `자갈` 할증을 뭄. - 이중계상 방어 ㉠ — 앞 단계 `surcharge_applied` 깃발을 실제로 읽어 경고. 자재총괄 응답은 `True`, 원단위표는 `False` 로 어느 쪽 값인지 명시. - 관급/사급 이름은 B09 와 동일(`owner_supplied`/`contractor_supplied`). 관급 줄에만 설치 주체(`install_by`)를 붙이고, 미지정은 기본값으로 때우지 않고 드러냄 — 안전관리비 대상액이 「도급자설치 관급금액」이라서임. - 라우터 `GET /quantity/material-summary` 신설, 화면에 「구조물 원단위」· 「자재총괄」 탭 추가. `design_owner` 가 붙은 타입(측구)은 중복 계상 방지로 제외. 검증 — 전용 테스트 23건 통과, 전체 회귀 473 passed(기존 B05 깨짐 1건 제외). 공용 브라우저 실조작으로 탭 5장·머리글·값 4줄·미확보 안내 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
14 KiB
TypeScript
385 lines
14 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, 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,
|
|
}),
|
|
},
|
|
);
|
|
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;
|
|
}
|
|
|
|
/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */
|
|
interface DraftSettings {
|
|
rock_class_set?: string;
|
|
rock_ratios_pct: Record<string, number>;
|
|
application_ratios_pct: Record<string, number>;
|
|
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 ?? [];
|
|
if (classes.length) {
|
|
panel.append(field(L("B08_Quantity_Side_RockRatios"), ""));
|
|
for (const name of classes) {
|
|
panel.append(
|
|
numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => {
|
|
draft.rock_ratios_pct[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: "outlined",
|
|
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,
|
|
): 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)
|
|
: 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 ?? {}) },
|
|
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),
|
|
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);
|
|
}
|