diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index a24a27d9..0c51a168 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -33,6 +33,7 @@ import { getActiveLayer, readTemplate, type StructureNumbers, + type TreeEdit, } from "./M02_MasterTemplete_Api_Fetch"; import { createItemCards, @@ -221,6 +222,18 @@ export function comboMerge(stored: unknown, edited: T): T { } as unknown as T; } +/** 표 머리 이름 칸 고침 → 나무 이름 바꾸기(PLAN 31-1 · 한 정본 = 나무) — 칸이 항목 이름(가장 깊은 칸)이면 + * 항목(열 id 로 부름) · 위 칸이면 그 폴더(머리 앞마디 경로). */ +export function headRenameEdit(col: StructureColumn, level: number, name: string): TreeEdit { + const named = (parts: (string | null)[]): string[] => + parts.filter((p): p is string => !!p && p.trim() !== ""); + const path = named(col.머리); + const upto = named(col.머리.slice(0, level + 1)); + return upto.length >= path.length + ? { op: "rename", 경로: path.slice(0, -1), 키: col.id, 이름: name } + : { op: "rename", 경로: upto, 이름: name }; +} + /** 구조물 이름 + 변수값 — 제목행 층을 빈칸으로 이음(빈 층은 건너뜀 · 다 비면 열 id) */ export const columnHead = (col: StructureColumn): string => col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index b82b5df4..b252f53e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -110,6 +110,8 @@ export interface TreePanelHandle { select: (colId: string | null) => void; /** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */ newItem: () => void; + /** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */ + editFromTable: (body: TreeEdit, colId: string | null) => Promise; } const why = (error: unknown): string => (error instanceof Error ? error.message : ""); @@ -685,5 +687,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { void addItem(at?.kind === "folder" ? at.path : []); }; - return { root, search: search.root, add: tools, refresh, select, newItem }; + const editFromTable = async (body: TreeEdit, colId: string | null): Promise => { + if (opt.canEdit && (await ready())) await edit(body, colId); + }; + return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index e88beecd..0d578278 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -118,6 +118,8 @@ export interface MainExtras { search?: HTMLElement; newItem?: HTMLElement; onColumn?: (column: string | null) => void; + /** 구조물 조합 표 머리 이름 칸 고침 → 나무 이름 바꾸기(쓸 수 있는 층만 · PLAN 31-1) */ + onHeadRename?: (col: StructureColumn, level: number, name: string) => void; } /** 상세 제목 줄 오른쪽 끝 「새 …」 단추 — 공용 모양 `m02-main__new`(일위대가 「새 조합」과 같게 · 30-5) */ @@ -402,6 +404,16 @@ export function createMain( drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", materialLabel: (colId: string) => struct.numbers()[colId]?.재료번 ?? "", + combo: { + ...(readOnly || !extras.onHeadRename + ? {} + : { + onHeadEdit: (colId: string, level: number, name: string) => { + const col = columns().find((c) => c.id === colId); + if (col) extras.onHeadRename?.(col, level, name); + }, + }), + }, onSelect: (s: { colId: string | null }) => { struct.show(s.colId, columns()); extras.onColumn?.(s.colId); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index c735cae8..b53374b0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -28,7 +28,11 @@ import { type Root, } from "./M02_MasterTemplete_Layer"; import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; -import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; +import { + headRenameEdit, + STRUCTURE_TABLE, + type StructureColumn, +} from "./M02_MasterTemplete_Structure"; import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; import { companyOwner, @@ -135,6 +139,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise + void tree.editFromTable(headRenameEdit(col, level, name), col.id), + } + : {}), }, ); // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 diff --git a/resources/tester/test_m02_combo_screen.py b/resources/tester/test_m02_combo_screen.py index 74dc2bca..bc006268 100644 --- a/resources/tester/test_m02_combo_screen.py +++ b/resources/tester/test_m02_combo_screen.py @@ -93,7 +93,9 @@ def test_참조표는_열_머리를_못_고침(): assert "else if (layer && editCols) editLayer(layer);" in sheet assert 'ro || opts.lockHead ? { hidden: "" }' in sheet render = _text(SHEET / "ui_template_sheet_render.ts") - assert 'const heads = mode === "master" && !state.headLocked;' in render + assert ( + 'const heads = mode === "master" && (!state.headLocked || !!state.headEditable);' in render + ) # 31-1 머리 이름은 나무로 고침 main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "struct ? comboView(doc) : doc" in main assert "getDoc: () => comboMerge(loaded, shown.getDoc())" in main diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py new file mode 100644 index 00000000..11619815 --- /dev/null +++ b/resources/tester/test_m02_combo_table.py @@ -0,0 +1,87 @@ +"""구조물 조합 표(PLAN 31-1) — 머리 이름 칸 고침 = 나무 이름 바꾸기 · 단위 줄 숨김 · 열 폭 자동 · +고른 열의 마지막 분류 칸 강조 · 옛 안내글 없음. + +① `headRenameEdit`(Node) — 가장 깊은 칸이면 항목(열 id) · 위 칸이면 그 폴더 경로. +② 표 부품 `combo` 선택 — 단위 줄 · 안내 글 없음 · 번호 글 폭 · 가장 깊은 머리 칸 · + 머리 고침은 부른 쪽에. +③ 잇기 — 상세(Main)가 쓸 수 있을 때만 넘기고 페이지가 나무 고침(`editFromTable`)으로 보냄. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +SHEET = ROOT / "ui_template" / "sheet" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_머리_칸_고침은_나무_이름_바꾸기(): + src = _text(M02 / "M02_MasterTemplete_Structure.ts") + part = re.search(r"^export function headRenameEdit[\s\S]*?^}", src, re.M) + assert part, "headRenameEdit 를 못 찾음" + script = ( + "interface StructureColumn { id: string; 머리: (string | null)[] }\n" + "interface TreeEdit { op: string; 경로?: string[]; 이름?: string; 키?: string }\n" + + part.group(0).replace("export ", "") + + """ +const col = { id: "item3", 머리: ["옹벽", "콘크리트", "L형", null] }; +const flat = { id: "item9", 머리: ["대피소", null, null, null] }; +console.log(JSON.stringify([ + headRenameEdit(col, 0, "옹벽공"), headRenameEdit(col, 1, "철근"), + headRenameEdit(col, 2, "L자"), headRenameEdit(col, 3, "L자"), headRenameEdit(flat, 0, "쉼터"), +])); +""" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + {"op": "rename", "경로": ["옹벽"], "이름": "옹벽공"}, # 맨 위 폴더 + {"op": "rename", "경로": ["옹벽", "콘크리트"], "이름": "철근"}, # 가운데 폴더 + {"op": "rename", "경로": ["옹벽", "콘크리트"], "키": "item3", "이름": "L자"}, # 항목 + {"op": "rename", "경로": ["옹벽", "콘크리트"], "키": "item3", "이름": "L자"}, # 빈 아래 칸 + {"op": "rename", "경로": [], "키": "item9", "이름": "쉼터"}, # 깊이 1 항목 + ] + + +def test_표_부품_combo_선택(): + sheet = _text(SHEET / "ui_template_sheet.ts") + 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 + ) # 방향키가 숨은 단위 줄을 안 밟음 + 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 + 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 + + +def test_머리_고침은_쓸_수_있는_층만_나무로(): + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + assert "readOnly || !extras.onHeadRename" in main + page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") + assert "tree.editFromTable(headRenameEdit(col, level, name), col.id)" in page + assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0 + tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts") + assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index ed75a946..aa97203d 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -135,6 +135,16 @@ outline-offset: -2px; } +/* 구조물 조합(PLAN 31-1) — 고른 열의 가장 깊은 이름 칸 · 키 · 번호 줄바꿈 없음 */ +.ui-sheet__table th.is-head-picked { + outline: 2px solid var(--color-focus-ring); + 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 7cab951e..34435f64 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -65,6 +65,10 @@ export interface SheetOptions { * 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`. * 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */ onPick?: (sel: SheetSelection) => Promise | void; + /** 구조물 조합 표(PLAN 31-1) — 단위 줄 · 안내 글 없음 · 열 폭 자동 · 고른 열은 가장 깊은 이름 칸을 밝힘 · + * `onHeadEdit` 를 주면 이름 칸을 두 번 누르기 · F2 · Enter 로 고쳐 부른 쪽에 넘김(표 문서는 안 고침 — + * 나무 이름 바꾸기로 · 잠긴 머리여도). */ + combo?: { onHeadEdit?: (colId: string, level: number, name: string) => void }; } export interface SheetHandle { @@ -89,6 +93,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio materialLabel: opts.materialLabel, pick: !!opts.onPick, headLocked: !!opts.lockHead, + combo: !!opts.combo, + headEditable: !opts.readOnly && !!opts.combo?.onHeadEdit, }; const ro = !!opts.readOnly; const editCols = opts.mode === "master" && !ro && !opts.lockHead; // 열 · 머리 고치기 @@ -98,11 +104,11 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio attrs: ro || opts.lockHead ? { hidden: "" } : {}, }); const root = el("div", { - className: `ui-sheet ui-sheet--${opts.mode}`, + className: `ui-sheet ui-sheet--${opts.mode}${opts.combo ? " ui-sheet--combo" : ""}`, attrs: { tabindex: "0" }, children: [ toolbar, - ...(ro ? [] : [el("p", { className: "ui-sheet__hint", text: st("Hint") })]), + ...(ro || opts.combo ? [] : [el("p", { className: "ui-sheet__hint", text: st("Hint") })]), scroll, ], }); @@ -178,6 +184,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.result = recalcSheet(state.doc); const { scrollLeft, scrollTop } = scroll; scroll.replaceChildren(renderSheet(state)); + if (opts.combo && state.sel) deepestHead(state.sel.c)?.classList.add("is-head-picked"); scroll.scrollLeft = scrollLeft; scroll.scrollTop = scrollTop; syncToolbar(); @@ -192,18 +199,31 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio `td[data-r="${CSS.escape(r)}"][data-c="${CSS.escape(c)}"]`, ), ]; + /** 구조물 조합 — 그 열을 덮는 머리 칸 가운데 가장 깊은 칸(마지막 분류 = 항목 이름) */ + const deepestHead = (colId: string): HTMLElement | null => { + const at = state.doc.열.findIndex((c) => c.id === colId); + let best: HTMLElement | null = null; + for (const th of scroll.querySelectorAll("thead th[data-col]")) { + const start = state.doc.열.findIndex((c) => c.id === th.dataset.col); + if (at < start || at >= start + th.colSpan) continue; + if (!best || Number(th.dataset.level) > Number(best.dataset.level)) best = th; + } + return best; + }; const select = (r: string, c: string, reveal = false): void => { - for (const cell of scroll.querySelectorAll(".is-selected")) - cell.classList.remove("is-selected"); + for (const cell of scroll.querySelectorAll(".is-selected, .is-head-picked")) + cell.classList.remove("is-selected", "is-head-picked"); state.sel = { r, c }; + const head = opts.combo ? deepestHead(c) : null; + head?.classList.add("is-head-picked"); const cells = cellsAt(r, c); for (const cell of cells) cell.classList.add("is-selected"); - if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" }); + if (reveal) (cells[0] ?? head)?.scrollIntoView({ block: "nearest", inline: "nearest" }); syncToolbar(); opts.onSelect?.(selectionOf(state.doc, r, c)); }; const move = (dr: number, dc: number): void => { - const keys = navRows(state.doc, state.mode); + const keys = navRows(state.doc, state.mode).filter((k) => !opts.combo || k !== KEY_UNIT); const cols = state.doc.열; if (!state.sel) { if (keys.length && cols.length) select(keys[0], cols[0].id, true); @@ -247,6 +267,11 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const sel = state.sel; const col = sel && colOf(sel.c); if (!sel || !col) return; + if (opts.combo && sel.r === KEY_UNIT) { + const th = deepestHead(col.id); // 단위 줄 없음 — 머리(항목 이름) 고치기 + if (th && initial === undefined) editComboHead(th); + return; + } if (isBlankPick(sel.r, col)) return; // 글 열은 빈칸 · 잠김 if (opts.onPick && state.mode === "master" && PICK_ROWS.includes(sel.r)) { if (initial === undefined) void pick(sel.r, col.id); // 글자를 쳐서는 안 엶 @@ -288,6 +313,19 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio th.querySelector("input")?.addEventListener("blur", () => tip.remove()); }; + /** 구조물 조합 머리 이름 칸 — 고친 글을 부른 쪽(나무 이름 바꾸기)에 넘김 · 표 문서는 그대로 */ + const editComboHead = (th: HTMLElement): void => { + const onHeadEdit = opts.combo?.onHeadEdit; + const colId = th.dataset.col; + const level = Number(th.dataset.level); + if (ro || !onHeadEdit || !colId || Number.isNaN(level)) return; + const before = th.textContent ?? ""; + startEdit(th, before, (value) => { + render(); + if (value.trim() && value.trim() !== before) onHeadEdit(colId, level, value.trim()); + }); + }; + // ── 사건 ────────────────────────────────────────────────────────── const onClick = (event: MouseEvent): void => { const target = event.target as HTMLElement; @@ -304,7 +342,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const th = target.closest("th[data-col]"); const layer = target.closest("th[data-layer]"); if (ro) return; - if (th && editCols) editHead(th); + if (th && opts.combo?.onHeadEdit) editComboHead(th); + else if (th && editCols) editHead(th); else if (layer && editCols) editLayer(layer); else if (target.closest("td[data-r]")) edit(); }; diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 90e4834c..f42b007a 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -47,6 +47,10 @@ export interface RenderState { pick?: boolean; /** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */ headLocked?: boolean; + /** 구조물 조합 표(`combo`) — 단위 줄 없음 · 열 폭 = 머리 · 번호 글이 한 줄에 들게(저장 폭 안 씀) · + * `headEditable` = 머리 이름 칸을 고칠 수 있음(나무 이름 바꾸기로). */ + combo?: boolean; + headEditable?: boolean; } /** 도면 · 면적산출 · 재료산출 줄 칸 글. */ @@ -64,6 +68,18 @@ 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 = [col.일위대가 ?? "", ...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; @@ -97,6 +113,8 @@ 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( GUTTER_MAX, @@ -120,7 +138,7 @@ export function renderSheet(state: RenderState): HTMLElement { let left = GUTTER; for (const col of cols) { lefts.push(left); - left += columnWidth(doc, col, mins); + left += columnWidth(widthDoc, col, mins); } const freeze = (cell: HTMLElement, from: number, span = 1): void => { @@ -196,7 +214,7 @@ export function renderSheet(state: RenderState): HTMLElement { const thead = (): HTMLElement => { const rows = head.map((cells, level) => { - const heads = mode === "master" && !state.headLocked; + const heads = mode === "master" && (!state.headLocked || !!state.headEditable); const layer = gutter("th", doc.층?.[level] ?? "", heads ? "is-editable" : ""); layer.dataset.layer = String(level); const tr = el("tr", { children: [layer] }); @@ -227,7 +245,7 @@ export function renderSheet(state: RenderState): HTMLElement { th.append(el("span", { className: "ui-sheet__resize", attrs: { "data-resize": col.id } })); unit.append(th); }); - return el("thead", { children: [...rows, unit] }); + return el("thead", { children: state.combo ? rows : [...rows, unit] }); }; const totalRows = (): HTMLElement[] => @@ -251,7 +269,7 @@ ${formulaLabel(formula, names)}` el("col", { attrs: { style: `width:${GUTTER}px` } }), ...cols.map((col) => el("col", { - attrs: { style: `width:${columnWidth(doc, col, mins)}px`, "data-col": col.id }, + attrs: { style: `width:${columnWidth(widthDoc, col, mins)}px`, "data-col": col.id }, }), ), ],