Files
Aislo/B08_Quantity/B08_Quantity_UI_StructureSummary.ts
T
eomsangdonandClaude Opus 5 d9c84aaaf0 feat(b08): 구조물 집계표 탭 — 측점별 한 줄·종류별 표·칸마다 출처
- 정본 둘(structures.json·pipe_points.json)과 B06 관 연장을 읽어 종류별 표로 세움
- 한 줄 = 측점 + 실치수(제원 칸) + 개소·연장 · 표마다 개소·연장 합·평균치수
- 칸 출처 자동·사용자·라이브러리(양식 기본값)·빈칸 · 배수관과 세월교는 다른 표
- 이 표에서 고친 값을 B05 가 바꾸면 빨간 테두리와 알림(조용히 안 사라짐)
- 구조물도 탭 앞에 탭 등록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 20:12:24 +09:00

200 lines
7.8 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_StructureSummary.ts
* **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 (PLAN 2장).
*
* ⚠ 값을 셈하지 않음 — 서버(`…/quantity/structure-summary`)가 정본을 읽어 세운 줄을 적기만.
* ⚠ 칸마다 출처 표시 — 자동(정본) · 사용자(이 표에서 고침) · 라이브러리(양식 기본값) · 빈칸.
* ⚠ 이 표에서 고친 값을 B05 가 바꿔 자동으로 돌아간 칸은 **조용히 넘기지 않고** 줄에 알림.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
type Source = "auto" | "user" | "library" | "empty";
interface SummaryCell {
value: number | string | null;
source: Source;
was?: number | string | null;
replaced_user_value?: number | string | null;
}
interface SummaryRow {
id: string;
origin: "structures" | "pipe_points";
chainage_m: number | null;
start_m: number | null;
end_m: number | null;
count: number;
length_m: number | null;
length_basis: string;
memo: string;
note?: string;
replaced?: string[];
cells: Record<string, SummaryCell>;
}
interface SummaryTable {
type_id: string;
name: string;
group: string;
placement: string;
columns: { key: string; label: string; unit: string; input: string }[];
rows: SummaryRow[];
count: number;
length_total_m: number | null;
length_missing: number;
averages: Record<string, number>;
}
interface SummaryResponse {
tables: SummaryTable[];
notes: string[];
message?: string;
}
const SOURCE_LABELS: Record<Source, string> = {
auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값",
user: "사용자 — 이 표에서 고친 값(정본에 적힘)",
library: "라이브러리 — 정본이 비어 양식 기본값으로 섬",
empty: "빈칸 — 정본도 양식도 값 없음",
};
const STYLE_ID = "b08-structure-summary-style";
const CSS = `
.b08-sum { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.b08-sum__legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; }
.b08-sum__cell--auto { box-shadow: inset 3px 0 0 var(--color-border, #888); }
.b08-sum__cell--user { box-shadow: inset 3px 0 0 var(--color-accent, #6c8ebf); }
.b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); }
.b08-sum__cell--empty { color: var(--color-text-muted, #999); }
.b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
.b08-sum .b08-grid__table td { white-space: nowrap; }
`;
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
/** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */
function cellText(cell: SummaryCell | undefined): string {
if (!cell || cell.value === null || cell.value === "") return "";
const value = cell.value;
return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value);
}
function station(row: SummaryRow): string {
if (row.start_m !== null && row.end_m !== null) {
return `${stationLabel(row.start_m)} ~ ${stationLabel(row.end_m)}`;
}
return row.chainage_m === null ? "" : stationLabel(row.chainage_m);
}
function renderTable(table: SummaryTable): HTMLElement {
const wrap = el("div", "b08-grid");
const length =
table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`;
const missing = table.length_missing ? ` · 연장 빈 줄 ${table.length_missing}` : "";
wrap.append(el("p", "b08-sheet__head", `${table.name}${table.count}개소${length}${missing}`));
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table");
const head = document.createElement("tr");
for (const label of ["측점", "연장(m)", ...table.columns.map((c) => c.label), "비고"]) {
head.append(el("th", "", label));
}
const thead = document.createElement("thead");
thead.append(head);
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
const lengthCell = el("td", "", row.length_m === null ? "" : num(row.length_m, 2));
lengthCell.title = row.length_basis || "연장 없음";
tr.append(el("td", "", station(row)), lengthCell);
for (const column of table.columns) {
const cell = row.cells[column.key];
const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`, cellText(cell));
td.title = cell ? SOURCE_LABELS[cell.source] : "";
if (cell?.source === "user" && cell.was !== undefined && cell.was !== null) {
td.title += ` · 고치기 전 ${cell.was}`;
}
if (cell?.replaced_user_value !== undefined) {
td.classList.add("b08-sum__cell--replaced");
td.title = `이 표에서 ${cell.replaced_user_value}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`;
}
tr.append(td);
}
const notes = [
row.replaced?.length ? `⚠ B05 가 바꿈: ${row.replaced.join("·")}` : "",
row.note ?? "",
row.memo,
].filter(Boolean);
tr.append(el("td", "", notes.join(" · ")));
tbody.append(tr);
}
const average = document.createElement("tr");
average.append(el("td", "", "평균치수"), el("td", "", ""));
for (const column of table.columns) {
const value = table.averages[column.key];
average.append(el("td", "", value === undefined ? "" : num(value, 2)));
}
average.append(el("td", "", ""));
tbody.append(average);
grid.append(thead, tbody);
scroller.append(grid);
wrap.append(scroller);
return wrap;
}
/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. */
export function renderStructureSummary(projectId: string | null): HTMLElement {
injectStyles();
const root = el("div", "b08-sum");
if (!projectId) {
root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
return root;
}
root.append(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…"));
void (async () => {
try {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`,
{ credentials: "include" },
);
const payload = (await response.json().catch(() => ({}))) as SummaryResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const legend = el("div", "b08-sum__legend");
for (const source of ["auto", "user", "library", "empty"] as Source[]) {
const chip = el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `);
legend.append(chip);
}
const total = payload.tables.reduce((sum, table) => sum + table.count, 0);
root.replaceChildren(
el(
"p",
"b08-sheet__head",
`구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`,
),
legend,
...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)),
...(payload.tables.length
? payload.tables.map(renderTable)
: [el("p", "b08-grid__caption", "놓인 구조물이 없음")]),
);
} catch (error) {
root.replaceChildren(
el(
"p",
"b08-grid__caption b08-grid__caption--warn",
`구조물 집계표를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
}
})();
return root;
}