From 626c60b68fca56372a8b5dccad6ed964330ff3d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:48:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=9B=90=EA=B0=80=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=ED=83=AD=20=EB=91=90=20=EC=B8=B5=20=E2=80=94=20?= =?UTF-8?q?=EC=9C=84=20=ED=83=AD=2010=20=C2=B7=20=EC=95=84=EB=9E=98=20?= =?UTF-8?q?=ED=83=AD=20=EB=AC=B6=EC=9D=8C(B08=20=EA=BC=B4)=20=C2=B7=20?= =?UTF-8?q?=EC=9C=84=20=ED=83=AD=20=EB=8B=A4=EC=8B=9C=20=EB=88=84=EB=A6=84?= =?UTF-8?q?=20=3D=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=B3=B8=20=ED=95=98?= =?UTF-8?q?=EC=9C=84(181-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_UI_Page.css | 7 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 23 +--- B09_Estimation/B09_Estimation_UI_TabBar.ts | 71 ++++++++++++ B09_Estimation/B09_Estimation_UI_Tabs.ts | 18 +++ resources/tester/helper_b09_group_cards.py | 1 + resources/tester/test_181_1_b09_tab_groups.py | 104 ++++++++++++++++++ 6 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_TabBar.ts create mode 100644 resources/tester/test_181_1_b09_tab_groups.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index b08290070..6045a5ed5 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -20,11 +20,16 @@ .b09-estimation__tabs { display: flex; flex-wrap: wrap; - gap: var(--spacing-4); + gap: var(--spacing-8); /* B08 위 · 보조 탭 줄과 같은 값(181-1) */ padding-bottom: var(--spacing-8); border-bottom: 1px solid var(--color-border); } +/* 아래 탭 없는 위 탭 — 보이지 않는 단추 한 줄로 높이만 채움(탭 바꿀 때 몸통이 안 튐) */ +.b09-estimation__tabs--blank > .ui-btn { + visibility: hidden; +} + /* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ [data-b09-table] thead th { white-space: normal; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index a9497f0e8..a36538a9f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -28,6 +28,7 @@ import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; +import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { buildCheck, @@ -150,15 +151,14 @@ export function renderB09Estimation(root: HTMLElement): void { let sheets: Row = {}; let current: TabId = "check"; - const tabBar = document.createElement("div"); - tabBar.className = "b09-estimation__tabs"; - tabBar.setAttribute("role", "tablist"); + // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` + const bars = createTabBars((tab) => show(tab)); const panel = document.createElement("div"); panel.className = "b09-estimation__panel"; panel.setAttribute("role", "tabpanel"); const body = document.createElement("div"); body.className = "b09-estimation"; - body.append(tabBar, panel); + body.append(bars.top, bars.sub, panel); const made = document.createElement("p"); made.className = "b09-estimation__hint"; @@ -181,20 +181,7 @@ export function renderB09Estimation(root: HTMLElement): void { /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; - tabBar.replaceChildren( - ...TABS.map(([id, label]) => { - const button = createButton({ - label, - variant: id === tab ? "filled" : "ghost", - size: "sm", - onClick: () => id !== current && show(id), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(id === tab)); - button.dataset.b09Tab = id; - return button; - }), - ); + bars.draw(tab); if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); const build = BUILDERS[tab]; diff --git a/B09_Estimation/B09_Estimation_UI_TabBar.ts b/B09_Estimation/B09_Estimation_UI_TabBar.ts new file mode 100644 index 000000000..c80caaf7e --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_TabBar.ts @@ -0,0 +1,71 @@ +/* ============================================================================= + * B09_Estimation_UI_TabBar.ts + * B09 탭 줄 두 층(PLAN 181-1) — 위 탭(md) · 아래 탭(sm) · B08 수량산출 페이지 꼴. + * + * 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움) + * 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭 + * 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { groupOf, tabName, TAB_GROUPS, type TabId } from "./B09_Estimation_UI_Tabs"; + +export interface TabBars { + top: HTMLElement; + sub: HTMLElement; + /** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */ + draw: (tab: TabId) => void; +} + +function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) { + const button = createButton({ + label, + variant: on ? "filled" : "ghost", + ...(size ? { size } : {}), + onClick: () => !on && pick(), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(on)); + return button; +} + +export function createTabBars(onPick: (tab: TabId) => void): TabBars { + const top = document.createElement("div"); + top.className = "b09-estimation__tabs"; + top.setAttribute("role", "tablist"); + const sub = document.createElement("div"); + sub.className = "b09-estimation__tabs"; + const lastSeen = new Map(); + + const draw = (tab: TabId): void => { + const [, picked] = groupOf(tab); + lastSeen.set(picked[0], tab); + top.replaceChildren( + ...TAB_GROUPS.map(([label, tabs]) => { + const button = tabButton(label, tabs === picked, undefined, () => + onPick(lastSeen.get(tabs[0]) ?? tabs[0]), + ); + button.dataset.b09Group = tabs[0]; + if (tabs.length === 1) button.dataset.b09Tab = tabs[0]; + return button; + }), + ); + const single = picked.length === 1; + sub.classList.toggle("b09-estimation__tabs--blank", single); + sub.setAttribute("role", single ? "presentation" : "tablist"); + sub.setAttribute("aria-hidden", String(single)); + if (single) { + const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" }); + ghost.tabIndex = -1; + return void sub.replaceChildren(ghost); + } + sub.replaceChildren( + ...picked.map((id) => { + const button = tabButton(tabName(id), id === tab, "sm", () => onPick(id)); + button.dataset.b09Tab = id; + return button; + }), + ); + }; + return { top, sub, draw }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index e2c2ae4aa..1b07aa109 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -56,6 +56,24 @@ export const TABS: Array<[TabId, string]> = [ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id; +/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ +export const TAB_GROUPS: Array<[string, TabId[]]> = [ + ["연결 점검", ["check"]], + ["공사원가계산서", ["cost"]], + ["총괄설계내역서", ["total"]], + ["설계내역서", ["bill"]], + ["일위대가", ["dg_list", "dg"]], + ["단가산출", ["sg_list", "sg"]], + ["환율및기초자료", ["basis"]], + ["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]], + ["재료", ["material", "compare", "sum_material"]], + ["노무", ["labor", "sum_labor"]], +]; + +/** 탭이 든 위 탭 묶음 */ +export const groupOf = (tab: TabId): [string, TabId[]] => + TAB_GROUPS.find(([, tabs]) => tabs.includes(tab)) ?? TAB_GROUPS[0]; + /** 다른 탭 줄 — 키가 같은 줄로 이동 */ export interface Link { tab: TabId; diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 104b84165..45027fdd4 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -58,6 +58,7 @@ class E { get className() { return [...this.cls].join(" "); } set className(v) { this.cls = new Set(String(v).split(/\s+/).filter(Boolean)); } append(...xs) { for (const x of xs) { x.parent = this; this.children.push(x); } } + replaceChildren(...xs) { this.children = []; this.append(...xs); } prepend(...xs) { for (const x of xs.reverse()) { x.parent = this; this.children.unshift(x); } } addEventListener(t, f) { (this.on[t] ??= []).push(f); } setAttribute(k, v) { this.attrs[k] = String(v); } diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py new file mode 100644 index 000000000..c1e288330 --- /dev/null +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -0,0 +1,104 @@ +"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. + +- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 +- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) +""" + +import json +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 + +BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" + +# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 +EXPECTED = [ + ["연결 점검", ["check"]], + ["공사원가계산서", ["cost"]], + ["총괄설계내역서", ["total"]], + ["설계내역서", ["bill"]], + ["일위대가", ["dg_list", "dg"]], + ["단가산출", ["sg_list", "sg"]], + ["환율및기초자료", ["basis"]], + ["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]], + ["재료", ["material", "compare", "sum_material"]], + ["노무", ["labor", "sum_labor"]], +] + + +def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): + out = run( + [TABS], + "\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t)," + " of: TABS.map(([t]) => groupOf(t)[0]) }));", + ) + assert out["groups"] == EXPECTED + flat = [t for _, tabs in out["groups"] for t in tabs] + assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19 + # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 + firsts = list(dict.fromkeys(out["of"])) + assert firsts == [g[0] for g in EXPECTED] + assert len(firsts) == 10 + + +def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): + out = run( + [TABS, BAR], + """ +const picks = []; +const bars = createTabBars((t) => picks.push(t)); +const view = () => ({ + top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'], + b.dataset.b09Group, b.dataset.b09Tab ?? null]), + sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]), + blank: bars.sub.classList.contains('b09-estimation__tabs--blank'), + role: bars.sub.attrs.role, +}); +bars.draw('dg'); +const a = view(); +bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.sub.children[0].click(); // 일위대가목록표 +bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 +const b = view(); +bars.draw('sum_material'); +bars.draw('dg'); +bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.draw('cost'); +const c = view(); +bars.top.children[0].click(); // 연결 점검 = 단독 위 탭 +bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 +console.log(JSON.stringify({ a, b, c, picks })); +""", + ) + a, b, c = out["a"], out["b"], out["c"] + assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [ + "연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가" + ] # fmt: skip + assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5 + assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") + assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] + assert not a["blank"] and a["role"] == "tablist" + assert b["sub"] == [ + ["재료비목록표", "false", "material"], + ["자재단가대비표", "true", "compare"], + ["재료비수량금액집계표", "false", "sum_material"], + ] # fmt: skip + assert b["top"][8][2] == "true" and b["top"][4][2] == "false" + # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 + assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 + assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None + assert out["picks"] == ["dg_list", "sum_material", "machine", "check"] + + +def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "createTabBars((tab) => show(tab))" in shell and "bars.draw(tab);" in shell + assert "body.append(bars.top, bars.sub, panel);" in shell + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert ".b09-estimation__tabs--blank > .ui-btn" in css and "visibility: hidden" in css