diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 37793d98f..c9e2fce17 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -8,6 +8,7 @@ * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) * 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`) * 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침 + * 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -18,13 +19,17 @@ import { editQty, editText, handEditable, + openLinkMenu, type BillTable, } from "./B09_Estimation_UI_Tabs_Bill_Edit"; export const buildBill: TabBuild = (ctx): BillTable => { const { sheets, user, setUser } = ctx; const bill: Row = sheets.bill || { rows: [], totals: null }; - const rows: DataRow[] = bill.rows; + const rows: DataRow[] = bill.rows.map((r: Row) => ({ + ...r, + connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "", + })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ money(`price_${key}`, "단가", [label]), @@ -40,6 +45,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { ...pair("labor", "노무비"), ...pair("material", "재료비"), ...pair("expense", "경비"), + text("connect", "연결", 96), text("note", "비고", 200), ], rows, @@ -47,7 +53,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { frozen: 1, hint: "공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " + - "수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " + + "수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " + "레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " + `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, editable: (i, key) => { @@ -79,7 +85,11 @@ export const buildBill: TabBuild = (ctx): BillTable => { return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" "); }, menu: (i) => billMenu(ctx, rows, i), - link: (i) => { + onCell: (i, key) => { + if (key === "connect") openLinkMenu(ctx, rows, i); + }, + link: (i, key) => { + if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함) const ref = rows[i]?.ref as Row | null | undefined; return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null; }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 4473d8de6..5f69f8ab6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -8,7 +8,7 @@ * 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) * 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용 * 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 · - * 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 + * 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -336,6 +336,17 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex } let shared: MapContextMenu | null = null; +/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */ +let lastCell: Element | null = null; + +/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */ +export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void { + const row = rows[i]; + const box = lastCell?.getBoundingClientRect(); + if (!shared || !box || !row?.hand || row.kind !== "item") return; + const [picks, more] = linkItems(ctx, String(row.key)); + shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); +} /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ export function attachRowMenu( @@ -348,6 +359,12 @@ export function attachRowMenu( ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) document.body.append(ctxMenu.element); } + // 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶 + root.addEventListener( + "click", + (event) => (lastCell = (event.target as Element).closest("td")), + true, + ); root.addEventListener("contextmenu", (event) => { const target = event.target as Element; if (target.closest("input")) return; diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index e9c126124..775a9e234 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -74,10 +74,11 @@ def _rows(sheets): def _node(source: str) -> str: + # 표준입력으로 줌 — 「-e」 는 윈도 명령 줄 길이(32k)를 넘음 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", source], - capture_output=True, text=True, encoding="utf-8", timeout=60, + "--input-type=module-typescript"], + input=source, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr return result.stdout @@ -228,3 +229,74 @@ def test_우클릭_레벨_메뉴_TS(): user = {"bill": {"rows": [r if r["key"] != "hand:x" else out["indent"] for r in HAND]}} rows2 = _rows(compute_estimation(INPUTS, user, None, BOOKS)) assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230) + + +LINK_JS = """ +const opened = []; +const picked = {}; +globalThis.createMapContextMenu = () => ({ + element: { isConnected: true, style: {} }, + open: (x, y, items) => opened.push({ x, y, items }), +}); +globalThis.requestAnimationFrame = () => 0; +globalThis.createFindList = () => ({ + search: { value: "", addEventListener() {}, focus() {} }, + hint: {}, + root: {}, + setRows: (rows) => (picked.rows = rows), +}); +globalThis.openModal = (o) => { + picked.title = o.title; + o.mount({ append() {} }, () => (picked.closed = true)); +}; +let user = __USER__; +const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {}, + get user() { return user; }, setUser: (u) => (user = u) }; +const listeners = {}; +const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true }; +attachRowMenu(root, () => []); +const cell = { getBoundingClientRect: () => ({ left: 10, bottom: 40 }) }; +listeners.click({ target: { closest: () => cell } }); +const table = buildBill(ctx); +const at = (key) => table.rows.findIndex((r) => r.key === key); +const name = (x) => (x === "-" ? "-" : x[0]); +const out = { + columns: table.columns.map((c) => c.key).slice(-2), + connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect), + link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")], + cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))), + edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")], +}; +table.onCell(at("hand:a"), "connect"); +table.onCell(at("hand:g1"), "connect"); +table.onCell(at("earth:4"), "connect"); +table.onCell(at("hand:c"), "connect"); +out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]); +opened[1].items[0][1](); // hand:c → 일위대가 호표 고르기 창 +picked.rows[0].onPick(); +out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)]; +out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref; +console.log(JSON.stringify(out)); +""" + + +def test_연결_단추_칸_메뉴_고르기_창_TS(): + sheets = compute_estimation(INPUTS, USER, None, BOOKS) + body = ( + LINK_JS.replace("__USER__", json.dumps(USER, ensure_ascii=False)) + .replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False)) + .replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False)) + ) + src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + out = json.loads(_node(src + body)) + assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸 + assert out["connect"] == ["대가3 ▾", "연결 ▾", "", ""] # 손 항목 줄만 · 연결됨 = 호표 번호 + assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함 + assert out["cls"] == [ + "is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip + assert out["edit"] == [True, False] # 묶음 줄 = 이름만 + picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"] + # 누른 칸 아래(left · bottom) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함 + assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]] + assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용 + assert out["ref"] == DG