feat(M02): 도면으로 보내기 — 41-1 표 변환 답 · 41-2 놓기 손잡이 꼴에 맞춤(41-3)

- 답 {표, 시트, 범위, 값} · 연결표 줄 = {연결, 시트, 범위, 글자크기, 값(시트!A1: 글)} · 다름 = 값 견줌
- placeTable(표, 연결) · [업데이트] = updateTable 바꾼 표만 값 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-04 22:47:45 +09:00
co-authored by Claude Opus 5.5
parent ecc4ed372d
commit 4a264d961e
3 changed files with 74 additions and 52 deletions
@@ -4,8 +4,8 @@
* ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5)
* → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열
* 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2)
* ② 도면 [저장] — 문서 맨 위 `연결표[]` = {id(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글)} · 표 style.연결 = id ·
* 도면에서 지운 표는 기록에서 뺌
* ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} ·
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`)
* ④ 표 여러 개 — id 마다 한 줄
* ========================================================================== */
@@ -36,14 +36,21 @@ interface TableEntityJson {
>;
[key: string]: unknown;
}
/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막으로 도면에 적힌 칸 글 · 다름 알림 기준) */
/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */
export type TableLink = NonNullable<StructureDoc["연결표"]>[number];
type LinkSpec = Omit<TableLink, "id" | "값">;
type LinkSpec = Pick<TableLink, "시트" | "범위" | "글자크기">;
/** 표 변환 길 답(41-1) — 시트 = 시트 id */
interface AreaTable {
표: TableEntityJson;
시트: string;
범위: string;
값: Record<string, string>;
}
/** 도면 손잡이에 더해지는 표 함수(41-2) — 없으면 단추 · 알림이 조용히 빠짐 */
/** 도면 손잡이에 더해지는 표 함수(41-2 · `DrawingTemplateHandle`) — 없으면 놓기 · 알림이 빠짐 */
interface TableHandle {
placeTable?: (entity: TableEntityJson) => void;
updateTable?: (link: string, cells: TableEntityJson["shapeData"]["cells"]) => void;
placeTable?: (entity: Record<string, unknown>, link: string) => void;
updateTable?: (link: string, cells: unknown[][]) => Promise<number>;
}
const TEXT = {
@@ -73,25 +80,22 @@ const fill = (text: string, key: string, value: string | number): string =>
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<TableEntityJson> {
/** 범위 → 표 객체 · 칸 글(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),
body: JSON.stringify({ 시트: spec.시트, 범위: spec.범위, 글자크기: spec.글자크기 }),
});
const data = (await res.json().catch(() => ({}))) as {
detail?: unknown;
entity?: TableEntityJson;
} & Partial<TableEntityJson>;
if (!res.ok) {
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.entity ?? data) as TableEntityJson;
return data as AreaTable;
}
const isTable = (e: unknown): e is TableEntityJson =>
@@ -100,10 +104,11 @@ 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);
/** 칸 글 묶음 견줌 — 열쇠 차례와 무관 */
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`) */
@@ -130,10 +135,10 @@ export interface AreaToDrawing {
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<string, LinkSpec>();
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;
/** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */
@@ -180,13 +185,13 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
const size = await askSize(picked);
if (size === null) return;
const spec: LinkSpec = { 시트: picked.시트, 범위: picked.범위, 글자크기: size };
let entity: TableEntityJson;
let got: AreaTable;
try {
entity = await fetchTable(at.layer, at.name, spec);
got = 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 };
pending = { layer: at.layer, column: at.name, got, 글자크기: size };
await opt.open({
layer: at.layer,
projectId: null,
@@ -198,7 +203,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
/** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */
const nextId = (doc: StructureDoc, taken: Set<string>): string => {
for (const row of doc.연결표 ?? []) taken.add(row.id);
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;
@@ -220,15 +225,21 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
);
if (shown !== key) return; // 그 사이 다른 화면
const stale = got.filter(
(x): x is { row: TableLink; e: TableEntityJson } =>
!!x && !sameTexts(x.row.값 ?? [], textsOf(x.e.shapeData.cells)),
(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", () => {
for (const { row, e } of stale) handle.updateTable?.(row.id, e.shapeData.cells);
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", stale.length), "success");
showToast(fill(TEXT.updated, "n", n), "success");
});
opt.showNotice([fill(TEXT.stale, "n", stale.length), update]);
}
@@ -248,6 +259,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
const handle = raw as TableHandle;
shown = `${at.layer}:${at.name}`;
fresh = new Map();
latest = new Map();
const give =
pending && pending.layer === at.layer && pending.column === at.name ? pending : null;
pending = null;
@@ -255,24 +267,24 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
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 } });
const { 표, 시트, 범위, 값 } = give.got;
const style = { ...(표.shapeData.style ?? {}), [LINK]: id };
fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 });
handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id);
showToast(TEXT.place, "info");
}
}
void checkStale(handle, at, doc);
},
withLinks: (doc) => {
const old = new Map((doc.연결표 ?? []).map((row) => [row.id, row]));
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 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) });
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;