Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6

This commit is contained in:
2026-09-30 20:25:12 +09:00
9 changed files with 211 additions and 14 deletions
@@ -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<T>(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;
@@ -110,6 +110,8 @@ export interface TreePanelHandle {
select: (colId: string | null) => void;
/** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */
newItem: () => void;
/** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */
editFromTable: (body: TreeEdit, colId: string | null) => Promise<void>;
}
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<void> => {
if (opt.canEdit && (await ready())) await edit(body, colId);
};
return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable };
}
@@ -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);
@@ -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<Mounte
readOnlyBar?.setColumn(column);
tree.select(column);
},
// 표 머리 이름 칸 고침 = 나무 이름 바꾸기(31-1) · 쓸 수 있는 층만
...(canEdit(layer, isAdmin)
? {
onHeadRename: (col: StructureColumn, level: number, name: string) =>
void tree.editFromTable(headRenameEdit(col, level, name), col.id),
}
: {}),
},
);
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
+3 -1
View File
@@ -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
+87
View File
@@ -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
+10
View File
@@ -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);
}
+46 -7
View File
@@ -65,6 +65,10 @@ export interface SheetOptions {
* 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`.
* 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | 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<HTMLTableCellElement>("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<HTMLElement>("th[data-col]");
const layer = target.closest<HTMLElement>("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();
};
+22 -4
View File
@@ -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<string, number>)
mins.get(col.id) ?? 0,
);
/** 구조물 조합 — 일위대가 · 도면 · 면적산출 · 재료산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘
* (`.ui-sheet__meta td` 글꼴과 같게 잼). */
function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map<string, number>): 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 },
}),
),
],