Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B08_Quantity_UI_Earthwork.ts
|
|
* 토공 탭(PLAN 61-3 · 69-2 · 69-3) — 보조 탭 넷 = 토공집계표 · 토적표 · 평균운반거리 · 종방향 무대운반거리.
|
|
*
|
|
* GET …/quantity/earthwork 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림.
|
|
* 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 암석 · 측구터파기 토사 · 암석 각 단면적 · 입적 ·
|
|
* 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각
|
|
* 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 「-」.
|
|
* 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀 · 나머지 셋은 `B08_Quantity_UI_Earthwork_Sheets`.
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { createButton, createCard } from "@ui/ui_template_elements";
|
|
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 ROCK_KIND_LABEL: Record<string, string> = { 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 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);
|
|
|
|
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;
|
|
}
|
|
|
|
export function note(text: string): HTMLElement {
|
|
const p = document.createElement("p");
|
|
p.className = "ui-general-block__empty";
|
|
p.textContent = text;
|
|
return p;
|
|
}
|
|
|
|
/** 표 한 장 — 1탭 집계표 모양 · 공용 컨테이너(카드) 안 */
|
|
export function tableCard(
|
|
info: HTMLElement[],
|
|
className: string,
|
|
parts: 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] });
|
|
}
|
|
|
|
/** 머리 석 줄 — 견본 차례 · 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 })));
|
|
}
|
|
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;
|
|
}
|
|
|
|
/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */
|
|
function totalRow(table: EarthworkTable): HTMLTableRowElement {
|
|
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;
|
|
}
|
|
|
|
function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void {
|
|
if (!table.rows.length) {
|
|
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 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");
|
|
}
|