From b2e2fcff91707937e59f5f1583ec9864dacc041d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 09:45:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(spreadsheet):=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20=EC=84=9C=EC=8B=9D=20?= =?UTF-8?q?=E2=80=94=20=EB=AC=B6=EC=9D=8C=20=EC=84=A0=ED=83=9D=EC=9E=90=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=C2=B7=20=EC=84=A0=20=EA=B5=B5?= =?UTF-8?q?=EA=B8=B0=EB=B3=84=20=EC=A2=85=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ` 안 `.xl65 {...}` 짜임(엑셀이 인라인 대신 클래스로 서식을 실을 때) → 클래스별 선언. */ +/** `` 안 `.xl65 {...}` · `.xl65, .xl70 {...}`(묶음 선택자) 짜임(엑셀이 인라인 대신 + * 클래스로 서식을 실을 때) → 클래스별 선언(같은 클래스가 여러 규칙에 나오면 뒤엣것이 이김). */ function parseClassRules(doc: Document): Record> { const css = Array.from(doc.querySelectorAll("style")) .map((s) => s.textContent ?? "") .join("\n"); const rules: Record> = {}; - for (const m of css.matchAll(/\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g)) { - rules[m[1]] = parseStyleAttr(m[2]); + for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) { + const decls = parseStyleAttr(m[2]); + for (const selectorPart of m[1].split(",")) { + const cls = /\.([a-zA-Z0-9_-]+)\s*$/.exec(selectorPart.trim()); + if (cls) Object.assign((rules[cls[1]] ??= {}), decls); + } } return rules; } diff --git a/resources/tester/spreadsheet/fixture_excel_style_paste.html b/resources/tester/spreadsheet/fixture_excel_style_paste.html new file mode 100644 index 00000000..139c4167 --- /dev/null +++ b/resources/tester/spreadsheet/fixture_excel_style_paste.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
품목수량금액
터파기1201200000
메우기80800000
합계2002000000
+ + + From 119a390bd7a8afd6cf39e853cf3f3dbf567ed3e5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 09:47:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(sheet):=20=EC=82=B0=EC=B6=9C=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EC=A4=84=20=C2=B7=20=EC=A1=B0=ED=9A=8C=20=ED=9B=84?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D(onPick)=20=C2=B7=20=EB=91=98=EC=A7=B8=20?= =?UTF-8?q?=EC=B8=B5=20=EB=AC=B6=EC=9D=8C=20=EB=8D=94=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=B8=B5=20=EC=9D=B4=EB=A6=84=20=EA=B3=A0=EC=B9=98?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마스터 첫 쪽 도면 줄 아래 산출근거 줄 s:basis — 칸 글 basisLabel(colId) · 없으면 「없음」 · 손으로 못 침 - onPick 을 주면 일위대가 · 도면 · 산출근거 칸은 두 번 누르기 · Enter · F2 로 조회 후 선택(글을 돌려주면 일위대가 칸에 넣음) - 「<둘째 층 이름> 더하기」 단추 — 같은 종류 묶음 끝에 [종류, 새 분류, 새 열] 열(층 셋 이상일 때) - 머리 층 줄 머리 칸 두 번 누르기 = 층 이름 고치기(master) - 시험 test_sheet_group_layer.py · 줄 차례 시험에 산출근거 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- resources/tester/test_sheet_drawing_select.py | 4 +- resources/tester/test_sheet_group_layer.py | 90 +++++++++++++++++++ ui_template/sheet/ui_template_sheet.css | 7 ++ ui_template/sheet/ui_template_sheet.ts | 77 ++++++++++++---- ui_template/sheet/ui_template_sheet_ops.ts | 40 ++++++++- ui_template/sheet/ui_template_sheet_render.ts | 36 +++++--- ui_template/sheet/ui_template_sheet_text.ts | 4 + 7 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 resources/tester/test_sheet_group_layer.py diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py index 240152f6..4d43320e 100644 --- a/resources/tester/test_sheet_drawing_select.py +++ b/resources/tester/test_sheet_drawing_select.py @@ -1,6 +1,6 @@ """표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄. -도면 줄 `s:drawing` = master 만 · 일위대가 줄 바로 아래(키보드 차례) · project 에는 없음. +도면 줄 `s:drawing` · 산출근거 줄 `s:basis` = master 만 · 일위대가 줄 바로 아래 차례(키보드) · project 에는 없음. `selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null). """ @@ -51,7 +51,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", "d:r1", "t:t1", + "s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "s:basis", "d:r1", "t:t1", ] # fmt: skip assert got["project"] == ["s:unit", "d:r1", "t:t1"] diff --git a/resources/tester/test_sheet_group_layer.py b/resources/tester/test_sheet_group_layer.py new file mode 100644 index 00000000..2d45de8e --- /dev/null +++ b/resources/tester/test_sheet_group_layer.py @@ -0,0 +1,90 @@ +"""표 둘째 층 묶음 더하기 · 층 이름 고치기 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄. + +`addGroup` = 고른 열과 같은 맨 위 묶음(종류) 끝에 `[종류, 새 분류, …, 새 열]` · 다른 종류 열은 안 건드림. +`setLayerName` = 층 이름(줄 머리 글) · 모자란 층은 빈 글로 채움. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +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 base = JSON.parse(process.argv[2]); +const out = {}; +let doc = structuredClone(base); +out.id = addGroup(doc, "a1", 3, "새 분류", "새 열"); +out.after_a1 = doc.열.map((c) => [c.id, c.머리]); +doc = structuredClone(base); +addGroup(doc, "b1", 4, "G", "L"); +out.deep = doc.열.at(-1).머리; +doc = structuredClone(base); +addGroup(doc, null, 3, "G", "L"); +out.none = doc.열.at(-1).머리; +doc = structuredClone(base); +setLayerName(doc, 1, " 분류1 "); +out.layers = doc.층; +doc = structuredClone(base); +delete doc.층; +setLayerName(doc, 2, "분류2"); +out.padded = doc.층; +console.log(JSON.stringify(out)); +""" + +DOC = { + "양식": "시험", + "종류": "표", + "판": 1, + "층": ["종류", "공법", "규격"], + "열": [ + {"id": "a1", "머리": ["가", "x", "1"], "단위": "m"}, + {"id": "a2", "머리": ["가", "x", "2"]}, + {"id": "a3", "머리": ["가", "y", None]}, + {"id": "b1", "머리": ["나", None, None]}, + ], + "줄": [], +} + + +def _run() -> dict: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", + SCRIPT, OPS.as_uri(), json.dumps(DOC, ensure_ascii=False)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_group_goes_to_end_of_same_top(): + got = _run() + assert got["id"] == "c1" + assert got["after_a1"] == [ + ["a1", ["가", "x", "1"]], + ["a2", ["가", "x", "2"]], + ["a3", ["가", "y", None]], + ["c1", ["가", "새 분류", "새 열"]], + ["b1", ["나", None, None]], + ] + + +def test_group_depth_and_no_selection(): + got = _run() + assert got["deep"] == ["나", "G", None, "L"] + assert got["none"] == ["G", "G", "L"] + + +def test_layer_name(): + got = _run() + assert got["layers"] == ["종류", "분류1", "규격"] + assert got["padded"] == ["", "", "분류2"] diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index e9a1f569..4ad94436 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -123,6 +123,13 @@ cursor: cell; } +/* 조회 후 선택 칸 — 두 번 누르거나 Enter 로 고름 */ +.ui-sheet__table td.is-pick { + cursor: pointer; + text-decoration: underline dotted; + text-underline-offset: 3px; +} + .ui-sheet__table td.is-selected { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index fba9df26..df3fa706 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -1,9 +1,12 @@ /* ============================================================================= * ui_template_sheet.ts - * 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, onSelect})` → `{getDoc, setDoc, recalc, destroy}`. + * 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, basisLabel, onSelect, onPick})` + * → `{getDoc, setDoc, recalc, destroy}`. * - * master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열. - * 도면 줄(읽기만) = `drawingLabel` 글 · 칸을 고르면 `onSelect`. + * master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · + * 둘째 층 묶음 더하기 · 손 열. + * 도면 · 산출근거 줄 = `drawingLabel` · `basisLabel` 글 · 칸을 고르면 `onSelect` · + * `onPick` 을 주면 일위대가 · 도면 · 산출근거 칸은 치지 않고 조회 후 선택. * project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽. * 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음. * 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). @@ -14,27 +17,27 @@ import { createButton, el } from "@ui/ui_template_elements"; import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells"; import { addColumn, + addGroup, addRow, canDeleteRow, deleteColumn, deleteRow, + KEY_BASIS, KEY_DRAWING, + KEY_PRICE, KEY_UNIT, navRows, + PICK_ROWS, selectionOf, type SheetMode, setCell, setColumnWidth, setHeadLabel, + setLayerName, } from "./ui_template_sheet_ops"; import { headDepth } from "./ui_template_sheet_header"; import { recalcSheet } from "./ui_template_sheet_recalc"; -import { - drawingText, - keyEditable, - type RenderState, - renderSheet, -} from "./ui_template_sheet_render"; +import { keyEditable, refText, type RenderState, renderSheet } from "./ui_template_sheet_render"; import { st } from "./ui_template_sheet_text"; import type { SheetColumn, SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types"; import "./ui_template_sheet.css"; @@ -47,8 +50,14 @@ export interface SheetOptions { onChange?: (doc: SheetDoc) => void; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; + /** 산출근거 줄 칸 글(표번) — `drawingLabel` 과 같음. */ + basisLabel?: (colId: string) => string; /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; + /** 조회 후 선택 — master 일위대가 · 도면 · 산출근거 칸을 두 번 누르기 · Enter · F2 로 부름. + * 글을 돌려주면 일위대가 칸에 넣음 · 도면 · 산출근거는 부른 쪽이 가져온 뒤 `recalc()`. + * 없으면 일위대가는 글로 고침 · 도면 · 산출근거는 읽기만. */ + onPick?: (sel: SheetSelection) => Promise | void; } export interface SheetHandle { @@ -67,6 +76,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio result: { 계산: {}, 합계: {}, 오류: [] }, sel: null, drawingLabel: opts.drawingLabel, + basisLabel: opts.basisLabel, + pick: !!opts.onPick, }; const scroll = el("div", { className: "ui-sheet__scroll" }); const toolbar = el("div", { className: "ui-sheet__toolbar" }); @@ -103,6 +114,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio 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; @@ -120,13 +137,20 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio changed(); }); toolbar.append(rowAdd, rowDelete); - if (opts.mode === "master") toolbar.append(colAdd, colDelete, colHand); + if (opts.mode === "master") toolbar.append(colAdd, groupAdd, colDelete, colHand); 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"); colDelete.disabled = !col || state.doc.열.length <= 1; colHand.disabled = !col; colHand.classList.toggle("is-on", !!col?.손); @@ -180,8 +204,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (key === KEY_UNIT) return col.단위 ?? ""; if (key === "s:formula") return col.식 ?? ""; if (key === "s:desc") return col.설명 ?? ""; - if (key === "s:price") return col.일위대가 ?? ""; - if (key === KEY_DRAWING) return drawingText(state, col.id); + if (key === KEY_PRICE) return col.일위대가 ?? ""; + if (key === KEY_DRAWING || key === KEY_BASIS) 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}`; @@ -189,27 +213,48 @@ 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) return; // 읽기만 + if (key === KEY_DRAWING || key === KEY_BASIS) 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 === "s:price") col.일위대가 = value || null; + else if (key === KEY_PRICE) col.일위대가 = value || null; else setCell(state.doc, key.slice(2), col, text); changed(); }; + /** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */ + const pick = async (key: string, colId: string): Promise => { + const got = await opts.onPick?.(selectionOf(state.doc, key, colId)); + const col = colOf(colId); + if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got); + root.focus({ preventScroll: true }); + }; const edit = (initial?: string): void => { const sel = state.sel; const col = sel && colOf(sel.c); - if (!sel || !col || !keyEditable(state, sel.r, col)) return; + if (!sel || !col) return; + if (opts.onPick && state.mode === "master" && PICK_ROWS.includes(sel.r)) { + if (initial === undefined) void pick(sel.r, col.id); // 글자를 쳐서는 안 엶 + return; + } + if (!keyEditable(state, sel.r, col)) return; const td = cellsAt(sel.r, sel.c)[0]; if (!td) return; startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value)); const box = td.querySelector("input"); if (box && initial !== undefined) box.value = initial; }; + /** 층 이름(머리 층 줄 머리 칸) 고치기 — master */ + const editLayer = (th: HTMLElement): void => { + const level = Number(th.dataset.layer); + if (Number.isNaN(level)) return; + startEdit(th, th.textContent ?? "", (value) => { + setLayerName(state.doc, level, value); + changed(); + }); + }; const editHead = (th: HTMLElement): void => { const start = state.doc.열.findIndex((c) => c.id === th.dataset.col); const level = Number(th.dataset.level); @@ -242,7 +287,9 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const target = event.target as HTMLElement; if (target.closest("input")) return; const th = target.closest("th[data-col]"); + const layer = target.closest("th[data-layer]"); if (th && state.mode === "master") editHead(th); + else if (layer && state.mode === "master") editLayer(layer); else if (target.closest("td[data-r]")) edit(); }; const onKey = (event: KeyboardEvent): void => { diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index 334cad9d..02996e43 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -19,10 +19,14 @@ export const ROW_FIXED_ALL = "전구간"; const PAGE_ROWS = 50; export const KEY_UNIT = "s:unit"; +export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; -/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 줄은 읽기만. */ -export const MASTER_ROWS = ["s:formula", "s:desc", "s:price", KEY_DRAWING] as const; +export const KEY_BASIS = "s:basis"; +/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 산출근거 줄은 손으로 못 침. */ +export const MASTER_ROWS = ["s:formula", "s:desc", KEY_PRICE, KEY_DRAWING, KEY_BASIS] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; +/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ +export const PICK_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_BASIS]; /** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */ export function navRows(doc: SheetDoc, mode: SheetMode): string[] { @@ -124,6 +128,38 @@ export function addColumn(doc: SheetDoc, after: string | null, label: string): s return id; } +/** `after` 열과 같은 맨 위 묶음(종류) 끝에 둘째 층 새 묶음 열 — `[위, group, …null, leaf]`(층 `depth` 가 + * 3 이상일 때 · 가운데 층은 위 칸과 합침) · 새 열 id. */ +export function addGroup( + doc: SheetDoc, + after: string | null, + depth: number, + group: string, + leaf: string, +): string { + 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); + return id; +} + +/** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */ +export function setLayerName(doc: SheetDoc, level: number, text: string): void { + const layers = (doc.층 ??= []); + while (layers.length <= level) layers.push(""); + layers[level] = text.trim(); +} + /** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */ export function deleteColumn(doc: SheetDoc, id: string): void { doc.열 = doc.열.filter((c) => c.id !== id); diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 7de1d3f9..dc488dc3 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -3,8 +3,9 @@ * 표 그리기 — 문서 + 풀이 결과 → 쪽마다 ``(머리 층 · 단위 줄 · 본문 · 합계 줄). * * 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음. - * 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing`. - * master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). + * 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing|basis`. + * master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 · 산출근거 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). + * 머리 층 줄 머리 칸은 `data-layer`(층 차례) — master 에서 층 이름을 고침. * ========================================================================== */ import { el } from "@ui/ui_template_elements"; @@ -14,10 +15,12 @@ import { cellEditable, isBoundColumn, isCalcColumn, + KEY_BASIS, KEY_DRAWING, KEY_UNIT, MASTER_ROWS, type MasterRowKey, + PICK_ROWS, pagePlan, type SheetMode, } from "./ui_template_sheet_ops"; @@ -34,13 +37,17 @@ export interface RenderState { mode: SheetMode; result: SheetResult; sel: { r: string; c: string } | null; - /** 도면 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + /** 도면 · 산출근거 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ drawingLabel?: (colId: string) => string; + basisLabel?: (colId: string) => string; + /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ + pick?: boolean; } -/** 도면 줄 칸 글. */ -export const drawingText = (state: RenderState, colId: string): string => - state.drawingLabel?.(colId)?.trim() || st("Drawing_None"); +/** 도면 · 산출근거 줄 칸 글. */ +export const refText = (state: RenderState, key: string, colId: string): string => + (key === KEY_BASIS ? state.basisLabel : state.drawingLabel)?.(colId)?.trim() || + st("Drawing_None"); /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => @@ -55,9 +62,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) return false; + if (key === KEY_DRAWING || key === KEY_BASIS) 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); @@ -86,7 +93,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( @@ -98,6 +105,7 @@ export function renderSheet(state: RenderState): HTMLElement { st("Row_Desc"), st("Row_Unit_Price"), st("Row_Drawing"), + st("Row_Basis"), ...(doc.합계줄 ?? []).map((t) => t.이름), ].map((label) => Math.ceil(textWidth(label, font)) + 16), ), @@ -165,6 +173,7 @@ export function renderSheet(state: RenderState): HTMLElement { "s:desc": "Row_Desc", "s:price": "Row_Unit_Price", "s:drawing": "Row_Drawing", + "s:basis": "Row_Basis", } as const )[key]; const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); @@ -174,11 +183,12 @@ export function renderSheet(state: RenderState): HTMLElement { ? formulaLabel(col.식 ?? "", names, col.id) : key === "s:desc" ? "" - : key === KEY_DRAWING - ? drawingText(state, col.id) + : key === KEY_DRAWING || key === KEY_BASIS + ? 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)) td.classList.add("is-pick"); if (key === "s:formula" && col.식) { // 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식 td.textContent = ""; @@ -209,7 +219,9 @@ export function renderSheet(state: RenderState): HTMLElement { const thead = (): HTMLElement => { const rows = head.map((cells, level) => { - const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] }); + const layer = gutter("th", doc.층?.[level] ?? "", mode === "master" ? "is-editable" : ""); + layer.dataset.layer = String(level); + const tr = el("tr", { children: [layer] }); for (const cell of cells) { const th = el("th", { text: cell.label, diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index c883d75b..8eeb35b1 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -17,6 +17,10 @@ const TEXT = { Row_Desc: ["들어갈 것", "Source"], Row_Unit_Price: ["일위대가", "Unit price"], Row_Drawing: ["도면", "Drawing"], + Row_Basis: ["산출근거", "Basis"], + Group_Add: ["묶음 더하기", "Add group"], + Group_New: ["새 분류", "New group"], + Add_Suffix: [" 더하기", " +"], Drawing_None: ["없음", "None"], Kind_Bound: ["설계값", "Design"], Kind_Calc: ["계산", "Calc"], From dde6f0c2f84774253ffb32449b66ae4cfbd99924 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 09:48:05 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=A0=9C=EB=AA=A9=ED=96=89=20?= =?UTF-8?q?=EC=B8=B5=20=EC=9D=B4=EB=A6=84=20=E2=80=94=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?=20=C2=B7=20=EB=B6=84=EB=A5=981=20=C2=B7=20=EB=B6=84=EB=A5=982?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- resources/master_template/table/구조물집계표.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/master_template/table/구조물집계표.json b/resources/master_template/table/구조물집계표.json index 6b91bff4..990bd7b2 100644 --- a/resources/master_template/table/구조물집계표.json +++ b/resources/master_template/table/구조물집계표.json @@ -4,7 +4,7 @@ "판": 1, "설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.", "바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)", - "층": ["종류", "공법", "규격"], + "층": ["종류", "분류1", "분류2"], "변수": { "파형강관_1본_m": 8, "흄관_1본_m": "",