feat(m02): 상세 이동 단추 · 도면 상세에서 면적산출로 보내기(105-2 · 105-4)

- 상세 머리 제목과 ? 사이 [도면][일위대가][면적산출] — 지금 것 눌림 · 문서 없는 열은 꺼짐 · 뒤로 단추와 같은 보임
- 구조물 도면 상세 머리 [면적산출로 보내기] · TOAREA 듣기 — 같은 열 면적 상세로 옮겨 가 넣을 자리 창
- 미저장 도면이면 물음 · 읽기 · 프로젝트 층 막음 · 저장은 사용자 [저장]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-08 16:22:20 +09:00
co-authored by Claude Opus 5.5
parent 36fc3d6c3b
commit 16ac946212
5 changed files with 301 additions and 1 deletions
@@ -0,0 +1,71 @@
/* =============================================================================
* M02_MasterTemplete_DetailNav.ts
* 상세 머리 제목과 [?] 사이 [도면][일위대가][면적산출] 묶음(105-4) — 같은 열의 다른 상세로 옮겨 감.
* 지금 상세 = 눌림(aria-pressed) · 그 열 문서가 없으면 그 단추 꺼짐(번호 목록에 번호 없음).
* 보임 조건 · 옮겨 가기(confirmLeave → open)는 부른 쪽(`UI_Main`) 몫.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import {
fetchStructureNumbers,
getActiveLayer,
type Kind,
type Layer,
type StructureNumbers,
} from "./M02_MasterTemplete_Api_Fetch";
type DetailKind = "structure" | "labor" | "area";
const ITEMS: [DetailKind, string, keyof StructureNumbers][] = [
["structure", "도면", "도번"],
["labor", "일위대가", "일위대가번"],
["area", "면적산출", "면적번"],
];
const TIP = "같은 열의 다른 상세로 — 저장 안 한 고침이 있으면 물음";
const NONE = "이 열은 문서가 없음";
export interface DetailNav {
root: HTMLElement;
/** 연 상세 — null 이면 숨김 · 눌림 · 꺼짐을 새로 */
update: (at: { layer: Layer; kind: Kind; name: string } | null) => void;
}
export function createDetailNav(go: (kind: DetailKind) => void): DetailNav {
const buttons = ITEMS.map(([kind, label]) => {
const button = createButton({ label, size: "sm", onClick: () => go(kind) });
button.dataset.detailNav = kind;
return button;
});
const root = el("div", {
className: "ui-btn-group m02-main__nav",
attrs: { role: "group", "aria-label": TIP },
children: buttons,
});
let ask = 0;
const update: DetailNav["update"] = (at) => {
root.hidden = !at;
const mine = ++ask;
buttons.forEach((button, i) => {
const on = ITEMS[i][0] === at?.kind;
button.setAttribute("aria-pressed", String(on)); // 눌림 = 지금 상세(누르면 그대로)
button.disabled = false;
button.title = on ? "" : TIP;
});
// 번호 목록은 지금 층만(프로젝트 층 · 남의 층이면 안 끔)
if (!at || at.layer === "project" || at.layer !== getActiveLayer()) return;
void fetchStructureNumbers().then(
(numbers) => {
if (mine !== ask) return;
buttons.forEach((button, i) => {
const [kind, , field] = ITEMS[i];
if (kind === at.kind || numbers[at.name]?.[field]) return;
button.disabled = true;
button.title = NONE;
});
},
() => undefined,
);
};
return { root, update };
}
@@ -0,0 +1,48 @@
/* =============================================================================
* M02_MasterTemplete_DrawingToArea.ts
* 구조물 도면 상세 → 같은 열 면적산출 상세(105-2) — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청
* (`EXPORT_TABLE_REQUEST`) · 받은 표 자료(`EXPORT_TABLE` · CAD 명령 TOAREA 도 같은 길)를 `onTable` 로.
* 옮겨 가기 · 넣을 자리 창은 부른 쪽(`UI_Main` → `importDrawingTable`) · 저장은 사용자 [저장].
* 쓸 수 없는 면적(읽기 · 프로젝트 층)이면 단추 없음 · TOAREA 는 알림만.
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { EXPORT_TABLE, EXPORT_TABLE_REQUEST } from "./M02_MasterTemplete_AreaDrawing";
import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
const TEXT = {
send: "면적산출로 보내기",
sendTip:
"도면에서 고른 범위(표 · 선으로 그린 표 · 글)를 같은 열 면적산출로 옮겨 감 — 새 시트 또는 고른 칸부터 · 연동 아님",
blocked: "이 층은 면적산출을 고칠 수 없음 — 보내지 않음",
};
/** `host` = 도면 상세 칸(iframe 이 든 곳) · `slot` = 머리 칸 · 떼는 함수를 돌려줌 */
export function attachDrawingToArea(
host: HTMLElement,
slot: HTMLElement,
onTable: ((table: DrawingTable) => void) | null,
): () => void {
const frame = (): HTMLIFrameElement | null => host.querySelector("iframe");
const send = createButton({ label: TEXT.send, variant: "ghost" });
send.title = TEXT.sendTip;
send.dataset.drawingToArea = "";
send.addEventListener("click", () =>
frame()?.contentWindow?.postMessage({ type: EXPORT_TABLE_REQUEST }, window.location.origin),
);
if (onTable) slot.append(send);
const onMessage = (event: MessageEvent): void => {
const win = frame()?.contentWindow;
if (!win || event.source !== win || event.origin !== window.location.origin) return;
const data = event.data as { type?: string; table?: DrawingTable } | null;
if (data?.type !== EXPORT_TABLE || !data.table) return;
if (onTable) onTable(data.table);
else showToast(TEXT.blocked, "info");
};
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
send.remove();
};
}
@@ -30,6 +30,9 @@ import {
} from "./M02_MasterTemplete_Structure";
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing";
import { importDrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
import { createDetailNav } from "./M02_MasterTemplete_DetailNav";
import { attachDrawingToArea } from "./M02_MasterTemplete_DrawingToArea";
import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp";
import { helpParts } from "./M02_MasterTemplete_CardShell";
import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
@@ -135,6 +138,12 @@ export function createMain(
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
const back = createButton({ label: L("M02_Back") });
const helpSlot = el("span", { className: "m02-main__help" }); // 상세 [?](49-7) — 제목 바로 옆
// 제목과 [?] 사이 [도면][일위대가][면적산출](105-4) — 같은 열 다른 상세 · 지금 것은 그대로
const nav = createDetailNav(async (kind) => {
const at = sel;
if (!at || at.kind === kind || !(await confirmLeave())) return;
void open({ ...at, kind });
});
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
const host = el("div", { className: "m02-main__host" });
@@ -149,6 +158,7 @@ export function createMain(
children: [
back,
title,
nav.root,
helpSlot,
slot,
reload,
@@ -236,6 +246,7 @@ export function createMain(
reload.hidden = !sel;
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음
const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null;
const help = kind && helpParts(HELP_TITLE[kind], detailHelp(kind)); // 열 때마다 새로 — 상자는 닫힘
helpSlot.replaceChildren(...(help ? [help.button, help.box] : []));
@@ -445,7 +456,25 @@ export function createMain(
noArea: at.kind === "structure",
frame: at.kind === "structure", // 도각 = 보여주기만(56장)
});
if (at.kind === "structure") toDrawing.onDrawing(editor, at, doc as StructureDoc);
if (at.kind === "structure") {
toDrawing.onDrawing(editor, at, doc as StructureDoc);
// 도면 → 같은 열 면적산출 상세로 옮겨 가 넣을 자리 창(105-2) · 저장은 사용자 [저장] ·
// 읽기 · 프로젝트 층 면적은 막음
const off = attachDrawingToArea(
host,
slot,
readOnly || at.layer === "project"
? null
: async (table) => {
if (!(await confirmLeave())) return;
await open({ ...at, kind: "area" });
if (sel?.kind === "area" && sel.name === at.name)
void importDrawingTable(sheetHandle, table);
},
);
const made = editor;
editor = { ...made, destroy: () => (off(), made.destroy()) };
}
return;
}
empty(L("M02_NoDrawing"));
@@ -241,3 +241,13 @@
.m02-main__host[hidden] {
display: none;
}
/* 상세 이동 [도면][일위대가][면적산출](105-4) — 지금 상세 = 눌림(강조색 · 밝음 · 어두움 같은 토큰) */
.m02-main__nav[hidden] {
display: none;
}
.m02-main__nav .ui-btn[aria-pressed="true"] {
border-color: var(--color-accent);
color: var(--color-accent);
}
+142
View File
@@ -0,0 +1,142 @@
"""상세 이동 단추 · 도면 상세 → 면적산출(PLAN 105-2 · 105-4) — Node 로 판정 · 화면은 ORCA.
못박는 것
- 머리 제목과 [?] 사이 [도면][일위대가][면적산출](`ui-btn-group`) · 지금 상세 = 눌림(aria-pressed) ·
그 열 문서(번호)가 없으면 그 단추 꺼짐 · 프로젝트 층 · 남의 층은 번호를 안 읽고 다 켬 · 상세 아니면 숨김.
- 보임 조건 = 뒤로 단추와 같음 · 누름 = confirmLeave → open({...sel, kind}) · 지금 것 누름 = 그대로.
- 도면 상세 머리 [면적산출로 보내기] = CAD 요청 · 받은 표(TOAREA 도 같은 길) → 같은 열 면적 상세로 옮겨 가
넣을 자리 창(`importDrawingTable`) · 읽기 · 프로젝트 층은 단추 없음 · 떠나면 듣기를 뗌.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
SCRIPT = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts", "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
let fetched = 0;
let numbers = {};
const button = (o) => {
const b = { label: o.label, size: o.size, dataset: {}, attrs: {}, disabled: false, title: "", onClick: o.onClick };
b.setAttribute = (k, v) => (b.attrs[k] = v);
return b;
};
const stubs = {
"@ui/ui_template_elements": {
createButton: button,
el: (tag, o) => ({ tag, className: o.className, attrs: o.attrs, children: o.children, hidden: false }),
},
"./M02_MasterTemplete_Api_Fetch": {
fetchStructureNumbers: async () => (fetched++, numbers),
getActiveLayer: () => "system",
},
};
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
const gone = [];
const nav = box.exports.createDetailNav((kind) => gone.push(kind));
const view = () => ({
hidden: nav.root.hidden,
pressed: nav.root.children.map((b) => b.attrs["aria-pressed"]),
disabled: nav.root.children.map((b) => b.disabled),
});
const tick = () => new Promise((r) => setTimeout(r, 0));
(async () => {
const out = { className: nav.root.className, labels: nav.root.children.map((b) => b.label) };
numbers = { c1: { 도번: "SD-000001", 일위대가번: "", 면적번: "SA-000001" } };
nav.update({ layer: "system", kind: "area", name: "c1" });
out.before = view();
await tick();
out.area = view();
nav.update({ layer: "project", kind: "structure", name: "c1" });
await tick();
out.project = view();
nav.update({ layer: "company", kind: "labor", name: "c1" }); // 남의 층(지금 층 아님)
await tick();
out.other = view();
out.fetched = fetched;
nav.update(null);
out.none = view();
nav.root.children[1].onClick();
out.gone = gone;
console.log(JSON.stringify(out));
})();
"""
@pytest.fixture(scope="module")
def run():
node = shutil.which("node")
if not node:
pytest.skip("node 없음")
done = subprocess.run(
[node, "-e", SCRIPT, str(ROOT)],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
def test_단추_셋_묶음(run):
assert run["labels"] == ["도면", "일위대가", "면적산출"]
assert "ui-btn-group" in run["className"]
def test_지금_것_눌림_없는_문서_꺼짐(run):
assert run["before"]["pressed"] == ["false", "false", "true"]
assert run["before"]["disabled"] == [False, False, False] # 번호 받기 전엔 켬
assert run["area"]["disabled"] == [False, True, False] # 일위대가번 없음 = 꺼짐
assert run["area"]["hidden"] is False
def test_프로젝트_남의_층은_안_읽음(run):
assert run["project"]["pressed"] == ["true", "false", "false"]
assert run["project"]["disabled"] == [False, False, False]
assert run["other"]["disabled"] == [False, False, False]
assert run["fetched"] == 1
def test_상세_아니면_숨김_누름은_옮겨_감(run):
assert run["none"]["hidden"] is True
assert run["gone"] == ["labor"]
def test_머리_줄_자리와_보임_조건():
main = _text("M02_MasterTemplete_UI_Main.ts")
assert " title,\n nav.root,\n helpSlot," in main # 제목과 ? 사이
assert "nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음" in main
assert "if (!at || at.kind === kind || !(await confirmLeave())) return;" in main
assert "void open({ ...at, kind });" in main
css = (M02 / "M02_MasterTemplete_UI_Style.css").read_text(encoding="utf-8")
assert '.m02-main__nav .ui-btn[aria-pressed="true"]' in css
def test_도면_상세에서_면적으로_보내기():
main = _text("M02_MasterTemplete_UI_Main.ts")
block = main.split("const off = attachDrawingToArea(")[1].split("return;\n }\n empty(")[0]
assert 'readOnly || at.layer === "project"' in block # 읽기 · 프로젝트 층 막음
assert "if (!(await confirmLeave())) return;" in block # 미저장 도면이면 물음
assert 'await open({ ...at, kind: "area" });' in block
assert "void importDrawingTable(sheetHandle, table);" in block # 넣을 자리 창 · 저장은 사용자
assert "editor = { ...made, destroy: () => (off(), made.destroy()) };" in block # 떠나면 뗌
to_area = _text("M02_MasterTemplete_DrawingToArea.ts")
assert "postMessage({ type: EXPORT_TABLE_REQUEST }, window.location.origin)" in to_area
assert "if (data?.type !== EXPORT_TABLE || !data.table) return;" in to_area
assert "if (onTable) slot.append(send);" in to_area
assert "event.source !== win" in to_area # 다른 iframe(면적 도면 패널) 메시지는 안 받음