diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index def15d290..1c6a3648c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -16,7 +16,7 @@ * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * ========================================================================== */ -import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { buildCrossSvg, @@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane { const station = root.querySelector(".b05-routeedit__cross-station")!; const foot = root.querySelector(".b05-routeedit__cross-foot")!; const box = root.querySelector(".b05-routeedit__cross-box")!; + /** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */ + let drawn: CrossPreviewResponse | null = null; + let size = { width: 0, height: 0 }; + let zoom: ZoomPanState | undefined; + function draw(preview: CrossPreviewResponse): void { + // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고 + // 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고, + // 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다. + const rect = box.getBoundingClientRect(); + size = { width: Math.round(rect.width), height: Math.round(rect.height) }; + const plot = buildCrossSvg(preview, size.width || 240, size.height || 150); + box.replaceChildren(plot.svg); + observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. + // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). + // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). + attachSvgZoomPan({ + svg: plot.svg, + layer: plot.layer, + widthPx: plot.width, + heightPx: plot.height, + pad: CROSS_PLOT_PAD, + content: plot.content, + wheelZoom: true, + initial: zoom, + onChange: (state) => (zoom = state), + }); + } + const observer = new ResizeObserver(() => { + if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다. + const rect = box.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (!drawn || width === 0 || (width === size.width && height === size.height)) return; + draw(drawn); + }); return { root, show(preview, intervalM) { box.replaceChildren(); + drawn = preview; + zoom = undefined; if (!preview) { station.textContent = ""; foot.textContent = empty; @@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane { // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. station.textContent = formatStation(preview.chainage_m, intervalM); - // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다. - // 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다. - const rect = box.getBoundingClientRect(); - const plot = buildCrossSvg( - preview, - Math.max(Math.round(rect.width), 240), - Math.max(Math.round(rect.height), 150), - ); - box.append(plot.svg); - // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). - // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). - attachSvgZoomPan({ - svg: plot.svg, - layer: plot.layer, - widthPx: plot.width, - heightPx: plot.height, - pad: CROSS_PLOT_PAD, - content: plot.content, - wheelZoom: true, - }); + draw(preview); foot.textContent = summarizeCross(preview); }, wait(text) { box.replaceChildren(); + drawn = null; foot.textContent = text; }, }; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index a7ebab158..506fa6eb1 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -235,7 +235,7 @@ .b05-routeedit__cross-key { fill: var(--color-text-secondary, #94a3b8); - font-size: 5.5px; /* 44-2 — 지금(11px)의 절반 */ + font-size: 11px; } .b05-routeedit__cross-key.is-design { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css index 711326450..853a55b29 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css @@ -1,22 +1,28 @@ -/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 접힌 패널은 얇은 띠 */ +/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 공용 접기 카드(접힌 카드 = 제목 행) */ .m01lab-side { - display: flex; - align-items: stretch; min-width: 0; } +.m01lab-side__card, .m01lab-side__slot { - flex: 0 0 40px; min-width: 0; } -.m01lab-side__slot.is-open { - flex: 1 1 auto; +/* 카드 제목 행이 접기 단추 — 흐름도 패널 자체 접기 단추 · 테두리는 뺌 */ +.m01lab-side .m02-dfp__toggle { + display: none; } -/* 접힌 흐름도 자리 — 띠만(기다림 글 · 변환 알림은 숨김) */ -.m01lab-side__slot:not(.is-open) > :not(.m01lab-flow__panel, .m01lab-src) { - display: none; +.m01lab-side .m02-dfp { + border-left: 0; + background: none; +} + +.m01lab-src { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; } .m01lab-src__body { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts index 13f8da260..3077d01bb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts @@ -1,12 +1,14 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Side.ts * 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절) - * 둘 중 하나만 열림(하나 열면 다른 하나 접힘) · 둘 다 접기 됨 · 접힌 패널 = 얇은 띠(머리 단추 그대로) + * 공용 접기 카드(`ui-collapsible` · 제목 행 누름 · 캐럿) 두 장 — attachCollapsible 토글 위에 하나만 열림 규칙 + * 둘 다 접기 됨 · 접힌 카드 = 제목 행만 * 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { el } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow"; @@ -14,9 +16,8 @@ import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css"; import "./M01_MasterData_UI_LogicLab_Side.css"; const TEXT = { - Toggle: ["원문", "Source"], - Open: ["원문 펴기", "Open the source panel"], - Close: ["원문 접기", "Collapse the source panel"], + Flow: ["흐름도", "Flow"], + Source: ["원문", "Source"], Loading: ["원문 찾는 중", "Looking up the source"], None: ["원문 연결 없음", "No linked source"], From: ["출처", "Source"], @@ -27,8 +28,8 @@ const TEXT = { const ts = (key: keyof typeof TEXT, v = ""): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); -/** 접힌 띠 너비 — M02_DF_Panel 의 STRIP_WIDTH 와 같음(패널 모듈은 늦게 받으므로 따로 둠) */ -const STRIP = 40; +/** 둘 다 접힘 — 제목 행 둘이 들어가는 칸 너비 */ +const FOLDED = 140; export type PanelPick = "flow" | "source" | "none"; @@ -86,21 +87,28 @@ export function buildLogicSide(split: SplitHandle): LogicSide { const flowSlot = el("div", { className: "m01lab-side__slot" }); const body = el("div", { className: "m01lab-src__body" }); const title = el("strong", { className: "m02-dfp__title" }); - const toggle = createButton({ - label: ts("Toggle"), - size: "sm", - variant: "ghost", - onClick: () => pick(now === "source" ? "none" : "source"), - }); - toggle.classList.add("m02-dfp__toggle"); - const head = el("header", { className: "m02-dfp__head", children: [toggle, title] }); - const open = el("div", { className: "m02-dfp__open", children: [head, body] }); - const strip = el("div", { className: "m02-dfp__strip" }); - const source = el("aside", { className: "m02-dfp m01lab-src", children: [open, strip] }); - const sourceSlot = el("div", { className: "m01lab-side__slot", children: [source] }); - split.right.replaceChildren( - el("div", { className: "m01lab-side", children: [flowSlot, sourceSlot] }), + const card = (label: string, inner: HTMLElement): HTMLElement => + el("section", { + className: "m01lab-side__card ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: label }), inner], + }); + const flowCard = card(ts("Flow"), flowSlot); + const sourceCard = card( + ts("Source"), + el("div", { className: "m01lab-src", children: [title, body] }), ); + const side = el("div", { + className: "m01lab-side ui-sidebar-panel", + children: [flowCard, sourceCard], + }); + attachCollapsible(side); + // 공용 토글 뒤 — 연 카드만 남기고 다른 카드는 접음(접었으면 둘 다 닫힘) + side.addEventListener("click", (ev) => { + const at = (ev.target as HTMLElement).closest(".ui-collapsible__title")?.parentElement; + if (at !== flowCard && at !== sourceCard) return; + pick(at.classList.contains("is-collapsed") ? "none" : at === flowCard ? "flow" : "source"); + }); + split.right.replaceChildren(side); let now = readPick(); let flowPx: number | null = null; @@ -108,14 +116,22 @@ export function buildLogicSide(split: SplitHandle): LogicSide { let want = ""; let shown: string | null = null; - /** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 띠 · 둘 다 접힘 = 띠 둘 */ + /** 카드 안팎 여백(좌우 안쪽 + 테두리) — 흐름도 패널 너비에 더함 */ + const pad = (): number => { + const s = getComputedStyle(flowCard); + return ["paddingLeft", "paddingRight", "borderLeftWidth", "borderRightWidth"].reduce( + (n, k) => n + (parseFloat(s[k as "paddingLeft"]) || 0), + 0, + ); + }; + /** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 카드 여백 · 둘 다 접힘 = 제목 행 */ const width = (): void => split.setRight( now === "source" || (now === "flow" && flowPx === null) ? null : now === "flow" - ? (flowPx ?? 0) + STRIP - : STRIP * 2, + ? (flowPx ?? 0) + pad() + : FOLDED, ); const load = (): void => { @@ -149,21 +165,12 @@ export function buildLogicSide(split: SplitHandle): LogicSide { }); }; - const fold = (on: boolean): void => { - open.hidden = on; - strip.hidden = !on; - (on ? strip : head).prepend(toggle); - toggle.setAttribute("aria-expanded", String(!on)); - toggle.title = ts(on ? "Open" : "Close"); - }; - const pick = (next: PanelPick): void => { if (next !== now) writeStateRaw("m01-logic-panel", next); now = next; flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠 - flowSlot.classList.toggle("is-open", now === "flow"); - sourceSlot.classList.toggle("is-open", now === "source"); - fold(now !== "source"); + flowCard.classList.toggle("is-collapsed", now !== "flow"); + sourceCard.classList.toggle("is-collapsed", now !== "source"); width(); load(); }; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index 6f068462c..6e03e078e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -147,7 +147,7 @@ export function renderInputPanel( children: [...GROUPS, "다른 로직" as const, "변수" as const].map((k) => createButton({ label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k), - variant: k === kind ? "filled" : "pill", + variant: k === kind ? "filled" : "ghost", size: "sm", onClick: () => k !== kind && on.change(setKind(lg, boxId, k)), }), @@ -193,7 +193,7 @@ function renderGroup( ...(["fixed", "rule"] as const).map((m) => createButton({ label: m === "fixed" ? t("ModeFixed") : t("ModeRule"), - variant: mode === m ? "filled" : "pill", + variant: mode === m ? "filled" : "ghost", size: "sm", onClick: () => ((mode = m), drawChips(), draw()), }), diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts index 0d5ec6384..6203674e2 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts @@ -445,7 +445,7 @@ export function renderMaterial( ...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) => createButton({ label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"), - variant: mode === m ? "filled" : "pill", + variant: mode === m ? "filled" : "ghost", size: "sm", onClick: () => ((mode = m), drawChips(), drawBody()), }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index a51a88c67..b5b6bd70c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_ItemList.ts - * 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나 - * 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표 + * 구조물 항목 목록 하나(PLAN 39-5) — 복사 생성 · 항목 하나(도면 · 일위대가 · 면적산출) 복사가 같이 씀. + * 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표 * (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음). * 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다. * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). @@ -142,22 +142,6 @@ export function pageOf( export const loadItems = async (layer: Layer, owner: string | null): Promise => (await fetchTree(layer, owner)).items as ItemRow[]; -/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */ -export function buildAllItems( - kind: ListKind, - layer: Layer, - owner: string | null, - onOpen: (row: ItemRow) => void, -): HTMLElement { - const list = buildItemList(); - loadItems(layer, owner).then( - (rows) => list.show(rows, ITEM_COLS[kind], onOpen), - (error: unknown) => - list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`), - ); - return list.root; -} - export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 69d7b1ef4..385de7bbd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -92,18 +92,20 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { let seq = 0; const buttons = tabs.map((tab) => { - const b = el("button", { - className: "m02-layers__btn", - text: tab.label, - attrs: { type: "button", "data-source": tab.id }, - }); - b.addEventListener("click", () => void show(tab)); + const b = createButton({ label: tab.label, variant: "ghost", onClick: () => void show(tab) }); + b.classList.add("m02-layers__btn"); + b.dataset.source = tab.id; return b; }); async function show(tab: Source): Promise { const mine = ++seq; - buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id)); + buttons.forEach((b) => { + const on = b.dataset.source === tab.id; + b.classList.toggle("is-active", on); + b.classList.toggle("ui-btn--filled", on); + b.classList.toggle("ui-btn--ghost", !on); + }); people.root.hidden = tab.id !== "people"; list.message(); try { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 674d1bd2d..2284bcbfe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -18,9 +18,7 @@ flex: none; grid-auto-rows: var(--m02-struct-card-h); grid-template-columns: repeat(3, minmax(0, 1fr)); /* 도면 · 일위대가 · 면적산출(40-2) */ - padding-top: var(--spacing-12); - border-top: 1px solid var(--color-border); - min-width: 0; + min-width: 0; /* 표와 사이 = 메인 칸 간격 하나 · 구분선 없음(45-5) */ } /* 상세 칸이 좁으면(카드 하나 300px 아래) 1열 — 좌측 패널을 접어도 맞게 상세 칸 폭으로 잼 @@ -46,9 +44,18 @@ overflow-y: auto; } -/* 표는 줄어들지 않음(줄면 표가 카드 위로 넘침) */ +/* 표는 줄어들지 않음(줄면 표가 카드 위로 넘침) · 표 높이만큼만(빈 칸 최소 높이 없음 · 45-5) */ .m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) > .m02-main__host { flex-shrink: 0; + min-height: 0; +} + +/* 분류 표 틀 아래 줄 없음 — 마지막 줄 칸 선이 표를 닫음(45-5) */ +.m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) + > .m02-main__host + > .ui-sheet--combo + > .ui-sheet__scroll { + border-bottom: 0; } /* 카드 = 제목 + 머리 + 본문 — 본문이 남는 높이를 채움 · 넘치면 카드 안 스크롤 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 0bfca0ffe..11c49c3d3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -143,10 +143,6 @@ export function headRenameEdit(col: StructureColumn, level: number, name: string export const columnHead = (col: StructureColumn): string => col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id; -/** 번호가 맨 앞 — 「SD010101 돌기슭막이 H=1.0」(번호가 없으면 이름만) */ -export const structureLabel = (number: string, col: StructureColumn): string => - [number, columnHead(col)].filter(Boolean).join(" "); - /** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」 * (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽). * 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css index 9d656bd9f..760776347 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -42,28 +42,6 @@ gap: var(--spacing-4); } -.m02-layers__btn { - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-buttons); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - font-size: var(--text-body-sm); - cursor: pointer; -} - -.m02-layers__btn:hover { - background: var(--color-mist-violet); -} - -.m02-layers__btn.is-active { - border-color: var(--color-accent); - background: var(--color-mist-violet); - color: var(--color-accent); - font-weight: 600; -} - /* 층 단추 줄 오른쪽 끝 — 「N개 고름」 · 「+」(PLAN 30-2) */ .m02-tree__tools { display: inline-flex; @@ -94,28 +72,6 @@ background: var(--color-mist-violet); } -/* 층 단추 옆 「+」 — 나무 맨 위 폴더 · 항목 더하기(PLAN 29-6) */ -.m02-tree__add { - min-width: 1.75rem; - padding: var(--spacing-4) var(--spacing-8); - border: 1px dashed var(--color-border); - border-radius: var(--radius-buttons); - background: none; - color: var(--color-text-secondary); - font: inherit; - font-weight: 600; - cursor: pointer; -} - -.m02-tree__add:hover { - border-color: var(--color-accent); - color: var(--color-accent); -} - -.m02-tree__add[hidden] { - display: none; -} - /* 남의 회사 「개인」 사람 나무 — 고른 사람 줄 아래 그 사람 라이브러리(PLAN 29-6) */ .m02-people__panel { padding-left: var(--spacing-16); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 99402a371..3fa73d61b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -10,7 +10,13 @@ * ========================================================================== */ import "./M02_MasterTemplete_TreePanel.css"; -import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; import { @@ -141,13 +147,15 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const root = el("div", { className: "m02-tree", children: [body] }); search.root.classList.add("m02-tree__search"); // 층 단추 옆 「+」 — 맨 위에 폴더 · 항목 더하기(뿌리 줄 오른쪽 클릭을 갈음) - const add = el("button", { - className: "m02-tree__add", - text: "+", - attrs: { type: "button", title: L("M02_TreeAddTop"), "aria-label": L("M02_TreeAddTop") }, + const add = createButton({ + label: "+", + variant: "ghost", + onClick: (event) => openTreeMenu(event, topMenu()), }); + add.classList.add("m02-tree__add"); + add.title = L("M02_TreeAddTop"); + add.setAttribute("aria-label", L("M02_TreeAddTop")); add.hidden = !opt.canEdit; - add.addEventListener("click", (event) => openTreeMenu(event, topMenu())); // 「N개 고름」 · 「+」 — 층 단추 줄 오른쪽 끝(30-2 · 부르는 쪽이 붙임) const status = el("span", { className: "m02-tree__picked", attrs: { hidden: "" } }); const tools = el("span", { className: "m02-tree__tools", children: [status, add] }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index 4c4ba094e..039deddc4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -9,7 +9,7 @@ * 누르면 `go`(부르는 쪽이 해시로 다시 엶). * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createTreeRow } from "@ui/ui_template_tree"; import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; @@ -67,15 +67,17 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { : "M02_PickerPersonal", ); const current = root === active.root && (root !== "personal" || people || !active.owner); - const b = el("button", { - className: `m02-layers__btn${current ? " is-active" : ""}`, - text: readOnly ? `${name} · ${L("M02_RootReadOnly")}` : name, - attrs: { type: "button", "data-root": root, "aria-pressed": String(current) }, - }); - b.addEventListener("click", () => { - if (!current) opt.go(root, owner); - else opt.reselect?.(); + const b = createButton({ + label: readOnly ? `${name} · ${L("M02_RootReadOnly")}` : name, + variant: current ? "filled" : "ghost", + onClick: () => { + if (!current) opt.go(root, owner); + else opt.reselect?.(); + }, }); + b.classList.add("m02-layers__btn"); + b.dataset.root = root; + b.setAttribute("aria-pressed", String(current)); return b; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 21b36e7fe..e18034463 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, @@ -40,8 +40,7 @@ import { seesOthers, } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; -import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; -import { buildAllItems } from "./M02_MasterTemplete_ItemList"; +import { buildSide } from "./M02_MasterTemplete_UI_Side"; import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; @@ -49,13 +48,6 @@ const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴 const NEW_ASK = "{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름"; -/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ -const ALL_TITLE = { - structure: "M02_KindStructure", - labor: "M02_KindLabor", - area: "M02_KindArea", -} as const satisfies Record; - export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); const entry = entryFromHash(); @@ -97,9 +89,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer, item.열), settle: () => main.settle(), - // 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호 + // 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 onChanged: async (colId) => { - await Promise.all([main.openTable(layer, colId, true), side.refresh()]); + await main.openTable(layer, colId, true); }, }); const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { @@ -110,7 +102,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void): HTMLElement => @@ -193,20 +185,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - // 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`) - onAll: (kind) => - void main.showView( - `${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`, - buildAllItems(kind, layer, owner, (row) => { - void main.open({ - layer, - projectId: null, - kind: kind as Kind, - name: row.열, - label: row.이름, - }); - }), - ), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 0d2b83eb8..ad7e1b396 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,11 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 · - * 면적산출 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기). + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기). * 서버 길은 계약 `6_계약.md`. - * 구조물 도면 · 일위대가 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — - * 서버가 미리 만들어 둠(도번 SD · 일위대가번 SU · 면적번 SA + 같은 뒷자리) · - * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). + * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. * 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임). * ========================================================================== */ @@ -22,23 +19,15 @@ import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { deleteTemplate, - fetchStructureNumbers, listTemplates, readTemplate, saveTemplate, StaleError, type Kind, type Layer, - type StructureNumbers, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; -import { - STRUCTURE_TABLE, - columnHead, - isStructureColumn, - structureLabel, - type StructureColumn, -} from "./M02_MasterTemplete_Structure"; +import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { buildOptionsSection } from "./M02_MasterTemplete_Options"; import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; @@ -47,21 +36,6 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 · - * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */ -type ColumnKind = "structure" | "labor" | "area"; -const COLUMN_KINDS: { - kind: ColumnKind; - key: keyof StructureNumbers; - label: () => string; - all: boolean; -}[] = [ - { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, - { kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true }, - { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, -]; -/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */ -export type AllKind = "structure" | "labor" | "area"; /** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */ const STUB_DRAWING = { format: 6, @@ -84,8 +58,6 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; - /** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */ - onAll?: (kind: AllKind) => void; } export interface SideHandle { @@ -111,10 +83,6 @@ const failed = (error: unknown): void => export function buildSide(opt: SideOptions): SideHandle { const layer = opt.layer; let items: TemplateInfo[] = []; - let columns: StructureColumn[] = []; - let numbers: Record = {}; - /** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */ - let allPicked: AllKind | null = null; let seq = 0; const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => @@ -125,9 +93,6 @@ export function buildSide(opt: SideOptions): SideHandle { const bodies = new Map( KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]), ); - const columnBodies = COLUMN_KINDS.map(() => - el("div", { className: "m02-side__items ui-sidebar-list" }), - ); /** 새로 · 본떠 만들기 · 지우기 — 도면 양식 컨테이너 안(구조물 집계 양식은 집계표 하나라 없음) · 관리자만 */ const editButtons = new Map(); const actionRow = (kind: Kind): HTMLElement[] => { @@ -146,7 +111,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (practice?.clear(), (allPicked = null), paint()), + onOpen: () => (practice?.clear(), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -155,17 +120,13 @@ export function buildSide(opt: SideOptions): SideHandle { }); return sec; }; - const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])); - // 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · + // 구조물 집계 양식 — 층 단추 · 나무 · 처음부터 펼침(집계표가 열려 있음) · // 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6) const summary = section( L("M02_KindTable"), el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }), - el("hr", { className: "m02-side__rule" }), - ...columnSections, ); summary.classList.remove("is-collapsed"); - summary.classList.add("m02-side__summary"); openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); @@ -183,7 +144,7 @@ export function buildSide(opt: SideOptions): SideHandle { ? buildPracticeSection({ confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (options.clear(), (allPicked = null), paint()), + onOpen: () => (options.clear(), paint()), }) : null; const listHost = el("div", { @@ -234,94 +195,27 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */ - function allRow(kind: AllKind): HTMLElement { - const b = el("button", { - className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`, - text: L("M02_ItemsAll"), - attrs: { type: "button", "data-all": kind }, - }); - b.addEventListener("click", () => void selectAll(kind)); - return b; - } - - /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ - function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { - const { kind, key } = k; - const head = k.all ? [allRow(kind as AllKind)] : []; - body.replaceChildren( - ...head, - ...(columns.length - ? columns.map((col) => { - const num = numbers[col.id]?.[key] || L("M02_StructureNone"); - const b = el("button", { - className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`, - text: structureLabel(num, col), - attrs: { type: "button" }, - }); - b.addEventListener("click", () => void selectColumn(col, kind)); - return b; - }) - : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), - ); - } - const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k)); - /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { const cur = currentInfo(); for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind; } - /** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음 + /** 고름 표시는 여기서만 — 목록 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음 * (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */ function paint(): void { - if (opt.getOpen()) allPicked = null; paintList(); - paintColumns(); paintButtons(); if (opt.getOpen()) options.clear(); if (opt.getOpen()) practice?.clear(); } - async function selectAll(kind: AllKind): Promise { - if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; - options.clear(); - practice?.clear(); - allPicked = kind; - opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 - paint(); - } - // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) async function select(info: TemplateInfo): Promise { if (!isOpen(info) && !(await opt.confirmLeave())) return; opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { - if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; - // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) - opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); - } - - async function refreshStructure(): Promise { - try { - const [table, nums] = await Promise.all([ - readTemplate(layer, "table", STRUCTURE_TABLE, null), - fetchStructureNumbers(), - ]); - const all = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[]; - columns = all.filter(isStructureColumn); - numbers = nums; - } catch (error) { - columns = []; - numbers = {}; - showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); - } - paintColumns(); - } - async function refresh(): Promise { const mine = ++seq; paintButtons(); @@ -334,7 +228,6 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - await refreshStructure(); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 63caa709f..a5b38543d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -10,18 +10,6 @@ font-size: var(--text-body-sm); } -/* 구조물 집계 양식 하위 컨테이너(구조물 도면 · 면적산출 · 재료산출 · 자재 조합) — 들여 씀 */ -.m02-side__summary > .ui-sidebar-card { - margin-left: var(--spacing-8); -} - -/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */ -.m02-side__rule { - margin: var(--spacing-8) 0; - border: 0; - border-top: 1px solid var(--color-border); -} - /* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */ .m02-practice { display: flex; diff --git a/resources/master_template/area/item1.json b/resources/master_template/area/item1.json index 02dfac0e2..7ae03696e 100644 --- a/resources/master_template/area/item1.json +++ b/resources/master_template/area/item1.json @@ -9,27 +9,112 @@ { "id": "s1", "이름": "면적산출", - "칸": {}, - "연결": [ - { - "키": "콘크리트 표층 인력포설|㎥", - "이름": "콘크리트 표층 인력포설", - "칸": "B6", - "단위": "㎥" + "칸": { + "E7": { + "값": "구 분" }, - { - "키": "와이어메시 바닥깔기|㎡", - "이름": "와이어메시 바닥깔기", - "칸": "D15", - "단위": "㎡" + "F7": { + "값": "규 격" }, - { - "키": "기계비빔타설|㎥", - "이름": "기계비빔타설", - "칸": "F14", - "단위": "㎥" + "G7": { + "값": "수 량" + }, + "H7": { + "값": "단 위" + }, + "I7": { + "값": "산 출 근 거" + }, + "J7": { + "값": "비 고" + }, + "E8": { + "값": "야면석" + }, + "E9": { + "값": "고임돌량" + }, + "E10": { + "값": "뒤채움돌" + }, + "E11": { + "값": "구조물터파기량" + }, + "E12": { + "값": "되메우기량" + }, + "E13": { + "값": "잔토량" + }, + "F8": { + "값": "뒤길이35cm" + }, + "F10": { + "값": "9~15cm" + }, + "G8": { + "값": 2.65 + }, + "G9": { + "값": 0.24 + }, + "G10": { + "값": 0.94 + }, + "G11": { + "값": 0.38 + }, + "G12": { + "값": 0.11 + }, + "G13": { + "값": 0.26 + }, + "H8": { + "값": "㎡" + }, + "H9": { + "값": "㎥" + }, + "H10": { + "값": "㎥" + }, + "H11": { + "값": "㎥" + }, + "H12": { + "값": "㎥" + }, + "H13": { + "값": "㎥" + }, + "I8": { + "값": "2.5m * 1.059m / 1.0 m * 1.0 m = 2.6475m" + }, + "I9": { + "값": "2.6475m * 0.09m /1.0 m = 0.2383㎥" + }, + "I10": { + "값": "(0.45 + 0.3)/2 * 2.5 * 1.0 = 0.9375" + }, + "I11": { + "값": "(1.1+1.4)/2 * 0.3 * 1.0 = 0.375" + }, + "I12": { + "값": "(0.3+0.45)/2 * 0.3 * 1.0 = 0.1125" + }, + "I13": { + "값": "0.375 - 0.1125 = 0.2625" } - ] + }, + "열": { + "E": { + "폭": 14.460318429129464 + }, + "I": { + "폭": 39.60316685267857 + } + } } ], "활성": "s1", diff --git a/resources/master_template/labor/item1.json b/resources/master_template/labor/item1.json index b9889e9e3..77ea48848 100644 --- a/resources/master_template/labor/item1.json +++ b/resources/master_template/labor/item1.json @@ -11,84 +11,98 @@ "담은로직": [ { "로직": "GC000198", + "별칭": null, "메모": "레미콘타설(장비) 무근,소형", "입력값": {}, "수량": null }, { "로직": "GC000214", + "별칭": null, "메모": "유로폼 설치 및 해체 보통", "입력값": {}, "수량": null }, { "로직": "GC000206", + "별칭": null, "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", "입력값": {}, "수량": null }, { "로직": "GC000207", + "별칭": null, "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", "입력값": {}, "수량": null }, { "로직": "GF000218", + "별칭": null, "메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)", "입력값": {}, "수량": null }, { "로직": "GF000225", + "별칭": null, "메모": "깬잡석찰붙임 L3=30cm내외", "입력값": {}, "수량": null }, { "로직": "GF000156", + "별칭": null, "메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)", "입력값": {}, "수량": null }, { "로직": "GF000226", + "별칭": null, "메모": "큰돌메쌓기 뒷길이 0.6~0.8m", "입력값": {}, "수량": null }, { "로직": "GF000214", + "별칭": null, "메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)", "입력값": {}, "수량": null }, { "로직": "GF000222", + "별칭": null, "메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)", "입력값": {}, "수량": null }, { "로직": "GC000677", + "별칭": null, "메모": "모르터바르기 1:3", "입력값": {}, "수량": null }, { "로직": "GF000221", + "별칭": null, "메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)", "입력값": {}, "수량": null }, { "로직": "GC000088", + "별칭": null, "메모": "구조물터파기(토사) 굴착기0.6m3", "입력값": {}, "수량": null }, { "로직": "GF000113", + "별칭": null, "메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)", "입력값": {}, "수량": null diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index 0fc11d34a..cccd2e3f7 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -56,7 +56,7 @@ def test_칸_지정_모달은_setLink_만(): def test_빈_칸은_수량_없음_서버와_같음(): - """39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값.""" + """39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 빈 칸(A1)」) · 0 칸 = 0 · 값 칸 = 값.""" from M02_MasterTemplete import M02_Labor_Doc as labor doc = { @@ -79,7 +79,7 @@ def test_빈_칸은_수량_없음_서버와_같음(): sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet rows = _text("M02_MasterTemplete_Labor_Rows.ts") - assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows + assert '${tc("Labor_Qty")} ${tc("In_QtyEmpty")}(${at})' in rows assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text( "M02_MasterTemplete_AreaLink.ts" ) diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index 11619815b..f6d74b82b 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -64,17 +64,22 @@ def test_표_부품_combo_선택(): assert "combo?: { onHeadEdit?: (colId: string, level: number, name: string) => void };" in sheet assert '...(ro || opts.combo ? [] : [el("p", { className: "ui-sheet__hint"' in sheet assert ( - "filter((k) => !opts.combo || k !== KEY_UNIT)" in sheet - ) # 방향키가 숨은 단위 줄을 안 밟음 + "const keys = opts.combo ? [KEY_UNIT] : navRows(state.doc, state.mode);" in sheet + ) # 방향키가 숨은 단위 · 번호 줄을 안 밟음 assert 'head?.classList.add("is-head-picked")' in sheet assert "if (th && opts.combo?.onHeadEdit) editComboHead(th);" in sheet assert "if (opts.combo && sel.r === KEY_UNIT)" in sheet # F2 · Enter = 머리 이름 고침 render = _text(SHEET / "ui_template_sheet_render.ts") assert "children: state.combo ? rows : [...rows, unit]" in render # 단위 줄 없음 - assert "if (state.combo) fitMeta(state, cols, mins);" in render + # 45-3 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만) + assert ( + '...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : [])' + in render + ) + assert "fitMeta" not in render assert "열너비: {}" in render # 저장 폭 안 씀 — 자동 css = _text(SHEET / "ui_template_sheet.css") - assert ".ui-sheet--combo .ui-sheet__meta td" in css and "th.is-head-picked" in css + assert ".ui-sheet--combo .ui-sheet__meta td" not in css and "th.is-head-picked" in css def test_머리_고침은_쓸_수_있는_층만_나무로(): diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 02df52984..4675902fc 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -29,24 +29,30 @@ def test_나무_부품에_일위대가_전용_칸_없음(): assert gone not in panel, gone -def test_하위_차례_전체_항목_이름(): +def test_좌측_하위_컨테이너와_전체_항목_길_없음(): + """45-2 — 구조물 조합 안 하위(구조물 도면 · 일위대가 · 면적산출 · 각 「전체 항목」) 없앰 · + 상세는 항목 카드 [수정]으로.""" side = _text("M02_MasterTemplete_UI_Side.ts") - kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] - order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] - assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 - assert 'kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true' in kinds - # 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = onOpen({kind: "labor", name: 열}) — 같은 selectColumn 길 - assert "opt.onOpen({ layer, projectId: null, kind, name: col.id" in side + for gone in ( + "COLUMN_KINDS", + "columnSections", + "onAll", + "allPicked", + "selectColumn", + "m02-side__rule", + ): + assert gone not in side, gone + page = _text("M02_MasterTemplete_UI_Page.ts") + assert "buildAllItems" not in page and "onAll" not in page and "ALL_TITLE" not in page + assert "buildAllItems" not in _text("M02_MasterTemplete_ItemList.ts") api = _text("M02_MasterTemplete_Api_Fetch.ts") assert '| "labor";' in api and "일위대가번: string;" in api - assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 - assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side - assert 'selectAll("labor")' not in side # 해시 open=일위대가 길 없앰(44-7) locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") - assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale + for gone in ("M02_ItemsAll", "M02_KindLabor", "M02_KindStructure", "M02_KindArea"): + assert gone not in locale, gone assert "B01_Dashboard_CardUnitCombo" not in locale # 대시보드 일위대가 카드 없앰(44-7) css = _text("M02_MasterTemplete_UI_Style.css") - assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css + assert ".m02-side__rule" not in css and ".m02-side__summary" not in css def test_층_단추는_구조물_조합_한_벌(): @@ -55,11 +61,11 @@ def test_층_단추는_구조물_조합_한_벌(): assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page -def test_나무가_바뀌면_좌측_하위_열_줄도_다시_받음(): +def test_나무가_바뀌면_집계표를_다시_엶(): """39-8 H1 — 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 `apply` → - `onChanged` 한 길 · 집계표만 다시 열면 좌측 하위 줄이 새로고침 전까지 안 따라옴.""" + `onChanged` 한 길 · 좌측 하위 열 줄은 없앰(45-2).""" page = _text("M02_MasterTemplete_UI_Page.ts") changed = page.split("onChanged: async (colId) => {")[1].split("},")[0] - assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" in changed + assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" not in changed panel = _text("M02_MasterTemplete_TreePanel.ts") assert panel.count("await opt.onChanged(") == 1 # 나무 고침은 모두 apply 한 곳을 거침 diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 280859a46..17fcd6f02 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -282,7 +282,7 @@ def test_켜진_층_단추는_목록으로_복사_끌기(): """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · 30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") - assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots + assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page # 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 943b45132..cd525adb7 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -125,15 +125,16 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert ( order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice") ) - # 실무문서를 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 실무문서 · … 지움(자재 조합 없앰) - assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side - assert "onOpen: () => (practice?.clear(), (allPicked = null), paint())" in side + # 실무문서를 열면 이 줄 고름 지움 / 이 줄을 열면 실무문서 고름 지움(자재 조합 · 「전체 항목」 없앰) + assert "onOpen: () => (options.clear(), paint())" in side + assert "onOpen: () => (practice?.clear(), paint())" in side assert "matset" not in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") assert ( - 'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api + 'call("/layers/system/structure-options")' in api + and 'call("/structure-options", { method: "PUT"' in api ) ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8") assert 'o.phase === "detail"' in ui # B06 전용 표시 diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index d73ce71e0..c1fd55cea 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -51,10 +51,6 @@ def test_소스가_700줄을_넘지_않음(): ), # 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함 ("M02_MasterTemplete_Api_Fetch.ts", ["saveTemplate"], MAIN), - # 좌측 구조물 도면 · 면적산출 줄 번호는 한 번에 받음 - # (`{열 id: {도번, 면적번}}`) - ("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE), - ("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE), # 도면 카드(31-4)는 카드 파일에서 뗀 `DrawingCard` 가 부름 ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], DRAWING_CARD), ("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], CARDS), @@ -101,21 +97,15 @@ def test_집계표만_컨테이너를_붙임(): @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_열_머리_글_잇기(): - """`columnHead` — 빈 층은 건너뛰고 빈칸으로 이음 · 다 비면 열 id · - `structureLabel` — 번호가 맨 앞(「SD010101 돌기슭막이 …」) · 번호 없으면 이름만.""" - funcs = [ - re.search(rf"^export const {name}[\s\S]*?;$", _text(PANEL), re.M) - for name in ("columnHead", "structureLabel") - ] - assert all(funcs), "columnHead · structureLabel 을 못 찾음" + """`columnHead` — 빈 층은 건너뛰고 빈칸으로 이음 · 다 비면 열 id.""" + funcs = [re.search(r"^export const columnHead[\s\S]*?;$", _text(PANEL), re.M)] + assert all(funcs), "columnHead 를 못 찾음" # 부품 파일은 화면 모듈을 부르므로 통째로 못 싣음 — 그 함수만 떼어 Node 가 타입을 벗겨 돌림 script = ( "interface StructureColumn { id: string; 머리: (string | null)[] }\n" + "\n".join(f.group(0).replace("export ", "") for f in funcs) + "\nconsole.log(JSON.stringify(process.argv.slice(1)" - + ".map((a) => columnHead(JSON.parse(a)))" - + ".concat([structureLabel('SD010101', {id: 'rv', 머리: ['돌기슭막이', null, 'H=1.0']})," - + " structureLabel('', {id: 'rv', 머리: ['돌기슭막이', null, null]})])));" + + ".map((a) => columnHead(JSON.parse(a)))));" ) cases = [ {"id": "pv_l", "머리": ["콘크리트포장", "T=(두께별)", "L"]}, @@ -133,8 +123,6 @@ def test_열_머리_글_잇기(): "콘크리트포장 T=(두께별) L", "NO", "etc", - "SD010101 돌기슭막이 H=1.0", - "돌기슭막이", ] @@ -159,16 +147,10 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만 assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만 assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만 - # 구조물 도면 · 일위대가 · 면적산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰 · - # 층 단추 · 나무와 하위 사이 수평선(39-2) + # 구조물 조합 컨테이너 = 층 단추 · 나무만(하위 구조물 도면 · 일위대가 · 면적산출 없앰 · 45-2) summary = side.split("const summary = section(")[1].split(");")[0] - assert ( - summary.index("opt.tree") - < summary.index("m02-side__rule") - < summary.index("...columnSections") - ) + assert "opt.tree" in summary and "columnSections" not in summary and '"hr"' not in summary assert "matset" not in side - assert "const columnSections = COLUMN_KINDS.map(" in side assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a) ROOT / "ui_template" / "ui_template_locale_m2.ts" ) @@ -197,17 +179,12 @@ def test_첫_화면과_닫은_뒤는_구조물집계표(): def test_좌측_고름_표시는_열린_것_한_줄뿐(): - """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림.""" + """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 옵션 줄을 함께 그림.""" side = _text(SIDE) assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄 - assert re.search( - r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*" - r"paintList\(\);\s*paintColumns\(\);", - side, - ) # 양식이 열리면 「전체 항목」 고름도 지움(39-2) - for fn in ("async function select(", "async function selectColumn("): - body = side.split(fn)[1].split("\n }\n")[0] - assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음 + assert re.search(r"function paint\(\): void \{\s*paintList\(\);", side) + body = side.split("async function select(")[1].split("\n }\n")[0] + assert "paint" not in body # 여기서 한쪽만 그리면 두 줄이 남음 assert re.search(r"sel = next;\s*onOpened\?\.\(\);", _text(MAIN)) # 옵션 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 옵션 고름 지움 assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN)) diff --git a/resources/tester/ui_checks/44-2_횡단_글자.js b/resources/tester/ui_checks/44-2_횡단_글자.js index d5d0f35fc..0e69f47d7 100644 --- a/resources/tester/ui_checks/44-2_횡단_글자.js +++ b/resources/tester/ui_checks/44-2_횡단_글자.js @@ -1,11 +1,16 @@ -/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기 +/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기 · 판 크기 바뀜에도 배율 1 * 시작 화면: B05 종단설계(#/b05-profile) · 환경 검증 프로젝트(OWNERS.md) · 로그인됨 · 계획노선 편집 닫힘 + * · 창 너비 1500px 이상(그 아래면 오른쪽 칸이 접힘) * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) - * 하는 일: [계획노선 편집] 열기 → 첫 측점 라벨 누름(앱 모듈의 라벨 상자 자리) → 횡단 판 글자 잼 → [취소]로 닫음 - * 기대 수치: - * - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 5.5px(옛 11px 의 절반) + * 하는 일: [계획노선 편집] 열기 → 오른쪽 칸 크기를 세 갈래로 둔 채 측점 라벨 누름 → 칸을 원래대로 → 잼 + * folded = 칸 접힘(display none) 상태로 셈 → 펼침 · narrow = 260×300 으로 셈 → 원래 + * wide = 원래 크기로 셈 · shrink = wide 뒤 260×300 으로 줄임 → [취소]로 닫음 + * · 탭이 화면에 안 보여 프레임이 없으면(frames = false) ResizeObserver 대역(100ms 재기)을 깔고 돎 + * 기대 수치(갈래마다): + * - sx · sy = 화면 크기 ÷ viewBox = 1(±0.02 · 글자가 늘어나 보이지 않음) + * - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 11px · screenPx(11 × sy) 10~12 * - station.fontSize = title.fontSize(측점 표기 = 제목 「횡단」 크기) - * - 바꾸는 값 없음(취소로 닫음 · 노선 안 고침) + * - 바꾸는 값 없음(칸 크기 임시 style 지움 · 취소로 닫음 · 노선 안 고침) */ (async () => { const w = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -21,13 +26,50 @@ [...document.querySelectorAll("button")].find( (b) => b.offsetParent && b.textContent.trim() === text, ); + // 화면에 안 보이는 ORCA 탭은 프레임을 안 그려 ResizeObserver 가 안 옴 — 그때만 100ms 마다 + // 크기를 재어 같은 방식(처음 한 번 · 바뀔 때마다)으로 부르는 대역을 편집 창 열기 전에 깖 + const OrigRO = window.ResizeObserver; + const frames = await Promise.race([ + new Promise((r) => requestAnimationFrame(() => r(true))), + w(500).then(() => false), + ]); + if (!frames) + window.ResizeObserver = class { + constructor(cb) { + this.cb = cb; + this.t = new Map(); + this.iv = setInterval(() => { + for (const [el, last] of this.t) { + const r = el.getBoundingClientRect(); + const k = `${r.width}x${r.height}`; + if (k === last) continue; + this.t.set(el, k); + this.cb([{ target: el }], this); + } + }, 100); + } + observe(el) { + if (!this.t.has(el)) this.t.set(el, null); + } + unobserve(el) { + this.t.delete(el); + } + disconnect() { + this.t.clear(); + clearInterval(this.iv); + } + }; btn("계획노선 편집")?.click(); const canvas = await until(() => [...document.querySelectorAll("canvas")].find( (c) => c.offsetParent && c.closest("[class*='routeedit']"), ), ); + window.ResizeObserver = OrigRO; // 편집 창(횡단 판)은 다 지어짐 if (!canvas) return JSON.stringify({ ok: false, why: "편집 캔버스 없음" }); + const side = document.querySelector(".b05-routeedit__side"); + if (!side || !side.offsetParent) + return JSON.stringify({ ok: false, why: "오른쪽 칸 접힘", innerWidth }); // 앱이 실제로 부른 그 모듈 주소(@fs/<폴더>/…) — 같은 주소라야 라벨 상자 WeakMap 을 같이 씀 const url = performance .getEntriesByType("resource") @@ -39,34 +81,75 @@ return b.length ? b : null; }); if (!boxes) return JSON.stringify({ ok: false, why: "측점 라벨 상자 없음" }); - const rect = canvas.getBoundingClientRect(); - const box = boxes[Math.floor(boxes.length / 2)]; - const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true }; - canvas.dispatchEvent(new PointerEvent("pointerdown", at)); - canvas.dispatchEvent(new PointerEvent("pointerup", at)); - const pane = await until(() => - [...document.querySelectorAll(".b05-routeedit__cross")].find((p) => - p.querySelector(".b05-routeedit__cross-key"), - ), - ); - if (!pane) return JSON.stringify({ ok: false, why: "횡단 판 안 뜸", chainage: box.chainage }); - const fs = (el) => el && getComputedStyle(el).fontSize; - const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({ - text: t.textContent.slice(0, 20), - fontSize: fs(t), - h: +t.getBoundingClientRect().height.toFixed(1), - })); - const title = pane.querySelector(".b05-routeedit__cross-title"); - const station = pane.querySelector(".b05-routeedit__cross-station"); - const out = { - keys, - title: { text: title.textContent, fontSize: fs(title) }, - station: { text: station.textContent, fontSize: fs(station) }, + const pane = side.querySelector(".b05-routeedit__cross"); + const draw = async () => { + const old = pane.querySelector("svg"); + const rect = canvas.getBoundingClientRect(); + const box = boxes[Math.floor(boxes.length / 2)]; + const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true }; + canvas.dispatchEvent(new PointerEvent("pointerdown", at)); + canvas.dispatchEvent(new PointerEvent("pointerup", at)); + return until(() => { + const svg = pane.querySelector("svg"); + return svg && svg !== old && svg.querySelector(".b05-routeedit__cross-key"); + }); }; - out.ok = - keys.length === 3 && - keys.every((k) => k.fontSize === "5.5px") && - out.title.fontSize === out.station.fontSize; - btn("취소")?.click(); - return JSON.stringify(out); + const fs = (el) => el && getComputedStyle(el).fontSize; + const measure = (tag) => { + const svg = pane.querySelector("svg"); + const vb = svg.viewBox.baseVal; + const r = svg.getBoundingClientRect(); + const sx = +(r.width / vb.width).toFixed(3); + const sy = +(r.height / vb.height).toFixed(3); + const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({ + text: t.textContent.slice(0, 12), + fontSize: fs(t), + screenPx: +(parseFloat(fs(t)) * sy).toFixed(1), + h: +t.getBoundingClientRect().height.toFixed(1), + })); + const title = pane.querySelector(".b05-routeedit__cross-title"); + const station = pane.querySelector(".b05-routeedit__cross-station"); + const res = { + tag, + viewBox: [vb.width, vb.height], + box: [Math.round(r.width), Math.round(r.height)], + sx, + sy, + keys, + title: fs(title), + station: fs(station), + }; + res.ok = + Math.abs(sx - 1) <= 0.02 && + Math.abs(sy - 1) <= 0.02 && + keys.length === 3 && + keys.every((k) => k.fontSize === "11px" && k.screenPx >= 10 && k.screenPx <= 12) && + res.title === res.station; + return res; + }; + const narrow = () => Object.assign(side.style, { width: "260px", height: "300px" }); + const reset = () => Object.assign(side.style, { display: "", width: "", height: "" }); + const out = []; + try { + side.style.display = "none"; + if (!(await draw())) return JSON.stringify({ ok: false, why: "접힘 · 횡단 안 뜸" }); + reset(); + await w(600); + out.push(measure("folded")); + narrow(); + if (!(await draw())) return JSON.stringify({ ok: false, why: "좁음 · 횡단 안 뜸" }); + reset(); + await w(600); + out.push(measure("narrow")); + if (!(await draw())) return JSON.stringify({ ok: false, why: "넓음 · 횡단 안 뜸" }); + await w(300); + out.push(measure("wide")); + narrow(); + await w(600); + out.push(measure("shrink")); + } finally { + reset(); + btn("취소")?.click(); + } + return JSON.stringify({ ok: out.every((o) => o.ok), innerWidth, frames, out }); })(); diff --git a/resources/tester/ui_checks/44-4_로직상세_패널.js b/resources/tester/ui_checks/44-4_로직상세_패널.js index 682c0204e..df8d948c0 100644 --- a/resources/tester/ui_checks/44-4_로직상세_패널.js +++ b/resources/tester/ui_checks/44-4_로직상세_패널.js @@ -4,10 +4,11 @@ * 로직 GC000277(건설품셈 토목 1-1-2 유도선 설치 및 해체)을 엶 · 고치지 않음 · 끝에 패널 취향을 처음 값으로 되돌림. * 기대 수치: * order = ["기본정보","시험 계산","단가산출 상세","텍스트 수식"](흐름 로직이면 셋째 · 넷째는 흐름 보기 안) - * flow = {flow:true, source:false, pref:"flow"} · 오른쪽 칸 너비 > 300 - * source = {flow:false, source:true, pref:"source"} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」 - * none = {flow:false, source:false, pref:"none"} · 오른쪽 칸 너비 = 80(띠 둘) - * back = {flow:true, source:false, pref:"flow"}(흐름도 띠 단추로 다시 폄) + * 두 패널 = 공용 접기 카드(ui-collapsible · 제목 행 누름) · 흐름도 패널 자체 접기 단추는 안 보임(dfpToggle 0) + * flow = {flow:true, source:false, pref:"flow", caret:["▾","▸"]} · 오른쪽 칸 너비 > 300 + * source = {flow:false, source:true, pref:"source", caret:["▸","▾"]} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」 + * none = {flow:false, source:false, pref:"none", caret:["▸","▸"]} · 오른쪽 칸 너비 = 140(제목 행 둘) + * back = {flow:true, source:false, pref:"flow"}(흐름도 제목 행으로 다시 폄) * noLink = 「원문 연결 없음」 + 「출처 · …」(서버가 빈 목록을 줄 때 · fetch 를 잠시 바꿔 봄) * restored = 처음 취향 값 */ @@ -43,17 +44,23 @@ out.order = $$(".m01lab-flow__left .m01lab__box > .m01-logic__section-head h3").map((h) => h.innerText.trim(), ); - const slots = () => $$(".m01lab-side__slot"); + const cards = () => $$(".m01lab-side__card"); + const heads = () => cards().map((c) => $(".ui-collapsible__title", c)); + const shown = (n) => !!n && n.getBoundingClientRect().height > 0; const state = () => ({ flow: - slots()[0].classList.contains("is-open") && - !$(".m02-dfp", slots()[0]).classList.contains("is-folded"), - source: slots()[1].classList.contains("is-open") && !$(".m02-dfp__open", slots()[1]).hidden, + !cards()[0].classList.contains("is-collapsed") && + shown($(".m02-dfp__open", cards()[0])) && + !$(".m02-dfp", cards()[0]).classList.contains("is-folded"), + source: !cards()[1].classList.contains("is-collapsed") && shown($(".m01lab-src", cards()[1])), pref: localStorage.getItem(PREF), + caret: heads().map((h) => getComputedStyle(h, "::after").content.replace(/"/g, "")), width: Math.round($(".m01lab-flow__right").getBoundingClientRect().width), }); - const flowToggle = () => $(".m02-dfp__toggle", slots()[0]); - const srcToggle = () => $(".m02-dfp__toggle", slots()[1]); + const flowToggle = () => heads()[0]; + const srcToggle = () => heads()[1]; + out.titles = heads().map((h) => h.innerText.trim()); + out.dfpToggle = $$(".m01lab-side .m02-dfp__toggle").filter(shown).length; // 처음 = 흐름도 열림으로 맞춤 if (!state().flow) { if (state().source) srcToggle().click(); diff --git a/resources/tester/ui_checks/45-1_M02_단추_꼴.js b/resources/tester/ui_checks/45-1_M02_단추_꼴.js new file mode 100644 index 000000000..17bc5b9e2 --- /dev/null +++ b/resources/tester/ui_checks/45-1_M02_단추_꼴.js @@ -0,0 +1,64 @@ +/* 45-1 — M02 단추 = 공용 기본 단추(알약 꼴 아님) · 알약은 그대로 알약 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1(구조물 조합)이 있음 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 알약 꼴 = 모서리 반지름 ≥ 높이 / 2 - 1(캡슐) · 진짜 알약 = 알약 · 칩 · 이름표 클래스(.ss-link · *__pill · *-chip · .ui-tag) + * 기대 수치: + * - pillButtons = [](M02 안 보이는 단추 중 알약 꼴 0 · 머리 공용 메뉴는 M02 밖이라 뺌) + * - layers = 층 단추가 ui-btn · 켜진 층은 filled · add = 「+」 가 ui-btn--ghost + * - smRadius = 4(작은 단추 모서리 = 8 / 2) · buttons ≥ 20 + * - pillOn: [알약] 켬 → realPills > 0 · 모두 알약 꼴(realRound = realPills) → 끔으로 되돌림(pressedAfter = "false") + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + 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 round = (e) => { + const r = e.getBoundingClientRect(); + return parseFloat(getComputedStyle(e).borderTopLeftRadius) >= r.height / 2 - 1; + }; + const REAL = ".ss-link, [class*='__pill'], [class*='-chip'], [class*='__chip'], .ui-tag"; + const scope = () => + [...document.querySelectorAll("button")].filter( + (b) => vis(b) && !b.closest("header, .app-header") && !b.matches(REAL), + ); + const out = {}; + const node = await until(() => document.querySelector('[data-node="i|item1"]')); + if (!node) return JSON.stringify({ error: "item1 없음 — 기슭막이 폴더를 펼쳐 둘 것" }); + node.click(); + await until(() => document.querySelector(".m02-struct__card-tools .ui-btn")); + await w(1500); + const all = scope(); + out.buttons = all.length; + out.pillButtons = all + .filter((b) => round(b) && b.getBoundingClientRect().height > 0) + .map((b) => `${b.innerText.trim().slice(0, 12)} | ${b.className}`); + out.layers = [...document.querySelectorAll(".m02-layers__btn")].map( + (b) => `${b.innerText.trim()}:${b.classList.contains("ui-btn--filled") ? "filled" : b.classList.contains("ui-btn--ghost") ? "ghost" : b.className}`, + ); + out.add = document.querySelector(".m02-tree__add")?.className ?? null; + const sm = all.find((b) => b.classList.contains("ui-btn--sm")); + out.smRadius = sm ? parseFloat(getComputedStyle(sm).borderTopLeftRadius) : null; + // [알약] 켬 → 진짜 알약은 알약 꼴 그대로 → 끔으로 되돌림 + const pillBtn = [...document.querySelectorAll(".m02-struct__card-tools button")].find( + (b) => vis(b) && b.innerText.trim() === "알약", + ); + if (pillBtn) { + const before = pillBtn.getAttribute("aria-pressed"); + if (before !== "true") pillBtn.click(); + await w(600); + const real = [...document.querySelectorAll(".ss-link")].filter(vis); + out.pillOn = { realPills: real.length, realRound: real.filter(round).length }; + if (before !== "true") pillBtn.click(); + await w(300); + out.pillOn.pressedAfter = pillBtn.getAttribute("aria-pressed"); + } else out.pillOn = "알약 단추 없음"; + return JSON.stringify(out); +})() diff --git a/resources/tester/ui_checks/45-2_좌측_하위_없앰.js b/resources/tester/ui_checks/45-2_좌측_하위_없앰.js new file mode 100644 index 000000000..cf7c58b55 --- /dev/null +++ b/resources/tester/ui_checks/45-2_좌측_하위_없앰.js @@ -0,0 +1,60 @@ +/* 45-2 — M02 좌측 「구조물 조합」 안 하위 컨테이너 셋(구조물 도면 · 일위대가 · 면적산출 · 「전체 항목」) 없앰 · + * 상세 길 = 나무 항목 → 카드 셋 [수정] → 상세 → [돌아가기] + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1 이 있음 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안) + * 기대 수치: + * - side.nested = 0 · side.all = 0 · side.rule = 0(구조물 조합 안 하위 카드 · 「전체 항목」 · 수평선 없음) + * - side.titles = ["구조물 조합", 옵션 줄, "도면 양식", "실무문서"] · side.gap > 0(카드 사이 틈 = 구분) + * - cards = 3 · edits = 3(카드마다 [수정]) + * - detail 셋 모두 title 있음 · back = true · 돌아가기 뒤 cardsAfter = 3 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 5000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await w(100); + } + return null; + }; + const vis = (b) => b && !b.hidden && b.offsetParent !== null; + const lists = document.querySelector(".m02-side__lists"); + const tops = [...lists.children].filter((c) => c.matches("section")); + const summary = tops[0]; + const box = (e) => e.getBoundingClientRect(); + const out = { + side: { + titles: tops.map((s) => s.querySelector("h3")?.textContent.trim()), + nested: summary.querySelectorAll(".ui-sidebar-card").length, + all: document.querySelectorAll("[data-all], .m02-side__all").length, + rule: summary.querySelectorAll("hr").length, + gap: tops[1] ? Math.round(box(tops[1]).top - box(summary).bottom) : null, + }, + }; + const edits = () => + [...document.querySelectorAll(".m02-struct button")].filter( + (b) => vis(b) && b.textContent.trim() === "수정", + ); + document.querySelector('[data-node="i|item1"]').click(); + await until(() => edits().length === 3); + out.cards = document.querySelectorAll(".m02-struct .ui-sidebar-card, .m02-struct > *").length; + out.edits = edits().map((b) => b.title); + out.detail = []; + for (let i = 0; i < 3; i += 1) { + const b = await until(() => edits()[i]); + if (!b) break; + b.click(); + const back = await until(() => + [...document.querySelectorAll(".m02-main__head button")].find( + (x) => vis(x) && x.textContent.trim() === "돌아가기", + ), + ); + const title = document.querySelector(".m02-main__title")?.textContent.trim() ?? null; + back?.click(); + const again = await until(() => edits().length === 3); + out.detail.push({ title, back: !!back, cardsAfter: again ? 3 : edits().length }); + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/45-3_분류표_키줄.js b/resources/tester/ui_checks/45-3_분류표_키줄.js new file mode 100644 index 000000000..a62c5f083 --- /dev/null +++ b/resources/tester/ui_checks/45-3_분류표_키줄.js @@ -0,0 +1,72 @@ +/* 45-3 — 구조물 조합 상세 분류 표: 일위대가 · 도면 · 면적산출 키 줄 안 보임 · [저장] 뒤 장부 번호 그대로 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글 · 20초 안) + * 쓰는 것: 표 [저장] 한 번(고침 없음) — 끝나면 resources/master_template 사본으로 되씀 + * 기대 수치: + * - metaRows = 0 · refRowNames = 0(줄 머리 「일위대가 · 도면 · 면적산출」) · refCells = 0(data-r s:price|drawing|area) + * - headPick = true(머리 칸 누르면 열 고름 · 카드 셋) · keyMove = true(방향키로 옆 열) + * - book.same = true(저장 앞뒤 장부 줄의 키 · 열 · 도번 · 면적번 같음) · book.laborAfter = rows(일위대가번 모두 있음) + * (git 장부가 옛 꼴(재료번)이면 저장 때 서버 맞춤이 일위대가번으로 바꿈 — 보기와 무관) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + 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 sheet = await until(() => document.querySelector(".ui-sheet--combo")); + if (!sheet) return JSON.stringify({ error: "구조물 조합 표 없음" }); + const out = {}; + const gutters = () => + [...sheet.querySelectorAll(".ui-sheet__gutter")].map((g) => g.textContent.trim()); + out.metaRows = sheet.querySelectorAll(".ui-sheet__meta").length; + out.refRowNames = gutters().filter((t) => ["일위대가", "도면", "면적산출"].includes(t)).length; + out.refCells = sheet.querySelectorAll( + '[data-r="s:price"],[data-r="s:drawing"],[data-r="s:area"]', + ).length; + out.gutters = gutters(); + + // 머리 칸 누르면 열 고름(카드) · 방향키 = 옆 열 + const heads = [...sheet.querySelectorAll("thead th[data-col]")].filter(vis); + const deepest = Math.max(...heads.map((h) => Number(h.dataset.level))); + const leaves = heads.filter((h) => Number(h.dataset.level) === deepest); + leaves[0].click(); + await w(400); + out.headPick = !!sheet.querySelector("th.is-head-picked"); + const first = sheet.querySelector("th.is-head-picked")?.dataset.col; + sheet.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + await w(300); + const second = sheet.querySelector("th.is-head-picked")?.dataset.col; + out.keyMove = !!first && !!second && first !== second; + + // [저장] 앞뒤 장부(구조물집계표조합) 번호 + const book = () => + fetch("/api/m02/layers/system/structure-combos", { credentials: "include" }) + .then((r) => r.json()) + .then((b) => + (b.줄 ?? []).map((r) => ({ + id: JSON.stringify([r.키, r.열, r.도번, r.면적번]), + su: r.일위대가번, + })), + ); + const before = await book(); + const save = [...document.querySelectorAll("button")].find( + (b) => vis(b) && b.innerText.trim() === "저장", + ); + save.click(); + await w(2500); + const after = await book(); + out.book = { + rows: before.length, + same: JSON.stringify(before.map((r) => r.id)) === JSON.stringify(after.map((r) => r.id)), + laborAfter: after.filter((r) => r.su).length, + sample: after[0], + }; + out.metaAfterSave = document.querySelectorAll(".ui-sheet--combo .ui-sheet__meta").length; + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/45-5_분류표_카드_간격.js b/resources/tester/ui_checks/45-5_분류표_카드_간격.js new file mode 100644 index 000000000..1cf8bcf97 --- /dev/null +++ b/resources/tester/ui_checks/45-5_분류표_카드_간격.js @@ -0,0 +1,32 @@ +/* 45-5 — M02 구조물 조합 상세(항목 고른 화면) · 분류 표와 카드 셋(도면 정보 · 일위대가 · 면적산출) 사이 간격 · 구분선 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 나무에 item1 이 있음 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - gap = 표 틀 아래 끝 ~ 첫 카드 위 끝 = 메인 칸 간격(12px 안팎 · 옛 107px) + * - tableLine = 0(표 틀 아래 줄 없음) · lastRow > 0(마지막 줄 칸 선이 표를 닫음) + * - cardsLine = 0 · cardsPad = 0(카드 칸 위 줄 · 안쪽 여백 없음) · hr = 0 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + document.querySelector('[data-node="i|item1"]').click(); + for ( + let t = 0; + t < 5000 && !document.querySelector(".m02-struct:not([hidden]) .m02-struct__card"); + t += 100 + ) + await w(100); + await w(300); + const scroll = document.querySelector(".m02-main__host .ui-sheet__scroll"); + const struct = document.querySelector(".m02-struct"); + const card = struct.querySelector(".m02-struct__card"); + const cells = scroll.querySelectorAll("th, td"); + const cs = (e) => getComputedStyle(e); + return JSON.stringify({ + gap: Math.round(card.getBoundingClientRect().top - scroll.getBoundingClientRect().bottom), + tableLine: parseFloat(cs(scroll).borderBottomWidth), + lastRow: parseFloat(cs(cells[cells.length - 1]).borderBottomWidth), + cardsLine: parseFloat(cs(struct).borderTopWidth), + cardsPad: parseFloat(cs(struct).paddingTop), + hr: document.querySelectorAll(".m02-main hr").length, + }); +})(); diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index aa97203d9..6f2beb712 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -141,10 +141,6 @@ outline-offset: -2px; } -.ui-sheet--combo .ui-sheet__meta td { - white-space: nowrap; -} - .ui-sheet__table tr.is-fixed td { border-bottom: 2px solid var(--color-border); } diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index b8f38ebe6..418dcb355 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -222,7 +222,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio opts.onSelect?.(selectionOf(state.doc, r, c)); }; const move = (dr: number, dc: number): void => { - const keys = navRows(state.doc, state.mode).filter((k) => !opts.combo || k !== KEY_UNIT); + // 구조물 조합 = 머리만(단위 · 번호 줄 없음) — 방향키는 열만 옮김(머리 = 단위 줄 자리) + const keys = opts.combo ? [KEY_UNIT] : navRows(state.doc, state.mode); const cols = state.doc.열; if (!state.sel) { if (keys.length && cols.length) select(keys[0], cols[0].id, true); diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index e9992bbf7..8ef33269c 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -47,7 +47,7 @@ export interface RenderState { pick?: boolean; /** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */ headLocked?: boolean; - /** 구조물 조합 표(`combo`) — 단위 줄 없음 · 열 폭 = 머리 · 번호 글이 한 줄에 들게(저장 폭 안 씀) · + /** 구조물 조합 표(`combo`) — 단위 줄 · 일위대가 · 도면 · 면적산출 줄 없음 · 열 폭 = 머리(저장 폭 안 씀) · * `headEditable` = 머리 이름 칸을 고칠 수 있음(나무 이름 바꾸기로). */ combo?: boolean; headEditable?: boolean; @@ -68,18 +68,6 @@ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map) mins.get(col.id) ?? 0, ); -/** 구조물 조합 — 일위대가 · 도면 · 면적산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘 - * (`.ui-sheet__meta td` 글꼴과 같게 잼). */ -function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map): void { - const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - const font = `400 ${rem * 0.76}px ${getComputedStyle(document.body).fontFamily}`; - for (const col of cols) { - const texts = REF_ROWS.map((key) => refText(state, key, col.id)); - const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14; - mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need)); - } -} - /** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */ function headFont(): string { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; @@ -113,7 +101,6 @@ export function renderSheet(state: RenderState): HTMLElement { const names = columnNames(cols); const font = headFont(); const mins = headMinWidths(cols, depth, font); - if (state.combo) fitMeta(state, cols, mins); const widthDoc = state.combo ? { ...doc, 보기: { ...doc.보기, 열너비: {} } } : doc; // 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 합계 이름이 안 잘리게 const GUTTER = Math.min( @@ -275,7 +262,8 @@ ${formulaLabel(formula, names)}` for (const [p, page] of plan.entries()) { const body = el("tbody", { children: [ - ...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []), + // 구조물 조합 = 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만 · 번호는 장부 · 카드에) + ...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : []), ...page.rows.map((row, i) => dataRow(row, page.first + i)), ...(page.totals ? totalRows() : []), ], diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index 0eb579239..ec674b701 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -33,10 +33,11 @@ const BASE_CSS = ` .ui-btn:disabled { opacity: 0.5; cursor: not-allowed; } .ui-btn__icon { display: inline-flex; width: 16px; height: 16px; } -/* 작은 크기 — 다섯 모양 모두에 먹음(padding · 글자만 바꿈) */ +/* 작은 크기 — 다섯 모양 모두에 먹음(padding · 글자 · 모서리) · 낮은 키에 8px 모서리면 알약 꼴이라 반으로 */ .ui-btn--sm { padding: 2px var(--spacing-8); font-size: var(--text-caption); + border-radius: calc(var(--radius-buttons) / 2); } .ui-btn--filled { diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index ee9999c55..15a1000a0 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -10,8 +10,6 @@ export const ui_locales_m2 = { M02_Title: ["마스터 템플릿", "Master Templates"], M02_KindTable: ["구조물 조합", "Structure combos"], M02_KindDrawing: ["도면 양식", "Drawing templates"], - M02_KindStructure: ["구조물 도면", "Structure drawings"], - M02_KindArea: ["면적산출", "Area takeoff"], M02_NoTemplates: ["양식 없음", "None"], M02_New: ["새로", "New"], M02_Copy: ["본떠 만들기", "Copy as new"], @@ -56,7 +54,6 @@ export const ui_locales_m2 = { "Unsaved changes — discard and continue?", ], M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"], - M02_StructureNone: ["없음", "None"], M02_Back: ["돌아가기", "Back"], M02_TitleCompany: ["구조물 관리(회사)", "Structure management (company)"], M02_TitlePersonal: ["구조물 관리(개인)", "Structure management (personal)"], @@ -165,8 +162,6 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], - M02_KindLabor: ["일위대가", "Unit costs"], - M02_ItemsAll: ["전체 항목", "All items"], M02_PracticeTitle: ["실무문서", "Practice documents"], M02_PracticeRow: ["실무문서 목록", "Practice document list"], M02_PracticeCount: [