/* ============================================================================= * M02_MasterTemplete_Structure.ts * 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거 표. * * 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) · * 도번은 서버가 매김(`구-01` 꼴 · 없으면 「없음」) · 문서 = {열 · 도번 · 도면(CAD) · 산출근거(표)}. * 이 칸은 표 부품 host 밖 형제 자리에 붙음 — 집계표는 그대로 두고 여기만 열마다 다시 그림. * 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식). * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { createSheet, type SheetDoc, type SheetHandle } from "@ui/sheet/ui_template_sheet"; import { createStructure, fetchStructureNumbers, readTemplate, saveTemplate, StaleError, type StructureDoc, } from "./M02_MasterTemplete_Api_Fetch"; import { mountDrawingTemplate, type DrawingTemplateDoc, type DrawingTemplateHandle, } from "./M02_MasterTemplete_Drawing"; import "./M02_MasterTemplete_Structure.css"; /** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */ export const STRUCTURE_TABLE = "구조물집계표"; const TEXT = { edit: "도면 수정", save: "산출근거 저장", basis: "상세 산출근거", noNumber: "도번 없음", noDrawing: "도면 없음 — [도면 수정] 을 누르면 새로 만들어 엶", noBasis: "표 없음 — 도면이 아직 없음", pick: "집계표에서 열을 고를 것", created: "구조물 도면을 만들었음 — {value}", saved: "산출근거를 저장했음", dirty: "저장 안 한 산출근거 고침이 있음 — 버리고 넘어갈까?", kept: "열을 바꾸지 않음 — 산출근거를 먼저 저장할 것", stale: "그 사이 구조물 도면이 바뀜 — 열을 다시 고를 것", readFailed: "구조물 도면을 못 읽음 — {value}", makeFailed: "구조물 도면을 못 만듦 — {value}", saveFailed: "저장 못 함 — {value}", }; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const fill = (text: string, value: string): string => text.replace("{value}", value); /** 집계표 열 — 머리 글만 씀 */ export interface StructureColumn { id: string; 머리: (string | null)[]; } /** 열 머리 글 — 층을 ` · ` 로 이음(빈 층은 건너뜀 · 다 비면 열 id) */ export const columnHead = (col: StructureColumn): string => col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" · ") || col.id; export interface StructurePanelOptions { /** 시스템 관리자만 새로 만들고 저장함 */ isAdmin: boolean; /** [도면 수정] — 그 열 구조물 도면을 CAD 편집으로 엶(페이지의 `open`) */ onEdit: (column: string) => void; /** 새로 만든 뒤 — 집계표 도면 줄을 새 도번으로 다시 그림 */ onCreated: () => void; /** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ confirmLeave: () => Promise; } export interface StructurePanelHandle { root: HTMLElement; /** `{열 id: 도번}` — 집계표 `drawingLabel` 이 씀 */ numbers: () => Record; /** 도번 목록 다시 받기 */ reload: () => Promise; /** 고른 열이 바뀜 — 같은 열이면 아무것도 안 함(CAD 를 다시 안 띄움) */ show: (colId: string | null, columns: StructureColumn[]) => void; destroy: () => void; } export function createStructurePanel(opts: StructurePanelOptions): StructurePanelHandle { let numbers: Record = {}; let column: string | null = null; let doc: StructureDoc | null = null; let version = ""; let preview: DrawingTemplateHandle | null = null; let basis: SheetHandle | null = null; let basisDirty = false; let busy = false; let seq = 0; const title = el("h3", { className: "m02-struct__title", text: TEXT.pick }); const number = el("span", { className: "m02-struct__no" }); const edit = createButton({ label: TEXT.edit, variant: "filled" }); const saveBasis = createButton({ label: TEXT.save, variant: "ghost" }); const previewHost = el("div", { className: "m02-struct__preview" }); const basisHost = el("div", { className: "m02-struct__sheet" }); const root = el("section", { className: "m02-struct", attrs: { hidden: "" }, children: [ el("div", { className: "m02-struct__head", children: [title, number, edit] }), previewHost, el("div", { className: "m02-struct__subhead", children: [el("h4", { className: "m02-struct__subtitle", text: TEXT.basis }), saveBasis], }), basisHost, ], }); const drop = (): void => { preview?.destroy(); preview = null; basis?.destroy(); basis = null; basisDirty = false; }; const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); /** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */ const hideToolbar = (frame: HTMLIFrameElement): void => { const apply = (): void => { const inner = frame.contentDocument; if (!inner?.head || inner.getElementById("m02-struct-hide")) return; const style = inner.createElement("style"); style.id = "m02-struct-hide"; style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }"; inner.head.append(style); }; frame.addEventListener("load", apply); apply(); }; /** 미리보기 · 산출근거 표 — 문서가 없으면 없음 글 */ const paintBody = (): void => { drop(); number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber; edit.hidden = !opts.isAdmin; saveBasis.hidden = !opts.isAdmin || !doc; if (!doc) { previewHost.replaceChildren(note(TEXT.noDrawing)); basisHost.replaceChildren(note(TEXT.noBasis)); return; } previewHost.replaceChildren(); basisHost.replaceChildren(); // 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠. preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, { readOnly: true, name: `preview:${column ?? ""}`, layer: "system", }); const frame = previewHost.querySelector("iframe"); if (frame) hideToolbar(frame); basis = createSheet(basisHost, doc.산출근거 as unknown as SheetDoc, { mode: "master", onChange: () => (basisDirty = true), }); }; async function load(col: StructureColumn | null): Promise { const mine = ++seq; drop(); if (!col) { root.hidden = true; column = null; doc = null; return; } root.hidden = false; column = col.id; title.textContent = columnHead(col); number.textContent = numbers[col.id] ?? TEXT.noNumber; doc = null; version = ""; previewHost.replaceChildren(); basisHost.replaceChildren(); try { const got = await readTemplate("system", "structure", col.id, null); if (mine !== seq) return; doc = got.문서 as StructureDoc; version = got.판 ?? ""; } catch (error) { if (mine !== seq) return; // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(error)), "error"); } paintBody(); } const show = (colId: string | null, columns: StructureColumn[]): void => { if (colId === column) return; const next = colId ? (columns.find((c) => c.id === colId) ?? null) : null; if (!basisDirty) return void load(next); void showConfirmDialog(TEXT.dirty).then((ok) => { if (!ok) return void showToast(TEXT.kept, "info"); basisDirty = false; void load(next); }); }; saveBasis.addEventListener("click", async () => { const at = column; if (!at || !doc || !basis || busy) return; busy = true; saveBasis.disabled = true; try { const next: StructureDoc = { ...doc, 산출근거: basis.getDoc() as unknown as StructureDoc["산출근거"], }; const info = await saveTemplate("system", "structure", at, null, version, next); version = info.판; doc = next; basisDirty = false; showToast(TEXT.saved, "success"); } catch (error) { showToast( error instanceof StaleError ? TEXT.stale : fill(TEXT.saveFailed, why(error)), "error", ); } finally { busy = false; saveBasis.disabled = false; } }); edit.addEventListener("click", async () => { const at = column; if (!at || busy) return; if (basisDirty && !(await showConfirmDialog(TEXT.dirty))) return; basisDirty = false; if (!(await opts.confirmLeave())) return; if (!doc) { busy = true; edit.disabled = true; try { const got = await createStructure(at); doc = got.문서 as StructureDoc; version = got.판 ?? ""; numbers = await fetchStructureNumbers(); showToast(fill(TEXT.created, doc.도번), "success"); opts.onCreated(); // 좌측 목록 · 집계표 도면 줄 } catch (error) { showToast(fill(TEXT.makeFailed, why(error)), "error"); return; } finally { busy = false; edit.disabled = false; } } opts.onEdit(at); }); return { root, numbers: () => numbers, reload: async () => { numbers = await fetchStructureNumbers().catch(() => ({})); }, show, destroy: () => { seq += 1; drop(); root.remove(); }, }; }