Files
Aislo/B08_Quantity/B08_Quantity_Summary_Table.ts
T
eomsangdonandClaude Opus 5.5 271c460641 fix(부품·테마): 위험 채움 대비 4.72 · 남은 옛 꼴 → 부품(계산 줄 · 칸 램프 · 넓은 중간 창 · 규칙 꼴 · 구역 머리 · 입력 칸 2열) (PLAN 557-2)
- 위험 채움 토큰 밝음 #e5484d → #d6363c(흰 글 3.91 → 4.72) · 어두움 5.6 · 위험 글 #c0392b 그대로
- 새 부품 계산 줄(ui_template_calc_row) — B08 수리집수면적표 b08-hydro__row · 식 칸 배치를 대신함
- 표 부품 칸 램프(ui-data-table__lamp · --only · ✓) — B08 집계표 · 거리이정표 · B09 설계내역 램프 CSS 세 벌을 걷음(표지 class 는 풍선 · 가이드용으로 남김)
- 모달 넓은 중간 창(wide + width 1100px · 1100 × 680) — B09 운반 창 손 인라인 크기를 걷음
- 값 목록 규칙 꼴(rule) — ☰ 설정 규칙 줄 ul/li 를 걷음
- 접이 구역 머리(createCollapsibleHead = 패널 머리 + 접는 제목) — B08 구조물도 「조합 연결」 제목(옛 b08-sheets__title)을 대신함
- 일반 화면 틀 좁은 폭 · 입력 칸 2열 · 끝 단추 줄 — D01 · E01 이 B02 CSS 를 빌리던 것을 끊음(가이드 장면 선택자 맞춤)
- 부품 모음 견본 · 지킴 시험 test_557_2 · 낡은 시험(198_1 · 247_1 · 251_1 · 257_1 · 276_1 · 551_1)은 부품 CSS · 새 class 로 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:53:34 +09:00

252 lines
10 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_Summary_Table.ts
* 구조물 집계표(PLAN 61-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3).
* 화면(`B08_Quantity_UI_Summary`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀.
*
* `loadSummary` = 구조물 · 배관 · 측점 간격 · 배관 집계(서버) → 줄 측점 · 조합 열 · 사본 경로
* `summaryTable` = 공용 표 옵션(머리 · 측점 열 · 조합 열 · 배관 열 · 계 줄 · 램프 칸 class)
* 커플링 열 = 좌측 패널 1본 길이로 다시 셈(`view.live()` · 캐시 → 저장본 · PLAN 122)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
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 { stationLabel } from "../common_util/common_util_station";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
import {
comboId,
linkedOnly,
pipeOwnerId,
summaryColumns,
summaryStations,
type ComboColumn,
type ComboLabel,
type LinkedSnapshot,
type PlacedItem,
type UnlinkedFacility,
unassignedText,
unlinkedAt,
widthOf,
} from "./B08_Quantity_Summary_Data";
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
import { liveOnePipe } 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;
}
/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */
interface PipeColumn {
id: string;
group: string[];
label: string;
combos: string[];
}
export 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>;
/** 미연결 시설(154-1) */
unlinked: UnlinkedFacility[];
/** 모든 시설 칸(모달 「조합 연결」 · 156-1) */
facilities: UnlinkedFacility[];
}
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 = "관 공";
/** 미연결 램프 칸 표지(198-1 · 풍선 · 가이드가 잡음) · 꼴 = 표 부품 칸 램프 · 램프 자리 폭(12px 글 기준) */
export const LAMP_CLASS = "b08-summary__lamp";
/** 모두 연결 = 같은 램프 초록 ✓(247-1) */
const LINKED_CLASS = `ui-data-table__lamp ${LAMP_CLASS} is-linked`;
const LAMP_W = 14;
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),
};
});
}
/** 집계표 한 벌 — 화면 · 인쇄가 함께 씀 */
export interface SummaryView {
stations: number[];
/** 조합 열(횡단배수 = 서버 배관 열이라 뺌) */
columns: ComboColumn[];
/** 모달용 구조물 · 관 지점(사본 있는 것) */
things: PlacedItem[];
linked: Record<string, LinkedSnapshot>;
describe: (item: SummaryItem) => ComboLabel | null;
comboPath: (id: string) => string | null;
interval: number;
unlinked: UnlinkedFacility[];
saved: PipeSummary | null;
/** 배관 집계 실패 까닭 — 나머지 표는 그대로 */
error: string | null;
/** 지금 배관 집계 — 커플링 = 좌측 패널 1본 길이(캐시 → 저장본) */
live: () => PipeSummary | null;
}
export async function loadSummary(projectId: string): Promise<SummaryView> {
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;
// 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1)
const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)];
const placed = linkedOnly(
pipes.map((point) => ({
...point,
structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m),
})),
linked,
);
const mine = linkedOnly(structures.structures, linked);
const things: PlacedItem[] = [...mine, ...placed];
const unlinked = saved?.unlinked ?? [];
// 횡단배수는 서버 집계 열(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")),
];
return {
stations: summaryStations([...things, ...unlinked]),
columns: summaryColumns(others, describe),
things,
linked,
describe,
comboPath,
interval,
unlinked,
saved,
error: typeof pipe === "string" ? pipe : null,
live: () => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)),
};
}
/** 표 옵션 — 줄 측점 · 측점 칸 램프 class · labels = 측점 글 · missing = 줄마다 빈 조합 칸 글(호버) */
export function summaryTable(view: SummaryView): {
options: DataTableOptions;
labels: string[];
missing: string[];
} {
const { stations, columns, interval, unlinked, comboPath } = view;
const pipe = view.live();
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");
// 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2)
const missing = stations.map((station) =>
unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")),
);
// 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프
const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0);
const options: DataTableOptions = {
columns: [
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W },
...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,
cellClass: (i, key) =>
key !== "station"
? null
: missing[i]
? `ui-data-table__lamp ${LAMP_CLASS}`
: linked[i]
? LINKED_CLASS
: null,
frozen: 1,
empty: "",
caption: L("B08_Quantity_Tab_StructureSummary"),
};
return { options, labels, missing };
}