From 16ac94621221dca5304bc67a49dc19895c79d806 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 16:22:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02):=20=EC=83=81=EC=84=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=97=90=EC=84=9C=20=EB=A9=B4=EC=A0=81?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EB=A1=9C=20=EB=B3=B4=EB=82=B4=EA=B8=B0(105-2?= =?UTF-8?q?=20=C2=B7=20105-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 머리 제목과 ? 사이 [도면][일위대가][면적산출] — 지금 것 눌림 · 문서 없는 열은 꺼짐 · 뒤로 단추와 같은 보임 - 구조물 도면 상세 머리 [면적산출로 보내기] · TOAREA 듣기 — 같은 열 면적 상세로 옮겨 가 넣을 자리 창 - 미저장 도면이면 물음 · 읽기 · 프로젝트 층 막음 · 저장은 사용자 [저장] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../M02_MasterTemplete_DetailNav.ts | 71 +++++++++ .../M02_MasterTemplete_DrawingToArea.ts | 48 ++++++ .../M02_MasterTemplete_UI_Main.ts | 31 +++- .../M02_MasterTemplete_UI_Style.css | 10 ++ resources/tester/test_m02_detail_nav.py | 142 ++++++++++++++++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_DrawingToArea.ts create mode 100644 resources/tester/test_m02_detail_nav.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts b/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts new file mode 100644 index 000000000..ecf8d49b0 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts @@ -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 }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_DrawingToArea.ts b/M02_MasterTemplete/M02_MasterTemplete_DrawingToArea.ts new file mode 100644 index 000000000..a1953234f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_DrawingToArea.ts @@ -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(); + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 1a9d3fb11..34933506b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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")); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index d002e926d..18ed7f577 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -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); +} diff --git a/resources/tester/test_m02_detail_nav.py b/resources/tester/test_m02_detail_nav.py new file mode 100644 index 000000000..b1ff742f3 --- /dev/null +++ b/resources/tester/test_m02_detail_nav.py @@ -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(면적 도면 패널) 메시지는 안 받음