From 611f5413f387cdbedd78ba3597f8d5616bf2f6a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 19:06:21 +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=EC=83=81=EC=84=B8=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=3D=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20AreaPanel=20=E2=80=94=20=EB=8B=B4=EC=9D=80=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=C2=B7=20=EC=9E=90=EC=9E=AC=EB=AA=A9=EB=A1=9D(?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=C2=B7=20=EB=B0=98=EC=98=81)=20=C2=B7=20?= =?UTF-8?q?=EC=A4=84=20=EB=88=84=EB=A6=84=20=EC=B9=B8=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=20=C2=B7=20=EC=B9=B8=20=EA=B0=92=20=EB=B0=94=EB=80=8C=EB=A9=B4?= =?UTF-8?q?=20=EB=B0=94=EB=A1=9C=20=EA=B3=B1=ED=95=A8=20=C2=B7=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=A4=84=20=C2=B7=20[=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=98=EA=B8=B0]=20=EC=A7=80=EC=9B=80=20(PLAN=2039-?= =?UTF-8?q?4=20D2=20=C2=B7=20D6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AreaLink 칸 값 글(수 · 글 · 참 · 오류) 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_AreaLink.ts | 8 +- .../M02_MasterTemplete_AreaPanel.css | 64 +++++++++ .../M02_MasterTemplete_AreaPanel.ts | 125 ++++++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 42 +++--- resources/tester/test_m02_area_panel.py | 48 +++++++ 5 files changed, 267 insertions(+), 20 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts create mode 100644 resources/tester/test_m02_area_panel.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 9bd76f293..3f1df113f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -8,7 +8,7 @@ import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; const TEXT = { title: "칸 지정 — {value}", @@ -39,6 +39,10 @@ export interface AreaLinkOptions { readOnly: boolean; } +/** 칸 값 글 — 수 · 글 · 참거짓 · 오류 */ +export const cellText = (v: CalcValue | undefined): string => + !v ? "" : "수" in v ? v.수 : "글" in v ? v.글 : "참" in v ? String(v.참) : v.오류; + /** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */ function linkedText(opts: AreaLinkOptions): string | null { const link = opts.sheet @@ -47,7 +51,7 @@ function linkedText(opts: AreaLinkOptions): string | null { .find((l) => l.키 === opts.key); if (!link) return null; const value = opts.sheet.getLinkedValues()[opts.key]; - return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim(); + return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim(); } export function openAreaLink(opts: AreaLinkOptions): void { diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css new file mode 100644 index 000000000..9f7c0e18d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -0,0 +1,64 @@ +/* M02 면적산출 상세 오른쪽 패널(일위대가 담은 로직 · 자재목록) · 칸 지정 모달 — 틀은 공용 사이드바 카드 · + 접기 · 폭 손잡이 · 접기 손잡이 */ + +/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-apanel-width` 로 줌 */ +.m02-apanel { + position: relative; + 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); +} + +.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 { + flex: 1 1 0; + min-height: 0; + overflow: auto; + padding: var(--spacing-8); +} + +/* 칸 지정 모달 — 지금 연결 한 줄 · [지금 고른 칸으로] [풀기] · 주소 칸 [잇기] */ +.m02-area-link__now { + margin: 0 0 var(--spacing-12); + color: var(--color-text-secondary); +} + +.m02-area-link__row { + display: flex; + align-items: flex-end; + gap: var(--spacing-8); + margin-bottom: var(--spacing-12); +} + +.m02-area-link__row > .ui-field { + flex: 1 1 auto; + margin: 0; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts new file mode 100644 index 000000000..89e6ff7ef --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -0,0 +1,125 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaPanel.ts + * 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6) — 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영). + * 몸 = 일위대가 보기 갈래(`Labor_View` · 고치기 없음) · 틀 = 공용 사이드바 카드 · 접기 손잡이 · 폭 손잡이 + * (폭 · 접힘 기억은 옛 조합 패널 키 그대로). + * · 줄 누름 = 칸 지정 모달(`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 type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { openAreaLink } from "./M02_MasterTemplete_AreaLink"; +import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View"; +import "./M02_MasterTemplete_AreaPanel.css"; + +const TEXT = { + handle: "일위대가", + title: "이 열의 일위대가", + noPill: "이 줄은 면적산출 알약이 없음 — 돈 아닌 로직이거나 계산 전", +}; + +/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */ +const MIN_WIDTH = 280; +const MAX_RATIO = 0.6; + +export interface AreaPanelOptions { + layer: Layer; + /** 집계표 열 id */ + column: string; + sheet: () => SpreadsheetHandle | null; + /** 시트에서 지금 고른 칸(A1) */ + picked: () => string; + /** 알약 막대 항목 — 칸 지정 때 연결에 같이 적을 이름 · 단위 · 자재 */ + pills: () => LinkItem[]; + readOnly: boolean; +} + +export interface AreaPanel { + root: HTMLElement; + /** 시트 칸 값 · 연결이 바뀜 — 반영을 바로 다시 곱함 */ + refresh: () => void; + destroy: () => void; +} + +/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */ +function quantities(sheet: SpreadsheetHandle | null): Record { + const out: Record = {}; + for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) { + const n = "수" in v ? Number(v.수) : NaN; + if (Number.isFinite(n)) out[key] = n; + } + return out; +} + +export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { + const openLink = (row: LaborViewRow): void => { + const sheet = opts.sheet(); + if (!sheet) return; + if (!row.pill) return showToast(TEXT.noPill, "info"); + const item = opts.pills().find((p) => p.키 === row.pill); + openAreaLink({ + label: row.name, + key: row.pill, + info: { + 이름: item?.이름 ?? row.name, + ...((item?.단위 ?? row.unit) ? { 단위: item?.단위 ?? row.unit } : {}), + ...(item?.자재 ? { 자재: item.자재 } : {}), + }, + sheet, + picked: opts.picked, + readOnly: opts.readOnly, + }); + }; + 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], + }), + ], + }); + 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"); + + // 처음 = 저장 안 한 시트 칸 값으로 셈(서버 calc 한 번) + void view.show(opts.layer, opts.column, quantities(opts.sheet())); + return { + root, + refresh: () => view.setQty(quantities(opts.sheet())), + destroy: () => { + view.destroy(); + resizer.dispose(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 4675ae01c..821a1f8db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -28,7 +28,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { inputPills } from "./M02_MasterTemplete_Inputs"; import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; -import { buildComboBar } from "./M02_MasterTemplete_Combo"; +import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel"; import { buildMaterialPanel, @@ -193,7 +193,9 @@ export function createMain( let sheetHandle: SpreadsheetHandle | null = null; let matPanel: MaterialPanel | null = null; let matState: MaterialState | null = null; - let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(프로젝트 층 아님) + let cpanel: ComboPanel | null = null; // 재료산출 오른쪽 조합 패널(프로젝트 층 아님) + let apanel: AreaPanel | null = null; // 면적산출 오른쪽 일위대가 패널(프로젝트 층 아님 · 39-4) + let picked = "A1"; // 면적산출 시트에서 지금 고른 칸 — 칸 지정 모달 [지금 고른 칸으로] let seq = 0; let dirty = false; /** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 · 재료산출 31-3) */ @@ -242,6 +244,8 @@ export function createMain( matState = null; cpanel?.destroy(); cpanel = null; + apanel?.destroy(); + apanel = null; panel?.destroy(); panel = null; extra.replaceChildren(); @@ -264,8 +268,8 @@ export function createMain( return made; } - /** 면적산출 · 재료산출 스프레드시트 — 위 알약 막대(sub7 `links`) · 면적 = 그 열 일위대가 입력값 · - * 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] · + /** 면적산출 · 재료산출 스프레드시트 — 아래 알약 막대 · 면적 = 그 열 일위대가 알약(`pillsFor`) · + * 재료 = 자재DB 찾기 · 프로젝트 층 아니면 면적 = 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) · * 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판 * (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */ async function mountSheet( @@ -305,13 +309,29 @@ export function createMain( onChange: () => { dirty = !readOnly; matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신 + apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함 }, + onSelect: (s) => (picked = s.칸), links, }, ); editor = handle; sheetHandle = handle; if (at.layer === "project") return; + if (kind === "area") { + // 오른쪽 일위대가 패널 — 담은 로직 + 자재목록(기준 · 반영) · 줄 누름 = 칸 지정 모달 + apanel?.destroy(); + apanel = buildAreaPanel({ + layer: at.layer, + column: at.name, + sheet: () => sheetHandle, + picked: () => picked, + pills: () => links.항목, + readOnly, + }); + host.append(apanel.root); + return; + } // 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤) cpanel ??= buildComboPanel({ kind, @@ -320,20 +340,6 @@ export function createMain( 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 () => { - await remount(kind, at, readOnly, false); - await cpanel?.refresh(); - }, - }); - if (mine === seq) cpanel?.setMine(bar); - return; - } if (matPanel || !matState) return; matPanel = buildMaterialPanel({ column: at.name, diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py new file mode 100644 index 000000000..61276d60d --- /dev/null +++ b/resources/tester/test_m02_area_panel.py @@ -0,0 +1,48 @@ +"""면적산출 상세 오른쪽 일위대가 패널 · 칸 지정 모달(PLAN 39-4 D2 · D5 · D6) — 화면 조작은 ORCA. + +· 패널 = 그 열 일위대가 보기 갈래(`Labor_View`) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 없음 +· 줄 누름 = 칸 지정 모달 → 스프레드시트 `setLink`(면적 문서 `연결` 한 곳 · 알약과 같은 길) +· 시트 칸 값 · 연결이 바뀌면 `setQty` 로 바로 곱함(서버 안 부름) +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def test_면적_길은_일위대가_패널(): + main = _text("M02_MasterTemplete_UI_Main.ts") + body = main.split("async function mountSheet")[1].split("async function remount")[0] + area = body.split('if (kind === "area") {')[1].split("\n }\n")[0] + assert "apanel = buildAreaPanel({" in area and "host.append(apanel.root);" in area + assert "picked: () => picked," in area and "pills: () => links.항목," in area + assert "apanel?.refresh();" in body and "onSelect: (s) => (picked = s.칸)," in body + assert "buildComboBar" not in main # 조합 줄 · [조합 고치기] 지움 + assert body.index('if (at.layer === "project") return;') < body.index("buildAreaPanel") + assert "apanel?.destroy();\n apanel = null;" in main # drop + + +def test_패널_몸은_보기_갈래_줄_누름은_칸_지정(): + panel = _text("M02_MasterTemplete_AreaPanel.ts") + assert "createLaborView(openLink)" in panel + assert "void view.show(opts.layer, opts.column, quantities(opts.sheet()));" in panel + assert "refresh: () => view.setQty(quantities(opts.sheet()))," in panel + assert "openAreaLink({" in panel and "if (!row.pill) return showToast(TEXT.noPill" in panel + for gone in ("fetchUsableCombos", "setColumnCombo", "branchCombo"): + assert gone not in panel + + +def test_칸_지정_모달은_setLink_만(): + link = _text("M02_MasterTemplete_AreaLink.ts") + assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link + assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link + assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 + assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link + assert "saveTemplate" not in link # 저장은 [저장]