- 정본 둘(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
200 lines
7.8 KiB
TypeScript
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;
|
|
}
|