- 암석 → 연암 · 측구터파기 → 측구파기 · 측구 암 칸 → 암 · 암 칸 머리 풍선 「리핑암 · 발파암 합」 · 값 · 계산 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, Face>;
|
|
}
|
|
|
|
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<string, number>;
|
|
conversion_factors: Record<string, { compacted: number }>;
|
|
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<string, number>;
|
|
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<void> {
|
|
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");
|
|
}
|