From 5bdedb5da93b32b19b8c4f19ee4d24f94271685b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 12:07:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(sheet):=20=EC=B8=B5=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=97=B4=20=EB=8D=94=ED=95=98=EA=B8=B0(=EB=B6=84=EB=A5=981=20?= =?UTF-8?q?=C2=B7=20=EB=B6=84=EB=A5=982=20=C2=B7=20=EB=B6=84=EB=A5=983)=20?= =?UTF-8?q?=C2=B7=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=A4=84=20=3D=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=EB=A3=8C=EC=82=B0=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분류2 더하기 안 됨 고침 — 옛 「열 더하기」는 고른 열 맨 아래 글을 「새 열」로 바꿔 넣어, 같은 글이면 머리가 가로로 합쳐져 새 열이 안 보였음 · 같은 무리에 같은 글이면 뒤에 수를 붙임 - 단추 = 층 이름 + 더하기(「분류1 더하기」 · 「분류2 더하기」 · 「분류3 더하기」) · 고른 열과 그 층 위 머리가 같은 이웃 끝에 둠 · 아래 층은 빈칸 - 마스터 줄에서 식 · 들어갈 것 줄 지움 · 산출근거 줄 → 면적산출 · 재료산출 줄(areaLabel · materialLabel · 조회 후 선택 · 글 열은 빈칸 잠김) - 쓰지 않게 된 열 딱지 글 · 줄임 겉모양 지움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- resources/tester/test_sheet_drawing_select.py | 9 +- resources/tester/test_sheet_group_layer.py | 75 ++++++++++------ ui_template/sheet/ui_template_sheet.css | 32 ------- ui_template/sheet/ui_template_sheet.ts | 89 +++++++++--------- ui_template/sheet/ui_template_sheet_ops.ts | 68 ++++++-------- ui_template/sheet/ui_template_sheet_render.ts | 90 +++++++------------ ui_template/sheet/ui_template_sheet_text.ts | 12 +-- ui_template/sheet/ui_template_sheet_types.ts | 2 +- 8 files changed, 166 insertions(+), 211 deletions(-) diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py index 88822d95..957e4915 100644 --- a/resources/tester/test_sheet_drawing_select.py +++ b/resources/tester/test_sheet_drawing_select.py @@ -1,6 +1,7 @@ """표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄. -도면 줄 `s:drawing` · 산출근거 줄 `s:basis` = master 만 · 일위대가 줄 바로 아래 차례(키보드) · project 에는 없음. +마스터 줄 = 일위대가 · 도면 `s:drawing` · 면적산출 `s:area` · 재료산출 `s:material`(식 · 들어갈 것 줄 없음) · +master 만 · 단위 줄 바로 아래 차례(키보드) · project 에는 없음. `selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null). """ @@ -25,7 +26,7 @@ console.log(JSON.stringify({ master: navRows(doc, "master"), project: navRows(doc, "project"), pick: [selectionOf(doc, "s:drawing", "c2"), selectionOf(doc, "d:r1", "c1"), selectionOf(doc, "s:unit", "zz")], - blank: ["s:price", "s:drawing", "s:basis", "s:desc", "d:r1"].map((k) => [isBlankPick(k, { id: "t", 머리: [], 꼴: "글" }), isBlankPick(k, { id: "n", 머리: [], 꼴: "수" })]), + blank: ["s:price", "s:drawing", "s:area", "s:material", "s:unit", "d:r1"].map((k) => [isBlankPick(k, { id: "t", 머리: [], 꼴: "글" }), isBlankPick(k, { id: "n", 머리: [], 꼴: "수" })]), })); """ @@ -52,7 +53,7 @@ def _run() -> dict: def test_drawing_row_under_price_master_only(): got = _run() assert got["master"] == [ - "s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "s:basis", "d:r1", "t:t1", + "s:unit", "s:price", "s:drawing", "s:area", "s:material", "d:r1", "t:t1", ] # fmt: skip assert got["project"] == ["s:unit", "d:r1", "t:t1"] @@ -67,5 +68,5 @@ def test_selection_shape(): def test_text_column_pick_rows_blank(): assert _run()["blank"] == [ - [True, False], [True, False], [True, False], [False, False], [False, False], + [True, False], [True, False], [True, False], [True, False], [False, False], [False, False], ] # fmt: skip diff --git a/resources/tester/test_sheet_group_layer.py b/resources/tester/test_sheet_group_layer.py index 2d45de8e..a3f10eaf 100644 --- a/resources/tester/test_sheet_group_layer.py +++ b/resources/tester/test_sheet_group_layer.py @@ -1,6 +1,7 @@ -"""표 둘째 층 묶음 더하기 · 층 이름 고치기 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄. +"""표 층마다 열 더하기 · 층 이름 고치기 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄. -`addGroup` = 고른 열과 같은 맨 위 묶음(종류) 끝에 `[종류, 새 분류, …, 새 열]` · 다른 종류 열은 안 건드림. +`addLevel` = 고른 열과 그 층 위 머리가 같은 이웃 끝에 `[…위 머리, 새 글, "" …]` · 같은 무리에 같은 글이 +있으면 뒤에 수(같은 머리는 가로로 합쳐져 새 열이 안 보였음 — 「분류2 더하기 안 됨」). `setLayerName` = 층 이름(줄 머리 글) · 모자란 층은 빈 글로 채움. """ @@ -19,20 +20,27 @@ OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts" pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") SCRIPT = """ -const { addGroup, setLayerName } = await import(process.argv[1]); +const { addLevel, setLayerName } = await import(process.argv[1]); const base = JSON.parse(process.argv[2]); const out = {}; +const heads = (doc) => doc.열.map((c) => [c.id, c.머리]); let doc = structuredClone(base); -out.id = addGroup(doc, "a1", 3, "새 분류", "새 열"); -out.after_a1 = doc.열.map((c) => [c.id, c.머리]); +out.id = addLevel(doc, "a1", 1, 4, "새 분류1"); +out.level1 = heads(doc); doc = structuredClone(base); -addGroup(doc, "b1", 4, "G", "L"); -out.deep = doc.열.at(-1).머리; +addLevel(doc, "a1", 2, 4, "새 분류2"); +addLevel(doc, "a1", 2, 4, "새 분류2"); +out.level2 = heads(doc); doc = structuredClone(base); -addGroup(doc, null, 3, "G", "L"); -out.none = doc.열.at(-1).머리; +addLevel(doc, "a2", 3, 4, "새 분류3"); +out.level3 = heads(doc); doc = structuredClone(base); -setLayerName(doc, 1, " 분류1 "); +addLevel(doc, "b1", 2, 4, "L"); +out.merged = doc.열.at(-1).머리; +doc = structuredClone(base); +out.none = addLevel(doc, "zz", 1, 4, "G"); +doc = structuredClone(base); +setLayerName(doc, 1, " 공법 "); out.layers = doc.층; doc = structuredClone(base); delete doc.층; @@ -45,12 +53,12 @@ DOC = { "양식": "시험", "종류": "표", "판": 1, - "층": ["종류", "공법", "규격"], + "층": ["종류", "분류1", "분류2", "분류3"], "열": [ - {"id": "a1", "머리": ["가", "x", "1"], "단위": "m"}, - {"id": "a2", "머리": ["가", "x", "2"]}, - {"id": "a3", "머리": ["가", "y", None]}, - {"id": "b1", "머리": ["나", None, None]}, + {"id": "a1", "머리": ["가", "x", "1", ""], "단위": "m"}, + {"id": "a2", "머리": ["가", "x", "2", ""]}, + {"id": "a3", "머리": ["가", "y", None, None]}, + {"id": "b1", "머리": ["나", None, None, None]}, ], "줄": [], } @@ -66,25 +74,40 @@ def _run() -> dict: return json.loads(result.stdout) -def test_group_goes_to_end_of_same_top(): +def test_level1_goes_to_end_of_same_kind(): got = _run() assert got["id"] == "c1" - assert got["after_a1"] == [ - ["a1", ["가", "x", "1"]], - ["a2", ["가", "x", "2"]], - ["a3", ["가", "y", None]], - ["c1", ["가", "새 분류", "새 열"]], - ["b1", ["나", None, None]], + assert got["level1"] == [ + ["a1", ["가", "x", "1", ""]], + ["a2", ["가", "x", "2", ""]], + ["a3", ["가", "y", None, None]], + ["c1", ["가", "새 분류1", "", ""]], + ["b1", ["나", None, None, None]], ] -def test_group_depth_and_no_selection(): +def test_level2_twice_gets_distinct_labels(): + assert _run()["level2"] == [ + ["a1", ["가", "x", "1", ""]], + ["a2", ["가", "x", "2", ""]], + ["c1", ["가", "x", "새 분류2", ""]], + ["c2", ["가", "x", "새 분류2 2", ""]], + ["a3", ["가", "y", None, None]], + ["b1", ["나", None, None, None]], + ] + + +def test_level3_right_after_same_prefix(): + assert _run()["level3"][2] == ["c1", ["가", "x", "2", "새 분류3"]] + + +def test_merged_prefix_and_no_column(): got = _run() - assert got["deep"] == ["나", "G", None, "L"] - assert got["none"] == ["G", "G", "L"] + assert got["merged"] == ["나", None, "L", ""] + assert got["none"] is None def test_layer_name(): got = _run() - assert got["layers"] == ["종류", "분류1", "규격"] + assert got["layers"] == ["종류", "공법", "분류2", "분류3"] assert got["padded"] == ["", "", "분류2"] diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index 4ad94436..b6c030b1 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -151,38 +151,6 @@ font-weight: 600; } -.ui-sheet__clamp { - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - line-clamp: 3; -} - -.ui-sheet__chip { - display: inline-block; - margin-right: 4px; - padding: 0 4px; - border-radius: 3px; - font-size: 0.7rem; - font-weight: 600; -} - -.ui-sheet__chip.is-bound { - background: color-mix(in srgb, var(--color-success) 18%, var(--color-canvas)); - color: var(--color-success); -} - -.ui-sheet__chip.is-calc { - background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); - color: var(--color-warning); -} - -.ui-sheet__chip.is-hand { - background: var(--color-mist-violet); - color: var(--color-accent); -} - .ui-sheet__headhint { position: fixed; z-index: 10; diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 9b692e7d..0dace05e 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -1,12 +1,13 @@ /* ============================================================================= * ui_template_sheet.ts - * 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, basisLabel, onSelect, onPick})` + * 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, areaLabel, materialLabel, onSelect, onPick})` * → `{getDoc, setDoc, recalc, destroy}`. * - * master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · - * 둘째 층 묶음 더하기 · 손 열. - * 도면 · 산출근거 줄 = `drawingLabel` · `basisLabel` 글 · 칸을 고르면 `onSelect` · - * `onPick` 을 주면 일위대가 · 도면 · 산출근거 칸은 치지 않고 조회 후 선택. + * master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 일위대가 · 줄 더하기·지우기 · + * 층마다 열 더하기(「분류1 더하기」 … 층 이름 + 더하기) · 열 지우기 · 손 열. + * 도면 · 면적산출 · 재료산출 줄 = `drawingLabel` · `areaLabel` · `materialLabel` 글 · + * 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 · 재료산출 칸은 + * 치지 않고 조회 후 선택. * project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽. * 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음. * 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). @@ -16,19 +17,17 @@ import { createButton, el } from "@ui/ui_template_elements"; import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells"; import { - addColumn, - addGroup, + addLevel, addRow, canDeleteRow, deleteColumn, deleteRow, isBlankPick, - KEY_BASIS, - KEY_DRAWING, KEY_PRICE, KEY_UNIT, navRows, PICK_ROWS, + REF_ROWS, selectionOf, type SheetMode, setCell, @@ -51,13 +50,14 @@ export interface SheetOptions { onChange?: (doc: SheetDoc) => void; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; - /** 산출근거 줄 칸 글(표번) — `drawingLabel` 과 같음. */ - basisLabel?: (colId: string) => string; + /** 면적산출 · 재료산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */ + areaLabel?: (colId: string) => string; + materialLabel?: (colId: string) => string; /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; - /** 조회 후 선택 — master 일위대가 · 도면 · 산출근거 칸을 두 번 누르기 · Enter · F2 로 부름. - * 글을 돌려주면 일위대가 칸에 넣음 · 도면 · 산출근거는 부른 쪽이 가져온 뒤 `recalc()`. - * 없으면 일위대가는 글로 고침 · 도면 · 산출근거는 읽기만. */ + /** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 · 재료산출 칸을 두 번 누르기 · Enter · F2 로 부름. + * 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`. + * 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */ onPick?: (sel: SheetSelection) => Promise | void; } @@ -77,7 +77,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio result: { 계산: {}, 합계: {}, 오류: [] }, sel: null, drawingLabel: opts.drawingLabel, - basisLabel: opts.basisLabel, + areaLabel: opts.areaLabel, + materialLabel: opts.materialLabel, pick: !!opts.onPick, }; const scroll = el("div", { className: "ui-sheet__scroll" }); @@ -110,17 +111,17 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.sel = next && state.sel ? { r: next, c: state.sel.c } : null; changed(); }); - const colAdd = button(st("Col_Add"), () => { - const id = addColumn(state.doc, state.sel?.c ?? null, st("Col_New")); + /** `level` 층 새 칸 열 — 고른 열 무리 끝에(층 이름이 있으면 「새 분류2」 · 없으면 「새 열」) */ + const addAt = (level: number): void => { + const at = state.sel?.c; + if (!at) return; + const name = state.doc.층?.[level]?.trim(); + const label = name ? `${st("New_Prefix")}${name}` : st("Col_New"); + const id = addLevel(state.doc, at, level, headDepth(state.doc.열, state.doc.층), label); + if (!id) return; state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id }; changed(); - }); - const groupAdd = button(st("Group_Add"), () => { - const depth = headDepth(state.doc.열, state.doc.층); - const id = addGroup(state.doc, state.sel?.c ?? null, depth, st("Group_New"), st("Col_New")); - state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id }; - changed(); - }); + }; const colDelete = button(st("Col_Delete"), () => { const id = state.sel?.c; if (!id || state.doc.열.length <= 1) return; @@ -138,20 +139,34 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio changed(); }); toolbar.append(rowAdd, rowDelete); - if (opts.mode === "master") toolbar.append(colAdd, groupAdd, colDelete, colHand); + if (opts.mode === "master") toolbar.append(colDelete, colHand); + + // 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 · + // 층 이름 · 층 수가 바뀌면 다시 만듦 + let levelButtons: HTMLButtonElement[] = []; + 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 labels = levels.map((level) => { + const name = state.doc.층?.[level]?.trim(); + return name ? `${name}${st("Add_Suffix")}` : st("Col_Add"); + }); + if (labels.join("|") !== levelKey) { + levelKey = labels.join("|"); + for (const b of levelButtons) b.remove(); + levelButtons = levels.map((level, i) => button(labels[i], () => addAt(level))); + colDelete.before(...levelButtons); + } + for (const b of levelButtons) b.disabled = !hasCol; + }; const syncToolbar = (): void => { const rowId = rowIdOf(state.sel?.r); const row = state.doc.줄.find((r) => r.id === rowId); rowDelete.disabled = !row || !canDeleteRow(state.mode, row); const col = state.sel ? colOf(state.sel.c) : undefined; - // 둘째 층 묶음 — 층이 셋 이상일 때만 · 이름은 둘째 층 이름(「분류1 더하기」) - const group = state.doc.층?.[1]?.trim(); - groupAdd.hidden = headDepth(state.doc.열, state.doc.층) < 3; - groupAdd.disabled = !col; - groupAdd.querySelector(".ui-btn__label")!.textContent = group - ? `${group}${st("Add_Suffix")}` - : st("Group_Add"); + if (opts.mode === "master") syncLevels(!!col); colDelete.disabled = !col || state.doc.열.length <= 1; colHand.disabled = !col; colHand.classList.toggle("is-on", !!col?.손); @@ -203,10 +218,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio // ── 칸 고치기 ───────────────────────────────────────────────────── const rawText = (key: string, col: SheetColumn): string => { if (key === KEY_UNIT) return col.단위 ?? ""; - if (key === "s:formula") return col.식 ?? ""; - if (key === "s:desc") return col.설명 ?? ""; if (key === KEY_PRICE) return col.일위대가 ?? ""; - if (key === KEY_DRAWING || key === KEY_BASIS) return refText(state, key, col.id); + if (REF_ROWS.includes(key)) return refText(state, key, col.id); const row = state.doc.줄.find((r) => `d:${r.id}` === key); const formula = row?.식?.[col.id] ?? col.식; if (formula) return `=${formula}`; @@ -214,13 +227,9 @@ 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 (key === KEY_DRAWING || key === KEY_BASIS) return; // 읽기만 + if (REF_ROWS.includes(key)) return; // 읽기만 const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; - else if (key === "s:formula") { - if (value) col.식 = value; - else delete col.식; - } else if (key === "s:desc") col.설명 = value; else if (key === KEY_PRICE) col.일위대가 = value || null; else setCell(state.doc, key.slice(2), col, text); changed(); diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index 488b39bb..dbc5f1fc 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -21,14 +21,17 @@ const PAGE_ROWS = 50; export const KEY_UNIT = "s:unit"; export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; -export const KEY_BASIS = "s:basis"; -/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 산출근거 줄은 손으로 못 침. */ -export const MASTER_ROWS = ["s:formula", "s:desc", KEY_PRICE, KEY_DRAWING, KEY_BASIS] as const; +export const KEY_AREA = "s:area"; +export const KEY_MATERIAL = "s:material"; +/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 · 재료산출 줄은 손으로 못 침(번호 글). */ +export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_MATERIAL] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; +/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */ +export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA, KEY_MATERIAL]; /** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ -export const PICK_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_BASIS]; +export const PICK_ROWS: readonly string[] = MASTER_ROWS; -/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 산출근거 칸 — 빈칸 · 잠김. */ +/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 면적산출 · 재료산출 칸 — 빈칸 · 잠김. */ export const isBlankPick = (key: string, col: SheetColumn): boolean => PICK_ROWS.includes(key) && col.꼴 === "글"; @@ -113,47 +116,32 @@ export function deleteRow(doc: SheetDoc, id: string): void { doc.줄 = doc.줄.filter((r) => r.id !== id); } -/** `after` 열 오른쪽에 같은 묶음의 새 열 — 윗머리는 그대로 · 맨 아래 글만 「새 열」. */ -export function addColumn(doc: SheetDoc, after: string | null, label: string): string { - const id = freshId( - "c", - doc.열.map((c) => c.id), - ); - const at = doc.열.findIndex((c) => c.id === after); - const base = at < 0 ? null : doc.열[at]; - const head = base ? [...base.머리] : [label]; - if (base) { - let last = head.length - 1; - while (last > 0 && head[last] === null) last -= 1; - head[last] = label; - } - const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" }; - doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col); - return id; -} - -/** `after` 열과 같은 맨 위 묶음(종류) 끝에 둘째 층 새 묶음 열 — `[위, group, …null, leaf]`(층 `depth` 가 - * 3 이상일 때 · 가운데 층은 위 칸과 합침) · 새 열 id. */ -export function addGroup( +/** `level` 층 새 칸 열 — `after` 열과 그 층 위 머리(`머리[0..level-1]`)가 같은 이웃 끝에 둠 · + * `[…위 머리, label, "" …]`(아래 층은 빈칸) · 같은 무리에 같은 글이 있으면 뒤에 수를 붙임 + * (같은 머리는 가로로 합쳐져 새 열이 안 보임) · 새 열 id(`after` 가 없으면 null). */ +export function addLevel( doc: SheetDoc, - after: string | null, + after: string, + level: number, depth: number, - group: string, - leaf: string, -): string { + label: string, +): string | null { + let at = doc.열.findIndex((c) => c.id === after); + if (at < 0) return null; + const base = doc.열[at]; + const top = Array.from({ length: level }, (_, i) => base.머리[i] ?? null); + const mates = (col: SheetColumn): boolean => top.every((h, i) => (col.머리[i] ?? null) === h); + while (at + 1 < doc.열.length && mates(doc.열[at + 1])) at += 1; + const used = new Set(doc.열.filter(mates).map((c) => c.머리[level])); + let text = label; + for (let n = 2; used.has(text); n += 1) text = `${label} ${n}`; + const head: (string | null)[] = [...top, text]; + while (head.length < depth) head.push(""); const id = freshId( "c", doc.열.map((c) => c.id), ); - let at = doc.열.findIndex((c) => c.id === after); - const base = at < 0 ? null : doc.열[at]; - const top = base?.머리[0] ?? group; - if (base) while (at + 1 < doc.열.length && doc.열[at + 1].머리[0] === top) at += 1; - const head: (string | null)[] = [top, group]; - while (head.length < depth - 1) head.push(null); - head.push(leaf); - const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" }; - doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col); + doc.열.splice(at + 1, 0, { id, 머리: head, 단위: base.단위 ?? null, 꼴: "수" }); return id; } diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 1b34465c..0ae57807 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -3,8 +3,8 @@ * 표 그리기 — 문서 + 풀이 결과 → 쪽마다 ``(머리 층 · 단위 줄 · 본문 · 합계 줄). * * 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음. - * 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing|basis`. - * master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 · 산출근거 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). + * 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|price|drawing|area|material`. + * master = 본문 위에 열마다 일위대가 · 도면 · 면적산출 · 재료산출 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). * 머리 층 줄 머리 칸은 `data-layer`(층 차례) — master 에서 층 이름을 고침. * ========================================================================== */ @@ -16,13 +16,14 @@ import { isBoundColumn, isBlankPick, isCalcColumn, - KEY_BASIS, - KEY_DRAWING, + KEY_AREA, + KEY_MATERIAL, KEY_UNIT, MASTER_ROWS, type MasterRowKey, PICK_ROWS, pagePlan, + REF_ROWS, type SheetMode, } from "./ui_template_sheet_ops"; import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc"; @@ -38,17 +39,21 @@ export interface RenderState { mode: SheetMode; result: SheetResult; sel: { r: string; c: string } | null; - /** 도면 · 산출근거 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + /** 도면 · 면적산출 · 재료산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ drawingLabel?: (colId: string) => string; - basisLabel?: (colId: string) => string; + areaLabel?: (colId: string) => string; + materialLabel?: (colId: string) => string; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ pick?: boolean; } -/** 도면 · 산출근거 줄 칸 글. */ +/** 도면 · 면적산출 · 재료산출 줄 칸 글. */ export const refText = (state: RenderState, key: string, colId: string): string => - (key === KEY_BASIS ? state.basisLabel : state.drawingLabel)?.(colId)?.trim() || - st("Drawing_None"); + (key === KEY_AREA + ? state.areaLabel + : key === KEY_MATERIAL + ? state.materialLabel + : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => @@ -63,9 +68,9 @@ function headFont(): string { return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`; } -/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 · 산출근거 줄은 손으로 못 침. */ +/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 번호 글 줄은 손으로 못 침. */ export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean { - if (key === KEY_DRAWING || key === KEY_BASIS || isBlankPick(key, col)) return false; + if (REF_ROWS.includes(key) || isBlankPick(key, col)) return false; if (key.startsWith("s:")) return state.mode === "master"; if (key.startsWith("t:")) return false; const row = state.doc.줄.find((r) => `d:${r.id}` === key); @@ -75,15 +80,11 @@ export function keyEditable(state: RenderState, key: string, col: SheetColumn): const shown = (text: string | undefined): string => !text || text === "0" ? "" : groupDigits(text); -/** 열 딱지 — 식이 먼저(계산 열에 바인딩이 붙어 있어도 계산) · 바인딩 = 설계값(펼침이면 값마다 열) · 손 = 손 입력. */ -function kindOf(col: SheetColumn): { cls: string; label: string } | null { - if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") }; - if (isBoundColumn(col)) { - const label = st("Kind_Bound"); - return { cls: "is-bound", label: col.펼침 ? `${label} · ${st("Kind_Spread")}` : label }; - } - if (col.손) return { cls: "is-hand", label: st("Kind_Hand") }; - return null; +/** 열 겉모양 — 식이 먼저(계산 열에 바인딩이 붙어 있어도 계산) · 바인딩 = 설계값 · 손 = 손 입력. */ +function kindClass(col: SheetColumn): string | null { + if (isCalcColumn(col)) return "is-calc"; + if (isBoundColumn(col)) return "is-bound"; + return col.손 ? "is-hand" : null; } export function renderSheet(state: RenderState): HTMLElement { @@ -94,7 +95,7 @@ export function renderSheet(state: RenderState): HTMLElement { const names = columnNames(cols); const font = headFont(); const mins = headMinWidths(cols, depth, font); - // 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 도면 · 산출근거 · 합계 이름이 안 잘리게 + // 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 재료산출 · 합계 이름이 안 잘리게 const GUTTER = Math.min( GUTTER_MAX, Math.max( @@ -102,11 +103,10 @@ export function renderSheet(state: RenderState): HTMLElement { ...[ ...(doc.층 ?? []), st("Row_Unit"), - st("Row_Formula"), - st("Row_Desc"), st("Row_Unit_Price"), st("Row_Drawing"), - st("Row_Basis"), + st("Row_Area"), + st("Row_Material"), ...(doc.합계줄 ?? []).map((t) => t.이름), ].map((label) => Math.ceil(textWidth(label, font)) + 16), ), @@ -131,8 +131,8 @@ export function renderSheet(state: RenderState): HTMLElement { const bodyCell = (key: string, col: SheetColumn, j: number, text: string): HTMLElement => { const td = el("td", { text, attrs: { "data-r": key, "data-c": col.id } }); - const kind = kindOf(col); - if (kind) td.classList.add(kind.cls); + const kind = kindClass(col); + if (kind) td.classList.add(kind); if (col.꼴 !== "글") td.classList.add("is-num"); td.classList.add(keyEditable(state, key, col) ? "is-editable" : "is-locked"); const why = errors.get(`${key}|${col.id}`); @@ -170,11 +170,10 @@ export function renderSheet(state: RenderState): HTMLElement { const masterRow = (key: MasterRowKey): HTMLElement => { const label = ( { - "s:formula": "Row_Formula", - "s:desc": "Row_Desc", "s:price": "Row_Unit_Price", "s:drawing": "Row_Drawing", - "s:basis": "Row_Basis", + "s:area": "Row_Area", + "s:material": "Row_Material", } as const )[key]; const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); @@ -182,39 +181,12 @@ export function renderSheet(state: RenderState): HTMLElement { const blank = isBlankPick(key, col); const text = blank ? "" - : key === "s:formula" - ? formulaLabel(col.식 ?? "", names, col.id) - : key === "s:desc" - ? "" - : key === KEY_DRAWING || key === KEY_BASIS - ? refText(state, key, col.id) - : (col.일위대가 ?? ""); + : REF_ROWS.includes(key) + ? refText(state, key, col.id) + : (col.일위대가 ?? ""); const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick"); - if (key === "s:formula" && col.식) { - // 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식 - td.textContent = ""; - td.append(el("div", { className: "ui-sheet__clamp", text })); - td.title = `${formulaLabel(col.식, names)}\n${col.식}`; - } - const kind = kindOf(col); - if (key === "s:desc") { - // 두세 줄로 자름 — 전문은 마우스를 올리면(title) - const desc = col.설명 ?? ""; - td.title = desc; - td.append( - el("div", { - className: "ui-sheet__clamp", - children: [ - ...(kind - ? [el("span", { className: `ui-sheet__chip ${kind.cls}`, text: kind.label })] - : []), - desc, - ], - }), - ); - } tr.append(td); }); return tr; diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index 8eeb35b1..a1e7133f 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -13,19 +13,13 @@ const TEXT = { Col_Hand: ["손 입력 열", "Manual column"], Col_New: ["새 열", "New column"], Row_Unit: ["단위", "Unit"], - Row_Formula: ["식", "Formula"], - Row_Desc: ["들어갈 것", "Source"], Row_Unit_Price: ["일위대가", "Unit price"], Row_Drawing: ["도면", "Drawing"], - Row_Basis: ["산출근거", "Basis"], - Group_Add: ["묶음 더하기", "Add group"], - Group_New: ["새 분류", "New group"], + Row_Area: ["면적산출", "Area"], + Row_Material: ["재료산출", "Material"], Add_Suffix: [" 더하기", " +"], + New_Prefix: ["새 ", "New "], Drawing_None: ["없음", "None"], - Kind_Bound: ["설계값", "Design"], - Kind_Calc: ["계산", "Calc"], - Kind_Hand: ["손 입력", "Manual"], - Kind_Spread: ["값마다 열", "Per value"], Page: ["쪽", "page"], Head_Hint: [ "/ 로 이으면 층으로 나뉨 (예 관공/Φ800/관매설) · 적게 쓰면 남은 층은 위 칸과 합침", diff --git a/ui_template/sheet/ui_template_sheet_types.ts b/ui_template/sheet/ui_template_sheet_types.ts index e861e971..26673614 100644 --- a/ui_template/sheet/ui_template_sheet_types.ts +++ b/ui_template/sheet/ui_template_sheet_types.ts @@ -78,7 +78,7 @@ export interface SheetDoc { 보기?: SheetView; } -/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|formula|desc|price|drawing`) · +/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|price|drawing|area|material`) · * `col` = 열 차례(0부터 · 없으면 -1) · `colId` = 열 id(없으면 null). */ export interface SheetSelection { row: string;