diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 21808d2bf..aa1687d73 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -199,6 +199,7 @@ export function renderB09Estimation(root: HTMLElement): void { caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, onEdit: spec.onEdit, + rowClass: 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.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 8ffb8f9c9..ee28a789e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -85,6 +85,8 @@ export interface TabTable { onEdit?: (index: number, key: string, text: string) => void; /** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */ onCell?: (index: number, key: string) => void; + /** 줄 꼴 class(공용 표 `rowClass` · 예 제목 줄 굵게) */ + rowClass?: (index: number) => string | null | undefined; } export type TabBuild = (ctx: TabContext) => TabTable | string; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 8114f1b5e..7e3635ca0 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -4,6 +4,7 @@ * * 목록표 = 호표 한 줄(합계 · 노무비 · 재료비 · 경비) · 줄 누름 = 그 블록으로 * 일위대가표 = 블록(머리 · [품셈 인용] · 줄 · 합계) · 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록) + * 줄 꼴(155-2) = 머리 · 합계 굵게(`is-title`) · 인용 · 줄 = 들여쓰기 「ㄴ」(2단 · 겹 대가는 안 펼침) * 연결된 구조물 조합 사본이 없으면 빈 표 「연결 없음」 * ========================================================================== */ @@ -21,6 +22,8 @@ import { const EMPTY = "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; +/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ +const CHILD = " ㄴ "; const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; /** 블록 줄 → 단가를 낸 탭 */ @@ -59,7 +62,9 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { if (!blocks.length) return EMPTY; const rows: DataRow[] = []; const links: Array = []; + const titles = new Set(); for (const b of blocks) { + titles.add(rows.length); rows.push({ key: b.id, name: `제 ${b.no} 호표 ${b.name}`, @@ -69,13 +74,13 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { }); links.push(null); if (b.cite) { - rows.push({ key: `${b.id}|cite`, name: ` ${b.cite}`, note: "품셈 인용" }); + rows.push({ key: `${b.id}|cite`, name: `${CHILD}${b.cite}`, note: "품셈 인용" }); links.push(null); } b.lines.forEach((ln: Row, n: number) => { rows.push({ key: `${b.id}|${n}`, - name: ` ${ln.name || ""}`, + name: `${CHILD}${ln.name || ""}`, spec: ln.spec || "", qty: ln.qty, unit: ln.unit || "", @@ -92,6 +97,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { const tab = LINE_TAB[ln.kind]; links.push(tab && ln.key ? { tab, key: String(ln.key) } : null); }); + titles.add(rows.length); rows.push({ key: `${b.id}|합계`, name: " 합 계", @@ -122,5 +128,6 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { frozen: 1, hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, link: (i: number) => links[i] ?? null, + rowClass: (i: number) => (titles.has(i) ? "is-title" : null), }; }; diff --git a/resources/tester/helper_ui_data_table.cjs b/resources/tester/helper_ui_data_table.cjs index 653e6e114..65a334c15 100644 --- a/resources/tester/helper_ui_data_table.cjs +++ b/resources/tester/helper_ui_data_table.cjs @@ -90,6 +90,12 @@ console.log( }), escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "" }], rows: [{ x: "a&<" }] }), noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes(""), + rowClass: m.renderDataTableHtml({ + columns: [{ key: "x", label: "X" }], + rows: [{ x: "제 1 호표" }, { x: " ㄴ 석공" }], + total: { x: 3 }, + rowClass: (i) => (i === 0 ? "is-title" : null), + }), scale: m.SCALE, widths: m.renderDataTableHtml({ columns: [ diff --git a/resources/tester/test_155_b09_tabs.py b/resources/tester/test_155_b09_tabs.py new file mode 100644 index 000000000..8ca2bd81b --- /dev/null +++ b/resources/tester/test_155_b09_tabs.py @@ -0,0 +1,64 @@ +"""155 B09 탭 화면 꼴 — 일위대가표 줄 꼴(155-2). + +`B09_Estimation_UI_Tabs.ts` + `B09_Estimation_UI_Tabs_Labor.ts` 를 Node(타입 지움)로 돌려 +견본 블록 하나(머리 · 품셈 인용 · 줄 둘 · 합계)의 표 줄 글 · 줄 꼴을 판정. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +BLOCK = { + "id": "DG1", + "no": 1, + "name": "찰쌓기", + "spec": "", + "unit": "m2", + "kind": "품셈", + "cite": "건설표준품셈 7-1-2", + "lines": [ + {"kind": "labor", "key": "L00005", "name": "석공", "qty": 0.072, "unit": "인", + "price": 258935, "amount": 18643.3, "ref_no": "노무3", + "prices": {"labor": 258935, "material": None, "expense": None}, + "amounts": {"labor": 18643.3, "material": None, "expense": None}}, + {"kind": "sangeun", "key": "SG1", "name": "뒷채움", "qty": 0.5, "unit": "m3", + "price": 8328, "amount": 4164, "ref_no": "산근 1호표", + "prices": {"labor": 6878, "material": 538, "expense": 912}, + "amounts": {"labor": 3439, "material": 269, "expense": 456}}, + ], + "sum": 22807, "labor": 22082, "material": 269, "expense": 456, +} # fmt: skip + + +def _node(js_body): + src = "".join( + IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8")) + for n in ("UI_Tabs", "UI_Tabs_Labor") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + js_body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_155_2_일위대가표_제목_굵게_하위_ㄴ(): + out = _node( + f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});" + "\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name)," + " cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));" + ) + assert out["names"] == [ + "제 1 호표 찰쌓기", + " ㄴ 건설표준품셈 7-1-2", + " ㄴ 석공", + " ㄴ 뒷채움", + " 합 계", + ] + assert out["cls"] == ["is-title", None, None, None, "is-title"] diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py index d35017050..70ab16487 100644 --- a/resources/tester/test_ui_data_table.py +++ b/resources/tester/test_ui_data_table.py @@ -112,3 +112,13 @@ def test_155_1_글자_14px_열_폭_같은_비율(r): assert 'class="is-frozen is-edge" style="left:112px"' in w # 둘째 고정 열 left 도 같이 assert "font-size: var(--text-body-sm);" in r["css"] assert "--dt-row-h: 28px;" in r["css"] + + +def test_155_2_줄_꼴_곁값(r): + # 155-2 — rowClass 가 낸 class 를 본문 줄에만 · 빈 값이면 안 붙임 · 앞 빈칸을 접지 않는 서식 + rc = r["rowClass"] + assert '제 1 호표' in rc + assert ' ㄴ 석공' in rc + assert rc.count("is-title") == 1 # 합계 줄엔 안 붙음 + assert "white-space-collapse: preserve-spaces;" in r["css"] + assert "tr.is-title td" in r["css"] diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 85ad737d7..af0a5e26d 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -52,6 +52,7 @@ background: var(--color-canvas); text-overflow: ellipsis; white-space: nowrap; + white-space-collapse: preserve-spaces; /* 앞 빈칸(들여쓰기) · 겹 빈칸을 접지 않음 · 줄바꿈은 안 함 */ } /* 숫자 — 오른쪽 맞춤 · 자리 폭 같게 */ @@ -134,6 +135,12 @@ font-weight: var(--font-weight-bold); } +/* 제목 줄(PLAN 155-2 · rowClass) — 굵게 */ +.ui-data-table__table tbody tr.is-title td { + color: var(--color-text); + font-weight: var(--font-weight-bold); +} + .ui-data-table__table tbody tr.is-clickable { cursor: pointer; } diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 5c64b59d2..f16bc7ecf 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -9,6 +9,7 @@ * 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 } * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) + * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 * ========================================================================== */ import "./ui_template_data_table.css"; @@ -57,6 +58,8 @@ export interface DataTableOptions { editable?: (index: number, key: string) => boolean; /** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */ onEdit?: (index: number, key: string, text: string) => void; + /** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */ + rowClass?: (index: number) => string | null | undefined; } export interface DataTable { @@ -135,6 +138,7 @@ function rowHtml( edit: number | null = null, editable?: (index: number, key: string) => boolean, focus = false, + extra: string | null | undefined = null, ): string { let tr = ""; for (let j = 0; j < cols.length; j++) { @@ -157,11 +161,13 @@ function rowHtml( (fmts[j] ? ' data-num=""' : ""); tr += `${esc(txt)}`; } - const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus"] + const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra] .filter(Boolean) .join(" "); const data = index !== null ? ` data-i="${index}"` : ""; - return cls || data ? `${tr}` : `${tr}`; + return cls || data + ? `${tr}` + : `${tr}`; } /** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */ @@ -211,6 +217,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin i, o.editable, o.focus === i, + o.rowClass?.(i), ); if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : "";