diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts new file mode 100644 index 000000000..bcd413cca --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts @@ -0,0 +1,56 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaDrawing.ts + * 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기만(확대 · 이동). + * 그리기는 구조물 조합 도면 카드 미리보기와 같은 길(`DrawingCard.mountDrawingView`). + * 처음 펼 때 읽고 그림 — 접힌 채 iframe 을 띄우지 않음. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; +import { mountDrawingView } from "./M02_MasterTemplete_DrawingCard"; + +const TEXT = { + loading: "도면 불러오는 중…", + none: "이 열은 구조물 도면이 없음", +}; + +export interface AreaDrawing { + root: HTMLElement; + /** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */ + show: () => void; + destroy: () => void; +} + +export function createAreaDrawing(layer: Layer, column: string): AreaDrawing { + const root = el("div", { className: "m02-apanel__drawing" }); + let view: { destroy: () => void } | null = null; + let started = false; + let dead = false; + const note = (text: string): void => + root.replaceChildren(el("p", { className: "m02-apanel__note", text })); + + const show = (): void => { + if (started) return; + started = true; + note(TEXT.loading); + readTemplate(layer, "structure", column, null) + .then((got) => { + if (dead) return; + const doc = got.문서 as StructureDoc | null; + if (!doc?.도면) return note(TEXT.none); + root.replaceChildren(); + view = mountDrawingView(root, doc, { layer, projectId: null }, column); + }) + .catch(() => !dead && note(TEXT.none)); + }; + + return { + root, + show, + destroy: () => { + dead = true; + view?.destroy(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css index 36e932845..c1a2de4c4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -1,48 +1,25 @@ -/* M02 면적산출 상세 오른쪽 패널(일위대가 담은 로직 · 자재목록) · 칸 지정 모달 — 틀은 공용 사이드바 카드 · - 접기 · 폭 손잡이 · 접기 손잡이 */ +/* M02 면적산출 상세 오른쪽 패널(틀 = 공용 오른쪽 접는 패널 · 일위대가 · 도면) · 칸 지정 모달 */ -/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-apanel-width` 로 줌 */ -.m02-apanel { - position: relative; +/* 「도면」 패널 몸 — 패널 높이를 채움(CAD 는 높이를 정해야 보임) */ +.ui-side-panels__body:has(> .m02-apanel__drawing) { display: flex; - flex: 0 0 auto; - flex-direction: column; - width: var(--m02-apanel-width, 36rem); /* 금액 칸 넷이 가로 굴림 없이 보이게 */ - min-width: 280px; - max-width: 60%; - min-height: 0; - border-left: 1px solid var(--color-border); + padding: 0; } -.m02-apanel.is-resizing { - transition: none; -} - -.m02-apanel.is-collapsed { - width: 0; - min-width: 0; -} - -.m02-apanel.is-collapsed > .m02-apanel__body, -.m02-apanel.is-collapsed > .ui-resizer { - display: none; -} - -/* 접기 손잡이 — 패널 왼쪽 밖 세로 가운데(시트 위로) */ -.m02-apanel > .ui-workflow-overlay__handle--side { - z-index: 6; - right: auto; - left: calc(-1 * var(--spacing-24)); - border-right: 0; - border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); -} - -/* 안쪽만 굴림 — 페이지 스크롤 안 생김 */ -.m02-apanel__body { +.m02-apanel__drawing { + display: flex; flex: 1 1 0; + min-width: 0; min-height: 0; - overflow: auto; - padding: var(--spacing-8); +} + +.m02-apanel__drawing .cad-host { + min-height: 0; +} + +.m02-apanel__note { + margin: var(--spacing-12); + color: var(--color-text-secondary); } /* 칸 지정 모달 — 지금 연결 한 줄 · [지금 고른 칸으로] [풀기] · 주소 칸 [잇기] */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 37c68493a..9d806e4e9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -1,32 +1,29 @@ /* ============================================================================= * M02_MasterTemplete_AreaPanel.ts - * 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6) — 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영). - * 몸 = 일위대가 보기 갈래(`Labor_View` · 고치기 없음) · 틀 = 공용 사이드바 카드 · 접기 손잡이 · 폭 손잡이 - * (폭 · 접힘 기억은 옛 조합 패널 키 그대로). + * 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6 · 47-6) — 틀 = 공용 오른쪽 접는 패널(하나만 열림 · 폭 공유) + * · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`) + * · 「도면」 = 그 열 구조물 도면 보기만(`AreaDrawing` · 처음 펼 때 그림) * · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기) * · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`) * ========================================================================== */ -import { el, showToast } from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createPanelResizer } from "@ui/ui_template_resizer"; -import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; +import { showToast } from "@ui/ui_template_elements"; +import { createSidePanels } from "@ui/ui_template_side_panels"; import type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { createAreaDrawing } from "./M02_MasterTemplete_AreaDrawing"; import { openAreaLink } from "./M02_MasterTemplete_AreaLink"; import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View"; import "./M02_MasterTemplete_AreaPanel.css"; const TEXT = { - handle: "일위대가", - title: "이 열의 일위대가", + labor: "일위대가", + drawing: "도면", noPill: "이 줄은 면적산출 알약이 없음 — 돈 아닌 로직이거나 계산 전", }; -/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */ -const MIN_WIDTH = 280; -const MAX_RATIO = 0.6; +/** 기억 없을 때 폭(px) — 금액 칸 넷이 가로 굴림 없이 보이게 */ +const DEFAULT_WIDTH = 576; export interface AreaPanelOptions { layer: Layer; @@ -98,39 +95,18 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { }); }; const view = createLaborView(openLink); - const body = el("div", { - className: "m02-apanel__body ui-sidebar-panel", - children: [ - el("section", { - className: "ui-collapsible ui-sidebar-section ui-sidebar-card", - children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), view.root], - }), + const drawing = createAreaDrawing(opts.layer, opts.column); + const panels = createSidePanels({ + prefKey: "m02-area-panels", + defaultWidth: DEFAULT_WIDTH, + panels: [ + { id: "labor", title: TEXT.labor, body: view.root }, + { id: "drawing", title: TEXT.drawing, body: drawing.root }, ], + onChange: (open) => open === "drawing" && drawing.show(), }); - attachCollapsible(body); - - const root = el("aside", { className: "m02-apanel" }); - const handle = createWorkflowPanelHandle("side", "right", TEXT.handle); - // 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭 - const resizer = createPanelResizer({ - axis: "horizontal", - target: root, - cssVar: "--m02-apanel-width", - direction: -1, - min: MIN_WIDTH, - max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_RATIO, - storageKey: stateKey("m02-combo-panel-width") ?? undefined, - }); - root.append(handle.root, resizer.root, body); - const setCollapsed = (collapsed: boolean): void => { - root.classList.toggle("is-collapsed", collapsed); - handle.setOpen(!collapsed); - writeStateRaw("m02-combo-panel-collapsed", String(collapsed)); - }; - handle.root.addEventListener("click", () => - setCollapsed(!root.classList.contains("is-collapsed")), - ); - setCollapsed(readStateRaw("m02-combo-panel-collapsed") === "true"); + if (panels.current() === "drawing") drawing.show(); + const root = panels.root; // 처음 = 저장 안 한 시트 칸 값으로 셈(서버 calc 한 번) const refresh = (): void => view.setQty(quantities(opts.sheet()), cellsOf(opts.sheet())); @@ -141,7 +117,7 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { refresh, destroy: () => { view.destroy(); - resizer.dispose(); + drawing.destroy(); root.remove(); }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts index 1f310bd07..ef623c928 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts @@ -10,9 +10,16 @@ import type { StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; import type { SaveControl } from "./M02_MasterTemplete_CardShell"; -import { mountDrawingTemplate, type DrawingTemplateDoc } from "./M02_MasterTemplete_Drawing"; +import { + mountDrawingTemplate, + type DrawingTemplateDoc, + type DrawingTemplateHandle, + type DrawingTemplateOptions, +} from "./M02_MasterTemplete_Drawing"; import type { ItemSource } from "./M02_MasterTemplete_ItemSource"; +export const DRAWING_HELP = "단축키 — 글자를 치고 Enter"; + /** 단축키 도움말 — 전체 편집기 명령 별칭(openwebcad `commands.*.ts`)과 같은 것만 */ export const DRAWING_KEYS: [string, string][] = [ ["L · PL", "선 · 폴리선"], @@ -54,6 +61,45 @@ function hideChrome(frame: HTMLIFrameElement, css: string): void { export const drawingEditable = (editable: boolean, source: ItemSource): boolean => editable && !!source.write; +type Where = Pick; + +/** 구조물 도면 미리보기 한 벌 — 카드 · 면적산출 도면 패널이 같이 씀 · `hooks` 있으면 고치기 */ +function mountStructure( + host: HTMLElement, + doc: StructureDoc, + source: Where, + column: string, + hooks?: Pick, +): DrawingTemplateHandle { + const edit = !!hooks; + const handle = mountDrawingTemplate(host, doc.도면 as unknown as DrawingTemplateDoc, { + readOnly: !edit, + name: `preview:${column}`, // 자동백업 칸은 전체 편집 화면과 갈라 둠 + layer: source.layer, + projectId: source.projectId, + panelsCollapsed: true, + preview: !edit, // 보기만 = 왼쪽 끌기 화면 옮기기 · 고치기 = 누름 고르기 · 단축키 + originMarker: true, // 좌표 0,0 기준점 십자 + noArea: true, // 구조물도는 도각이 없음 + noImport: true, // 파일 불러오기는 도면 상세에서만(47-2) + ...hooks, + }); + const frame = host.querySelector("iframe"); + if (frame) hideChrome(frame, edit ? HIDE_EDIT : HIDE_VIEW); + return handle; +} + +/** 보기만 도면 — 카드 미리보기의 보기 갈래와 같은 길(확대 · 이동만 · 면적산출 오른쪽 도면 패널 47-6) */ +export function mountDrawingView( + host: HTMLElement, + doc: StructureDoc, + source: Where, + column: string, +): { destroy: () => void } { + const handle = mountStructure(host, doc, source, column); + return { destroy: () => handle.destroy() }; +} + export interface DrawingCardOptions { /** 구조물 도면 문서 통째(`도번` · `도면` …) — 저장은 `도면` 칸만 갈아 끼움 */ doc: StructureDoc; @@ -87,24 +133,17 @@ export function mountDrawingCard(host: HTMLElement, opt: DrawingCardOptions): Dr doc = next; } - const handle = mountDrawingTemplate(host, doc.도면 as unknown as DrawingTemplateDoc, { - readOnly: !edit, - name: `preview:${opt.column}`, // 자동백업 칸은 전체 편집 화면과 갈라 둠 - layer: opt.source.layer, - projectId: opt.source.projectId, - panelsCollapsed: true, - preview: !edit, // 보기만 = 왼쪽 끌기 화면 옮기기 · 고치기 = 누름 고르기 · 단축키 - originMarker: true, // 좌표 0,0 기준점 십자 - noArea: true, // 구조물도는 도각이 없음 - noImport: true, // 파일 불러오기는 도면 상세에서만(47-2) - ...(edit + const handle = mountStructure( + host, + doc, + opt.source, + opt.column, + edit ? { onChanged: (dirty: boolean) => opt.saver.mark(dirty), onSave: (drawing: DrawingTemplateDoc) => void opt.saver.run(() => doSave(drawing)), } - : {}), - }); - const frame = host.querySelector("iframe"); - if (frame) hideChrome(frame, edit ? HIDE_EDIT : HIDE_VIEW); + : undefined, + ); return { write: () => doSave(), destroy: () => handle.destroy() }; } diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index c1fd55cea..1ac3f4d7b 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -157,11 +157,12 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 def test_면적_오른쪽_일위대가_패널은_공용_틀_손잡이(): - """좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음(39-4 · 옛 조합 패널 지움).""" + """틀 = 공용 오른쪽 접는 패널(47-6 · 일위대가 · 도면) — 새로 그리지 않음(옛 조합 패널 지움).""" apanel = _text(M02 / "M02_MasterTemplete_AreaPanel.ts") + assert "createSidePanels(" in apanel and "createAreaDrawing(" in apanel for name in ("createPanelResizer", "createWorkflowPanelHandle", "attachCollapsible"): - assert name in apanel, name - assert "ui-sidebar-card" in apanel and "setColumnCombo" not in apanel + assert name not in apanel, name + assert "setColumnCombo" not in apanel for gone in ("ComboPanel.ts", "ComboPanel.css", "Combo.ts", "Combo.css", "Combo_Api.ts"): assert not (M02 / f"M02_MasterTemplete_{gone}").exists(), gone api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") diff --git a/resources/tester/ui_checks/47-6_면적_도면패널.js b/resources/tester/ui_checks/47-6_면적_도면패널.js new file mode 100644 index 000000000..15c2ee904 --- /dev/null +++ b/resources/tester/ui_checks/47-6_면적_도면패널.js @@ -0,0 +1,94 @@ +/* 47-6 — M02 면적산출 상세 오른쪽 = 공용 접는 패널 둘(일위대가 · 도면) · 하나만 열림 · 폭 공유 · 모두 접힘 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 + * · 나무 item1 → 면적산출 카드 [수정] → 상세 · 끝에 [돌아가기] · 저장 안 함 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - titles = ["일위대가", "도면"] · 일위대가 폄 → open = ["labor"] · labor.sections 에 「단가산출 로직」 + * - 도면 띠 누름 → open = ["drawing"](일위대가 접힘) · drawing.iframe = true · drawing.h > 200 + * · drawing.w = labor.w(폭 공유) + * - 접기 ▸ → open = [] · strips = 2(모두 접힘) + * - 끝에 처음 열림 상태로 되돌림(restored = true) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await W(150); + } + return null; + }; + const vis = (b) => b && !b.hidden && b.offsetParent !== null; + const edits = () => + $$(".m02-struct button").filter((b) => vis(b) && b.textContent.trim() === "수정"); + $('[data-node="i|item1"]')?.click(); + await until(() => edits().length === 3); + const edit = edits().find((b) => /면적/.test(b.title)) ?? edits()[2]; + if (!edit) return JSON.stringify({ error: "면적산출 [수정] 없음" }); + edit.click(); + const sp = await until(() => $(".m02-main__host .ui-side-panels")); + if (!sp) return JSON.stringify({ error: "오른쪽 패널 없음" }); + const panel = (id) => $(`.ui-side-panels__panel[data-panel="${id}"]`, sp); + const strip = (title) => + $$(".ui-side-panels__strip", sp).find((s) => s.innerText.trim() === title); + const openIds = () => + $$(".ui-side-panels__panel", sp) + .filter((p) => !p.hidden) + .map((p) => p.dataset.panel); + const strips = () => $$(".ui-side-panels__strip", sp).filter((s) => !s.hidden).length; + const first = openIds()[0] ?? null; + const out = { titles: $$(".ui-side-panels__strip", sp).map((s) => s.innerText.trim()) }; + + if (panel("labor").hidden) strip("일위대가").click(); + await W(1500); + out.labor = { + open: openIds(), + sections: $$( + "h3, h4, .ui-sidebar-section__title, strong", + panel("labor").querySelector(".ui-side-panels__body"), + ) + .map((h) => h.innerText.trim()) + .filter(Boolean) + .slice(0, 4), + w: Math.round(panel("labor").getBoundingClientRect().width), + }; + + strip("도면").click(); + await until(() => panel("drawing").querySelector("iframe"), 8000); + await W(1500); + const f = panel("drawing").querySelector("iframe"); + out.drawing = { + open: openIds(), + iframe: !!f, + h: f ? Math.round(f.getBoundingClientRect().height) : 0, + w: Math.round(panel("drawing").getBoundingClientRect().width), + note: panel("drawing").querySelector(".m02-apanel__note")?.innerText ?? null, + }; + + panel("drawing").querySelector(".ui-side-panels__fold").click(); + await W(300); + out.folded = { open: openIds(), strips: strips() }; + + // 되돌림 — 처음 열려 있던 패널 + if (first) strip(first === "labor" ? "일위대가" : "도면").click(); + await W(300); + out.restored = (openIds()[0] ?? null) === first; + out.title = $(".m02-main__title")?.textContent.trim() ?? null; + $$(".m02-main__head button") + .find((x) => vis(x) && x.textContent.trim() === "돌아가기") + ?.click(); + out.pass = + out.titles.join() === "일위대가,도면" && + out.labor.open.join() === "labor" && + out.drawing.open.join() === "drawing" && + out.drawing.iframe && + out.drawing.h > 200 && + out.drawing.w === out.labor.w && + out.folded.open.length === 0 && + out.folded.strips === 2 && + out.restored; + return JSON.stringify(out); +})();