/* ============================================================================= * B08_Quantity_UI_StructureSheet.ts * 구조물도 탭 — 제원 조합 하나 = 한 장. 장마다 하위 탭 · 원단위 수량표 · 제원 칸 (PLAN 3장). * * ⛔ 탭 등록은 이 파일이 하지 않음 — `B08_Quantity_UI_Page.ts` 탭 배선은 브레인 몫(PLAN 0장 충돌 막이). * 부르는 법: `{ label: "구조물도", build: () => renderStructureSheets(projectId) }` * ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음. * ⚠ 한 장 = 상단 그림(`_StructureFigure`, 읽기 전용 SVG) + 원단위 수량표 + 하단 일위대가. * ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로. * ⚠ 식 고치기(PLAN 3장 ⑤) — 칸에서 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂(왕복 없음). * [식 저장] 때는 식만 보내고 **값은 서버가 Node 로 다시 냄**(판정 Ⓐ). 저장은 양식 + 프로젝트 단위. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import type { FormulaSheet } from "./B08_Quantity_Formula"; import { amountText, DESTINATION_LABELS, el, formulaTable, noteText, num, type FormulaEditRow, type Rounding, } from "./B08_Quantity_UI_StructureSheet_Formula"; 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"; import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library"; import { unitPriceSection } from "./B08_Quantity_UI_StructureSheet_UnitPrice"; import { FIGURE_CSS, figureSection, type FigureShape } from "./B08_Quantity_UI_StructureFigure"; export interface StructureSheetRow { no: number; name: string; spec: string; basis: string; /** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */ unit_amount: number | null; amount: number | null; unit: string; basis_kind: string; source: string; /** 양식 줄만 — 기계가 푸는 식(명세 13장). 비면 고정형(지금 전개). */ formula?: string; /** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */ default_formula?: string; destination?: string; rounding?: Rounding | null; /** 양식 원래 반올림 — 고친 줄이면 `rounding` 과 다름. */ default_rounding?: Rounding | null; /** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */ skipped?: boolean; reason?: string; error?: 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[]; /** 상단 그림 모양 목록 — 못 그리는 장은 `null` + `figure_reason`(PLAN 12장). */ figure?: FigureShape[] | null; figure_reason?: string | null; /** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */ /** `imported_from` 없음 = 프로그램 기본을 읽는 중(가져오기 전). */ library_item?: { type_id: string; name: string; code?: string | null; imported_from?: string | null; /** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */ origin_project?: string | null; }; /** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */ formula_sheet?: FormulaSheet; /** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */ spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null; /** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */ var_candidates?: { name: string; label: string; value: number | string | null; candidates: { value: number | string; source?: 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: pre-line; min-width: 16rem; } .b08-sheet__rows td:nth-child(7) { white-space: normal; max-width: 18rem; } /* 식 칸 — 고칠 수 있는 칸은 입력으로, 고친 칸은 표시가 남음. */ .b08-sheet__formula { display: flex; gap: 4px; margin-top: 2px; } .b08-sheet__formula input { flex: 1 1 auto; min-width: 12rem; font: 12px var(--font-mono, monospace); padding: 1px 4px; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); } .b08-sheet__formula input.is-changed { border-color: var(--color-accent, #6c8ebf); } .b08-sheet__formula button { font-size: 11px; padding: 0 6px; cursor: pointer; } .b08-sheet__formula select { font-size: 11px; max-width: 11rem; } .b08-sheet__formula select.is-changed { outline: 1px solid var(--color-accent, #6c8ebf); } .b08-sheet__formula .b08-sheet__digits { flex: 0 0 3.2rem; min-width: 3.2rem; } .b08-sheet__actions { display: flex; gap: 8px; align-items: center; } /* 수동 단가 — 빨간 테두리 + 「미확정 N건」 배지(PLAN 확정 ⑦). */ .b08-unit__manual { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; } .b08-unit__badge { margin-left: 8px; padding: 0 6px; border-radius: 8px; color: #fff; background: var(--color-danger, #d9534f); font-size: 12px; white-space: nowrap; } .b08-unit-edit { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } .b08-unit-edit td { white-space: nowrap; } .b08-unit-edit__picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } .b08-unit-edit__results { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; max-height: 14rem; overflow: auto; } @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 + FIGURE_CSS; document.head.append(style); } async function fetchStructureSheets(projectId: string): Promise { 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 ?? [] }; } /** 고친 식·반올림 저장 — 값은 안 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */ async function putStructureSheetFormulas( projectId: string, sheetKey: string, rows: FormulaEditRow[], ): Promise<{ changed: number; errors: string[] }> { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/formulas`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sheet_key: sheetKey, rows }), }, ); const payload = (await response.json().catch(() => ({}))) as { changed?: number; errors?: string[]; message?: string; }; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); return { changed: payload.changed ?? 0, errors: payload.errors ?? [] }; } 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 editedRows(sheet: StructureSheet): number { return sheet.rows.filter((row) => row.source === "user").length; } /** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */ function sheetBody( sheet: StructureSheet, editor: HTMLElement | null, below: HTMLElement | null = null, ): 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} · ` + // 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음. (sheet.library_item ? libraryLabel(sheet.library_item, editedRows(sheet)) : "양식 없음(지금 전개)"), ), // 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음). el( "span", "b08-grid__caption", // 10-A ⑯ 양식은 L=1 로 풂(`replace_with_templates` · m당 양식만) — 규칙을 머리에 드러냄. sheet.library_item && sheet.unit_label === "m당" ? `m당 — 반올림은 m당 값에 걸고 수량 = m당 × 연장` : sheet.unit_label, ), ); // ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음. if (sheet.spec_mismatch) { const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch; head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`)); main.classList.add("b08-unit__manual"); } // 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭. main.append(head, figureSection(sheet.figure, sheet.figure_reason)); if (!sheet.rows.length) { main.append( el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"), ); } else if (editor) { main.append(editor); } else { main.append( table( ["공종", "규격", "산출 근거", "수량", "단위", "갈 곳", "비고"], sheet.rows.map((row) => [ row.name, row.spec, // 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임. // 양식 줄은 **식을 버리지 않고** 설명 밑에 함께 보임(명세 13장 지킬 것 ④). row.basis.replace(/\*\*/g, "") + (row.formula ? `\n= ${row.formula}` : ""), amountText(row), row.unit, DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? "", noteText(row), ]), "b08-sheet__rows", ), ); } for (const item of sheet.var_candidates ?? []) { const others = item.candidates .map((c) => `${c.value}${c.source ? `(${c.source})` : ""}`) .join(" · "); main.append( el( "p", "b08-grid__caption", `대안 후보 — ${item.label}: 지금 ${item.value} · 후보 ${others}`, ), ); } // 하단 일위대가(미리보기) — 수량표 바로 밑(PLAN 3장 한 장의 짜임). if (below) main.append(below); 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; } // 저장 안 한 식이 조용히 사라지지 않게 — 나갈 때 묻기(자동저장은 안 만듦, CLAUDE.md 5장). let dirty = false; window.addEventListener("beforeunload", (event) => { if (!dirty || !wrap.isConnected) return; event.preventDefault(); event.returnValue = "저장 안 한 식이 있음"; }); 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 => { // 새로 그린 표에는 고친 칸이 없음 — [식 저장] 뒤 다시 그릴 때 「저장 안 한 식」이 남으면 // 나갈 때·다른 장으로 갈 때 헛물음이 뜨고 [내 라이브러리에 저장]이 막힘(2026-09-13 화면 실측). dirty = false; 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, ), ); if (sheet.library_item) { aside.append( buildLibraryPanel({ projectId, sheetKey: sheet.key, typeId: sheet.library_item.type_id, currentCode: sheet.library_item.code ?? null, originProject: sheet.library_item.origin_project, defaultName: sheet.title, isDirty: () => dirty, confirmTake: () => { const edited = editedRows(sheet); const lost = [edited ? `고친 식 ${edited}줄` : "", dirty ? "저장 안 한 식" : ""] .filter(Boolean) .join(" · "); if (!lost) return true; if (!window.confirm(`가져오면 이 종류의 ${lost}이 비워짐 — 가져올까요?`)) return false; dirty = false; return true; }, onImported: (after) => load(sheet.members[0]?.structure_id ?? null, after), }), ); } const editor = sheet.formula_sheet ? formulaTable( sheet, async (rows) => { const result = await putStructureSheetFormulas(projectId, sheet.key, rows); // 서버가 다시 푼 값으로 **다시 받아** 그림 — 화면 계산을 정본으로 적지 않음. await load(sheet.members[0]?.structure_id ?? null); return result.errors; }, (next) => { dirty = next; }, ) : null; const unitPrice = sheet.library_item ? unitPriceSection(projectId, sheet.key, sheet.rows) : null; pane.replaceChildren(sheetBody(sheet, editor, unitPrice), aside); }; sheets.forEach((sheet, index) => { const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`); button.type = "button"; button.addEventListener("click", () => { // 다른 장으로 가면 고친 식이 사라짐 — 조용히 버리지 않고 물음. if (dirty && !window.confirm("저장 안 한 식이 있음 — 버리고 다른 장으로 갈까요?")) return; dirty = false; 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 => { 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; }