From fabc033c530f1b2565604171995c60b328a3718c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 11:09:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M02):=20=EC=96=91=EC=8B=9D=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20structure=20=E2=80=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=EC=83=88=EB=A1=9C(=EB=8F=84=EB=B2=88?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99)=20=C2=B7=20=EB=8F=84=EB=B2=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B8=B8=20=C2=B7=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EB=B3=B5=EC=82=AC=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종류 셋(table · drawing · structure) · 뼈대 검사 한 벌(도면.entities · 산출근거.열) - POST /api/m02/structures/{열 id} — A1 도각 + 빈 산출근거 표(머리 다섯) · 도번 = 있는 것 중 가장 큰 번호 + 1 · 있으면 409 · 없는 열 404 - GET /api/m02/structures/numbers — {열 id: 도번} - Api_Fetch — Kind 에 structure · StructureDoc · listStructures · createStructure · fetchStructureNumbers - 폴더 resources/master_template/structure/ · 시험 test_m02_structure.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014yTsevyL7QysasVVgWW7vW --- .../M02_MasterTemplete_Api_Fetch.ts | 30 +++++- .../M02_MasterTemplete_Router.py | 11 +++ .../M02_MasterTemplete_Store.py | 65 +++++++++++-- M02_MasterTemplete/M02_Template_Layers.py | 30 ++++-- resources/master_template/structure/.gitkeep | 0 resources/tester/test_m02_structure.py | 93 +++++++++++++++++++ 6 files changed, 212 insertions(+), 17 deletions(-) create mode 100644 resources/master_template/structure/.gitkeep create mode 100644 resources/tester/test_m02_structure.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index ca67f4c9..68dd9ca7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -7,8 +7,8 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 층 이름 — 서버와 같은 글 */ export type Layer = "system" | "company" | "personal" | "project"; -/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing */ -export type Kind = "table" | "drawing"; +/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure */ +export type Kind = "table" | "drawing" | "structure"; export interface TemplateInfo { 종류: Kind; @@ -92,6 +92,32 @@ export const deleteLayerTemplate = ( ): Promise<{ ok: boolean }> => call(`/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); +/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- + * 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */ + +/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · `산출근거` = 표 양식과 같은 표 문서 */ +export interface StructureDoc { + 양식: "구조물도면"; + 종류: "structure"; + 판: number; + 열: string; + 도번: string; + 도면: { entities: unknown[] } & Record; + 산출근거: { 열: unknown[]; 줄: unknown[] } & Record; +} + +/** 시스템 층 구조물 도면 목록 */ +export const listStructures = async (): Promise => + (await listTemplates("system", null)).filter((t) => t.종류 === "structure"); + +/** 새로 — 도번 자동 · A1 도각을 깐 빈 도면 + 빈 산출근거 표 · 이미 있으면 StaleError */ +export const createStructure = (column: string): Promise => + call(`/structures/${enc(column)}`, { method: "POST" }); + +/** `{열 id: 도번}` — 집계표 도면 줄(없는 열은 키 없음 = 「없음」) */ +export const fetchStructureNumbers = (): Promise> => + call("/structures/numbers"); + /* --- 프로젝트 층 단추 다섯 --- */ const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`; const post = (path: string, body: object = {}): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index 7dcaf3d6..54a4c74f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -32,6 +32,17 @@ def get_templates() -> list[dict]: return _call(store.list_all) +@router.get("/structures/numbers") +def get_structure_numbers() -> dict: + return _call(store.structure_numbers) + + +@router.post("/structures/{column}") +def post_structure(column: str) -> dict: + """구조물 도면 새로 — 열 id 하나 · 도번 자동 · A1 도각 + 빈 산출근거 표 · 이미 있으면 409.""" + return _call(store.create_structure, column) + + @router.get("/templates/{kind}/{name}") def get_template(kind: str, name: str) -> dict: return _call(store.read, kind, name) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 3573c1d4..c7147d19 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -1,4 +1,4 @@ -"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{table,drawing}/<이름>.json`). +"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{종류}/<이름>.json`). M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기. 권한은 등록하는 쪽(`main.py`)이 붙임. @@ -15,9 +15,15 @@ from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Template_Layers as layers FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" -KINDS = ("table", "drawing") # 시험은 FOLDER 를 사본으로 바꿈 +KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈 +# 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 새로 만들 때 A1 도각을 깖 +STRUCTURE_TABLE = "구조물집계표" +STRUCTURE_FRAME = "00_template_A1" +_BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위")) +_NUMBER = re.compile(r"^구-(\d+)$") _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -66,10 +72,11 @@ def read(kind: str, name: str) -> dict[str, Any]: def _check_skeleton(kind: str, doc: Any) -> None: - """빈 문서·뼈대 없는 문서는 거절 — 표는 열 목록 · 도면은 entities 목록.""" - key = "열" if kind == "table" else "entities" - if not isinstance(doc, dict) or not isinstance(doc.get(key), list): - raise StoreError(400, f"양식 문서에 「{key}」 목록이 없음 — 저장하지 않음") + """빈 문서·뼈대 없는 문서는 거절 — 층 저장과 같은 규칙(`layers.check_skeleton`).""" + try: + layers.check_skeleton(kind, doc) + except ValueError as e: + raise StoreError(400, str(e)) from e def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: @@ -92,3 +99,49 @@ def delete(kind: str, name: str, version: str | None = None) -> None: if version is not None and version_of(path.read_bytes()) != version: raise StoreError(409, {"stale": [name], "판": version_of(path.read_bytes())}) path.unlink() + + +# ── 구조물 도면 ─────────────────────────────────────── + + +def _structures() -> list[tuple[str, Any]]: + folder = FOLDER / "structure" + return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))] + + +def structure_numbers() -> dict[str, str | None]: + """`{열 id: 도번}` — 집계표 도면 줄이 한 번에 받음.""" + return { + name: (doc.get("도번") if isinstance(doc, dict) else None) for name, doc in _structures() + } + + +def _number(doc: Any) -> int: + found = _NUMBER.match(str(doc.get("도번") or "")) if isinstance(doc, dict) else None + return int(found.group(1)) if found else 0 + + +def create_structure(column: str) -> dict[str, Any]: + """열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409.""" + path = _path("structure", column) + table = read("table", STRUCTURE_TABLE)["문서"] + if column not in {col.get("id") for col in table.get("열", []) if isinstance(col, dict)}: + raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」") + frame = read("drawing", STRUCTURE_FRAME)["문서"] + basis = [{"id": key, "머리": [label], "단위": None, "꼴": "글"} for key, label in _BASIS_HEAD] + basis.append({"id": "qty", "머리": ["수량(m당)"], "단위": None, "꼴": "수"}) + with _LOCK: + if path.is_file(): + raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") + number = max((_number(doc) for _name, doc in _structures()), default=0) + 1 + doc = { + "양식": "구조물도면", + "종류": "structure", + "판": 1, + "열": column, + "도번": f"구-{number:02d}", + "도면": frame, + "산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": []}, + } + atomic_write_json(path, doc) + return read("structure", column) diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 03d6a52f..9c889c25 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -1,10 +1,10 @@ """M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest. 층 넷 (PLAN 10-5): - system `resources/master_template/{table,drawing}/<이름>.json` (git) - company `storage/{회사}/templates/{table,drawing}/` - personal `storage/{회사}/{사용자}/templates/{table,drawing}/` - project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing}/` + system `resources/master_template/{table,drawing,structure}/<이름>.json` (git) + company `storage/{회사}/templates/{table,drawing,structure}/` + personal `storage/{회사}/{사용자}/templates/{table,drawing,structure}/` + project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure}/` + 초기 사본 `templates/_initial/` - 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`. @@ -26,7 +26,7 @@ from common_util.common_util_json import atomic_write_json from config import config_system LAYERS = ("system", "company", "personal", "project") -KINDS = ("table", "drawing") +KINDS = ("table", "drawing", "structure") TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" @@ -162,13 +162,25 @@ def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any] } +_SKELETON = { + "table": ("열",), + "drawing": ("entities",), + "structure": ("도면.entities", "산출근거.열"), +} + + def check_skeleton(kind: str, document: Any) -> None: - """뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록(빈 `{}` 저장 막기).""" - key = {"table": "열", "drawing": "entities"}.get(kind) + """뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록 · + 구조물 도면 = `도면.entities` · `산출근거.열` 목록(빈 `{}` 저장 막기).""" if not isinstance(document, dict): raise ValueError("양식 문서는 JSON 객체여야 합니다.") - if key and not isinstance(document.get(key), list): - raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.") + for key in _SKELETON.get(kind, ()): + node: Any = document + *parents, last = key.split(".") + for part in parents: + node = node.get(part) if isinstance(node, dict) else None + if not isinstance(node, dict) or not isinstance(node.get(last), list): + raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.") def write_template( diff --git a/resources/master_template/structure/.gitkeep b/resources/master_template/structure/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py new file mode 100644 index 00000000..90fc9142 --- /dev/null +++ b/resources/tester/test_m02_structure.py @@ -0,0 +1,93 @@ +"""M02 구조물 도면(structure) — 새로 · 도번 자동 · 409 · 저장 · 지운 뒤 번호 · 프로젝트 복사.""" + +import shutil + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_MasterTemplete_Router import router + +REAL = store.FOLDER + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + for kind in store.KINDS: + (tmp_path / kind).mkdir() + shutil.copy(REAL / "table/구조물집계표.json", tmp_path / "table") + shutil.copy(REAL / "drawing/00_template_A1.json", tmp_path / "drawing") + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_새로_도번_자동_409_없는_열(client): + made = client.post("/api/m02/structures/pp_len") + assert made.status_code == 200 + doc = made.json()["문서"] + assert (doc["양식"], doc["종류"], doc["열"], doc["도번"]) == ( + "구조물도면", + "structure", + "pp_len", + "구-01", + ) + assert ( + doc["도면"]["entities"] and doc["도면"] == store.read("drawing", "00_template_A1")["문서"] + ) + basis = doc["산출근거"] + assert [c["머리"][0] for c in basis["열"]] == ["공종", "규격", "산출 내역", "단위", "수량(m당)"] + assert basis["줄"] == [] and basis["종류"] == "표" + + assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "구-02" + assert client.post("/api/m02/structures/pp_len").status_code == 409 + assert client.post("/api/m02/structures/없는열").status_code == 404 + assert client.get("/api/m02/structures/numbers").json() == {"pp_len": "구-01", "rv": "구-02"} + rows = client.get("/api/m02/templates").json() + assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == { + ("structure", "pp_len"), + ("structure", "rv"), + } + + +def test_저장_뼈대_거름(client): + got = client.post("/api/m02/structures/pp_len").json() + doc = got["문서"] + doc["산출근거"]["줄"] = [{"id": "r1", "값": {"work": "관 부설"}}] + saved = client.put("/api/m02/templates/structure/pp_len", json={"판": got["판"], "문서": doc}) + assert saved.status_code == 200 and saved.json()["판"] != got["판"] + for bad in ({}, {**doc, "도면": {}}, {**doc, "산출근거": {"열": "x"}}, {**doc, "도면": []}): + r = client.put( + "/api/m02/templates/structure/pp_len", json={"판": saved.json()["판"], "문서": bad} + ) + assert r.status_code == 400 + assert client.get("/api/m02/templates/structure/pp_len").json()["문서"] == doc + + +def test_지워도_번호를_당기지_않음(client): + for column in ("pp_len", "rv", "ms"): + client.post(f"/api/m02/structures/{column}") + assert client.delete("/api/m02/templates/structure/pp_len").status_code == 200 + assert client.get("/api/m02/structures/numbers").json() == {"rv": "구-02", "ms": "구-03"} + assert client.post("/api/m02/structures/fb").json()["문서"]["도번"] == "구-04" + # 가장 큰 번호를 지우면 그 번호부터 다시(있는 것 중 가장 큰 번호 + 1) + client.delete("/api/m02/templates/structure/fb") + assert client.post("/api/m02/structures/ec").json()["문서"]["도번"] == "구-04" + + +def test_프로젝트_복사와_층_저장에_실림(client, tmp_path, monkeypatch): + doc = client.post("/api/m02/structures/pp_len").json()["문서"] + monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path) + project = tmp_path / "project" + copied = layers.seed_project(project) + assert "structure/pp_len" in copied["작업본"] and "structure/pp_len" in copied["초기"] + work = layers.project_dir(project) + assert layers.read_template(work, "structure", "pp_len")["문서"] == doc + assert layers.read_template(layers.initial_dir(project), "structure", "pp_len")["문서"] == doc + assert ("structure", "pp_len") in {(r["종류"], r["이름"]) for r in layers.list_templates(work)} + layers.check_skeleton("structure", doc) + with pytest.raises(ValueError): + layers.check_skeleton("structure", {**doc, "산출근거": None}) From 1ae10e64e230faa621228fd7297b0b2f3869cb92 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 11:14:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(sheet):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EC=B2=AB=20=EC=AA=BD=20=EB=8F=84=EB=A9=B4=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B8=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=20onSelect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 줄 아래 도면 줄 s:drawing — 칸 글은 SheetOptions.drawingLabel(colId) · 없거나 빈 글이면 「없음」 · 읽기만 - 줄 차례(navRows) · MASTER_ROWS · KEY_UNIT 를 ops 로 옮김(Node 시험 가능) - 칸을 고르면 SheetOptions.onSelect({ row, col, colId }) — 누르기 · 방향키 · 머리 칸 - 시험 test_sheet_drawing_select.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- resources/tester/test_sheet_drawing_select.py | 64 +++++++++++++++++++ ui_template/sheet/ui_template_sheet.ts | 22 +++++-- ui_template/sheet/ui_template_sheet_ops.ts | 30 ++++++++- ui_template/sheet/ui_template_sheet_render.ts | 50 ++++++++------- ui_template/sheet/ui_template_sheet_text.ts | 2 + ui_template/sheet/ui_template_sheet_types.ts | 8 +++ 6 files changed, 148 insertions(+), 28 deletions(-) create mode 100644 resources/tester/test_sheet_drawing_select.py diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py new file mode 100644 index 00000000..240152f6 --- /dev/null +++ b/resources/tester/test_sheet_drawing_select.py @@ -0,0 +1,64 @@ +"""표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄. + +도면 줄 `s:drawing` = master 만 · 일위대가 줄 바로 아래(키보드 차례) · project 에는 없음. +`selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null). +""" + +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 { navRows, selectionOf } = await import(process.argv[1]); +const doc = JSON.parse(process.argv[2]); +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")], +})); +""" + +DOC = { + "양식": "시험", + "종류": "표", + "판": 1, + "열": [{"id": "c1", "머리": ["가"]}, {"id": "c2", "머리": ["나"]}], + "줄": [{"id": "r1", "값": {}}], + "합계줄": [{"id": "t1", "이름": "계", "식": "SUM"}], +} + + +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_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", + ] # fmt: skip + assert got["project"] == ["s:unit", "d:r1", "t:t1"] + + +def test_selection_shape(): + assert _run()["pick"] == [ + {"row": "s:drawing", "col": 1, "colId": "c2"}, + {"row": "d:r1", "col": 0, "colId": "c1"}, + {"row": "s:unit", "col": -1, "colId": None}, + ] diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 2249e240..fba9df26 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -1,8 +1,9 @@ /* ============================================================================= * ui_template_sheet.ts - * 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`. + * 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, onSelect})` → `{getDoc, setDoc, recalc, destroy}`. * * master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열. + * 도면 줄(읽기만) = `drawingLabel` 글 · 칸을 고르면 `onSelect`. * project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽. * 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음. * 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). @@ -17,6 +18,10 @@ import { canDeleteRow, deleteColumn, deleteRow, + KEY_DRAWING, + KEY_UNIT, + navRows, + selectionOf, type SheetMode, setCell, setColumnWidth, @@ -25,22 +30,25 @@ import { import { headDepth } from "./ui_template_sheet_header"; import { recalcSheet } from "./ui_template_sheet_recalc"; import { - KEY_UNIT, + drawingText, keyEditable, - navRows, type RenderState, renderSheet, } from "./ui_template_sheet_render"; import { st } from "./ui_template_sheet_text"; -import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types"; +import type { SheetColumn, SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types"; import "./ui_template_sheet.css"; export type { SheetMode } from "./ui_template_sheet_ops"; -export type { SheetDoc, SheetResult } from "./ui_template_sheet_types"; +export type { SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types"; export interface SheetOptions { mode: SheetMode; onChange?: (doc: SheetDoc) => void; + /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ + drawingLabel?: (colId: string) => string; + /** 칸을 고름 — 누르기 · 방향키 · Tab. */ + onSelect?: (sel: SheetSelection) => void; } export interface SheetHandle { @@ -58,6 +66,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio mode: opts.mode, result: { 계산: {}, 합계: {}, 오류: [] }, sel: null, + drawingLabel: opts.drawingLabel, }; const scroll = el("div", { className: "ui-sheet__scroll" }); const toolbar = el("div", { className: "ui-sheet__toolbar" }); @@ -151,6 +160,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio for (const cell of cells) cell.classList.add("is-selected"); if (reveal) cells[0]?.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); @@ -171,6 +181,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio 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); const row = state.doc.줄.find((r) => `d:${r.id}` === key); const formula = row?.식?.[col.id] ?? col.식; if (formula) return `=${formula}`; @@ -178,6 +189,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 (key === KEY_DRAWING) return; // 읽기만 const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; else if (key === "s:formula") { diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index e3d1be71..334cad9d 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -5,13 +5,41 @@ * ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`). * ========================================================================== */ -import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types"; +import type { + SheetCell, + SheetColumn, + SheetDoc, + SheetRow, + SheetSelection, +} from "./ui_template_sheet_types"; export type SheetMode = "master" | "project"; export const ROW_FIXED_ALL = "전구간"; const PAGE_ROWS = 50; +export const KEY_UNIT = "s:unit"; +export const KEY_DRAWING = "s:drawing"; +/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 줄은 읽기만. */ +export const MASTER_ROWS = ["s:formula", "s:desc", "s:price", KEY_DRAWING] as const; +export type MasterRowKey = (typeof MASTER_ROWS)[number]; + +/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */ +export function navRows(doc: SheetDoc, mode: SheetMode): string[] { + const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT]; + return [ + ...special, + ...orderedRows(doc).map((r) => `d:${r.id}`), + ...(doc.합계줄 ?? []).map((t) => `t:${t.id}`), + ]; +} + +/** 고른 칸 → 페이지에 알릴 모양. */ +export function selectionOf(doc: SheetDoc, row: string, colId: string): SheetSelection { + const col = doc.열.findIndex((c) => c.id === colId); + return { row, col, colId: col >= 0 ? colId : null }; +} + /** 겹치지 않는 새 id — `c1` · `c2` … */ function freshId(prefix: string, used: Iterable): string { const taken = new Set(used); diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 72737824..7de1d3f9 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`. - * master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). + * 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing`. + * master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽). * ========================================================================== */ import { el } from "@ui/ui_template_elements"; @@ -14,7 +14,10 @@ import { cellEditable, isBoundColumn, isCalcColumn, - orderedRows, + KEY_DRAWING, + KEY_UNIT, + MASTER_ROWS, + type MasterRowKey, pagePlan, type SheetMode, } from "./ui_template_sheet_ops"; @@ -22,8 +25,6 @@ import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc" import { st } from "./ui_template_sheet_text"; import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types"; -export const KEY_UNIT = "s:unit"; -const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const; const GUTTER_MIN = 64; const GUTTER_MAX = 140; const DEFAULT_WIDTH = { 수: 72, 글: 96 }; @@ -33,8 +34,14 @@ export interface RenderState { mode: SheetMode; result: SheetResult; sel: { r: string; c: string } | null; + /** 도면 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + drawingLabel?: (colId: string) => string; } +/** 도면 줄 칸 글. */ +export const drawingText = (state: RenderState, colId: string): string => + state.drawingLabel?.(colId)?.trim() || st("Drawing_None"); + /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => Math.max( @@ -48,18 +55,9 @@ function headFont(): string { return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`; } -/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */ -export function navRows(doc: SheetDoc, mode: SheetMode): string[] { - const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT]; - return [ - ...special, - ...orderedRows(doc).map((r) => `d:${r.id}`), - ...(doc.합계줄 ?? []).map((t) => `t:${t.id}`), - ]; -} - -/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */ +/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 줄은 읽기만. */ export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean { + if (key === KEY_DRAWING) 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); @@ -88,7 +86,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( @@ -99,6 +97,7 @@ export function renderSheet(state: RenderState): HTMLElement { st("Row_Formula"), st("Row_Desc"), st("Row_Unit_Price"), + st("Row_Drawing"), ...(doc.합계줄 ?? []).map((t) => t.이름), ].map((label) => Math.ceil(textWidth(label, font)) + 16), ), @@ -159,10 +158,15 @@ export function renderSheet(state: RenderState): HTMLElement { return tr; }; - const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => { - const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[ - key - ] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price"; + const masterRow = (key: MasterRowKey): HTMLElement => { + const label = ( + { + "s:formula": "Row_Formula", + "s:desc": "Row_Desc", + "s:price": "Row_Unit_Price", + "s:drawing": "Row_Drawing", + } as const + )[key]; const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); cols.forEach((col, j) => { const text = @@ -170,7 +174,9 @@ export function renderSheet(state: RenderState): HTMLElement { ? formulaLabel(col.식 ?? "", names, col.id) : key === "s:desc" ? "" - : (col.일위대가 ?? ""); + : key === KEY_DRAWING + ? drawingText(state, col.id) + : (col.일위대가 ?? ""); const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); if (key === "s:formula" && col.식) { diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index 6dd8d091..c883d75b 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -16,6 +16,8 @@ const TEXT = { Row_Formula: ["식", "Formula"], Row_Desc: ["들어갈 것", "Source"], Row_Unit_Price: ["일위대가", "Unit price"], + Row_Drawing: ["도면", "Drawing"], + Drawing_None: ["없음", "None"], Kind_Bound: ["설계값", "Design"], Kind_Calc: ["계산", "Calc"], Kind_Hand: ["손 입력", "Manual"], diff --git a/ui_template/sheet/ui_template_sheet_types.ts b/ui_template/sheet/ui_template_sheet_types.ts index 29cd5a36..e861e971 100644 --- a/ui_template/sheet/ui_template_sheet_types.ts +++ b/ui_template/sheet/ui_template_sheet_types.ts @@ -78,6 +78,14 @@ export interface SheetDoc { 보기?: SheetView; } +/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|formula|desc|price|drawing`) · + * `col` = 열 차례(0부터 · 없으면 -1) · `colId` = 열 id(없으면 null). */ +export interface SheetSelection { + row: string; + col: number; + colId: string | null; +} + export interface SheetError { 줄: string; 열: string;