From 8b1fd2f6eddd07b36439c4756295d0205b29dcc2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:19:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=ED=86=A0=EA=B3=B5=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=20=ED=83=AD=20=EB=84=B7=EC=9D=84=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=EB=A7=8E=EC=9D=80=20=EC=9E=90=EB=A3=8C=20=ED=91=9C=EB=A1=9C?= =?UTF-8?q?=20(PLAN=2069-2=20=C2=B7=2069-3=20=C2=B7=2070-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토적표 32칸 · 측점 고정 · 묶음 머리 석 줄 · 합계 줄 / 토공집계표 · 운반 시트도 같은 표 · 카드 안 - 옛 토적표 전용 CSS 걷어냄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 267 ++++++++---------- .../B08_Quantity_UI_Earthwork_Sheets.ts | 122 ++++---- B08_Quantity/B08_Quantity_UI_Page.css | 30 -- 3 files changed, 165 insertions(+), 254 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 2a82c1051..8bb5a5f22 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -5,12 +5,13 @@ * GET …/quantity/earthwork 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림. * 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 암석 · 측구터파기 토사 · 암석 각 단면적 · 입적 · * 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각 - * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 「-」. - * 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀 · 나머지 셋은 `B08_Quantity_UI_Earthwork_Sheets`. + * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—). + * 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard } from "@ui/ui_template_elements"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { stationLabel } from "../common_util/common_util_station"; import { stationInterval } from "./B08_Quantity_UI_Summary"; import { @@ -81,39 +82,51 @@ export interface EarthworkTable { haul_saved: boolean; } -const ROCK_KIND_LABEL: Record = { ripping_rock: "리핑암", blasting_rock: "발파암" }; -const TRIPLE = ["단면적", "입적", "보정량"]; -const FACE_GROUPS: Array<[string, Array<[string, string]>]> = [ - [ - "면고르기", - [ - ["grading_fill", "성토면"], - ["grading_cut", "절토면"], - ], - ], - [ - "지장목제거", - [ - ["tree_removal_fill", "성토"], - ["tree_removal_cut", "절토"], - ], - ], - ["제근", [["grubbing", ""]]], - ["노면정리", [["road_surface", ""]]], +/** 칸 묶음 — [머리 묶음, 키 앞머리] · 단면적 · 입적 · 보정량 셋 */ +const TRIPLES: Array<[string[], string]> = [ + [["절토", "토사"], "cut_soil"], + [["절토", "암석"], "cut_rock"], + [["측구터파기", "토사"], "ditch_soil"], + [["측구터파기", "암석"], "ditch_rock"], +]; +/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */ +const FACES: Array<[string[], string]> = [ + [["면고르기", "성토면"], "grading_fill"], + [["면고르기", "절토면"], "grading_cut"], + [["지장목제거", "성토"], "tree_removal_fill"], + [["지장목제거", "절토"], "tree_removal_cut"], + [["제근"], "grubbing"], + [["노면정리"], "road_surface"], ]; -const FACE_KEYS = FACE_GROUPS.flatMap(([, faces]) => faces.map(([key]) => key)); -const DASH = "-"; -const num = (value: number | undefined | null): string => - value == null || !Number.isFinite(value) ? "" : value.toFixed(2); +const n2 = (key: string, label: string, group?: string[]): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals: 2, +}); -export function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: number }) { - const node = document.createElement(tag); - node.textContent = text; - if (span?.row) node.rowSpan = span.row; - if (span?.col) node.colSpan = span.col; - return node; -} +/** 견본 차례 32칸 */ +const COLUMNS: DataColumn[] = [ + { key: "station", label: "측점", width: 72 }, + n2("distance", "거리"), + ...TRIPLES.flatMap(([group, key]) => [ + n2(`${key}_area`, "단면적", group), + n2(`${key}_volume`, "입적", group), + n2(`${key}_adjusted`, "보정량", group), + ]), + n2("adjusted_total", "보정량계"), + n2("fill_area", "단면적", ["성토"]), + n2("fill_volume", "입적", ["성토"]), + n2("diverted", "유용토"), + n2("balance", "차인토량"), + n2("cumulative", "누가토량"), + ...FACES.flatMap(([group, key]) => [ + n2(`${key}_length`, "거리", group), + n2(`${key}_area`, "면적", group), + ]), +]; export function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -122,125 +135,63 @@ export function note(text: string): HTMLElement { return p; } -/** 표 한 장 — 1탭 집계표 모양 · 공용 컨테이너(카드) 안 */ +/** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */ export function tableCard( info: HTMLElement[], - className: string, - parts: HTMLElement[], + table: HTMLElement, + after: HTMLElement[] = [], ): HTMLElement { - const el = document.createElement("table"); - el.className = `ui-general-block__table b08-summary__table ${className}`; - el.append(...parts); - const wrap = document.createElement("div"); - wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; - wrap.append(el); - return createCard({ body: [...info, wrap] }); + return createCard({ body: [...info, table, ...after] }); } -/** 머리 석 줄 — 견본 차례 · 32칸 */ -function head(): HTMLTableSectionElement { - const station = cell("th", "측점", { row: 3 }); - station.classList.add("b08-summary__station"); - const top = document.createElement("tr"); - top.append( - station, - cell("th", "거리", { row: 3 }), - cell("th", "절토", { col: 6 }), - cell("th", "측구터파기", { col: 6 }), - cell("th", "보정량계", { row: 3 }), - cell("th", "성토", { col: 2, row: 2 }), - cell("th", "유용토", { row: 3 }), - cell("th", "차인토량", { row: 3 }), - cell("th", "누가토량", { row: 3 }), - ...FACE_GROUPS.map(([label, faces]) => - cell("th", label, { col: faces.length * 2, row: faces.length > 1 ? 1 : 2 }), - ), - ); - const mid = document.createElement("tr"); - for (let i = 0; i < 2; i += 1) - mid.append(cell("th", "토사", { col: 3 }), cell("th", "암석", { col: 3 })); - for (const [, faces] of FACE_GROUPS) { - if (faces.length > 1) mid.append(...faces.map(([, label]) => cell("th", label, { col: 2 }))); +function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow { + // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값) + const v = (value: number): number | null => (first ? null : value); + const out: DataRow = { + station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), + distance: row.distance_m, + adjusted_total: v(row.adjusted_total_m3), + fill_area: row.fill_area_m2, + fill_volume: v(row.fill_volume_m3), + diverted: v(row.diverted_m3), + balance: v(row.balance_m3), + cumulative: v(row.cumulative_m3), + }; + const r = row as unknown as Record; + for (const [, key] of TRIPLES) { + out[`${key}_area`] = r[`${key}_area_m2`]; + out[`${key}_volume`] = v(r[`${key}_volume_m3`]); + out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]); } - const sub = document.createElement("tr"); - for (let i = 0; i < 4; i += 1) sub.append(...TRIPLE.map((t) => cell("th", t))); - sub.append(cell("th", "단면적"), cell("th", "입적")); - for (const _ of FACE_KEYS) sub.append(cell("th", "거리"), cell("th", "면적")); - const thead = document.createElement("thead"); - thead.append(top, mid, sub); - return thead; -} - -function bodyRow(row: EarthworkRow, first: boolean, interval: number): HTMLTableRowElement { - const tr = document.createElement("tr"); - tr.dataset.b08Earthwork = String(row.chainage_m); - const station = cell( - "th", - stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), - ); - station.className = "b08-summary__station"; - if (row.notes.length) station.title = row.notes.join("\n"); - // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(견본 「-」) - const v = (value: number): string => (first ? DASH : num(value)); - const triple = (area: number, volume: number, adjusted: number) => [ - num(area), - v(volume), - v(adjusted), - ]; - const values = [ - num(row.distance_m), - ...triple(row.cut_soil_area_m2, row.cut_soil_volume_m3, row.cut_soil_adjusted_m3), - ...triple(row.cut_rock_area_m2, row.cut_rock_volume_m3, row.cut_rock_adjusted_m3), - ...triple(row.ditch_soil_area_m2, row.ditch_soil_volume_m3, row.ditch_soil_adjusted_m3), - ...triple(row.ditch_rock_area_m2, row.ditch_rock_volume_m3, row.ditch_rock_adjusted_m3), - v(row.adjusted_total_m3), - num(row.fill_area_m2), - v(row.fill_volume_m3), - v(row.diverted_m3), - v(row.balance_m3), - v(row.cumulative_m3), - ...FACE_KEYS.flatMap((key) => { - const face = row.faces?.[key]; - return [num(face?.length_m), first ? DASH : num(face?.area_m2)]; - }), - ]; - const cells = values.map((text) => cell("td", text)); - // 암석 칸 — 어느 갈래(환산계수)로 셌는지 마우스를 올려 봄 - if (row.cut_rock_area_m2 > 0) cells[4].title = ROCK_KIND_LABEL[row.cut_rock_kind] ?? ""; - tr.append(station, ...cells); - return tr; + for (const [, key] of FACES) { + out[`${key}_length`] = row.faces?.[key]?.length_m ?? null; + out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null); + } + return out; } /** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ -function totalRow(table: EarthworkTable): HTMLTableRowElement { +function totalRow(table: EarthworkTable): DataRow { const t = table.totals; - const last = table.rows[table.rows.length - 1]; - const triple = (key: string) => ["", num(t[`${key}_volume_m3`]), num(t[`${key}_adjusted_m3`])]; - const values = [ - num(t.distance_m), - ...triple("cut_soil"), - ...triple("cut_rock"), - ...triple("ditch_soil"), - ...triple("ditch_rock"), - num(t.adjusted_total_m3), - "", - num(t.fill_volume_m3), - num(t.diverted_m3), - num(t.balance_m3), - num(last?.cumulative_m3), - // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 - ...FACE_KEYS.flatMap((key) => [ - "", - table.rows.some((row) => row.faces?.[key]?.area_m2 != null) ? num(t[`${key}_area_m2`]) : "", - ]), - ]; - const tr = document.createElement("tr"); - tr.className = "b08-earthwork__total"; - tr.dataset.b08EarthworkTotal = ""; - const label = cell("th", "합계"); - label.className = "b08-summary__station"; - tr.append(label, ...values.map((v) => cell("td", v))); - return tr; + const out: DataRow = { + station: "합계", + distance: t.distance_m, + adjusted_total: t.adjusted_total_m3, + fill_volume: t.fill_volume_m3, + diverted: t.diverted_m3, + balance: t.balance_m3, + cumulative: table.rows[table.rows.length - 1]?.cumulative_m3, + }; + for (const [, key] of TRIPLES) { + out[`${key}_volume`] = t[`${key}_volume_m3`]; + out[`${key}_adjusted`] = t[`${key}_adjusted_m3`]; + } + // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 + for (const [, key] of FACES) { + const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null); + out[`${key}_area`] = any ? t[`${key}_area_m2`] : null; + } + return out; } function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void { @@ -248,20 +199,26 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); return; } - const c = (kind: string): string => num(table.conversion_factors[kind]?.compacted); - const info = note( - `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + - `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + - `발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + - `단위 m · ㎡ · ㎥ · * = 사유 있음(마우스를 올림)`, - ); - const tbody = document.createElement("tbody"); - tbody.append(...table.rows.map((row, i) => bodyRow(row, i === 0, interval))); - const tfoot = document.createElement("tfoot"); - tfoot.append(totalRow(table)); - const card = tableCard([info], "b08-earthwork__table", [head(), tbody, tfoot]); - card.querySelector("table")!.dataset.b08Earthwork = "table"; - host.replaceChildren(card); + const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? ""; + const flagged = table.rows.filter((row) => row.notes.length); + const info = [ + note( + `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + + `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + + `발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + + `단위 m · ㎡ · ㎥`, + ), + ...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), + ]; + const grid = createDataTable({ + columns: COLUMNS, + rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)), + frozen: 1, + total: totalRow(table), + caption: "토적표", + }); + grid.root.dataset.b08Earthwork = "table"; + host.replaceChildren(tableCard(info, grid.root)); } const SHEETS = [ diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index 2a04e3521..884c95f86 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -4,60 +4,45 @@ * * 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`). * 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임. + * 표 = 공용 많은 자료 표(`ui_template_data_table`). * ========================================================================== */ -import { - cell, - note, - tableCard, - type EarthworkTable, - type HaulList, -} from "./B08_Quantity_UI_Earthwork"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork"; const fmt = (value: number | null | undefined): string => value == null || !Number.isFinite(value) ? "—" : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); -function row(cells: HTMLElement[], className = ""): HTMLTableRowElement { - const tr = document.createElement("tr"); - if (className) tr.className = className; - tr.append(...cells); - return tr; -} +const SUMMARY_COLUMNS: DataColumn[] = [ + { key: "group", label: "구분", width: 120 }, + { key: "kind", label: "공종", width: 72 }, + { key: "spec", label: "규격", width: 128 }, + { key: "unit", label: "단위", width: 56 }, + { key: "value", label: "계", kind: "number", decimals: 2, width: 112 }, + { key: "note", label: "비고", width: 200 }, +]; -function station(text: string, span?: { row?: number; col?: number }): HTMLElement { - const th = cell("th", text, span); - th.className = "b08-summary__station"; - return th; -} - -/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 · 같은 구분은 한 칸으로 묶음 */ +/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { - const thead = document.createElement("thead"); - thead.append( - row([station("구분"), ...["공종", "규격", "단위", "계", "비고"].map((t) => cell("th", t))]), - ); - const tbody = document.createElement("tbody"); - const rows = table.summary; - rows.forEach((item, i) => { - const cells: HTMLElement[] = []; - if (rows[i - 1]?.group !== item.group) { - let span = 1; - while (rows[i + span]?.group === item.group) span += 1; - cells.push(station(item.group, { row: span })); - } - const value = cell("td", fmt(item.value)); - value.className = "b08-earthwork__num"; - cells.push(cell("td", item.kind), cell("td", item.spec), cell("td", item.unit), value); - cells.push(cell("td", item.note)); - tbody.append(row(cells)); - }); const info = note( "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + "리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(— = 아직 값 없음)", ); - host.replaceChildren(tableCard([info], "b08-earthwork__sheet", [thead, tbody])); + const grid = createDataTable({ + columns: SUMMARY_COLUMNS, + // 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「—」(값 없음)로 그림 + rows: table.summary.map((row) => ({ + ...row, + kind: row.kind || " ", + spec: row.spec || " ", + note: row.note || " ", + })), + frozen: 1, + caption: "토공집계표", + }); + host.replaceChildren(tableCard([info], grid.root)); } function haulNote(table: EarthworkTable): HTMLElement[] { @@ -73,42 +58,41 @@ function haulSheet( groups: Array<[string, HaulList]>, info: string, ): void { - const thead = document.createElement("thead"); - thead.append( - row([station("번호", { row: 2 }), ...groups.map(([label]) => cell("th", label, { col: 3 }))]), - row(groups.flatMap(() => ["토량(Q)", "거리(L)", "Q×L"].map((t) => cell("th", t)))), - ); - const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); - const tbody = document.createElement("tbody"); - for (let i = 0; i < count; i += 1) { - const cells = groups.flatMap(([, list]) => { - const item = list.rows[i]; - return item ? [fmt(item.q), fmt(item.l), fmt(item.ql)] : ["", "", ""]; - }); - tbody.append(row([station(String(i + 1)), ...cells.map((t) => cell("td", t))])); - } - const tfoot = document.createElement("tfoot"); - tfoot.append( - row( + const columns: DataColumn[] = [ + { key: "no", label: "번호", width: 56 }, + ...groups.flatMap(([label], g) => [ - station("합계"), - ...groups.flatMap(([, list]) => [fmt(list.q), "", fmt(list.ql)].map((t) => cell("td", t))), - ], - "b08-earthwork__total", + ["q", "토량(Q)"], + ["l", "거리(L)"], + ["ql", "Q×L"], + ].map(([key, text]) => ({ + key: `${key}${g}`, + label: text, + group: [label], + kind: "number" as const, + decimals: 2, + width: 104, + })), ), - ); + ]; + const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); + const rows: DataRow[] = Array.from({ length: count }, (_, i) => { + const out: DataRow = { no: String(i + 1) }; + groups.forEach(([, list], g) => { + const item = list.rows[i]; + if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql }); + }); + return out; + }); + const total: DataRow = { no: "합계" }; + groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql })); const averages = groups.map(([label, list], i) => note( `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, ), ); - const card = tableCard([note(info), ...haulNote(table)], "b08-earthwork__haul", [ - thead, - tbody, - tfoot, - ]); - card.append(...averages); - host.replaceChildren(card); + const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info }); + host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index d9991c177..6fd399917 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -67,33 +67,3 @@ .b08-summary__name.is-missing { color: var(--color-danger); } - -/* 토공 탭 — 수는 오른쪽 · 합계 줄은 아래에 붙어 굵게 */ -.b08-earthwork__table td, -.b08-earthwork__haul td, -.b08-earthwork__sheet .b08-earthwork__num { - text-align: right; -} - -/* 토적표 머리 셋째 줄 — 둘째 줄 아래에 붙음 */ -.b08-summary__table thead tr + tr + tr th { - top: calc(2 * (1lh + 2 * var(--spacing-4, 4px) + 1px)); -} - -.b08-earthwork__total th, -.b08-earthwork__total td { - position: sticky; - /* 칸 테두리는 표(border-collapse) 것이라 붙은 칸과 같이 안 움직임 — 그림자로 선을 긋고 - 아래로 1px 더 내려 뒤 줄이 비치지 않게 */ - bottom: -1px; - z-index: 2; - font-weight: 700; - background: var(--color-surface-raised, var(--color-surface)); - box-shadow: - inset 0 2px 0 var(--color-text-muted), - 0 1px 0 var(--color-surface-raised, var(--color-surface)); -} - -.b08-earthwork__total th { - z-index: 3; -}