/* ============================================================================= * M02_MasterTemplete_AreaToDrawing.ts * 면적산출 범위 → 도면 표(PLAN 41-3) — 편집 칸(`UI_Main`)은 갈고리만 · 몸은 여기. * ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5) * → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열 * 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2) * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {id(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글)} · 표 style.연결 = id · * 도면에서 지운 표는 기록에서 뺌 * ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`) * ④ 표 여러 개 — id 마다 한 줄 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; /** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */ interface TableCell { text?: string; [key: string]: unknown; } /** CAD 표 객체 JSON(`TableEntity.toJson` 꼴에서 쓰는 것만) */ interface TableEntityJson { type: string; shapeData: { cells: (TableCell | null)[][]; style?: Record } & Record< string, unknown >; [key: string]: unknown; } /** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막으로 도면에 적힌 칸 글 · 다름 알림 기준) */ export type TableLink = NonNullable[number]; type LinkSpec = Omit; /** 도면 손잡이에 더해지는 표 함수(41-2) — 없으면 단추 · 알림이 조용히 빠짐 */ interface TableHandle { placeTable?: (entity: TableEntityJson) => void; updateTable?: (link: string, cells: TableEntityJson["shapeData"]["cells"]) => void; } const TEXT = { send: "도면으로 보내기", sendTip: "고른 범위를 이 열 도면에 표로 넣음 — 놓을 자리는 도면에서 누름", title: "도면으로 보내기", range: "범위", size: "글자 크기", ok: "보내기", close: "닫기", badSize: "글자 크기는 0 보다 큰 수", saveFirst: "저장 안 한 고침이 있음 — 저장하고 보낼까요?", saveLabel: "저장하고 보내기", failed: "표를 못 만듦 — {value}", place: "도면에서 표를 놓을 자리를 누름(Esc = 그만)", noPlace: "이 도면 화면은 표 놓기를 아직 못 함", stale: "면적산출 값이 바뀐 표 {n}개", update: "업데이트", updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음", } as const; const SIZE = 2.5; // 표 글자 크기 기본(정함 41) const LINK = "연결"; // 표 style 안 표지 칸 const fill = (text: string, key: string, value: string | number): string => text.replace(`{${key}}`, String(value)); const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const enc = encodeURIComponent; /** 범위 → 표 객체(41-1 서버 · 저장 안 함) — 지금 보는 층 · 주인 */ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise { const owner = getActiveOwner(); const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; const res = await fetch(`${API_BASE_URL}/m02/layers/${layer}/area/${enc(column)}/table${q}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(spec), }); const data = (await res.json().catch(() => ({}))) as { detail?: unknown; entity?: TableEntityJson; } & Partial; if (!res.ok) { const d = data.detail; throw new Error(typeof d === "string" ? d : `HTTP ${res.status}`); } return (data.entity ?? data) as TableEntityJson; } const isTable = (e: unknown): e is TableEntityJson => !!e && typeof e === "object" && (e as { type?: unknown }).type === "Table"; const linkOf = (e: TableEntityJson): string | null => { const id = e.shapeData?.style?.[LINK]; return typeof id === "string" && id ? id : null; }; const textsOf = (cells: TableEntityJson["shapeData"]["cells"]): (string | null)[][] => (cells ?? []).map((row) => (row ?? []).map((c) => (c ? String(c.text ?? "") : null))); const sameTexts = (a: (string | null)[][], b: (string | null)[][]): boolean => JSON.stringify(a) === JSON.stringify(b); export interface AreaToDrawingOptions { /** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */ open: (sel: Selection) => Promise; /** 지금 화면 [저장] */ save: () => Promise; /** 저장 안 한 고침이 남았나 */ isDirty: () => boolean; /** 편집 칸 알림 줄 */ showNotice: (nodes: (HTMLElement | string)[]) => void; } export interface AreaToDrawing { /** 면적 시트 고름(`onSelect`) — 범위를 들고 있음 */ select: (s: { 시트: string; 범위: string }) => void; /** 면적 상세가 섰음 — 머리 칸에 [도면으로 보내기](고칠 수 있는 층 · 프로젝트 층 아님) */ onArea: (at: Selection, readOnly: boolean, slot: HTMLElement) => void; /** 도면 상세가 섰음 — 넘겨받은 표 놓기 · 연결표 다름 알림 */ onDrawing: (handle: unknown, at: Selection, doc: StructureDoc) => void; /** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */ withLinks: (doc: StructureDoc) => StructureDoc; } export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { let range: { 시트: string; 범위: string } | null = null; /** 면적 상세 → 도면 상세로 넘길 표(같은 열 · 같은 층일 때만 놓음) */ let pending: { layer: Layer; column: string; entity: TableEntityJson; spec: LinkSpec } | null = null; /** 이 도면에서 새로 놓은 표의 연결(저장 때 기록) · 열어 둔 도면 */ let fresh = new Map(); let shown: string | null = null; /** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */ const askSize = (spec: { 범위: string }): Promise => new Promise((resolve) => { let done = false; const finish = (v: number | null): void => { if (!done) resolve(v); done = true; }; const size = createInputField({ label: TEXT.size, type: "number", value: String(SIZE) }); size.input.step = "0.5"; size.input.min = "0.1"; const { close } = openModal({ title: TEXT.title, closeLabel: TEXT.close, dialogClass: "m02-modal", beforeClose: async () => (finish(null), true), mount: (body) => { const ok = createButton({ label: TEXT.ok, variant: "filled" }); ok.addEventListener("click", () => { const v = Number(size.input.value); if (!(v > 0)) return showToast(TEXT.badSize, "error"); finish(v); close(); }); size.input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.isComposing) ok.click(); }); body.append(el("p", { text: `${TEXT.range} ${spec.범위}` }), size.root, ok); size.input.select(); }, }); }); async function send(at: Selection): Promise { if (!range) return; if (opt.isDirty()) { if (!(await showConfirmDialog(TEXT.saveFirst, TEXT.saveLabel))) return; await opt.save(); if (opt.isDirty()) return; // 저장 실패 — 알림은 저장 길이 띄움 } const picked = range; const size = await askSize(picked); if (size === null) return; const spec: LinkSpec = { 시트: picked.시트, 범위: picked.범위, 글자크기: size }; let entity: TableEntityJson; try { entity = await fetchTable(at.layer, at.name, spec); } catch (error) { return showToast(fill(TEXT.failed, "value", why(error)), "error"); } pending = { layer: at.layer, column: at.name, entity, spec }; await opt.open({ layer: at.layer, projectId: null, kind: "structure", name: at.name, label: at.label, }); } /** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */ const nextId = (doc: StructureDoc, taken: Set): string => { for (const row of doc.연결표 ?? []) taken.add(row.id); for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? ""); let n = 1; while (taken.has(`T${n}`)) n += 1; return `T${n}`; }; /** 연결표마다 지금 면적 값 → 마지막 값과 다르면 알림 + [업데이트] */ async function checkStale(handle: TableHandle, at: Selection, doc: StructureDoc): Promise { const rows = doc.연결표 ?? []; if (!rows.length || !handle.updateTable) return; const key = `${at.layer}:${at.name}`; const got = await Promise.all( rows.map((row) => fetchTable(at.layer, at.name, row).then( (e) => ({ row, e }), () => null, ), ), ); if (shown !== key) return; // 그 사이 다른 화면 const stale = got.filter( (x): x is { row: TableLink; e: TableEntityJson } => !!x && !sameTexts(x.row.값 ?? [], textsOf(x.e.shapeData.cells)), ); if (!stale.length) return; const update = createButton({ label: TEXT.update, variant: "filled", size: "sm" }); update.addEventListener("click", () => { for (const { row, e } of stale) handle.updateTable?.(row.id, e.shapeData.cells); opt.showNotice([]); showToast(fill(TEXT.updated, "n", stale.length), "success"); }); opt.showNotice([fill(TEXT.stale, "n", stale.length), update]); } return { select: (s) => (range = { 시트: s.시트, 범위: s.범위 }), onArea: (at, readOnly, slot) => { range = null; if (readOnly || at.layer === "project") return; const button = createButton({ label: TEXT.send, variant: "ghost", size: "sm" }); button.title = TEXT.sendTip; button.dataset.areaToDrawing = ""; button.addEventListener("click", () => void send(at)); slot.append(button); }, onDrawing: (raw, at, doc) => { const handle = raw as TableHandle; shown = `${at.layer}:${at.name}`; fresh = new Map(); const give = pending && pending.layer === at.layer && pending.column === at.name ? pending : null; pending = null; if (give) { if (!handle.placeTable) showToast(TEXT.noPlace, "error"); else { const id = nextId(doc, new Set()); const style = { ...(give.entity.shapeData.style ?? {}), [LINK]: id }; fresh.set(id, give.spec); handle.placeTable({ ...give.entity, shapeData: { ...give.entity.shapeData, style } }); showToast(TEXT.place, "info"); } } void checkStale(handle, at, doc); }, withLinks: (doc) => { const old = new Map((doc.연결표 ?? []).map((row) => [row.id, row])); const rows: TableLink[] = []; for (const e of doc.도면?.entities ?? []) { if (!isTable(e)) continue; const id = linkOf(e); const spec = id ? (old.get(id) ?? fresh.get(id)) : undefined; if (!id || !spec || rows.some((r) => r.id === id)) continue; const { 시트, 범위, 글자크기 } = spec; rows.push({ id, 시트, 범위, 글자크기, 값: textsOf(e.shapeData.cells) }); } // 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로) return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc; }, }; }