From 4756f0fe727da90a902866860b9316727083dbad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:12:41 +0900 Subject: [PATCH] auto: 2026-09-07 23:12 (EOMSANGDON-HOME) --- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 14 ++ .../B08_Quantity_UI_EarthworkGrid_Style.ts | 29 +++ B08_Quantity/B08_Quantity_UI_Page.ts | 191 ++++++++++++++++-- ui_template/ui_template_locale_b2.ts | 17 ++ 4 files changed, 233 insertions(+), 18 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 02431449..87b97a84 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -53,6 +53,14 @@ export interface SlopeTable { unclosed_stations: number[]; } +/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */ +export interface QuantitySettings { + rock_class_set?: string; + rock_classes?: string[]; + rock_ratios_pct?: Record; + application_ratios_pct?: Record; +} + export interface EarthworkTable { method: string; station_count: number; @@ -61,6 +69,12 @@ export interface EarthworkTable { totals: Record; conversion_factors?: Record; slope?: SlopeTable; + /** 토공집계표·운반표는 같은 응답에 실려 온다 — 나눠 부르지 않는다. */ + summary?: import("./B08_Quantity_UI_SummaryGrid").SummaryTable; + haul?: import("./B08_Quantity_UI_SummaryGrid").HaulTable; + /** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */ + haul_available?: boolean; + settings?: QuantitySettings; } /** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index 2b629acc..9e06c426 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -121,6 +121,35 @@ const CSS = ` } .b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; } +.b08-quantity__pane { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; } + +/* 집계·운반표는 열이 적어 왼쪽 정렬이 읽기 좋다 — 숫자 칸만 오른쪽으로 둔다. */ +.b08-grid__table--summary th, +.b08-grid__table--summary td { text-align: left; } +.b08-grid__table--summary td:nth-child(5), +.b08-grid__table--summary td:nth-child(4) { text-align: right; } +.b08-grid__unit { text-align: center; } +.b08-grid__note { white-space: normal; max-width: 26rem; } + +/* 「내역 제외」 같은 표시 — 규칙이 코드에만 있으면 잊힌다. 화면에 남긴다. */ +.b08-grid__tag { + display: inline-block; + margin-right: 4px; + padding: 0 6px; + font-size: 11px; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); +} + +.b08-quantity__input { + width: 5rem; + font-size: 12px; + text-align: right; + font-variant-numeric: tabular-nums; + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); +} .b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); } .b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; } .b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 147265b1..ece0199e 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -19,6 +19,7 @@ import { } 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"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -46,6 +47,24 @@ async function fetchEarthworkTable(projectId: string): Promise { return (await response.json()) as EarthworkTable; } +/** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */ +async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise { + 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"); @@ -59,10 +78,43 @@ function field(label: string, value: string): HTMLElement { return row; } -/** 좌측 패널: 산출 조건(읽기 전용) + 하단 [확정] 액션 행. */ +/** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(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; + application_ratios_pct: Record; + 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"; @@ -77,6 +129,57 @@ function buildQuantitySidePanel( } } + // ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, 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(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", @@ -105,35 +208,67 @@ function buildQuantitySidePanel( return panel; } -/** 우측 본문 — 시트 탭 + 그 장의 표. 지금 서 있는 장은 토적표 하나다. */ +/** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */ function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement { const body = document.createElement("div"); body.className = "b08-quantity__body"; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs"; - const tab = document.createElement("button"); - tab.type = "button"; - tab.className = "b08-quantity__tab is-active"; - tab.textContent = L("B08_Quantity_Tab_Earthwork"); - tabs.append(tab); - body.append(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) { - const message = document.createElement("p"); - message.className = "b08-quantity__message"; - message.textContent = L("B08_Quantity_Grid_Failed"); - body.append(message); + body.append(tabs, message(L("B08_Quantity_Grid_Failed"))); return body; } if (!table || !table.rows?.length) { - const message = document.createElement("p"); - message.className = "b08-quantity__message"; - message.textContent = L("B08_Quantity_Grid_Empty"); - body.append(message); + body.append(tabs, message(L("B08_Quantity_Grid_Empty"))); return body; } - body.append(renderEarthworkGrid(table)); + + 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")), + }, + ]; + + 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; } @@ -155,6 +290,26 @@ export async function renderB08Quantity(root: HTMLElement): Promise { } } + // 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(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> | undefined; if (projectId) { try { @@ -168,7 +323,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, - leftPanel: buildQuantitySidePanel(projectId, table), + leftPanel: buildQuantitySidePanel(projectId, table, draft, reload), mainContent: buildQuantityBody(table, failed), stages: workflowState?.stages, currentStage: workflowState?.current_stage, diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 75fceeee..123f55c1 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -619,6 +619,23 @@ export const ui_locales_b2 = { "토적표를 불러오지 못했습니다.", "Failed to load the earthwork table.", ], + B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"], + B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], + B08_Quantity_Haul_Missing: [ + "운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.", + "No haul plan yet. Press [Confirm] on the profile design to build it.", + ], + B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"], + B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"], + B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"], + B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"], + B08_Quantity_Btn_Save: ["저장", "Save"], + B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."], + B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."], + B08_Quantity_Unsaved: [ + "저장하지 않은 변경이 있습니다.", + "You have unsaved changes.", + ], B08_Quantity_Side_Method: ["산출법", "Method"], B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],