Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
306 lines
11 KiB
TypeScript
306 lines
11 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`.
|
||
* 절토 연암 · 경암 = 암 × 좌측 패널 비율 — 조작 중 같은 식으로 바로 다시 셈(`…_Options_Calc` · PLAN 122).
|
||
* ========================================================================== */
|
||
|
||
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";
|
||
import { splitRockColumns, splitRockSummary } from "./B08_Quantity_Options_Calc";
|
||
import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options";
|
||
|
||
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;
|
||
/** 저장된 연암 · 경암 %(암 전체 기준) */
|
||
rock_ratios_pct: { 연암: number; 경암: number };
|
||
}
|
||
|
||
/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */
|
||
const TRIPLES: Array<[string[], string, string?]> = [
|
||
[["절토", "토사"], "cut_soil"],
|
||
[["절토", "연암"], "cut_soft_rock", "암 × 연암 %"],
|
||
[["절토", "경암"], "cut_hard_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 summary = table.summary;
|
||
const applyRock = (): void => {
|
||
const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90);
|
||
for (const record of [...table.rows, table.totals])
|
||
splitRockColumns(record as unknown as Record<string, unknown>, soft);
|
||
table.summary = splitRockSummary(summary, table.totals, soft);
|
||
};
|
||
applyRock();
|
||
|
||
const tabs = document.createElement("div");
|
||
tabs.className = "b08-quantity__tabs b08-earthwork__tabs";
|
||
tabs.setAttribute("role", "tablist");
|
||
const view = document.createElement("div");
|
||
let current: SheetId = "summary";
|
||
const select = (picked: SheetId): void => {
|
||
current = picked;
|
||
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");
|
||
onOptionsChange(host, () => {
|
||
applyRock();
|
||
select(current);
|
||
});
|
||
}
|