Files
Aislo/B08_Quantity/B08_Quantity_UI_Summary.ts
T
eomsangdonandClaude Opus 5.5 22a54abe99 feat(B08): 자재 셋 값 없는 줄 숨김 · 구조물 탭 표 넷 공용 카드 안(143-3)
- 총괄 · 콘크리트 · 자재수량 — 수량 · 금액 · 손 단위수량 없는 줄 숨김(화면만 · 고친 칸은 원래 줄 자리로 저장)
- 구조물 집계표 · 자재 셋 — 공용 카드 안(토공 표와 같은 꼴)
- 시험 — 새 test_143_3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-09 14:59:58 +09:00

252 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B08_Quantity_UI_Summary.ts
* 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 맨 아래 계 줄.
* 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정.
*
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
* 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`)
* 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 없으면 그 시설은 없음
* 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
* 열 폭 = 맨 아래 단 글(+ 칸 값)만 잼 · 위 단 머리는 줄바꿈(`B08_Quantity_UI_Page.css` · 120-2)
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
* 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createCard, showToast } from "@ui/ui_template_elements";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import { stationLabel } from "../common_util/common_util_station";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
import {
combosAt,
comboId,
linkedOnly,
summaryColumns,
summaryStations,
type ComboColumn,
type ComboLabel,
type LinkedSnapshot,
type PlacedItem,
type StationCombo,
widthOf,
} from "./B08_Quantity_Summary_Data";
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */
export async function stationInterval(projectId: string): Promise<number> {
const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId));
return latest.route_params?.station_interval_m || 20;
}
function note(text: string): HTMLElement {
const node = document.createElement("p");
node.className = "ui-general-block__empty";
node.textContent = text;
return node;
}
/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */
interface PipeColumn {
id: string;
group: string[];
label: string;
combos: string[];
}
interface PipeSummary {
columns: PipeColumn[];
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
total: Record<string, number>;
notes: string[];
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
types: string[];
one_length_m: number | null;
pipes: PipeLength[];
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
linked: Record<string, LinkedSnapshot>;
}
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, {
credentials: "include",
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response.json();
}
const PIPE_TOP = "관 공";
const stationKey = (station: number): number => Math.round(station * 100);
/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */
const placesOf = (values: number[]): number =>
Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length));
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */
function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] {
const top =
summary.columns
.filter((column) => column.group[0] === PIPE_TOP)
.flatMap((column) => column.combos)
.map(comboPath)
.find((path) => !!path) ?? PIPE_TOP;
return summary.columns.map((column) => {
const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group;
const values = [
...summary.rows.map((row) => row.values[column.id]),
summary.total[column.id],
].filter((value): value is number => typeof value === "number");
const decimals = placesOf(values);
const texts = values.map((value) =>
value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}),
);
return {
key: column.id,
label: column.label,
group,
kind: "number",
decimals,
title: [...group, column.label].join(" › "),
width: widthOf(column.label, ...texts),
};
});
}
function buildTable(
stations: number[],
columns: ComboColumn[],
pipe: PipeSummary | null,
comboPath: (id: string) => string | null,
interval: number,
onRowClick: (station: number, label: string) => void,
): HTMLElement {
const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values]));
const total: DataRow | null = pipe?.columns.length
? { station: L("B08_Quantity_Summary_Total"), ...pipe.total }
: null;
const labels = stations.map((station) => stationLabel(station, interval));
const stationHead = L("B08_Quantity_Summary_Station");
const table = createDataTable({
columns: [
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) },
...columns.map((column) => ({
key: column.id,
label: column.name,
group: [column.group || L("B08_Quantity_Summary_MissingGroup")],
title: summaryTooltip(column.item),
className: column.missing ? "is-missing" : undefined,
width: widthOf(column.name),
})),
...(pipe ? pipeColumns(pipe, comboPath) : []),
],
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
rows: stations.map((station, i) => ({
station: labels[i],
...values.get(stationKey(station)),
})),
total,
onRowClick: (i) => onRowClick(stations[i], labels[i]),
frozen: 1,
empty: "",
caption: L("B08_Quantity_Tab_StructureSummary"),
});
table.root.dataset.b08Summary = "table";
return table.root;
}
/** 행 누름 — 카드 · CAD 묶음은 이때 처음 받음. */
async function openSheets(
projectId: string,
station: string,
combos: StationCombo[],
linked: Record<string, LinkedSnapshot>,
describe: (item: SummaryItem) => ComboLabel | null,
): Promise<void> {
try {
const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing");
openStructureSheets({ projectId, station, combos, linked, describe });
} catch (error) {
showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error");
}
}
/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
try {
const [structures, pipes, interval, pipe] = await Promise.all([
fetchStructures(projectId),
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
fetchDetailPipePoints(projectId).then(
(response) => response.pipe_points,
() => [],
),
stationInterval(projectId).catch(() => 20),
// 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로
fetchPipeSummary(projectId).catch((error: unknown) => String(error)),
]);
const saved = typeof pipe === "string" ? null : pipe;
// 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1)
const linked = saved?.linked ?? {};
const paths = new Map(
Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const),
);
const describe = (item: SummaryItem): ComboLabel | null => {
const [group = "", ...rest] = paths.get(comboId(item)) ?? [];
return group ? { group, name: rest.join(" ") || group } : null;
};
const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null;
const placed = linkedOnly(
pipes.map((point) => ({ ...point, structure_id: pipeStructureId(point.chainage_m) })),
linked,
);
const mine = linkedOnly(structures.structures, linked);
const things: PlacedItem[] = [...mine, ...placed];
const stations = summaryStations(things);
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
const served = new Set(saved?.types ?? []);
const others = [
...mine.filter((item) => !served.has(item.type_id)),
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
];
const columns = summaryColumns(others, describe);
const live = (): PipeSummary | null =>
saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m));
const summary = live();
const parts: HTMLElement[] = [];
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
for (const text of summary?.notes ?? []) parts.push(note(text));
if (!columns.length && !summary?.columns.length)
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
const draw = (): HTMLElement =>
stations.length
? buildTable(stations, columns, live(), comboPath, interval, (station, label) =>
openSheets(projectId, label, combosAt(things, station), linked, describe),
)
: note(L("B08_Quantity_Summary_NoStation"));
let table = draw();
parts.push(table);
host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3)
onOptionsChange(host, () => {
const next = draw();
table.replaceWith(next);
table = next;
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
host.replaceChildren(note(`${L("B08_Quantity_Summary_Failed")} — ${reason}`));
}
}