From 626c60b68fca56372a8b5dccad6ed964330ff3d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:48:09 +0900 Subject: [PATCH 1/2] =?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 From be57e54fffd531398e7ed9f452d828231f0dcb17 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 11:51:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b09):=20=ED=98=B8=ED=91=9C=EB=B3=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=E2=86=92=20=EB=B0=95=EC=8A=A4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A0=91=EA=B8=B0=20=EC=97=86=EC=95=B0=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A9=EB=A1=9D=ED=91=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=ED=98=B8=ED=91=9C=20=EB=B6=89=EC=9D=80=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=EC=84=A0(=EB=B0=95=EC=8A=A4=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=20=EC=A4=84=20=EA=B5=AC=EA=B0=84=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?)(181-2)?= 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 | 25 ++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 7 +- .../B09_Estimation_UI_Tabs_Groups.ts | 27 +++++-- .../tester/test_157_2_b09_sangeun_views.py | 4 +- resources/tester/test_171_2_group_cards.py | 44 ++++++----- resources/tester/test_171_3_b09_group_tabs.py | 6 +- resources/tester/test_181_2_b09_box_pick.py | 76 +++++++++++++++++++ ui_template/ui_template_group_cards.css | 14 ++-- ui_template/ui_template_group_cards.ts | 29 ++----- 9 files changed, 171 insertions(+), 61 deletions(-) create mode 100644 resources/tester/test_181_2_b09_box_pick.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 6045a5ed5..ecd0e603e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -48,3 +48,28 @@ .b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); } + +/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ +.b09-estimation .ui-data-table__table tbody tr.is-pick td { + --pick-t: inset 0 0 0 0 transparent; + --pick-b: inset 0 0 0 0 transparent; + --pick-l: inset 0 0 0 0 transparent; + --pick-r: inset 0 0 0 0 transparent; + box-shadow: var(--pick-t), var(--pick-b), var(--pick-l), var(--pick-r); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-top td { + --pick-t: inset 0 2px 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-end td { + --pick-b: inset 0 -2px 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick td:first-child { + --pick-l: inset 2px 0 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child { + --pick-r: inset -2px 0 0 var(--color-danger); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index a36538a9f..39109d305 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -192,7 +192,8 @@ export function renderB09Estimation(root: HTMLElement): void { const goto = (link: Link | null): void => { if (link) show(link.tab, link.key); }; - const makeTable = (): DataTable => { + // extra = 줄 class 곁값(181-2 표 보기 호표 구간) + const makeTable = (extra?: (index: number) => string | null): DataTable => { const made = createDataTable({ columns: spec.columns, rows: spec.rows, @@ -202,7 +203,9 @@ export function renderB09Estimation(root: HTMLElement): void { caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, onEdit: spec.onEdit, - rowClass: spec.rowClass, + rowClass: extra + ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null + : spec.rowClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 957d62c6d..8b523847e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Groups.ts * B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 · - * [표로 보기] = 공용 표 그대로. + * [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]). * * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) + * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 * ========================================================================== */ @@ -29,8 +30,7 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"]; export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab); /** 단추 글 = 바꿀 보기 이름 */ -export const viewLabel = (view: GroupView): string => - view === "cards" ? "표로 보기" : "호표별 보기"; +export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기"); export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards"); @@ -146,6 +146,22 @@ export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] { return cards; } +/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */ +export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null { + if (focus === undefined) return null; + const head = focus.split("|")[0]; + const at = rows.flatMap((r, i) => (str(r.key).split("|")[0] === head ? [i] : [])); + return at.length ? [at[0], at[at.length - 1]] : null; +} + +/** 표 줄 class — 구간 안 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` */ +export function pickClass(range: [number, number] | null, i: number): string | null { + if (!range || i < range[0] || i > range[1]) return null; + return ["is-pick", i === range[0] && "is-pick-top", i === range[1] && "is-pick-end"] + .filter(Boolean) + .join(" "); +} + /** 표 열과 같은 숫자 꼴(열 소수 자리) */ function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string { const c = spec.columns.find((col) => col.key === key); @@ -168,7 +184,7 @@ export function renderGroupPanel(o: { hint: HTMLElement | null; focus?: string; goto: (link: Link | null) => void; - makeTable: () => DataTable; + makeTable: (rowClass?: (index: number) => string | null) => DataTable; redraw: () => void; }): HTMLElement[] { const view = chosen.get(o.tab) ?? readGroupView(o.tab); @@ -201,7 +217,8 @@ export function renderGroupPanel(o: { cards.dataset.b09Table = o.tab; return [bar, cards]; } - const table = o.makeTable(); + const range = pickRange(o.spec.rows, o.focus); + const table = o.makeTable((i) => pickClass(range, i)); if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); return [bar, table.root]; } diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index 81dd451f8..b8cbb03d4 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -1,7 +1,7 @@ """157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」. - 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 값 · 같은 차례(171-3 공용 묶음 카드로 갈음) -- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본) +- 단추 = 보기 바꿈(글 「박스보기」 ↔ 「표로 보기」 · 181-2) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본) - 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip) """ @@ -66,7 +66,7 @@ def test_157_2_단추_바꿈(): "\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')," " flipView('table'), flipView(flipView('table'))]));" ) - assert out == ["호표별 보기", "표로 보기", "cards", "table"] + assert out == ["박스보기", "표로 보기", "cards", "table"] def test_157_2_기억_실패_때_카드(): diff --git a/resources/tester/test_171_2_group_cards.py b/resources/tester/test_171_2_group_cards.py index 679ea754d..3a5fe8211 100644 --- a/resources/tester/test_171_2_group_cards.py +++ b/resources/tester/test_171_2_group_cards.py @@ -2,7 +2,7 @@ - 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 이름 · 규격 · 수량 · 단위 · 합계 · 비고 / 합계 줄 = 노무 · 재료 · 경비 · 계 - 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 오른쪽 · 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음) -- 가로 열 수 1~4(기본 2 · 고르면 알림) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) +- 가로 열 수 1~4(기본 2 · 고르면 알림) · 접기 없음(181-2) · 이동해 온 박스 = `is-pick`(붉은 외곽선) · 머리 누름 = 이동 - 157-2 산근 카드 파일 = 이 양식으로 갈음(지움) """ @@ -43,11 +43,9 @@ def test_도우미_열수_요약_풍선(): out = _run( "\nconsole.log(JSON.stringify({" " cols: [0, 1, 2, 3, 4, 5, '3', null, undefined].map(clampColumns)," - " summary: [12, 13].map(isSummary)," " tip: [partsTip([1000, 0, null]), partsTip([null, null, null]), partsTip(null)] }));" ) assert out["cols"] == [2, 1, 2, 3, 4, 2, 2, 2, 2] - assert out["summary"] == [False, True] assert out["tip"] == ["노무 1,000 · 재료 0 · 경비 -", None, None] @@ -91,7 +89,7 @@ def test_카드_머리_몸_합계_줄(): assert out["foot"] == ["노무 100", "재료 20", "경비 3", "계 123", "빈 줄 1"] assert out["link"] == [True, False] assert out["cols"] == "2" # 기본 2 - assert out["summary"] == ["1", "2", "3", "4"] # 12 이하 = 요약 단추 없음 + assert out["summary"] == ["1", "2", "3", "4"] # 가로 열 수 단추뿐(요약 · 접기 단추 없음) def test_가로_열수_고름_알림_누름_이동(): @@ -111,29 +109,35 @@ def test_가로_열수_고름_알림_누름_이동(): assert out["picks"] == [21, 20] # 줄 · 머리 누름 = 표 줄 번호 그대로(갈 곳은 부르는 쪽 link) -def test_묶음_12_넘으면_요약_보기(): +def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선(): out = _run( "\nconst root = make(13, { focus: 52 });" "\nconst cards = root.all('.ui-group-card');" - "\nconst closed = () => cards.map((c) => c.classList.contains('is-closed'));" - "\nconst first = closed();" "\nconst focused = root.querySelector('[data-at=\"52\"]');" + "\nconst first = cards.map((c) => c.classList.contains('is-pick'));" "\ncards[0].querySelector('.ui-group-card__head').click();" - "\nconst opened = closed()[0];" - "\nconst btn = (label) => root.all('.ui-btn').find((b) => b.textContent === label);" - "\nbtn('모두 펼침').click(); const all = closed();" - "\nbtn('모두 접기').click(); const none = closed();" - "\nconsole.log(JSON.stringify({ first, opened, all, none, picks," + "\nconsole.log(JSON.stringify({ first, picks," + " closed: cards.map((c) => c.classList.contains('is-closed'))," + " buttons: root.all('.ui-btn').map((b) => b.textContent)," " foot: cards.every((c) => c.querySelector('.ui-group-card__foot'))," - " focus: [focused.classList.contains('is-focus'), !!focused.scrolled] }));" + " focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));" ) - # 이동해 온 줄(5번째 묶음 줄) = 그 묶음만 펼침 · 강조 · 스크롤 - assert out["first"] == [True] * 4 + [False] + [True] * 8 + # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤 + assert out["first"] == [False] * 4 + [True] + [False] * 8 assert out["focus"] == [True, True] - assert out["opened"] is False # 머리 누름 = 펼침 - assert out["picks"] == [] # 요약 보기 머리 누름은 이동 아님 - assert out["all"] == [False] * 13 and out["none"] == [True] * 13 - assert out["foot"] is True # 접어도 합계 줄은 보임(몸만 숨김) + assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음 + assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로) + assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음 + assert out["foot"] is True + + +def test_고른_박스_없으면_외곽선_없음(): + out = _run( + "\nconst root = make(3);" + "\nconsole.log(JSON.stringify(root.all('.ui-group-card').map(" + "(c) => c.classList.contains('is-pick'))));" + ) + assert out == [False, False, False] def test_양식_글자_간격_숫자(): @@ -146,6 +150,8 @@ def test_양식_글자_간격_숫자(): "font-size: inherit;", ] assert "padding: 1px var(--spacing-4);" in css # 줄 간격 좁게 + assert "is-closed" not in css and "is-closed" not in (ROOT / COMMON).read_text("utf-8") + assert "border-color: var(--color-danger);" in css # 고른 박스 = B06 고름 꼴(181-2) assert "spacing-16" not in css and "card-padding" not in css # 공용 카드 16px 끼움 없음 assert "grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr));" in css assert "text-align: right;" in css diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 98d98d0d2..cd5dd8e83 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -151,9 +151,10 @@ def test_화면_보기_바꿈_누름_이동(): "\nview.querySelector('[data-at=\"2\"]').click();" "\nview.querySelector('[data-at=\"0\"]').click();" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" + "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" "\nbar.children[0].click();" "\nconst [bar2, view2] = renderGroupPanel(o);" - "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, redraws," + "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," " label2: bar2.children[0].textContent, tag2: view2.tagName, focused," " kept: Object.fromEntries(kept) }));", RAW, @@ -161,8 +162,9 @@ def test_화면_보기_바꿈_누름_이동(): assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조 + assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) assert out["redraws"] == 1 - assert out["label2"] == "호표별 보기" and out["tag2"] == "TABLE" and out["focused"] == [3] + assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py new file mode 100644 index 000000000..05d14ce34 --- /dev/null +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -0,0 +1,76 @@ +"""181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체. + +- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움 +- 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` +- 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴) +""" + +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 COMMON, GROUPS, TABS, run # noqa: E402 + +ROWS = [ # 호표 A(3줄) · 호표 B(4줄) · 호표 C(1줄) + {"key": "A"}, {"key": "A|1"}, {"key": "A|합계"}, + {"key": "B"}, {"key": "B|1"}, {"key": "B|2"}, {"key": "B|총계"}, + {"key": "C"}, +] # fmt: skip + + +def test_구간_첫_끝_줄(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst rows = {json.dumps(ROWS)};" + "\nconst show = (focus) => { const r = pickRange(rows, focus);" + " return [r, rows.map((_, i) => pickClass(r, i))]; };" + "\nconsole.log(JSON.stringify({ a: show('A|1'), b: show('B'), c: show('C')," + " none: show(undefined), unknown: show('Z|1') }));", + ) + assert out["a"][0] == [0, 2] + assert out["a"][1] == [ + "is-pick is-pick-top", "is-pick", "is-pick is-pick-end", None, None, None, None, None, + ] # fmt: skip + assert out["b"][0] == [3, 6] + assert out["b"][1][3:7] == [ + "is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end", + ] # fmt: skip + assert out["b"][1][:3] == [None] * 3 # 다른 호표 없음 + assert out["c"][0] == [7, 7] and out["c"][1][7] == "is-pick is-pick-top is-pick-end" + assert out["none"] == [None, [None] * 8] and out["unknown"] == [None, [None] * 8] + + +def test_표_보기_줄_class_넘김(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};" + "\nlet extra = null;" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," + " makeTable: (e) => { extra = e; return { root: document.createElement('table')," + " focusRow: () => {} }; } };" + "\nchosen.set('dg', 'table');" + "\nrenderGroupPanel(o);" + "\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));", + ) + assert out == ( + [None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end"] + [None] + ) + + +def test_글_접기_없음_CSS_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups + common = (ROOT / COMMON).read_text("utf-8") + for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"): + assert gone not in common + assert 'box?.classList.add("is-pick")' in common + page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + for sel in ("tr.is-pick td", "tr.is-pick.is-pick-top td", "tr.is-pick.is-pick-end td"): + assert sel in page + assert page.count("var(--color-danger)") == 4 # 위 · 아래 · 왼 · 오른 2px + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "extra?: (index: number) => string | null" in shell diff --git a/ui_template/ui_template_group_cards.css b/ui_template/ui_template_group_cards.css index a2e6f033f..3fa11762a 100644 --- a/ui_template/ui_template_group_cards.css +++ b/ui_template/ui_template_group_cards.css @@ -47,6 +47,12 @@ border-radius: var(--radius-cards); } +/* 목록표에서 고른 호표 — B06 횡단 카드 고름과 같은 붉은 외곽선(181-2) */ +.ui-group-card.is-pick { + border-color: var(--color-danger); + box-shadow: 0 0 0 2px var(--color-danger); +} + .ui-group-card__head { display: flex; flex-wrap: wrap; @@ -76,14 +82,6 @@ overflow-x: auto; } -.ui-group-card.is-closed .ui-group-card__body { - display: none; -} - -.ui-group-card.is-closed .ui-group-card__head { - cursor: pointer; -} - .ui-group-card__lines { width: 100%; border-collapse: collapse; diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 086ce3552..65923f767 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -5,8 +5,9 @@ * 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) / * 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선) * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) - * 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) - * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조 + * 접기 없음 — 묶음 모두 펼친 박스 + * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 + * (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`) * ========================================================================== */ import "./ui_template_group_cards.css"; @@ -68,7 +69,6 @@ export interface GroupCardsOptions { actions?: HTMLElement[]; } -export const GROUP_SUMMARY_OVER = 12; export const GROUP_COLUMNS = [1, 2, 3, 4] as const; export const PART_LABELS = ["노무", "재료", "경비"] as const; @@ -76,9 +76,6 @@ export const PART_LABELS = ["노무", "재료", "경비"] as const; export const clampColumns = (value: unknown): number => GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2; -/** 요약 보기 — 묶음 12 넘음 */ -export const isSummary = (count: number): boolean => count > GROUP_SUMMARY_OVER; - const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 }); const plain = (value: number | null | undefined): string => value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value); @@ -173,7 +170,7 @@ function card(g: GroupCard, o: Required return box; } -/** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */ +/** 묶음 카드 판 — 머리 띠(가로 열 수 · 부르는 쪽 단추) + 카드 격자 */ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain }; const root = document.createElement("div"); @@ -184,9 +181,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.style.setProperty("--group-cols", String(columns)); const cards = opts.groups.map((g) => card(g, fmt)); grid.append(...cards); - const summary = isSummary(opts.groups.length); - const setOpen = (box: Element, open: boolean) => box.classList.toggle("is-closed", !open); - if (summary) cards.forEach((c) => setOpen(c, false)); const bar = document.createElement("div"); bar.className = "ui-group-cards__bar"; @@ -215,14 +209,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { }); pick.append(...colButtons); bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`)); - if (summary) { - const all = (open: boolean) => () => cards.forEach((c) => setOpen(c, open)); - bar.append( - span("ui-group-cards__count", "요약 보기 — 머리를 누르면 펼침"), - createButton({ label: "모두 펼침", variant: "ghost", size: "sm", onClick: all(true) }), - createButton({ label: "모두 접기", variant: "ghost", size: "sm", onClick: all(false) }), - ); - } const tail = document.createElement("div"); tail.className = "ui-group-cards__actions"; tail.append(...(opts.actions ?? [])); @@ -232,18 +218,15 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.addEventListener("click", (event) => { const el = (event.target as Element).closest("[data-at]"); if (!el || !grid.contains(el)) return; - const box = el.closest(".ui-group-card"); - if (summary && box && el.classList.contains("ui-group-card__head")) - return setOpen(box, box.classList.contains("is-closed")); opts.onPick?.(Number(el.dataset.at)); }); if (opts.focus !== undefined && opts.focus >= 0) { const el = grid.querySelector(`[data-at="${opts.focus}"]`); const box = el?.closest(".ui-group-card"); - if (box) setOpen(box, true); + box?.classList.add("is-pick"); el?.classList.add("is-focus"); - requestAnimationFrame(() => el?.scrollIntoView({ block: "center" })); + requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" })); } return root; }