From 00cb6db66f697276b0f043de7b7b19dce2b33a8b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:21:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(M02):=20=ED=95=AD=EB=AA=A9=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=EB=B9=84=EC=9A=B0=EA=B8=B0=20=EA=B8=B8=20=E2=80=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20=EC=83=88=20=EC=97=B4=EA=B3=BC=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EB=B9=88=20=EB=AC=B8=EC=84=9C=EB=A1=9C=20(40-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/m02/layers/{층}/{structures|labor|area}/{열}/clear(owner) · 번호 · 열 그대로 · 받는 층 쓰기 권한(_root) - Store.clear · 면적을 비우면 그 열 일위대가 계산값 다시 · 장부 맞춤 - 화면 함수 Api_Fetch clearPart Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Api_Fetch.ts | 13 ++ .../M02_MasterTemplete_Router_Items.py | 14 ++ .../M02_MasterTemplete_Store.py | 15 ++ resources/tester/test_m02_part_clear.py | 134 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 resources/tester/test_m02_part_clear.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 5bc26211c..2b1982f93 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -226,6 +226,19 @@ export const copyPartFrom = async ( }); }; +/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */ +export const clearPart = ( + kind: "structure" | "labor" | "area", + column: string, +): Promise => + call( + owned( + activeLayer, + `/layers/${activeLayer}/${kind === "structure" ? "structures" : kind}/${enc(column)}/clear`, + ), + { method: "POST" }, + ); + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 89fb986df..413e5409c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -317,6 +317,20 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]: return store.copy_from("labor", column, key, body=labor.from_combo(key)) +@router.post("/layers/{layer}/{kind}/{column}/clear") +def post_clear( + layer: Layer, + kind: Literal["structures", "labor", "area"], + column: str, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """항목 하나 비우기(40-4 · 카드 [삭제]) — 빈 문서 · 번호 · 열 그대로 · 받는 층 쓰기 권한.""" + folder = _root(session, layer, owner, write=True) + part = "structure" if kind == "structures" else kind + return _call(folder, store.clear, part, column) + + @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 5bfe9e227..0873d5d5e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -351,6 +351,21 @@ def create_structure(column: str) -> dict[str, Any]: return read("structure", column) +def clear(kind: str, column: str) -> dict[str, Any]: + """항목 하나 비우기(40-4) — 새 열 만들 때와 같은 빈 문서로 다시 씀 · 번호 · 열 그대로 · + 없는 파일은 404 · 면적산출을 비우면 그 열 일위대가 계산값도 다시 · 장부 맞춤.""" + if kind not in _KEY: + raise StoreError(404, f"없는 종류 「{kind}」") + number = read(kind, column)["문서"].get(_KEY[kind]) + with _LOCK: + atomic_write_json(_path(kind, column), {**_blank(kind, column), _KEY[kind]: number}) + info = _info(kind, _path(kind, column)) + if kind == "area": + _refresh_labor(column) + sync_combos() + return info + + def copy_from( kind: str, column: str, diff --git a/resources/tester/test_m02_part_clear.py b/resources/tester/test_m02_part_clear.py new file mode 100644 index 000000000..79a393f25 --- /dev/null +++ b/resources/tester/test_m02_part_clear.py @@ -0,0 +1,134 @@ +"""항목 하나 비우기(PLAN 40-4 · 카드 [삭제]) — `POST /api/m02/layers/{층}/{종류}/{열}/clear`. + +새 열과 같은 빈 문서 · 번호 · 열 그대로 · 권한 = 받는 층 쓰기(`_root`). +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 로직은 M01 마스터(읽기만). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" +URL = "/api/m02/layers/{}/{}/{}/clear" +NUMBER = { + "structures": ("structure", "도번"), + "labor": ("labor", "일위대가번"), + "area": ("area", "면적번"), +} + + +def _fill(folder: Path, cid: str) -> None: + """세 문서를 채움 — 도면 선 · 일위대가 로직 · 면적 칸 + 알약 연결.""" + with store.at(folder): + for kind in ("structure", "labor", "area"): + store._ensure(kind, cid) + got = store.read("structure", cid) + doc = got["문서"] + doc["도면"]["entities"] = [{"type": "line"}] + store.write("structure", cid, got["판"], doc) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], "담은로직": [{"로직": TEN}]}) + got = store.read("area", cid) + sheet = got["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": "사석 수상고르기|㎡", "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", cid, got["판"], got["문서"]) + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + _fill(system, "rv") + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + _fill(personal, "a") + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system, "personal": personal} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +def _doc(folder: Path, kind: str, cid: str) -> dict: + return json.loads((folder / kind / f"{cid}.json").read_bytes()) + + +@pytest.mark.parametrize("kind", ["structures", "labor", "area"]) +def test_세_종류_빈_문서_번호_열_그대로(world, kind): + system = world["system"] + part, key = NUMBER[kind] + before = _doc(system, part, "rv") + got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", kind, "rv")) + assert got.status_code == 200, got.text + after = _doc(system, part, "rv") + with store.at(system): + blank = store._blank(part, "rv") + assert after == {**blank, key: before[key]} # 새 열과 같은 빈 문서 + 번호 + assert after["열"] == "rv" and after[key] + with store.at(system): # 장부 번호 그대로 + row = next(r for r in store.read_combos()["줄"] if r["열"] == "rv") + assert row[key] == before[key] + + +def test_면적_비우면_일위대가_계산값_수량_없음으로(world): + system = world["system"] + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "칸" + _as(world, "SYSTEM_ADMIN").post(URL.format("system", "area", "rv")) + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "없음" + assert _doc(system, "labor", "rv")["담은로직"][0]["로직"] == TEN # 일위대가는 그대로 + + +def test_권한_일반_마스터_403_남의_개인_403_내_개인_됨(world): + system, personal = world["system"], world["personal"] + client = _as(world) + assert client.post(URL.format("system", "labor", "rv")).status_code == 403 + assert _doc(system, "labor", "rv")["담은로직"] # 안 지워짐 + other = URL.format("personal", "labor", "a") + "?owner=7/99" + assert client.post(other).status_code == 403 + assert client.post(URL.format("personal", "labor", "a")).status_code == 200 + assert _doc(personal, "labor", "a")["담은로직"] == [] + + +def test_회사_관리자는_동료_개인_층_비움(world): + personal = world["personal"] + client = _as(world, "ADMIN", user=41) + got = client.post(URL.format("personal", "structures", "a") + "?owner=7/42") + assert got.status_code == 200, got.text + assert _doc(personal, "structure", "a")["도면"]["entities"] == [] + + +def test_없는_열_404_모르는_종류_422(world): + client = _as(world, "SYSTEM_ADMIN") + assert client.post(URL.format("system", "labor", "없는열")).status_code == 404 + assert client.post(URL.format("system", "material", "rv")).status_code == 422 From fda2527c4b7525db2eb39d03ebb5c85b66db71a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:23:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(M02):=20=EC=97=91=EC=85=80=20=EC=B9=B8?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20AreaCellPick=20=E2=80=94=20=EA=B7=B8=20=EC=97=B4=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=EC=8B=9C=ED=8A=B8=20=C2=B7=20=EC=B9=B8=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=3D=20=EC=A3=BC=EC=86=8C=20=C2=B7=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=ED=92=80=EA=B8=B0=20=C2=B7=20=EC=88=9C=EC=88=98=20?= =?UTF-8?q?linkAreaCell(=EB=A9=B4=EC=A0=81=20=EB=AC=B8=EC=84=9C=20=C2=B7?= =?UTF-8?q?=20=EC=95=8C=EC=95=BD=20=ED=82=A4=20=C2=B7=20=EC=B9=B8)=20(PLAN?= =?UTF-8?q?=2040-3=20=E2=91=A0)?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../spreadsheet/spreadsheet_link_set.ts | 19 +++ .../M02_MasterTemplete_AreaCellPick.ts | 117 ++++++++++++++++++ .../M02_MasterTemplete_AreaPanel.css | 23 ++++ .../spreadsheet/spreadsheet_link_set_entry.ts | 13 +- resources/tester/test_m02_area_panel.py | 11 ++ resources/tester/test_spreadsheet_set_link.py | 2 +- 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 8f740992c..65822f7be 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -53,3 +53,22 @@ export function linkCommand( if (!commands.length) return null; return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; } + +/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · + * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ +export function linkAreaCell( + book: T, + key: string, + cell: string | null, + info: LinkInfo = {}, +): T | null { + const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0]; + if (!sheet) return null; + const command = linkCommand(book, sheet.id, key, cell, info); + if (command === false) return null; + const next = structuredClone(book); + const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command]; + for (const c of list) + if (c.종류 === "시트통째") next.시트 = next.시트.map((s) => (s.id === c.시트.id ? c.시트 : s)); + return next; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts new file mode 100644 index 000000000..8012ac0e6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaCellPick.ts + * 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌. + * 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어 + * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). + * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 + * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import "./M02_MasterTemplete_AreaPanel.css"; + +export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set"; + +const TEXT = { + title: "엑셀에서 고르기 — {value}", + close: "닫기", + hint: "칸을 누르면 그 칸으로 이음 · Enter 도 됨", + now: "지금 연결 {value}", + none: "지금 연결 없음", + unlink: "연결 풀기", + loading: "면적산출을 읽는 중…", + failed: "면적산출을 못 읽음 — {value}", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface AreaCellPickOptions { + /** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */ + doc?: Workbook | null; + layer: Layer; + /** 집계표 열 id */ + column: string; + /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */ + pill: string; + /** 모달 제목에 쓸 줄 이름 */ + label: string; +} + +/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */ +export type AreaCellPick = { 칸: string | null } | null; + +export function pickAreaCell(opts: AreaCellPickOptions): Promise { + return new Promise((resolve) => { + let handle: SpreadsheetHandle | null = null; + let done = false; + let picked = "A1"; + let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름 + const finish = (value: AreaCellPick): void => { + if (done) return; + done = true; + handle?.destroy(); + handle = null; + resolve(value); + }; + const { close } = openModal({ + title: fill(TEXT.title, opts.label), + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-cellpick", + beforeClose: () => (finish(null), true), + mount: (body) => { + const head = el("p", { className: "m02-cellpick__now", text: TEXT.loading }); + const unlink = createButton({ + label: TEXT.unlink, + variant: "ghost", + size: "sm", + onClick: () => (finish({ 칸: null }), close()), + }); + unlink.hidden = true; + const host = el("div", { className: "m02-cellpick__sheet" }); + body.append( + el("div", { className: "m02-cellpick__head", children: [head, unlink] }), + el("p", { className: "m02-cellpick__hint", text: TEXT.hint }), + host, + ); + const choose = (): void => (finish({ 칸: picked }), close()); + void (async () => { + try { + const [{ createSpreadsheet }, book] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + opts.doc + ? Promise.resolve(opts.doc) + : readTemplate(opts.layer, "area", opts.column, null).then( + (got) => got.문서 as Workbook, + ), + ]); + if (done) return; + const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; + head.textContent = at ? fill(TEXT.now, at) : TEXT.none; + unlink.hidden = !at; + handle = createSpreadsheet(host, structuredClone(book), { + readOnly: true, + tabs: false, + onSelect: (s) => { + picked = s.칸; + moved = true; + }, + }); + // 칸을 눌러 뗌 = 그 칸(처음 고름 A1 · 화살표 옮김은 안 닫음) + const grid = host.querySelector(".ss-grid-host"); + grid?.addEventListener("mousedown", () => (moved = false), true); + grid?.addEventListener("mouseup", (e) => { + if ((e as MouseEvent).button === 0 && moved) choose(); + }); + host.addEventListener("keydown", (e) => e.key === "Enter" && choose()); + } catch (error) { + if (done) return; + head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); + } + })(); + }, + }); + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css index 9f7c0e18d..36e932845 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -62,3 +62,26 @@ flex: 1 1 auto; margin: 0; } + +/* 엑셀 칸 고르기 모달 — 머리(지금 연결 · [연결 풀기]) · 알림 · 읽기만 시트(높이를 정해야 시트가 보임) */ +.m02-cellpick { + width: min(960px, 92vw); +} + +.m02-cellpick__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m02-cellpick__now, +.m02-cellpick__hint { + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m02-cellpick__sheet { + height: min(60vh, 520px); + min-height: 240px; +} diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index ce15d5b73..032bfb659 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -1,7 +1,7 @@ /* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; -import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; let pass = 0; @@ -89,4 +89,15 @@ eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); eq("문서 그대로", links(b), links(book())); +// linkAreaCell — 새 문서(받은 문서 그대로) · 활성 시트 · 번호 칸 그대로 · 틀린 주소 null · 풀기 +b = { ...book(), 면적번: "SA-000001" } as Workbook & { 면적번: string }; +const before = JSON.stringify(b); +const got = linkAreaCell(b, "철근|ton", "c9", { 이름: "철근", 단위: "ton" }); +eq("받은 문서 그대로", JSON.stringify(b), before); +eq("새 문서 연결", got?.시트[0].연결?.[1], { 키: "철근|ton", 칸: "C9", 이름: "철근", 단위: "ton" }); +eq("번호 그대로", (got as { 면적번?: string } | null)?.면적번, "SA-000001"); +eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); +eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); +eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index fb81c0d7a..d9a79db7c 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -88,3 +88,14 @@ def test_카드_알약_취향은_계정_받은_뒤_올림(): cards = _text("M02_MasterTemplete_ItemCards.ts") load = cards.split("async function load(")[1] assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load + + +def test_엑셀_칸_고르기_모달_부품(): + """40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함.""" + pick = _text("M02_MasterTemplete_AreaCellPick.ts") + assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick + assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick + assert "finish({ 칸: null })" in pick and "finish({ 칸: picked })" in pick + assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick + assert "handle?.destroy();" in pick + assert "saveTemplate" not in pick diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 13e3ba179..000430baf 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 13 + assert result["pass"] == 19 def test_손잡이_setLink_몸(): From 74a524202ec837a95f84d754e2aed3c6c10fbe01 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:26:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=B9=B4=EB=93=9C=20=EC=85=8B=203?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20=EB=8B=A8=EC=B6=94=20=E3=80=8C=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=E3=80=8D=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EC=88=98=EC=A0=95=E3=80=8D=20=C2=B7=20=E3=80=8C=EC=83=88=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=E3=80=8D=20=EA=B3=B5=EC=9A=A9=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40-2 ①~③ — 상세 칸 960px 아래 1열(상세 칸 폭 container query) · 카드 [수정] 툴팁에 무엇을 고치는지 · 「새 항목」 = 저장과 같은 크기. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemCards.ts | 13 +++++++------ .../M02_MasterTemplete_Structure.css | 16 +++++++++++----- M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts | 2 +- resources/tester/test_m02_card_shell.py | 2 +- resources/tester/test_m02_tree_paste.py | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index deb7368db..04f748bf9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -56,10 +56,10 @@ export const PART_WORD: Record = { const TEXT = { drawingInfo: "도면 정보", - edit: "도면 수정", + edit: "수정", // 카드 셋 같은 글(40-2) — 무엇을 고치는지는 툴팁 noNumber: "도번 없음", noDrawing: "도면 없음", - editSheet: "{value} 수정", + editTip: "{value} 수정 — 전체 편집 화면", noSheetDoc: "{value} 없음", noFieldNumber: "{value} 없음", noSheet: "스프레드시트를 못 띄움 — {value}", @@ -68,7 +68,7 @@ const TEXT = { sheetHelp: "단축키 — 칸을 고르고 바로 침", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", - take: "복사해 가져오기", + take: "가져오기", takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", makeNew: "새로 만들기", @@ -209,10 +209,11 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { kind === "structure" ? DRAWING_KEYS : SHEET_KEYS, ); const edit = createButton({ - label: kind === "structure" ? TEXT.edit : fill(TEXT.editSheet, PART_WORD[kind]), + label: TEXT.edit, variant: "ghost", size: "sm", }); + edit.title = fill(TEXT.editTip, PART_WORD[kind]); edit.addEventListener("click", () => (kind === "labor" ? opts.labor?.() : opts.onEdit(kind))); const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); take.title = fill(TEXT.takeTip, PART_WORD[kind]); @@ -325,8 +326,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets]; const defaults: Record = { structure: TEXT.edit, - area: fill(TEXT.editSheet, PART_WORD.area), - labor: fill(TEXT.editSheet, PART_WORD.labor), + area: TEXT.edit, + labor: TEXT.edit, }; const root = el("div", { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index fb4c86d4a..133551038 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -1,4 +1,4 @@ -/* M02 구조물집계표 아래 설계 컨테이너 — 카드 셋(도면 정보 · 면적산출 · 재료산출) · +/* M02 구조물집계표 아래 설계 컨테이너 — 카드 셋(도면 정보 · 일위대가 · 면적산출) · 카드 모양은 좌측 패널과 같은 공용 ui-sidebar-*(ui_template_overlay.css). */ /* 표 밖 형제 자리 — 비어 있을 때 메인 간격이 벌어지지 않게 칸을 지움. */ @@ -6,8 +6,8 @@ display: contents; } -/* 카드 넷(도면 정보 · 면적산출 · 재료산출 · 선택 입력) = 2행 2열 · 카드 높이 = 화면 높이에 맞춰 크게(PLAN 31-6) · - 칸이 좁으면(720px 아래) 1열 */ +/* 카드 셋(도면 정보 · 일위대가 · 면적산출) = 1행 3열(40-2) · 카드 높이 = 화면 높이에 맞춰 크게(PLAN 31-6) · + 상세 칸이 좁으면 1열 */ .m02-struct { /* 카드 본문은 카드 높이를 채움 — 넘치면 카드 안에서 스크롤 */ --m02-struct-body-min: 0; @@ -17,13 +17,19 @@ display: grid; flex: none; grid-auto-rows: var(--m02-struct-card-h); - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); /* 도면 · 일위대가 · 면적산출(40-2) */ padding-top: var(--spacing-12); border-top: 1px solid var(--color-border); min-width: 0; } -@media (max-width: 720px) { +/* 상세 칸이 좁으면(카드 하나 300px 아래) 1열 — 좌측 패널을 접어도 맞게 상세 칸 폭으로 잼 + (카드 자리 `m02-main__extra` 는 상자 없음 — 상세 칸이 잣대) */ +.m02-main:has(> .m02-main__extra > .m02-struct) { + container-type: inline-size; +} + +@container (max-width: 960px) { .m02-struct { grid-template-columns: minmax(0, 1fr); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index d70207bdb..64343243f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -113,7 +113,7 @@ export interface MainExtras { /** 상세 제목 줄 오른쪽 끝 「새 …」 단추 — 공용 모양 `m02-main__new`(일위대가 「새 조합」과 같게 · 30-5) */ export function newItemButton(run: () => void, label = L("M02_TreeNewItem")): HTMLElement { - const button = createButton({ label, variant: "filled", size: "sm" }); + const button = createButton({ label, variant: "filled" }); // 공용 버튼 크기(40-2 · 저장과 같음) button.classList.add("m02-main__new"); button.addEventListener("click", run); return button; diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 086b74207..ae89a8b8e 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -40,7 +40,7 @@ def test_저장_단추_알림_한_벌(): def test_네_카드가_틀을_씀_수정_단추_한_모양(): cards = _text("M02_MasterTemplete_ItemCards.ts") assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards - assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5) + assert "shell.tools.append(help, saver.button, take, edit);" in cards # 가져오기(39-5 · 40-2) assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 67b2786eb..9b40d26dd 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -138,7 +138,7 @@ def test_항목_추가_창_카드_격자_표_접기(): assert "일위대가:" not in panel and "pickCombo" not in panel + dlg # 39-1d — 고르기 줄 없음 assert "askNewItem({" in panel and "M02_TreeUnit" not in panel css = (m02 / "M02_MasterTemplete_Structure.css").read_text("utf-8") - assert "grid-template-columns: repeat(2, minmax(0, 1fr));" in css + assert "grid-template-columns: repeat(3, minmax(0, 1fr));" in css # 카드 셋 3열(40-2) assert "grid-auto-rows: var(--m02-struct-card-h);" in css assert "--m02-struct-card-h: min(640px, 70vh);" in css # 31-6 화면 높이에 맞춤 assert "flex: 1 0 var(--m02-struct-grid-min);" in css # 엑셀 격자가 카드 높이를 채움