diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 2d29972ac..b82d9cb06 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -130,9 +130,10 @@ --pick-r: inset -2px 0 0 var(--color-danger); } -/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */ +/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) · 257-3 초록 테두리 + * (바탕 색 아님 — 입력 칸 테두리(outline)와 달리 안쪽 상자 그림자) */ .b09-estimation .ui-data-table__table tbody td.is-picked { - background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas)); + box-shadow: inset 0 0 0 2px var(--color-success); font-weight: var(--font-weight-bold); } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1f7c9faeb..d1f6797ea 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -246,6 +246,17 @@ export function renderB09Estimation(root: HTMLElement): void { const box = cell.getBoundingClientRect(); openMenuAt(box.left, box.bottom, items); }); + // 257-3 칸 우클릭 메뉴 — 누른 칸 자리에 B09 공용 메뉴(줄 메뉴가 없는 표) + if (spec.cellMenu) + made.root.addEventListener("contextmenu", (event) => { + const cell = (event.target as Element).closest("tr[data-i] td"); + const column = cell ? spec.columns[cellColumn(cell)] : undefined; + const items = + cell && column && spec.cellMenu?.(Number(cell.parentElement?.dataset.i), column.key); + if (!items?.length) return; + event.preventDefault(); + openMenuAt(event.clientX, event.clientY, items); + }); // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3) if (spec.tip) attachHoverInfo( diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 6c5b8dd3b..dbaa1c443 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -111,6 +111,8 @@ export interface TabTable { cellClass?: (index: number, key: string) => string | null | undefined; /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ choices?: (index: number, key: string) => MapContextMenuItem[] | null; + /** 칸 우클릭 메뉴(257-3 · 줄 메뉴가 없는 표 · 빈 것 = 안 엶) */ + cellMenu?: (index: number, key: string) => MapContextMenuItem[] | null; /** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */ level?: DataTableOptions["level"]; /** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 4eb8eb286..02ed55245 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -6,7 +6,8 @@ * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * * 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 → - * `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) · + * `pick` · 옛 수기 `applied` 지움 · 257-3 우클릭 「적용 단가 바꾸기」 = 같은 목록) · + * 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처 · 초록 테두리) · * 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력 * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) * 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶) @@ -177,6 +178,14 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { else delete materials[key]; setUser({ ...user, materials }); }; + const chooseApplied = (r: Row): MapContextMenuItem[] => + appliedChoices(r, (s) => { + const cell = mine(r.key); + delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 + if (s) cell.pick = s; + else delete cell.pick; + put(r.key, cell); + }); return { columns: [ qty("no", "번호", 0), @@ -215,18 +224,15 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { else cell.quote = v; put(String(row.key), cell); }, - // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴 + // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조(257-3 초록 테두리) · 적용 단가 칸 누름 = 고르기 메뉴 cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null), - choices: (i, key) => { + choices: (i, key) => (key === "applied" && rows[i] ? chooseApplied(rows[i]) : null), + // 257-3 우클릭 「적용 단가 바꾸기」 → 출처 목록(적용 단가 칸 · 고른 출처 단가 칸 · 최저가여도 바꿈) + cellMenu: (i, key) => { const r = rows[i]; - if (key !== "applied" || !r) return null; - return appliedChoices(r, (s) => { - const cell = mine(r.key); - delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 - if (s) cell.pick = s; - else delete cell.pick; - put(r.key, cell); - }); + if (!r || (key !== "applied" && key !== `p:${r.applied_from}`)) return null; + const children = chooseApplied(r).filter((x): x is [string, () => void] => Array.isArray(x)); + return [{ label: "적용 단가 바꾸기", children }]; }, onCell: (i, key) => { const row = out[i]; diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py index dcc5b1c91..3e4f90e27 100644 --- a/resources/tester/test_183_3_b09_applied_pick.py +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -101,7 +101,7 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): assert 'editable: (_i, key) => key === "p:견적",' in compare assert "nextPick" not in base and 'key === "applied_from"' not in compare assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare - assert 'if (key !== "applied" || !r) return null;' in compare + assert 'key === "applied" && rows[i] ? chooseApplied(rows[i]) : null' in compare # 257-3 메뉴 둘 공용 assert "delete cell.applied" in compare shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") assert ( @@ -110,4 +110,4 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit css = _src("B09_Estimation/B09_Estimation_UI_Page.css") - assert "td.is-picked" in css and "var(--color-accent)" in css + assert "td.is-picked" in css and "var(--color-success)" in css # 257-3 초록 테두리 diff --git a/resources/tester/test_257_3_b09_compare_applied_menu.py b/resources/tester/test_257_3_b09_compare_applied_menu.py new file mode 100644 index 000000000..c631ea294 --- /dev/null +++ b/resources/tester/test_257_3_b09_compare_applied_menu.py @@ -0,0 +1,67 @@ +"""257-3 B09 자재단가대비표 적용 단가 — 초록 테두리 · 우클릭 「적용 단가 바꾸기」(출처 목록). + +- 고른 출처 단가 칸 = 초록 테두리(inset box-shadow · 바탕 색 아님) +- 우클릭 = 적용 단가 칸 · 고른 출처 단가 칸 → 「적용 단가 바꾸기」 하위 목록(왼쪽 누름 메뉴와 같은 항목 · 최저가여도 바꿈) +- 왼쪽 누름 메뉴(choices)는 그대로 +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +BASE = "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts" +PRELUDE = r""" +const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "견적"]; +const openQuotes = () => {}; +""" + +JS = """ +let user = {}; +const sheets = { compare: [ + { key: "m1", no: 1, name: "철근", spec: "D13", unit: "kg", lowest: 1000, lowest_from: "유통물가", + applied: 1000, applied_from: "유통물가", pick: null, + prices: { 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: null } }, +] }; +const t = buildCompare({ sheets, get user() { return user; }, setUser: (u) => (user = u) }); +const menu = t.cellMenu(0, "applied"); +const labels = menu[0].children.map((c) => c[0]); +menu[0].children[0][1](); // 물가정보 = 최저가 아닌 출처로 바꿈 +const afterPick = JSON.stringify(user.materials); +menu[0].children[1][1](); // 유통물가 = 최저가여도 고른 출처로 박음 +const afterLowest = JSON.stringify(user.materials); +console.log(JSON.stringify({ + label: menu[0].label, labels, afterPick, afterLowest, + onPicked: t.cellMenu(0, "p:유통물가")?.[0].label ?? null, + other: [t.cellMenu(0, "name"), t.cellMenu(0, "p:물가정보"), t.cellMenu(5, "applied")], + left: t.choices(0, "applied").length, leftOther: t.choices(0, "name"), + cls: [t.cellClass(0, "p:유통물가"), t.cellClass(0, "p:물가정보")], +})); +""" + + +def test_우클릭_적용_단가_바꾸기_출처_목록(): + out = run([TABS, BASE], JS, PRELUDE) + assert out["label"] == "적용 단가 바꾸기" + assert out["labels"] == ["물가정보 1,200", "✓ 유통물가 1,000 · 최저", "✓ 최저(자동)"] + assert out["afterPick"] == '{"m1":{"pick":"물가정보"}}' + assert out["afterLowest"] == '{"m1":{"pick":"유통물가"}}' # 최저가여도 바꿈 + assert out["onPicked"] == "적용 단가 바꾸기" # 초록 테두리 칸 우클릭도 같은 메뉴 + assert out["other"] == [None, None, None] + assert ( + out["left"] == 4 and out["leftOther"] is None + ) # 왼쪽 누름 메뉴 그대로(출처 둘 · 구분선 · 최저) + assert out["cls"] == ["is-picked", None] + + +def test_배선_쉘_우클릭_CSS_초록_테두리(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert 'made.root.addEventListener("contextmenu"' in shell and "spec.cellMenu" in shell + assert "openMenuAt(event.clientX, event.clientY, items)" in shell + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + box = css.split("td.is-picked {", 1)[1].split("}", 1)[0] + assert "box-shadow: inset 0 0 0 2px var(--color-success)" in box and "background" not in box