From 1bba901834e1b249406b6da12eb9dba25ba36fd1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 09:04:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=C2=B7=20=EC=9E=AC=EB=A3=8C=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EC=A1=B0=ED=95=A9=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=E2=80=94=20=EC=A1=B0=ED=95=A9=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=C2=B7=20=EC=9D=B4=20=EC=97=B4=EC=97=90=20=EC=93=B0=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=A1=B0=ED=95=A9=20=EA=B3=A0=EC=B9=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오른쪽 안쪽 패널: 공용 사이드바 카드 · 접기(attachCollapsible) · 공용 폭 손잡이(createPanelResizer · 폭 기억 · 최소 280 · 최대 60%) · 공용 접기 손잡이(createWorkflowPanelHandle) - 「이 열의 조합」 칸: 면적 = 일위대가 조합 줄 + [조합 고치기] · 재료 = 담은 자재 표(옆 판 aside 없앰) - 조합 목록: 면적 = 일위대가(UA) · 재료 = 자재 조합(UM) · 키 · 이름 · 수 · 본뜬키 · 찾기 · 이 열 조합 강조 - [이 열에 쓰기]: 서버 POST /api/m02/structures/{column}/use-combo(집계표 판 검사 · 그 열 키만 바꿈 · 조합은 그대로) → 알약 다시 받음 - 상태 등록표에 m02-combo-panel-width · collapsed · 시험 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- A00_Common/b_page_state.ts | 3 + .../M02_MasterTemplete_Api_Fetch.ts | 7 + .../M02_MasterTemplete_Branch.py | 38 ++- .../M02_MasterTemplete_Combo.css | 39 +-- .../M02_MasterTemplete_Combo.ts | 2 +- .../M02_MasterTemplete_ComboPanel.css | 79 ++++++ .../M02_MasterTemplete_ComboPanel.ts | 242 ++++++++++++++++++ .../M02_MasterTemplete_Materials.ts | 19 +- .../M02_MasterTemplete_Router.py | 12 + .../M02_MasterTemplete_UI_Main.ts | 48 +++- resources/tester/test_m02_combo_branch.py | 28 ++ resources/tester/test_m02_structure_panel.py | 12 + 12 files changed, 461 insertions(+), 68 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 9c05c761..ea25466e 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -57,6 +57,9 @@ export const STATE_REGISTRY = { legacy: () => "b05-route-drainage-collapsed", }, "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, + /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ + "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, + "m02-combo-panel-width": { bucket: "pref", scope: "global" }, "masshaul-open": { bucket: "pref", scope: "global", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 2a1efffe..a1a560cb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -184,6 +184,13 @@ export const branchCombo = ( ): Promise => call(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) }); +/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로 · `새로만듦` false) */ +export const setColumnCombo = ( + column: string, + body: { 종류: "일위대가" | "자재조합"; 키: string; 판?: string }, +): Promise => + call(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) }); + /** 자재 조합(키 `UM`) 담은 자재 한 줄 */ export interface MaterialItem { 코드: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Branch.py b/M02_MasterTemplete/M02_MasterTemplete_Branch.py index 4468d767..47fe3430 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Branch.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Branch.py @@ -49,12 +49,7 @@ def _branch( ) -> dict[str, Any]: """집계표 판 검사 → 같은 내용 찾기 · 없으면 `make(이름, 줄들)` 로 새 줄 → 집계표 그 열 `field`. `slot` = 담은 칸(담은로직 · 담은자재) · `ident` = 담은 줄에서 견주는 칸(로직 · 메모 / 코드).""" - table = store.read("table", store.STRUCTURE_TABLE) - if version and table["판"] != version: - raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": table["판"]}) - col = next((c for c in store._structure_columns() if c["id"] == column), None) - if col is None: - raise store.StoreError(404, f"{store.STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") + table = _table(column, version) if not isinstance(items, list) or not items: raise store.StoreError(400, "담은 것이 비었음") @@ -72,12 +67,39 @@ def _branch( key, made = make(made_name, rows), True # ponytail: 조합 쓰기와 집계표 쓰기는 한 묶음 아님 — 사이에 집계표가 바뀌면 409 뒤 주인 없는 # 조합 하나가 남음(다시 하면 같은 내용이라 그 키를 씀) + 판 = _point(table, column, field, key) + return {"키": key, "이름": made_name, "새로만듦": made, "집계표판": 판} + + +def _table(column: str, version: str) -> dict: + """집계표 판 검사 · 구조물 열 있는지 — 읽은 표(`{판, 문서}`).""" + table = store.read("table", store.STRUCTURE_TABLE) + if version and table["판"] != version: + raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": table["판"]}) + if not any(c["id"] == column for c in store._structure_columns()): + raise store.StoreError(404, f"{store.STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") + return table + + +def _point(table: dict, column: str, field: str, key: str) -> str: + """집계표 그 열 `field` = `key` 로 저장 — 새 판.""" doc = table["문서"] for c in doc.get("열", []): if isinstance(c, dict) and c.get("id") == column: c[field] = key - info = store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # SS 도 여기서 맞춤 - return {"키": key, "이름": made_name, "새로만듦": made, "집계표판": info["판"]} + return store.write("table", store.STRUCTURE_TABLE, table["판"], doc)["판"] # SS 도 여기서 맞춤 + + +def use_combo(column: str, field: str, key: str, version: str = "") -> dict[str, Any]: + """있는 조합을 이 열에 씀 — `field` 일위대가(UA) · 자재조합(UM) · 조합은 그대로(고르기만).""" + if field == "일위대가": + name = _m01(combo_store.combo, key)["combo"].get("이름") + elif field == "자재조합": + name = material_combo(key).get("이름") + else: + raise store.StoreError(400, f"모르는 조합 종류 「{field}」") + 판 = _point(_table(column, version), column, field, key) + return {"키": key, "이름": str(name or ""), "새로만듦": False, "집계표판": 판} def branch_combo( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.css b/M02_MasterTemplete/M02_MasterTemplete_Combo.css index 096e38a6..e2053362 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.css @@ -74,42 +74,19 @@ line-height: 1.5; } -/* 재료산출 — 엑셀 옆 접히는 판 */ -.m02-main__host:has(> .ss) > .m02-mat { - flex: none; -} - +/* 재료산출 — 오른쪽 조합 패널 「이 열의 조합」 칸: 담은 자재 표 */ .m02-mat { - order: 2; - display: flex; - flex-direction: column; - gap: var(--spacing-8); - width: min(30rem, 42%); - min-height: 0; - padding: var(--spacing-8); - border-left: 1px solid var(--color-border); - overflow: auto; -} - -.m02-mat.is-collapsed { - width: auto; -} - -.m02-mat.is-collapsed .m02-mat__body { - display: none; -} - -.m02-mat > .ui-btn { - align-self: flex-start; -} - -.m02-mat__body { display: flex; flex-direction: column; gap: var(--spacing-8); font-size: var(--text-body-sm); } +/* 패널 폭이 좁아도 줄이 넘치지 않게 — 이름 · 규격 칸이 줄어듦 */ +.m02-mat .m02-mat__row { + grid-template-columns: 5.5rem minmax(0, 1.4fr) minmax(0, 1fr) 2.5rem 3rem auto; +} + .m02-mat__title { display: flex; flex-wrap: wrap; @@ -138,7 +115,3 @@ flex-wrap: wrap; gap: var(--spacing-8); } - -.m02-main__combo:empty { - display: none; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts index de5b8b01..ca2112ad 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MasterTemplete_Combo.ts - * 면적산출 편집 화면 — 알약 막대 위 조합 줄 + [조합 고치기](시스템 관리자만). + * 면적산출 편집 화면 — 오른쪽 조합 패널 「이 열의 조합」 칸의 조합 줄 + [조합 고치기](시스템 관리자만). * 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어 * 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양. * 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀. diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css new file mode 100644 index 00000000..7d592174 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css @@ -0,0 +1,79 @@ +/* M02 면적산출 · 재료산출 오른쪽 조합 패널 — 틀은 공용 사이드바 카드 · 접기 · 폭 손잡이 · 접기 손잡이 */ + +/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-cpanel-width` 로 줌 */ +.m02-cpanel { + position: relative; + display: flex; + flex: 0 0 auto; + flex-direction: column; + width: var(--m02-cpanel-width, 24rem); + min-width: 280px; + max-width: 60%; + min-height: 0; + border-left: 1px solid var(--color-border); +} + +.m02-cpanel.is-resizing { + transition: none; +} + +.m02-cpanel.is-collapsed { + width: 0; + min-width: 0; +} + +.m02-cpanel.is-collapsed > .m02-cpanel__body, +.m02-cpanel.is-collapsed > .ui-resizer { + display: none; +} + +/* 접기 손잡이 — 패널 왼쪽 밖 세로 가운데(시트 위로) */ +.m02-cpanel > .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-cpanel__body { + flex: 1 1 0; + min-height: 0; + overflow: auto; + padding: var(--spacing-8); +} + +.m02-cpanel__list { + max-height: 24rem; + overflow: auto; +} + +.m02-cpanel__row { + display: grid; + grid-template-columns: auto 1fr; + gap: 0 var(--spacing-8); + text-align: left; +} + +.m02-cpanel__key { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.m02-cpanel__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-cpanel__meta { + grid-column: 1 / -1; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 이 열이 쓰는 조합 — 테두리로 늘 보임(고름 is-active 와 따로) */ +.m02-cpanel__row.is-current { + box-shadow: inset 3px 0 0 var(--color-primary); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts new file mode 100644 index 00000000..1624a049 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts @@ -0,0 +1,242 @@ +/* ============================================================================= + * M02_MasterTemplete_ComboPanel.ts + * 면적산출 · 재료산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) · + * 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`). + * · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) · + * 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움 + * · 조합 목록 — 면적 = 일위대가 조합(UA) · 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 · + * 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로) + * ========================================================================== */ + +import { + createButton, + createInputField, + el, + showConfirmDialog, + 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 { fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; +import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; +import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; +import "./M02_MasterTemplete_ComboPanel.css"; + +const TEXT = { + handle: "조합", + mine: "이 열의 조합", + noneArea: "이 열에 일위대가 조합 없음", + listArea: "일위대가 조합 목록", + listMaterial: "자재 조합 목록", + search: "키 · 이름으로 찾기", + logics: "로직 {n}", + items: "자재 {n}", + origin: "본뜬 {value}", + current: "이 열", + empty: "맞는 조합 없음", + none: "조합 없음", + use: "이 열에 쓰기", + useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로", + useOk: "바꿈", + used: "이 열이 {value} 을 씀", + loadFailed: "조합 목록을 못 받음 — {value}", + failed: "이 열에 못 씀 — {value}", +}; + +const fill = (text: string, value: string | number): string => + text.replace("{n}", String(value)).replace("{value}", String(value)); + +/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */ +const MIN_WIDTH = 280; +const MAX_RATIO = 0.6; + +interface Row { + 키: string; + 이름: string; + 수: number; + 본뜬키: string | null; +} + +export interface ComboPanelOptions { + kind: "area" | "material"; + /** 집계표 열 id */ + column: string; + isAdmin: boolean; + /** [이 열에 쓰기] 뒤 — 알약 · 「이 열의 조합」을 새 조합으로 다시 그림 */ + onUsed: () => Promise | void; +} + +export interface ComboPanel { + root: HTMLElement; + /** 「이 열의 조합」 부분을 갈아 끼움(면적 = 조합 줄 · 재료 = 담은 자재 표) */ + setMine: (node: HTMLElement | null) => void; + /** 목록 · 이 열 조합 키를 다시 받음(갈라 낸 뒤 등) */ + refresh: () => Promise; + destroy: () => void; +} + +export function buildComboPanel(opts: ComboPanelOptions): ComboPanel { + const area = opts.kind === "area"; + let rows: Row[] = []; + let current: string | null = null; + let picked: string | null = null; + let q = ""; + let seq = 0; + + const card = (title: string, ...children: HTMLElement[]): HTMLElement => + el("section", { + className: "ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: title }), ...children], + }); + const mine = el("div", { className: "m02-cpanel__mine" }); + const find = createInputField({ type: "search", placeholder: TEXT.search }); + const list = el("div", { className: "ui-sidebar-list m02-cpanel__list" }); + const use = createButton({ label: TEXT.use, variant: "filled", size: "sm" }); + const body = el("div", { + className: "m02-cpanel__body ui-sidebar-panel", + children: [ + card(TEXT.mine, mine), + card( + area ? TEXT.listArea : TEXT.listMaterial, + find.root, + list, + ...(opts.isAdmin ? [el("div", { className: "m02-side__row", children: [use] })] : []), + ), + ], + }); + attachCollapsible(body); + + const root = el("aside", { className: "m02-cpanel", attrs: { "data-kind": opts.kind } }); + const handle = createWorkflowPanelHandle("side", "right", TEXT.handle); + // 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭 + const resizer = createPanelResizer({ + axis: "horizontal", + target: root, + cssVar: "--m02-cpanel-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"); + + find.input.addEventListener("input", () => { + q = find.input.value.trim().toLowerCase(); + paint(); + }); + + function paint(): void { + const shown = rows.filter((r) => !q || `${r.키} ${r.이름}`.toLowerCase().includes(q)); + list.replaceChildren( + ...(shown.length + ? shown.map((r) => { + const meta = [ + fill(area ? TEXT.logics : TEXT.items, r.수), + r.본뜬키 ? fill(TEXT.origin, r.본뜬키) : "", + r.키 === current ? TEXT.current : "", + ]; + const b = el("button", { + className: + "m02-side__item ui-sidebar-row m02-cpanel__row" + + (r.키 === picked ? " is-active" : "") + + (r.키 === current ? " is-current" : ""), + attrs: { type: "button", "data-combo-key": r.키, title: `${r.키} ${r.이름}` }, + children: [ + el("span", { className: "m02-cpanel__key", text: r.키 }), + el("span", { className: "m02-cpanel__name", text: r.이름 }), + el("span", { + className: "m02-cpanel__meta", + text: meta.filter(Boolean).join(" · "), + }), + ], + }); + b.addEventListener("click", () => { + picked = r.키 === picked ? null : r.키; + paint(); + }); + return b; + }) + : [el("p", { className: "m02-side__empty", text: q ? TEXT.empty : TEXT.none })]), + ); + use.disabled = !picked || picked === current; + } + + async function refresh(): Promise { + const mineSeq = ++seq; + try { + const [base, got] = await Promise.all([ + readColumnBase(opts.column), + area + ? fetchCombos().then((cs) => + cs.map((c) => ({ + 키: c.키, + 이름: c.이름, + 수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴 + 본뜬키: c.본뜬키 ?? null, + })), + ) + : listMaterialCombos().then((d) => + d.줄.map((m) => ({ + 키: m.키, + 이름: m.이름, + 수: m.담은자재.length, + 본뜬키: m.본뜬키, + })), + ), + ]); + if (mineSeq !== seq) return; + current = (area ? base.일위대가 : base.자재조합) ?? null; + rows = got; + } catch (error) { + if (mineSeq !== seq) return; + showToast(fill(TEXT.loadFailed, reasonOf(error)), "error"); + } + if (picked === current) picked = null; + paint(); + } + + use.addEventListener("click", async () => { + const row = rows.find((r) => r.키 === picked); + if (!row || row.키 === current) return; + if (!(await showConfirmDialog(fill(TEXT.useAsk, `${row.키} ${row.이름}`), TEXT.useOk))) return; + use.disabled = true; + try { + const got = await setColumnCombo(opts.column, { + 종류: area ? "일위대가" : "자재조합", + 키: row.키, + }); + showToast(fill(TEXT.used, `${got.키} ${got.이름}`), "success"); + picked = null; + await opts.onUsed(); + await refresh(); + } catch (error) { + showToast(fill(TEXT.failed, reasonOf(error)), "error"); + paint(); + } + }); + + paint(); + void refresh(); + return { + root, + setMine: (node) => + mine.replaceChildren(node ?? el("p", { className: "m02-side__empty", text: TEXT.noneArea })), + refresh, + destroy: () => { + ++seq; + resizer.dispose(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts index a6db42a3..787b0d91 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts @@ -26,7 +26,6 @@ import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; const TEXT = { - toggle: "자재 목록", combo: "자재 조합", branched: "본뜬 조합 {value}", none: "자재 조합 없음 — 자재를 더하면 새로 만듦", @@ -119,6 +118,8 @@ export interface MaterialPanelOptions { links: () => CellLink[]; /** 자재 코드에 이어진 칸 연결을 풂(시트 문서를 고치고 고침 표시) */ unlink: (codes: string[]) => void; + /** [적용]으로 이 열이 새 · 다른 자재 조합을 가리킴 — 조합 목록 다시 받기 */ + onApplied?: () => void; } /** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(좌측 자재 조합 상세도 씀) */ @@ -181,21 +182,11 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { let items: MaterialItem[] = [...saved]; const sig = (list: MaterialItem[]): string => JSON.stringify(list.map((m) => m.코드)); - const toggle = createButton({ label: "", variant: "ghost", size: "sm" }); const title = el("div", { className: "m02-mat__title" }); const table = el("div", { className: "m02-mat__table" }); const foot = el("div", { className: "m02-mat__foot" }); - const body = el("div", { className: "m02-mat__body", children: [title, table, foot] }); - const root = el("aside", { className: "m02-mat", children: [toggle, body] }); - toggle.addEventListener("click", () => { - root.classList.toggle("is-collapsed"); - paintToggle(); - }); - const paintToggle = (): void => { - const open = !root.classList.contains("is-collapsed"); - toggle.querySelector(".ui-btn__label")!.textContent = - `${open ? "▸" : "◂"} ${TEXT.toggle} ${items.length}`; - }; + // 오른쪽 조합 패널의 「이 열의 조합」 칸에 들어감(접기 · 폭은 패널이 함) + const root = el("div", { className: "m02-mat", children: [title, table, foot] }); const cellsOf = (code: string): string[] => opts @@ -257,7 +248,6 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { ...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]), ); paintFoot(); - paintToggle(); }; const paintFoot = (): void => { @@ -298,6 +288,7 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { saved = combo?.담은자재 ?? [...items]; items = [...saved]; paint(); + opts.onApplied?.(); } catch (error) { apply.disabled = false; showToast(fill(TEXT.failed, { value: reasonOf(error) }), "error"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index b81f0515..2c85e265 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -84,6 +84,18 @@ def post_structure_material_combo(column: str, body: MaterialBranchBody) -> dict ) +class UseComboBody(BaseModel): + 종류: str # 일위대가 · 자재조합 + 키: str + 판: str = "" # 집계표 판 — 주면 어긋날 때 409 + + +@router.post("/structures/{column}/use-combo") +def post_structure_use_combo(column: str, body: UseComboBody) -> dict: + """있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로).""" + return _call(branch.use_combo, column, body.종류, body.키, body.판) + + # 자재 조합 표(마스터 템플릿 `자재조합.json` · 키 UM) — 좌측 「자재 조합」 컨테이너 @router.get("/material-combos") def get_material_combos() -> dict: diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index aa054865..8e0ceeb1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -24,6 +24,7 @@ import { type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; import { buildComboBar } from "./M02_MasterTemplete_Combo"; +import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel"; import { buildMaterialPanel, loadMaterialState, @@ -109,7 +110,6 @@ export function createMain( const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" }); const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴 const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); - const comboSlot = el("div", { className: "m02-main__combo" }); // 면적산출 — 알약 막대 위 조합 줄 const host = el("div", { className: "m02-main__host" }); const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너 const root = el("div", { @@ -117,7 +117,6 @@ export function createMain( children: [ el("div", { className: "m02-main__head", children: [title, back, slot, reload, save] }), notice, - comboSlot, host, extra, ], @@ -132,6 +131,7 @@ export function createMain( let sheetHandle: SpreadsheetHandle | null = null; let matPanel: MaterialPanel | null = null; let matState: MaterialState | null = null; + let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(시스템 층) let seq = 0; let dirty = false; @@ -176,11 +176,12 @@ export function createMain( sheetHandle = null; matPanel = null; matState = null; + cpanel?.destroy(); + cpanel = null; panel?.destroy(); panel = null; extra.replaceChildren(); slot.replaceChildren(); - comboSlot.replaceChildren(); }; /** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */ @@ -244,23 +245,26 @@ export function createMain( editor = handle; sheetHandle = handle; if (at.layer !== "system") return; + // 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤) + cpanel ??= buildComboPanel({ + kind, + column: at.name, + isAdmin, + onUsed: () => remount(kind, at, readOnly, true), + }); + host.append(cpanel.root); if (kind === "area") { const bar = await buildComboBar({ column: at.name, label: at.label ?? at.name, isAdmin, beforeEdit: async () => true, - // 알약만 다시 받음 — 저장 안 한 시트 고침은 그대로 옮김 onApplied: async () => { - if (mine !== seq) return; - const kept = handle.getDoc(); - editor?.destroy(); - editor = null; - host.replaceChildren(); - await mountSheet(kept, kind, at, readOnly); + await remount(kind, at, readOnly, false); + await cpanel?.refresh(); }, }); - if (mine === seq) comboSlot.replaceChildren(...(bar ? [bar] : [])); + if (mine === seq) cpanel?.setMine(bar); return; } if (matPanel || !matState) return; @@ -269,6 +273,7 @@ export function createMain( label: at.label ?? at.name, isAdmin, state: matState, + onApplied: () => void cpanel?.refresh(), links: () => sheetHandle?.getDoc().시트.flatMap((s) => s.연결 ?? []) ?? [], // 옆 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로) onChange: (items) => { @@ -294,12 +299,31 @@ export function createMain( dirty = !readOnly; }, }); - host.append(matPanel.root); + cpanel.setMine(matPanel.root); } catch { empty(L("M02_NoSpreadsheet")); } } + /** 시트만 다시 붙임(알약을 새로 받음) — 저장 안 한 시트 고침은 그대로 옮김 · + * `fresh` = 이 열 조합이 바뀜(재료는 자재 조합 · 담은 자재 표를 새로 받음) */ + async function remount( + kind: "area" | "material", + at: Selection, + readOnly: boolean, + fresh: boolean, + ): Promise { + const kept = sheetHandle?.getDoc(); + if (sel !== at || !kept) return; + editor?.destroy(); + editor = null; + if (fresh && kind === "material") { + matPanel = null; + matState = null; + } + await mountSheet(kept, kind, at, readOnly); + } + async function mount(doc: unknown, at: Selection): Promise { const readOnly = !canEdit(at.layer, isAdmin); host.replaceChildren(); diff --git a/resources/tester/test_m02_combo_branch.py b/resources/tester/test_m02_combo_branch.py index e5ac6d59..304fff54 100644 --- a/resources/tester/test_m02_combo_branch.py +++ b/resources/tester/test_m02_combo_branch.py @@ -185,6 +185,34 @@ def test_자재_조합_처음_만듦_같은_내용이면_있는_키_고치면_ _material(client, "rv", "", [{"코드": "없는모양"}], want=422) # 자재 조합 저장 검사 +def _use(client, column, kind, key, 판="", want=200): + body = {"종류": kind, "키": key, "판": 판} + res = client.post(f"/api/m02/structures/{column}/use-combo", json=body) + assert res.status_code == want, res.text + return res.json() + + +def test_있는_조합을_이_열에_씀_조합은_그대로(client): + ua = _source(client) + other = _branch(client, "rv", ua, [A, C], "다른") + count = len(m01.read(combo_store.FILE)[0]["줄"]) + 판 = client.get(TABLE).json()["판"] + got = _use(client, "c1", "일위대가", other["키"], 판) + assert (got["키"], got["이름"], got["새로만듦"]) == (other["키"], "다른", False) + assert got["집계표판"] == client.get(TABLE).json()["판"] != 판 + assert _cols(client)["c1"] == other["키"] and _ss(client)["c1"] == other["키"] + assert len(m01.read(combo_store.FILE)[0]["줄"]) == count # 새로 안 만듦 + _use(client, "c1", "일위대가", ua, 판, want=409) # 판 어긋남 — 안 바뀜 + assert _cols(client)["c1"] == other["키"] + _use(client, "c1", "일위대가", "UA999999", want=404) + _use(client, "no", "일위대가", ua, want=404) # 글 열 + _use(client, "c1", "모름", ua, want=400) + um = _material(client, "rv", "", [M1, M2])["키"] + _use(client, "c1", "자재조합", um) + assert _cols(client, "자재조합")["c1"] == um and _ss(client, "자재조합")["c1"] == um + _use(client, "c1", "자재조합", "UM999999", want=404) + + def test_분류_더하기로_복사한_열은_원본_키_세_파일_내용을_받음(client): ua = _source(client) table = client.get(TABLE).json() diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 401598a5..7604667f 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -154,6 +154,18 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 ) +def test_면적_재료_오른쪽_조합_패널은_공용_틀_손잡이(): + """좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음.""" + cpanel = _text(M02 / "M02_MasterTemplete_ComboPanel.ts") + for name in ("createPanelResizer", "createWorkflowPanelHandle", "attachCollapsible"): + assert name in cpanel, name + assert "ui-sidebar-card" in cpanel and "setColumnCombo" in cpanel + main = _text(MAIN) + assert "comboSlot" not in main # 조합 줄은 패널 「이 열의 조합」 칸으로 + assert "cpanel.setMine(matPanel.root)" in main # 담은 자재 표도 패널 안 + assert '"aside"' not in _text(M02 / "M02_MasterTemplete_Materials.ts") + + def test_첫_화면과_닫은_뒤는_구조물집계표(): page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert re.search(r'kind: "table",\s*name: STRUCTURE_TABLE', page)