diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 36449e77..41e571fc 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -123,8 +123,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; const PAIR_LABELS = ["단면적", "입 적"]; -const flatColumns = (): Column[] => - GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); +const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); /** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */ function stationLabel(chainage: number, interval = 20): string { diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts new file mode 100644 index 00000000..a3577ff0 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * B08_Quantity_UI_EarthworkGrid_Style.ts + * 토적표 그리드 스타일. 한 번만 주입한다. + * + * 실무 산출서와 눈으로 대조되는 것이 이 표의 목적이라, 장식보다 **줄·칸이 또렷한 것**을 + * 우선한다. 숫자는 등폭으로 두어 자릿수가 세로로 맞는다. + * ========================================================================== */ + +const STYLE_ID = "b08-earthwork-grid-style"; + +/* 색은 전부 프로젝트 테마 변수를 쓴다 — 어두운 테마에서 머리글이 묻히지 않아야 한다. + 글자색을 배경과 함께 지정하는 까닭이 그것이다(배경만 주면 상속색이 배경에 잠긴다). */ +const CSS = ` +.b08-grid { display: flex; flex-direction: column; gap: 8px; min-width: 0; height: 100%; } + +.b08-grid__caption { + margin: 0; + font-size: 12px; + color: var(--color-text-secondary); +} + +/* 열이 많아 화면을 넘친다 — 표만 가로로 구르고 페이지 몸통은 안 구른다. */ +.b08-grid__scroll { overflow: auto; flex: 1 1 auto; min-height: 0; } + +.b08-grid__table { + border-collapse: collapse; + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--color-text-body); +} + +.b08-grid__table th, +.b08-grid__table td { + border: 1px solid var(--color-border); + padding: 2px 8px; + text-align: right; +} + +.b08-grid__table thead th { + position: sticky; + top: 0; + background: var(--color-surface-raised); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); + text-align: center; + z-index: 1; +} + +/* 측점 열은 왼쪽에 붙어 있어야 가로로 굴러도 어느 줄인지 보인다. */ +.b08-grid__station { + position: sticky; + left: 0; + background: var(--color-surface); + color: var(--color-text); + text-align: left; + font-weight: var(--font-weight-medium, 500); +} + +.b08-grid__table tfoot td { + background: var(--color-surface-raised); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); +} + +.b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); } + +.b08-quantity__tab { + padding: 4px 12px; + font-size: 13px; + border: 1px solid var(--color-border); + border-bottom: none; + background: var(--color-surface-raised); + color: var(--color-text-secondary); + cursor: pointer; +} + +.b08-quantity__tab.is-active { + background: var(--color-surface); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); +} + +.b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; } +.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; } +`; + +/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */ +export function injectEarthworkGridStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.append(style); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index c6463b8d..147265b1 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,16 +2,23 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) * - * ⚠️ 본문 준비 중 — 워크플로우 셸 + 좌측 [확정] 버튼만 구성. + * 우측 = 실무 수량산출서의 시트를 탭으로 옮긴 것. 지금은 **토적표** 한 장이 서 있고 + * 나머지(토적집계·구조물위치·수량집계표·총괄집계·수리계산·운반거리)는 차례로 붙인다. * 확정 = 워크플로 stage 5(QUANTITY) 완료 처리 후 B09 설계도서로 이동. - * 수량 본문(B06 종횡단 기반 산출)은 후속 계획에서 구현한다. * ========================================================================== */ 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 { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +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"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -29,15 +36,46 @@ async function confirmQuantityStage(projectId: string): Promise { } } -/** 좌측 패널: 준비 중 안내 + 하단 [확정] 액션 행 (다른 워크플로우 페이지와 동일 배치). */ -function buildQuantitySidePanel(projectId: string | null): HTMLElement { +/** 토적표를 받아 온다. 노선을 안 주면 워크플로가 보고 있는 최신 노선으로 나온다. */ +async function fetchEarthworkTable(projectId: string): Promise { + 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; +} + +/** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */ +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; +} + +/** 좌측 패널: 산출 조건(읽기 전용) + 하단 [확정] 액션 행. */ +function buildQuantitySidePanel( + projectId: string | null, + table: EarthworkTable | null, +): HTMLElement { const panel = document.createElement("div"); panel.className = "b08-quantity__panel"; - const note = document.createElement("p"); - note.className = "b08-quantity__pending-note"; - note.textContent = L("B08_Quantity_Side_Pending"); - panel.append(note); + // 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다. + 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))); + } + } const confirmButton = createButton({ label: L("B08_Quantity_Btn_Confirm"), @@ -67,15 +105,77 @@ function buildQuantitySidePanel(projectId: string | null): HTMLElement { 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); + + if (failed) { + const message = document.createElement("p"); + message.className = "b08-quantity__message"; + message.textContent = L("B08_Quantity_Grid_Failed"); + body.append(message); + 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); + return body; + } + body.append(renderEarthworkGrid(table)); + return body; +} + /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export async function renderB08Quantity(root: HTMLElement): Promise { + injectEarthworkGridStyles(); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - await renderPendingWorkflow(root, { + + // 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다. + let table: EarthworkTable | null = null; + let failed = false; + if (projectId) { + try { + table = await fetchEarthworkTable(projectId); + } catch { + failed = true; + } + } + + let workflowState: Awaited> | 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), + leftPanel: buildQuantitySidePanel(projectId, table), + mainContent: buildQuantityBody(table, failed), + 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); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3e21a402..79f0576e 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -609,6 +609,19 @@ export const ui_locales_b2 = { "수량 단계 확정에 실패했습니다.", "Failed to confirm the quantity stage.", ], + B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"], + B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], + B08_Quantity_Grid_Empty: [ + "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", + "No cross-section areas yet. Finish the cross-section design first.", + ], + B08_Quantity_Grid_Failed: [ + "토적표를 불러오지 못했습니다.", + "Failed to load the earthwork table.", + ], + B08_Quantity_Side_Method: ["산출법", "Method"], + B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], + B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], /* --- B09_Estimation 견적·문서 --- */ B09_Estimation_Title: ["설계도서", "Design Docs"],