feat(b09): 원가검토 탭 두 층 — 위 탭 10 · 아래 탭 묶음(B08 꼴) · 위 탭 다시 누름 = 마지막 본 하위(181-1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 11:48:09 +09:00
co-authored by Claude Sonnet 5.5
parent 68e8c896e4
commit 626c60b68f
6 changed files with 205 additions and 19 deletions
+6 -1
View File
@@ -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;
+5 -18
View File
@@ -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];
@@ -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<TabId, TabId>();
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 };
}
+18
View File
@@ -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;
@@ -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); }
@@ -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