/* ============================================================================= * M02_MasterTemplete_UI_Side.ts * 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면/구조물 도면/면적산출/재료산출) · 새로 · 본떠 · 지우기. * 서버 길은 계약 `6_계약.md`. 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { deleteTemplate, fetchStructureNumbers, listTemplates, readTemplate, saveTemplate, StaleError, type Kind, type StructureNumbers, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; import { STRUCTURE_TABLE, columnHead, isStructureColumn, structureLabel, type StructureColumn, } from "./M02_MasterTemplete_Structure"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; /** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */ type ColumnKind = "structure" | "area" | "material"; const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [ { kind: "structure", key: "도번", label: () => L("M02_KindStructure") }, { kind: "area", key: "면적번", label: () => L("M02_KindArea") }, { kind: "material", key: "재료번", label: () => L("M02_KindMaterial") }, ]; /** 새 양식의 첫 문서 — 표는 `3_표양식.md` 4장 틀 · 도면은 빈 웹캐드 문서 */ function stubDoc(kind: Kind, name: string): unknown { return kind === "table" ? { 양식: name, 종류: "표", 판: 1, 층: ["종류", "공법", "규격"], 열: [{ id: "sta", 머리: ["측점", null, null], 단위: null, 꼴: "글", 고정: true }], 줄: [], 합계줄: [], 보기: {}, } : { format: 6, entities: [], // 기본 도면층 하나 — 없으면 B07 CAD 가 못 엶("Cannot read properties of // undefined (reading 'length')", 2026-09-27 실측) layers: [{ id: "기본", name: "기본", isVisible: true, isLocked: false }], }; } export interface SideOptions { isAdmin: boolean; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ confirmLeave: () => Promise; } export interface SideHandle { root: HTMLElement; refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ const badName = (name: string): boolean => /[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".") || name.startsWith("_") || name.length > 80; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const failed = (error: unknown): void => showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); export function buildSide(opt: SideOptions): SideHandle { const layer = "system" as const; let items: TemplateInfo[] = []; let columns: StructureColumn[] = []; let numbers: Record = {}; let seq = 0; const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => el("section", { className: "m02-side__group ui-collapsible ui-sidebar-section ui-sidebar-card is-collapsed", children: [el("h3", { className: "ui-collapsible__title", text: title }), body, ...more], }); const bodies = new Map( KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]), ); const columnBodies = COLUMN_KINDS.map(() => el("div", { className: "m02-side__items ui-sidebar-list" }), ); /** 새로 · 본떠 만들기 · 지우기 — 표 · 도면 컨테이너 안에 하나씩(그 종류로 동작) · 관리자만 */ const editButtons = new Map(); const actionRow = (kind: Kind): HTMLElement[] => { if (!canEdit(layer, opt.isAdmin)) return []; const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" }); const copy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" }); const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" }); editButtons.set(kind, { copy, del }); btnNew.addEventListener("click", () => newTemplate(kind)); copy.addEventListener("click", copyTemplate); del.addEventListener("click", () => void deleteCurrent()); return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })]; }; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ section(L("M02_KindTable"), bodies.get("table")!, ...actionRow("table")), section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), ], }); attachCollapsible(listHost); const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); /** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐 */ const isPicked = (kind: Kind, name: string): boolean => { const cur = opt.getOpen(); return !!cur && cur.layer === layer && cur.kind === kind && cur.name === name; }; const isOpen = (info: TemplateInfo): boolean => isPicked(info.종류, info.이름); const currentInfo = (): TemplateInfo | undefined => { const cur = opt.getOpen(); return cur ? items.find((i) => i.종류 === cur.kind && i.이름 === cur.name) : undefined; }; function paintList(): void { for (const [kind, body] of bodies) { const mine = items.filter((i) => i.종류 === kind); body.replaceChildren( ...(mine.length ? mine.map((info) => { const b = el("button", { className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`, text: info.이름, attrs: { type: "button", title: info.수정일 }, }); b.addEventListener("click", () => void select(info)); return b; }) : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } } /** 구조물 도면 · 면적산출 · 재료산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void { body.replaceChildren( ...(columns.length ? columns.map((col) => { const num = numbers[col.id]?.[key] || L("M02_StructureNone"); const b = el("button", { className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`, text: structureLabel(num, col), attrs: { type: "button" }, }); b.addEventListener("click", () => void selectColumn(col, kind)); return b; }) : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { const cur = currentInfo(); for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind; } /** 고름 표시는 여기서만 — 목록 · 열 줄을 함께 그려야 두 줄이 동시에 안 남음 */ function paint(): void { paintList(); paintColumns(); paintButtons(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) async function select(info: TemplateInfo): Promise { if (!isOpen(info) && !(await opt.confirmLeave())) return; opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); } async function refreshStructure(): Promise { try { const [table, nums] = await Promise.all([ readTemplate(layer, "table", STRUCTURE_TABLE, null), fetchStructureNumbers(), ]); const all = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[]; columns = all.filter(isStructureColumn); numbers = nums; } catch (error) { columns = []; numbers = {}; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); } paintColumns(); } async function refresh(): Promise { const mine = ++seq; paintButtons(); try { items = await listTemplates(layer, null); } catch (error) { items = []; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); } if (mine !== seq) return; paintList(); paintButtons(); await refreshStructure(); } /* --- 시스템 층 새로 · 본떠 · 지우기(표 · 도면만 — 구조물 도면은 집계표 열이 정함) --- */ const nameDialog = (title: string, onOk: (name: string) => Promise): void => { const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); openModal({ title, closeLabel: L("M02_Close"), dialogClass: "m02-modal", mount: (body, close) => { const ok = createButton({ label: L("M02_Create"), variant: "filled" }); ok.addEventListener("click", async () => { const text = name.input.value.trim(); if (!text) return showToast(L("M02_NameNeeded"), "error"); if (badName(text)) return showToast(L("M02_BadName"), "error"); ok.disabled = true; // 연달아 눌러도 한 번만 try { await onOk(text); close(); } catch (error) { if (error instanceof StaleError) showToast(L("M02_NameExists"), "error"); else failed(error); } finally { ok.disabled = false; } }); name.input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.isComposing) ok.click(); }); body.append(name.root, ok); }, }); }; const openCreated = async (kind: Kind, name: string): Promise => { showToast(L("M02_Created"), "success"); await refresh(); const info = items.find((i) => i.종류 === kind && i.이름 === name); if (info) await select(info); }; /** 이 컨테이너 종류의 새 양식 — 종류 고르기 없이 이름만 */ function newTemplate(kind: Kind): void { nameDialog(L("M02_NewTitle"), async (name) => { await saveTemplate("system", kind, name, null, "", stubDoc(kind, name)); await openCreated(kind, name); }); } function copyTemplate(): void { const from = currentInfo(); if (!from) return; nameDialog(L("M02_CopyTitle"), async (name) => { const got = await readTemplate("system", from.종류, from.이름, null); // 표 문서 머리 이름(`양식`)도 새 이름으로 — 안 바꾸면 복사본이 원본 이름을 달고 있음 const doc = from.종류 === "table" ? { ...(got.문서 as object), 양식: name } : got.문서; await saveTemplate("system", from.종류, name, null, "", doc); await openCreated(from.종류, name); }); } async function deleteCurrent(): Promise { await refresh(); // 저장 직후여도 최신 판으로 const info = currentInfo(); if (!info) return; if (info.쓰는곳) { showToast( L("M02_InUse").replace("{value}", info.이름).replace("{use}", info.쓰는곳), "error", ); return; } if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return; try { await deleteTemplate(info.종류, info.이름, info.판); showToast(L("M02_Deleted"), "success"); opt.onOpen(null); await refresh(); } catch (error) { failed(error); } } void refresh(); return { root, refresh, paint }; }