Files
Aislo/B08_Quantity/B08_Quantity_UI_StructureSheet.ts
T
eomsangdonandClaude Opus 5 f20213592c feat(b08): 구조물도 엔진·창구·화면 조각을 B07 에서 B08 로 이관
- 장 나눔·제원 입력 엔진과 기울기 판정 대상을 B08 로 옮김
- 창구를 /quantity/structure-sheets 로 옮기고 기초잡석 두께·지반 갈래를 함께 넘김
  (옛 B07 창구는 두께를 안 넘겨 원단위 탭과 값이 갈렸음)
- 구조물도 탭 조각 renderStructureSheets 신설 — 탭 등록은 브레인 몫이라 안 붙임
- B07 표준도 목록은 탭 배선 날까지 B08 엔진·창구를 불러 그대로 둠

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

315 lines
12 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_StructureSheet.ts
* 구조물도 탭 — 제원 조합 하나 = 한 장. 장마다 하위 탭 · 원단위 수량표 · 제원 칸 (PLAN 3장).
*
* ⛔ 탭 등록은 이 파일이 하지 않음 — `B08_Quantity_UI_Page.ts` 탭 배선은 브레인 몫(PLAN 0장 충돌 막이).
* 부르는 법: `{ label: "구조물도", build: () => renderStructureSheets(projectId) }`
* ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음.
* ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만.
* ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import {
buildStandardSpecPanel,
type StandardSheetSpec,
type StandardSpecResult,
} from "./B08_Quantity_UI_StructureSheet_Spec";
export interface StructureSheetRow {
no: number;
name: string;
spec: string;
basis: string;
/** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */
unit_amount: number | null;
amount: number;
unit: string;
basis_kind: string;
source: string;
}
export interface StructureSheet extends StandardSheetSpec {
height_m: number;
unit_label: string;
billing_unit: string;
billing_total: number;
rows: StructureSheetRow[];
members: {
structure_id: string | null;
name: string;
start_m: number | null;
end_m: number | null;
length_m: number;
billing_quantity: number;
}[];
notes: string[];
unpriced_rows: string[];
}
export interface StructureSheetsResponse {
sheets: StructureSheet[];
sheet_count: number;
structure_count: number;
skipped_structures: string[];
pending_choices: { label: string; effect?: string }[];
}
const STYLE_ID = "b08-structure-sheet-style";
const CSS = `
.b08-sheet { display: flex; gap: 12px; align-items: flex-start; min-height: 0; flex: 1 1 auto; }
.b08-sheet__main { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-width: 0; min-height: 0; }
.b08-sheet__aside { flex: 0 0 17rem; max-height: 100%; overflow: auto; }
.b08-sheet__head { display: flex; justify-content: space-between; gap: 8px; margin: 0; font-size: 13px; color: var(--color-text); }
.b08-sheet__tabs { flex-wrap: wrap; }
.b08-sheet .b08-grid__table--summary td:nth-child(5) { text-align: center; }
/* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */
.b08-sheet__rows td:nth-child(3) { white-space: normal; min-width: 16rem; }
@media (max-width: 900px) {
.b08-sheet { flex-direction: column; }
.b08-sheet__aside { flex-basis: auto; width: 100%; }
}
`;
function injectStyles(): void {
injectEarthworkGridStyles();
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
async function fetchStructureSheets(projectId: string): Promise<StructureSheetsResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`structure sheets failed: ${response.status}`);
return (await response.json()) as StructureSheetsResponse;
}
async function putStructureSheetSpec(
projectId: string,
body: StandardSpecResult & { base_revision: number },
): Promise<{ changed: number; notes: string[] }> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/spec`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = (await response.json().catch(() => ({}))) as {
changed?: number;
notes?: string[];
message?: string;
};
// 실패 사유(판번호 충돌 등)는 폼 안내 칸에 그대로 뜬다 — 조용히 끝나면 저장된 줄 앎.
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return { changed: payload.changed ?? 0, notes: payload.notes ?? [] };
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
node.textContent = text;
return node;
}
function num(value: number | null | undefined, digits: number): string {
if (value === null || value === undefined || Number.isNaN(value)) return "-";
return value.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function warn(title: string, items: string[]): HTMLElement | null {
if (!items.length) return null;
return el("p", "b08-grid__caption b08-grid__caption--warn", `${title}: ${items.join(" · ")}`);
}
function table(head: string[], rows: string[][], extraClass = ""): HTMLElement {
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", `b08-grid__table b08-grid__table--summary ${extraClass}`.trim());
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of head) headRow.append(el("th", "", label));
thead.append(headRow);
const tbody = document.createElement("tbody");
for (const cells of rows) {
const tr = document.createElement("tr");
for (const text of cells) tr.append(el("td", "", text));
tbody.append(tr);
}
grid.append(thead, tbody);
scroller.append(grid);
return scroller;
}
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표 · 막힌 사유 · 개소 목록. */
function sheetBody(sheet: StructureSheet): HTMLElement {
const main = el("div", "b08-sheet__main");
const head = el("p", "b08-sheet__head");
const total = sheet.billing_total
? ` · 합 ${num(sheet.billing_total, 2)}${sheet.billing_unit}`
: "";
head.append(
el("span", "", `${sheet.title}${sheet.member_count}개소${total}`),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
);
main.append(head);
if (!sheet.rows.length) {
main.append(
el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"),
);
} else {
main.append(
table(
["공종", "규격", "산출 근거", "수량", "단위", "비고"],
sheet.rows.map((row) => [
row.name,
row.spec,
// 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임.
row.basis.replace(/\*\*/g, ""),
num(row.unit_amount, 3),
row.unit,
row.basis_kind === "observed" ? "실무 관측" : "치수 전개",
]),
"b08-sheet__rows",
),
);
}
for (const notice of [
warn("단위당을 못 낸 줄", sheet.unpriced_rows),
warn(
"사유",
sheet.notes.map((note) => note.replace(/\*\*/g, "")),
),
]) {
if (notice) main.append(notice);
}
main.append(
el("p", "b08-grid__caption", "이 장에 묶인 개소"),
table(
["구조물", "구간", "연장(m)", `수량(${sheet.billing_unit})`],
sheet.members.map((member) => {
const { start_m: start, end_m: end } = member;
const span =
typeof start === "number" && typeof end === "number"
? start === end
? stationLabel(start)
: `${stationLabel(start)} ~ ${stationLabel(end)}`
: "";
return [member.name, span, num(member.length_m, 1), num(member.billing_quantity, 2)];
}),
),
);
return main;
}
/** 구조물도 탭 본문. 받아 오는 동안 안내를 띄우고, 제원을 저장하면 **정본에서 다시 받아** 그림. */
export function renderStructureSheets(projectId: string | null): HTMLElement {
injectStyles();
const wrap = el("div", "b08-grid");
if (!projectId) {
wrap.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
return wrap;
}
const paint = (response: StructureSheetsResponse, memberId: string | null, notes: string[]) => {
const sheets = response.sheets ?? [];
const caption = el(
"p",
"b08-grid__caption",
`구조물도 ${sheets.length}장 · 구조물 ${response.structure_count}개 · 제원 조합 하나가 한 장 · 할증 전 값`,
);
const nodes: HTMLElement[] = [caption];
const skipped = warn("건너뛴 구조물", response.skipped_structures ?? []);
if (skipped) nodes.push(skipped);
for (const choice of response.pending_choices ?? []) {
const effect = choice.effect ? ` · ${choice.effect.replace(/\*\*/g, "")}` : "";
nodes.push(el("p", "b08-quantity__notice", `⚠ 미확정: ${choice.label}${effect}`));
}
if (!sheets.length) {
nodes.push(
el(
"p",
"b08-quantity__message",
response.structure_count
? "구조물이 모두 다른 단계에서 셈되어 구조물도에 실리지 않음"
: "배치된 구조물이 없음 — 구조물을 먼저 배치할 것",
),
);
wrap.replaceChildren(...nodes);
return;
}
// 저장 뒤에는 **같은 개소가 든 장**을 다시 연다 — 제원이 바뀌면 장 이름(key)도 바뀌기 때문.
const found = sheets.findIndex((sheet) =>
sheet.members.some((member) => memberId && member.structure_id === memberId),
);
const tabs = el("div", "b08-quantity__tabs b08-sheet__tabs");
const pane = el("div", "b08-sheet");
const buttons: HTMLButtonElement[] = [];
const show = (index: number, initialNotes: string[] = []): void => {
buttons.forEach((button, i) => button.classList.toggle("is-active", i === index));
const sheet = sheets[index];
const aside = el("div", "b08-sheet__aside");
// 판정된 기울기는 **칸에 적지 않고 도움말로만** — 적어 두면 「안 정함」이 사라진다.
const judged = /1:([\d.]+)/.exec(sheet.title)?.[1] ?? null;
aside.append(
buildStandardSpecPanel(
sheet,
judged,
async (result) => {
const { revision } = await fetchStructures(projectId);
const saved = await putStructureSheetSpec(projectId, {
...result,
base_revision: revision,
});
const after = [`${saved.changed}개소에 반영했습니다.`, ...saved.notes];
// 정본이 바뀌었으니 표를 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 됨.
await load(sheet.members[0]?.structure_id ?? null, after);
return after;
},
initialNotes,
),
);
pane.replaceChildren(sheetBody(sheet), aside);
};
sheets.forEach((sheet, index) => {
const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`);
button.type = "button";
button.addEventListener("click", () => show(index));
buttons.push(button);
tabs.append(button);
});
wrap.replaceChildren(...nodes, tabs, pane);
show(found >= 0 ? found : 0, notes);
};
const load = async (memberId: string | null = null, notes: string[] = []): Promise<void> => {
try {
paint(await fetchStructureSheets(projectId), memberId, notes);
} catch {
wrap.replaceChildren(el("p", "b08-quantity__message", "구조물도를 불러오지 못함"));
}
};
wrap.append(el("p", "b08-quantity__message", "구조물도를 불러오는 중…"));
void load();
return wrap;
}