diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index a7a83fb7..76bc23f3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -354,6 +354,7 @@ export function createMain( const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? []; editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master", + readOnly, onChange: () => (dirty = !readOnly), ...(struct ? { diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 1e628924..febeedee 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -109,3 +109,43 @@ def test_읽기만_회사용_개인용으로_복사_막대(): assert "titleOf(layer)" in page layer = _text(LAYER) assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer + + +SHEET = ROOT / "ui_template" / "sheet" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_열_0_첫_열_만들기(): + """빈 개인 · 회사 층 — 열이 하나도 없어도 첫 열(머리 글 + 빈 아래 층)을 만듦.""" + ops = _text(SHEET / "ui_template_sheet_ops.ts") + fresh = re.search(r"^function freshId[\s\S]*?^}", ops, re.M) + first = re.search(r"^export function addFirstColumn[\s\S]*?^}", ops, re.M) + assert fresh and first, "freshId · addFirstColumn 을 못 찾음" + script = ( + "interface SheetColumn { id: string; 머리: (string | null)[]; 단위?: string | null;" + " 꼴?: string; 일위대가?: string | null; 자재조합?: string | null }\n" + "interface SheetDoc { 열: SheetColumn[] }\n" + + fresh.group(0) + + "\n" + + first.group(0).replace("export ", "") + + "\nconst doc: SheetDoc = { 열: [] };" + + "\nconst a = addFirstColumn(doc, 4, '새 열');" + + "\nconsole.log(JSON.stringify([a, doc.열[0].머리, doc.열[0].꼴, doc.열.length]));" + ) + 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) == ["c1", ["새 열", "", "", ""], "수", 1] + + +def test_읽기만_표는_단추_고치기_모두_막음(): + sheet = _text(SHEET / "ui_template_sheet.ts") + assert "const ro = !!opts.readOnly;" in sheet + assert sheet.count("if (ro") + sheet.count("!ro") + sheet.count("ro ||") >= 7 + # 열 0 = 「열 더하기」 하나만 켜짐 · 첫 열 + assert "b.disabled = !hasCol && !empty" in sheet and "addFirstColumn(" in sheet + assert re.search(r"readOnly,\s*onChange", _text(M02 / "M02_MasterTemplete_UI_Main.ts")) + assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css") diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index 47c2868e..ed75a946 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -181,3 +181,8 @@ font: inherit; text-align: inherit; } + +/* 읽기만 표 — 단추 줄 없음(`display:flex` 가 hidden 을 덮지 않게) */ +.ui-sheet__toolbar[hidden] { + display: none; +} diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index e26ceedd..c91e3704 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -8,6 +8,8 @@ * 도면 · 면적산출 · 재료산출 줄 = `drawingLabel` · `areaLabel` · `materialLabel` 글 · * 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 · 재료산출 칸은 * 치지 않고 조회 후 선택. + * `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 · + * 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기). * project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽. * 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음. * 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). @@ -17,6 +19,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells"; import { + addFirstColumn, addLevel, deleteColumn, isBlankPick, @@ -44,6 +47,8 @@ export type { SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_ export interface SheetOptions { mode: SheetMode; + /** 볼 수만 있음(단추 · 고치기 없음) */ + readOnly?: boolean; onChange?: (doc: SheetDoc) => void; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; @@ -80,12 +85,17 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio materialLabel: opts.materialLabel, pick: !!opts.onPick, }; + const ro = !!opts.readOnly; const scroll = el("div", { className: "ui-sheet__scroll" }); - const toolbar = el("div", { className: "ui-sheet__toolbar" }); + const toolbar = el("div", { className: "ui-sheet__toolbar", attrs: ro ? { hidden: "" } : {} }); const root = el("div", { className: `ui-sheet ui-sheet--${opts.mode}`, attrs: { tabindex: "0" }, - children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll], + children: [ + toolbar, + ...(ro ? [] : [el("p", { className: "ui-sheet__hint", text: st("Hint") })]), + scroll, + ], }); host.replaceChildren(root); @@ -96,6 +106,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio createButton({ label, variant: "ghost", onClick: run }); /** `level` 층 새 칸 열 — 고른 열 무리 끝에(층 이름이 있으면 「새 분류2」 · 없으면 「새 열」) */ const addAt = (level: number): void => { + if (!state.doc.열.length) return void addFirst(); const at = state.sel?.c; if (!at) return; const name = state.doc.층?.[level]?.trim(); @@ -105,6 +116,14 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id }; changed(); }; + /** 열 0 — 첫 열을 만들고 머리 글을 바로 고치게 엶(종류/분류 는 `/` 로 이음) */ + const addFirst = (): void => { + const id = addFirstColumn(state.doc, headDepth(state.doc.열, state.doc.층), st("Col_New")); + state.sel = { r: KEY_UNIT, c: id }; + changed(); + const th = scroll.querySelector(`th[data-col="${CSS.escape(id)}"]`); + if (th) editHead(th); + }; const colDelete = button(st("Col_Delete"), () => { const id = state.sel?.c; if (!id || state.doc.열.length <= 1) return; @@ -114,7 +133,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null; changed(); }); - if (opts.mode === "master") toolbar.append(colDelete); + if (opts.mode === "master" && !ro) toolbar.append(colDelete); // 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 · // 층 이름 · 층 수가 바뀌면 다시 만듦 @@ -122,8 +141,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio let levelKey = ""; const syncLevels = (hasCol: boolean): void => { const depth = headDepth(state.doc.열, state.doc.층); - const levels = depth < 2 ? [0] : Array.from({ length: depth - 1 }, (_, i) => i + 1); + const empty = !state.doc.열.length; // 열 0 = 「열 더하기」 하나만(첫 열) + const levels = empty || depth < 2 ? [0] : Array.from({ length: depth - 1 }, (_, i) => i + 1); const labels = levels.map((level) => { + if (empty) return st("Col_Add"); const name = state.doc.층?.[level]?.trim(); return name ? `${name}${st("Add_Suffix")}` : st("Col_Add"); }); @@ -133,12 +154,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio levelButtons = levels.map((level, i) => button(labels[i], () => addAt(level))); colDelete.before(...levelButtons); } - for (const b of levelButtons) b.disabled = !hasCol; + for (const b of levelButtons) b.disabled = !hasCol && !empty; }; const syncToolbar = (): void => { const col = state.sel ? colOf(state.sel.c) : undefined; - if (opts.mode === "master") syncLevels(!!col); + if (opts.mode === "master" && !ro) syncLevels(!!col); colDelete.disabled = !col || state.doc.열.length <= 1; }; @@ -197,7 +218,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio return raw === null || raw === undefined ? "" : String(raw); }; const write = (key: string, col: SheetColumn, text: string): void => { - if (REF_ROWS.includes(key)) return; // 읽기만 + if (ro || REF_ROWS.includes(key)) return; // 읽기만 const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; else if (key === KEY_PRICE) col.일위대가 = value || null; @@ -213,6 +234,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio }; /** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */ const edit = (initial?: string, atEnd = false): void => { + if (ro) return; const sel = state.sel; const col = sel && colOf(sel.c); if (!sel || !col) return; @@ -272,6 +294,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (target.closest("input")) return; const th = target.closest("th[data-col]"); const layer = target.closest("th[data-layer]"); + if (ro) return; if (th && state.mode === "master") editHead(th); else if (layer && state.mode === "master") editLayer(layer); else if (target.closest("td[data-r]")) edit(); @@ -310,7 +333,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio edit(undefined, true); } else if (event.key === "Delete" || event.key === "Backspace") { const col = state.sel && colOf(state.sel.c); - if (state.sel && col && keyEditable(state, state.sel.r, col)) { + if (!ro && state.sel && col && keyEditable(state, state.sel.r, col)) { event.preventDefault(); write(state.sel.r, col, ""); } @@ -325,7 +348,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const onPointerDown = (event: PointerEvent): void => { const handle = (event.target as HTMLElement).closest("[data-resize]"); const col = handle && colOf(handle.dataset.resize!); - if (!handle || !col) return; + if (ro || !handle || !col) return; event.preventDefault(); handle.setPointerCapture(event.pointerId); const total = parseFloat(scroll.querySelector("table")?.style.width ?? "0"); diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index ca4acd1c..0347aa99 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -133,6 +133,18 @@ export function addLevel( return id; } +/** 열이 하나도 없는 표의 첫 열 — `[label, "" …]`(아래 층은 빈칸) · 새 열 id. */ +export function addFirstColumn(doc: SheetDoc, depth: number, label: string): string { + const id = freshId( + "c", + doc.열.map((c) => c.id), + ); + const head: (string | null)[] = [label]; + while (head.length < depth) head.push(""); + doc.열.push({ id, 머리: head, 단위: null, 꼴: "수", 일위대가: null, 자재조합: null }); + return id; +} + /** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */ export function setLayerName(doc: SheetDoc, level: number, text: string): void { const layers = (doc.층 ??= []);