Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts
T

314 lines
14 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_AreaToDrawing.ts
* 면적산출 범위 → 도면 표(PLAN 41-3) — 편집 칸(`UI_Main`)은 갈고리만 · 몸은 여기.
* ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5)
* → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열
* 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2)
* ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} ·
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`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<string, unknown> } & Record<
string,
unknown
>;
[key: string]: unknown;
}
/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */
export type TableLink = NonNullable<StructureDoc["연결표"]>[number];
type LinkSpec = Pick<TableLink, "시트" | "범위" | "글자크기">;
/** 표 변환 길 답(41-1) — 시트 = 시트 id */
interface AreaTable {
표: TableEntityJson;
시트: string;
범위: string;
값: Record<string, string>;
}
/** 도면 손잡이에 더해지는 표 함수(41-2 · `DrawingTemplateHandle`) — 없으면 놓기 · 알림이 빠짐 */
interface TableHandle {
placeTable?: (entity: Record<string, unknown>, link: string) => void;
updateTable?: (link: string, cells: unknown[][]) => Promise<number>;
}
const TEXT = {
send: "도면으로 보내기",
sendTip: "고른 범위를 이 열 도면에 표로 넣음 — 놓을 자리는 도면에서 누름",
title: "도면으로 보내기",
range: "범위",
size: "글자 크기",
ok: "보내기",
close: "닫기",
badSize: "글자 크기는 0 보다 큰 수",
saveFirst: "저장 안 한 고침이 있음 — 저장하고 보낼까요?",
saveLabel: "저장하고 보내기",
failed: "표를 못 만듦 — {value}",
noRange: "보낼 범위를 시트에서 먼저 고름",
empty: "보낼 칸에 내용이 없음",
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<AreaTable> {
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.시트, 범위: spec.범위, 글자크기: spec.글자크기 }),
});
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & Partial<AreaTable>;
if (!res.ok || !data.표) {
const d = data.detail;
throw new Error(typeof d === "string" ? d : `HTTP ${res.status}`);
}
return data as AreaTable;
}
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 sameValues = (a: Record<string, string> = {}, b: Record<string, string> = {}): boolean => {
const ka = Object.keys(a);
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
};
export interface AreaToDrawingOptions {
/** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */
open: (sel: Selection) => Promise<void>;
/** 지금 화면 [저장] */
save: () => Promise<void>;
/** 저장 안 한 고침이 남았나 */
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;
/** 보낸 표 놓기 기다리는 중 — 편집 칸 「저장 안 한 고침」 에 듦 */
waiting: () => boolean;
/** 떠나기로 함(물음 [확인]) — 기다리던 표 버림 */
drop: () => void;
/** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */
withLinks: (doc: StructureDoc) => StructureDoc;
}
export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
let range: { 시트: string; 범위: string } | null = null;
/** 면적 상세 → 도면 상세로 넘길 표(같은 열 · 같은 층일 때만 놓음) */
let pending: { layer: Layer; column: string; got: AreaTable; 글자크기: number } | null = null;
/** 이 도면에서 새로 놓은 표 · [업데이트]로 바뀐 값(저장 때 기록) · 열어 둔 도면 */
let fresh = new Map<string, TableLink>();
let latest = new Map<string, Record<string, string>>();
let shown: string | null = null;
/** 보낸 표가 도면에 놓이길 기다림 — 떠날 때 「저장 안 한 고침」 물음(`waiting`) */
let waiting = false;
/** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */
const askSize = (spec: { 범위: string }): Promise<number | null> =>
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<void> {
if (!range) return showToast(TEXT.noRange, "error");
if (opt.isDirty()) {
if (!(await showConfirmDialog(TEXT.saveFirst, TEXT.saveLabel))) return;
await opt.save();
if (opt.isDirty()) return; // 저장 실패 — 알림은 저장 길이 띄움
}
const picked = range;
const get = (size: number): Promise<AreaTable | null> =>
fetchTable(at.layer, at.name, { 시트: picked.시트, 범위: picked.범위, 글자크기: size }).catch(
(error: unknown) => (showToast(fill(TEXT.failed, "value", why(error)), "error"), null),
);
// 보이는 글이 하나도 없는 범위(빈 칸만)는 크기를 묻기 전에 막음
let got = await get(SIZE);
if (!got) return;
if (!Object.values(got.값 ?? {}).some((v) => String(v ?? "").trim())) {
return showToast(TEXT.empty, "error");
}
const size = await askSize(picked);
if (size === null) return;
if (size !== SIZE) got = await get(size);
if (!got) return;
pending = { layer: at.layer, column: at.name, got, 글자크기: size };
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>): string => {
for (const row of doc.연결표 ?? []) taken.add(row.연결);
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<void> {
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: AreaTable } => !!x && !sameValues(x.row.값, x.e.값),
);
if (!stale.length) return;
const update = createButton({ label: TEXT.update, variant: "filled", size: "sm" });
update.addEventListener("click", async () => {
update.disabled = true;
let n = 0;
for (const { row, e } of stale) {
const changed = (await handle.updateTable?.(row.연결, e.표.shapeData.cells)) ?? 0;
if (changed) latest.set(row.연결, e.값);
n += changed ? 1 : 0;
}
if (shown !== key) return;
opt.showNotice([]);
showToast(fill(TEXT.updated, "n", n), "success");
});
opt.showNotice([fill(TEXT.stale, "n", stale.length), update]);
}
return {
select: (s) => (range = { 시트: s.시트, 범위: s.범위 }),
waiting: () => waiting,
drop: () => (waiting = false),
onArea: (at, readOnly, slot) => {
waiting = false;
// 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림
if (readOnly || at.layer === "project") return;
const button = createButton({ label: TEXT.send }); // 머리 일반 단추(43-5 · 돌아가기와 같은 높이)
button.title = TEXT.sendTip;
button.dataset.areaToDrawing = "";
button.addEventListener("click", () => void send(at));
slot.append(button);
},
onDrawing: (raw, at, doc) => {
waiting = false;
const handle = raw as TableHandle;
shown = `${at.layer}:${at.name}`;
range = null; // 면적 상세를 떠남
fresh = new Map();
latest = 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 { 표, 시트, 범위, 값 } = give.got;
const style = { ...(표.shapeData.style ?? {}), [LINK]: id };
fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 });
handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id);
waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂)
showToast(TEXT.place, "info");
}
}
void checkStale(handle, at, doc);
},
withLinks: (doc) => {
waiting = false;
const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row]));
const rows: TableLink[] = [];
for (const e of doc.도면?.entities ?? []) {
if (!isTable(e)) continue;
const id = linkOf(e);
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
rows.push({ ...row, 값: latest.get(id) ?? row.값 });
}
// 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로)
return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc;
},
};
}