feat(B08): 토공 보조 탭 넷을 공용 많은 자료 표로 (PLAN 69-2 · 69-3 · 70-2)

- 토적표 32칸 · 측점 고정 · 묶음 머리 석 줄 · 합계 줄 / 토공집계표 · 운반 시트도 같은 표 · 카드 안
- 옛 토적표 전용 CSS 걷어냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-06 23:19:14 +09:00
co-authored by Claude Opus 5.5
parent ec4267ecba
commit 8b1fd2f6ed
3 changed files with 165 additions and 254 deletions
+112 -155
View File
@@ -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<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 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<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`]);
}
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 = [
@@ -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 {
-30
View File
@@ -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;
}