/* ============================================================================= * B08_Quantity_UI_Earthwork.ts * 토공 탭(PLAN 61-3 · 69-2 · 69-3) — 보조 탭 넷 = 토공집계표 · 토적표 · 평균운반거리 · 종방향 무대운반거리. * * GET …/quantity/earthwork 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림. * 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 연암 · 측구파기 토사 · 암 각 단면적 · 입적 · * 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각 * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—). * 표 = 공용 많은 자료 표(`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 { renderEarthworkSummary, renderFreeHaul, renderHaulDistance, } from "./B08_Quantity_UI_Earthwork_Sheets"; type Face = { length_m: number | null; area_m2: number | null }; interface EarthworkRow { chainage_m: number; distance_m: number; cut_soil_area_m2: number; cut_soil_volume_m3: number; cut_soil_adjusted_m3: number; cut_rock_area_m2: number; cut_rock_volume_m3: number; cut_rock_adjusted_m3: number; cut_rock_kind: string; ditch_soil_area_m2: number; ditch_soil_volume_m3: number; ditch_soil_adjusted_m3: number; ditch_rock_area_m2: number; ditch_rock_volume_m3: number; ditch_rock_adjusted_m3: number; adjusted_total_m3: number; fill_area_m2: number; fill_volume_m3: number; diverted_m3: number; balance_m3: number; cumulative_m3: number; notes: string[]; faces: Record; } export interface HaulRow { q: number; l: number; ql: number; } export interface HaulList { rows: HaulRow[]; q: number; ql: number; average_m: number | null; } export interface SummaryRow { group: string; kind: string; spec: string; unit: string; value: number | null; note: string; } export interface EarthworkTable { rows: EarthworkRow[]; totals: Record; conversion_factors: Record; station_count: number; route_id: number; confirmed: boolean; summary: SummaryRow[]; haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; haul_saved: boolean; } /** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ const TRIPLES: Array<[string[], 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 n2 = (key: string, label: string, group?: string[]): DataColumn => ({ key, label, group, kind: "number", decimals: 2, }); /** 견본 차례 32칸 */ const COLUMNS: DataColumn[] = [ { key: "station", label: "측점", width: 72 }, n2("distance", "거리"), ...TRIPLES.flatMap(([group, key, tip]) => [ n2(`${key}_area`, "단면적", group), n2(`${key}_volume`, "입적", group), n2(`${key}_adjusted`, "보정량", group), ].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)), ), 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"); p.className = "ui-general-block__empty"; p.textContent = text; return p; } /** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */ export function tableCard( info: HTMLElement[], table: HTMLElement, after: HTMLElement[] = [], ): HTMLElement { return createCard({ body: [...info, table, ...after] }); } 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`]); } 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): DataRow { const t = table.totals; 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 { if (!table.rows.length) { host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); return; } 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 = [ { id: "summary", label: "토공집계표" }, { id: "sheet", label: "토적표" }, { id: "haul", label: "평균운반거리" }, { id: "free", label: "종방향 무대운반거리" }, ] as const; type SheetId = (typeof SHEETS)[number]["id"]; export async function renderEarthwork(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("토공 수량을 만드는 중…")); let table: EarthworkTable; let interval = 20; try { const [response, gap] = await Promise.all([ fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }), stationInterval(projectId).catch(() => 20), ]); const body = (await response.json().catch(() => null)) as (EarthworkTable & { detail?: unknown }) | null; if (!response.ok || !body) { const detail = body?.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); } table = body; interval = gap; } catch (error) { host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`)); return; } const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs b08-earthwork__tabs"; tabs.setAttribute("role", "tablist"); const view = document.createElement("div"); const select = (picked: SheetId): void => { tabs.replaceChildren( ...SHEETS.map(({ id, label }) => { const button = createButton({ label, variant: id === picked ? "filled" : "ghost", size: "sm", onClick: () => id !== picked && select(id), }); button.setAttribute("role", "tab"); button.setAttribute("aria-selected", String(id === picked)); button.dataset.b08EarthworkSheet = id; return button; }), ); if (picked === "sheet") renderSheet(view, table, interval); else if (picked === "summary") renderEarthworkSummary(view, table); else if (picked === "haul") renderHaulDistance(view, table); else renderFreeHaul(view, table); }; host.replaceChildren(tabs, view); select("summary"); }