From ac1961c0a3c3fc237db737f2f8e5fcf0704697d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:34:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(ui):=20=ED=99=94=EB=A9=B4=20=EA=B8=80?= =?UTF-8?q?=20=E3=80=8C=EC=86=90=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=86=90=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=86=90=20=EC=A4=84=E3=80=8D=20?= =?UTF-8?q?=E2=86=92=20=E3=80=8C=EC=88=98=EA=B8=B0=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20=EC=88=98=EA=B8=B0=20=EC=A4=84=E3=80=8D=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD(171-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 글 10 자리(8 파일) — 세월교 힌트 · B08 재료 엑셀 알림 · B09 내역 수기 줄 까닭(거울 둘) · 산근 쓰는 곳 · 단가 힌트 · 총괄 수기 입력 칸 · 로케일 둘 - 저장 키 · 주석 · 손료는 그대로 · 로케일 줄 수 그대로 - 걸린 시험 셋 글 맞춤 · 새 시험 test_171_1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../design/design_structure_ford_calc.ts | 2 +- B08_Quantity/B08_Quantity_UI_Material.ts | 4 +- B09_Estimation/B09_Estimation_Calc_Bill.py | 2 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 2 +- B09_Estimation/B09_Estimation_Sangeun.py | 2 +- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 2 +- .../B09_Estimation_UI_Tabs_Total.ts | 4 +- .../tester/test_160_4_b08_concrete_columns.py | 2 +- resources/tester/test_166_2_b09_bill_hand.py | 2 +- .../tester/test_171_1_manual_input_label.py | 52 +++++++++++++++++++ .../tester/test_52_4_structure_spec_ford.py | 6 ++- ui_template/ui_template_locale_b2.ts | 4 +- 12 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 resources/tester/test_171_1_manual_input_label.py diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index 83ed21f4f..5a7f1963e 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -27,5 +27,5 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe export function fordHeightHint(designFlowM3s: number | null, widthM: number): string { const need = fordHeightM(designFlowM3s, widthM); if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`; - return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 손 입력"; + return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 수기 입력"; } diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 0d4ca77f4..f569f58be 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -188,7 +188,9 @@ function excelDiffs(s: MaterialSheets): string { if (want != null && (row.surcharge ?? 0) !== want) out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`); } - return out.length ? `2공구 엑셀 손값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` : ""; + return out.length + ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` + : ""; } /** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */ diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 92ff613a7..8ecc2ff6f 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -119,7 +119,7 @@ def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> li PriceOf = Callable[[str], dict[str, Any] | None] -WHY_HAND = "손 줄 — 연결 없음(우클릭 [연결])" +WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])" WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)" diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 5f6f5fa9a..c1065cf0b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -108,7 +108,7 @@ function materialItems(inputs: Row, compare: Row[]): Row[] { /** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */ export type PriceOf = (owner: string) => Row | null; -const WHY_HAND = "손 줄 — 연결 없음(우클릭 [연결])"; +const WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"; const WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"; /** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */ diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 6e3f8d857..e8c05bcf9 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -483,7 +483,7 @@ def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, continue src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")), "rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip - out.append((row["key"], f"손 줄 {row.get('name') or row['key']}", src)) + out.append((row["key"], f"수기 줄 {row.get('name') or row['key']}", src)) return out diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index a86daea13..90935c418 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -157,7 +157,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 손입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적", onEdit: (i, key, value) => { const row = out[i]; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 4eccc4440..e0256392b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -191,7 +191,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { money("material", "재료비"), money("expense", "경비"), qty("rate", "요율(%)", 5), - money("hand", "손입력", undefined, 2), + money("hand", "수기 입력", undefined, 2), text("note", "산식", 320), text("candidate", "요율 출처", 260), ], @@ -200,7 +200,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { hint: "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + - "손입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " + + "수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " + "공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || diff --git a/resources/tester/test_160_4_b08_concrete_columns.py b/resources/tester/test_160_4_b08_concrete_columns.py index 863182704..7b3c7ffa0 100644 --- a/resources/tester/test_160_4_b08_concrete_columns.py +++ b/resources/tester/test_160_4_b08_concrete_columns.py @@ -41,7 +41,7 @@ def test_엑셀_검산_시멘트_모래_자갈_레미콘(): def test_엑셀_손값과_다른_상수는_알림만(): ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") - assert "2공구 엑셀 손값과 다름" in ui and "b08MaterialExcel" in ui + assert "2공구 엑셀 수기 입력값과 다름" in ui and "b08MaterialExcel" in ui # 기본 배합은 품셈 근거 그대로(124) per = {r["key"]: r["per"] for r in compute(SUMMARY, clean_overrides({}))["concrete"]["rows"]} assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612} diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 0b0398f19..ec00b9bfe 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -177,7 +177,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path): base = compute_base(prices.build_inputs(None, keys), {}) got = sg.collect([("s1", a)], base, sg.hand_sources(user)) listed = [(r["no"], r["logic"], r["uses"]) for r in got["sg_list"]] - assert listed == [(1, NET, ["s1", "손 줄 새 산근"]), (2, STONE, ["손 줄 메쌓기"])] + assert listed == [(1, NET, ["s1", "수기 줄 새 산근"]), (2, STONE, ["수기 줄 메쌓기"])] assert got["sg_hand"] == { "hand:c": got["sg_list"][1]["key"], "hand:d": got["sg_list"][0]["key"], diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py new file mode 100644 index 000000000..c8a37513c --- /dev/null +++ b/resources/tester/test_171_1_manual_input_label.py @@ -0,0 +1,52 @@ +"""171-1 「손입력 · 손 입력 · 손 줄」 → 「수기 입력 · 수기 줄」 화면 글 — 소스 시험. + +화면 글(따옴표 안)만 바꿈 · 저장 키(`{손: 수}` · 수량출처 「손」 · 표 `손` 깃) · 주석 · 손료는 그대로. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +SPOTS = { + "A00_Common/design/design_structure_ford_calc.ts": ["설계유량 없음 — 수기 입력"], + "B08_Quantity/B08_Quantity_UI_Material.ts": ["2공구 엑셀 수기 입력값과 다름"], + "B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], + "B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], + "B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'], + "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"], + "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'], + "ui_template/ui_template_locale_b2.ts": [ + "수량 원천: 수기 입력(직접비)", + "수량 원천: 수기 입력 공종", + ], +} + +# 따옴표 안 화면 글에 남은 옛 이름(주석 · 문서 글 빼고) +OLD = re.compile(r"[\"'`][^\"'`\n]*(손 ?입력|손 줄|손값)") +NOTE = re.compile(r'"""[\s\S]*?"""|/\*[\s\S]*?\*/|^\s*(//|#).*$', re.M) + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_새_이름_자리(): + for rel, wants in SPOTS.items(): + src = _text(rel) + for w in wants: + assert w in src, f"{rel}: {w}" + + +def test_화면_글에_옛_이름_없음(): + for rel in SPOTS: + for line in NOTE.sub("", _text(rel)).splitlines(): + assert not OLD.search(line), f"{rel}: {line.strip()}" + + +def test_저장_키는_그대로(): + calc = _text("M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts") + assert 'export type QtySource = "손" | "칸" | "없음";' in calc + assert "export type Qty = { 손: number } | null;" in calc + assert "손?: boolean;" in _text("ui_template/sheet/ui_template_sheet_types.ts") + assert "rows[] 손 줄" in _text("B09_Estimation/B09_Estimation_Calc_Bill.ts") # 주석 그대로 diff --git a/resources/tester/test_52_4_structure_spec_ford.py b/resources/tester/test_52_4_structure_spec_ford.py index 54fc613fa..218a7f2b2 100644 --- a/resources/tester/test_52_4_structure_spec_ford.py +++ b/resources/tester/test_52_4_structure_spec_ford.py @@ -100,7 +100,11 @@ def test_월류_높이는_폭과_연동_필요_수심_올림(out): row = [h for q, b, h in out["table"] if q == 2.54] assert row == sorted(row, reverse=True) and len(set(row)) == len(row) assert out["none"] == [None, None] # 설계유량 · 폭이 없으면 손 입력 - assert out["hints"] == ["필요 수심 0.08 이상", "월류 폭을 넣으면 셈", "설계유량 없음 — 손 입력"] + assert out["hints"] == [ + "필요 수심 0.08 이상", + "월류 폭을 넣으면 셈", + "설계유량 없음 — 수기 입력", + ] def test_정의_파일에_세월교_이름_표가_올라_있음(): diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 328a6abd4..e84c27cf3 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -973,9 +973,9 @@ export const ui_locales_b2 = { "공종별 수량 (한 줄에 「공종코드=수량」)", 'Quantities (one "code=qty" per line)', ], - B09_Estimation_Src_Manual: ["수량 원천: 손입력(직접비 직접 입력)", "Source: manual direct costs"], + B09_Estimation_Src_Manual: ["수량 원천: 수기 입력(직접비)", "Source: manual direct costs"], B09_Estimation_Src_Quantities: [ - "수량 원천: 손입력 공종 수량 × 일위대가", + "수량 원천: 수기 입력 공종 수량 × 일위대가", "Source: manual quantities × unit prices", ], B09_Estimation_Missing_UP: [ From 40ed04b15f15c821a1c26f77c164ced132a40c3b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:46:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(ui):=20=EB=AC=B6=EC=9D=8C=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B3=B5=EC=9A=A9=20=EC=96=91=EC=8B=9D=20=C2=B7=20?= =?UTF-8?q?B09=20=EB=AC=B6=EC=9D=8C=20=ED=83=AD=20=EC=85=8B=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=B3=B8(171-2=20=C2=B7=20171-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 171-2 새 공용 ui_template_group_cards(.ts · .css) — 머리(호표 · 명칭 · 규격 · 단위 · 합계) · 몸 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) · 합계 줄(노무 · 재료 · 경비 · 계) - 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음) · 숫자 오른쪽 · 줄 금액 = 합계만 + 비목 셋 풍선 - 가로 열 수 1~4(기본 2) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) - 157-2 산근 카드 파일 갈음(지움) - 171-3 일위대가표 · 중기사용료 · 단가산출근거 = 카드 기본 · [표로 보기] = 표 그대로 · 보기 · 열 수 기억(b_page_state 두 줄 · 탭마다) - 값 · 풍선 · 칸 누름 이동 = 표 줄 그대로 · 집계표 · 설계내역서 · B08 대상 밖 - 시험 test_171_2 · test_171_3 · 공용 도우미(가짜 DOM) · test_157_2 갈음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- A00_Common/b_page_state.ts | 3 + B09_Estimation/B09_Estimation_UI_Page.css | 74 +----- B09_Estimation/B09_Estimation_UI_Shell.ts | 10 +- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 9 +- .../B09_Estimation_UI_Tabs_Groups.ts | 207 +++++++++++++++ .../B09_Estimation_UI_Tabs_Labor.ts | 2 + .../B09_Estimation_UI_Tabs_Sangeun_Cards.ts | 217 --------------- resources/tester/helper_b09_group_cards.py | 94 +++++++ .../tester/test_157_2_b09_sangeun_views.py | 72 ++--- resources/tester/test_171_2_group_cards.py | 154 +++++++++++ resources/tester/test_171_3_b09_group_tabs.py | 176 +++++++++++++ ui_template/ui_template_group_cards.css | 153 +++++++++++ ui_template/ui_template_group_cards.ts | 249 ++++++++++++++++++ 13 files changed, 1079 insertions(+), 341 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts delete mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts create mode 100644 resources/tester/helper_b09_group_cards.py create mode 100644 resources/tester/test_171_2_group_cards.py create mode 100644 resources/tester/test_171_3_b09_group_tabs.py create mode 100644 ui_template/ui_template_group_cards.css create mode 100644 ui_template/ui_template_group_cards.ts diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 815d6b440..1861a4ed7 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -66,6 +66,9 @@ export const STATE_REGISTRY = { "m02-card-pills": { bucket: "pref", scope: "global" }, /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ "m01-logic-panel": { bucket: "pref", scope: "global" }, + /** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */ + "b09-group-view": { bucket: "pref", scope: "global" }, + "b09-group-cols": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 6c61a2550..b08290070 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -31,7 +31,7 @@ overflow-wrap: anywhere; } -/* 157-2 산근 탭 머리 — 안내 글 + 보기 단추 */ +/* 171-3 묶음 탭 머리 — 안내 글 + 보기 단추 */ .b09-estimation__bar { display: flex; align-items: flex-start; @@ -39,78 +39,6 @@ gap: var(--spacing-8); } -/* 157-2 산근 호표별 카드 — 호표 사이 또렷이 · 안은 가름줄 없이 간격 */ -.b09-sg-cards { - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} - -.b09-sg-card__head { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-8); - margin-bottom: var(--spacing-8); - font-weight: var(--font-weight-bold); -} - -.b09-sg-card__no { - color: var(--color-accent); -} - -.b09-sg-card__unit { - color: var(--color-text-secondary); -} - -.b09-sg-card__total { - margin-left: auto; - font-variant-numeric: tabular-nums; -} - -.b09-sg-card__line { - display: grid; - grid-template-columns: 7em minmax(0, 1fr) repeat(4, 7.5em) 10em; - column-gap: var(--spacing-8); - padding: var(--spacing-4) 0; -} - -.b09-sg-card__cols, -.b09-sg-card__sub { - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b09-sg-card__line.is-title { - margin-top: var(--spacing-4); - font-weight: var(--font-weight-bold); -} - -.b09-sg-card__line.is-clickable { - cursor: pointer; -} - -.b09-sg-card__line.is-clickable:hover, -.b09-sg-card__line.is-focus, -.b09-sg-card.is-focus { - background: color-mix(in srgb, var(--color-accent) 8%, transparent); -} - -.b09-sg-card__text, -.b09-sg-card__note { - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.b09-sg-card__num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.b09-sg-card__cols .b09-sg-card__num { - font-variant-numeric: normal; -} - /* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ .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); diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 3b6efb1ad..a9497f0e8 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -57,7 +57,7 @@ import { } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; -import { renderSgPanel } from "./B09_Estimation_UI_Tabs_Sangeun_Cards"; +import { isGroupTab, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -238,11 +238,11 @@ export function renderB09Estimation(root: HTMLElement): void { return made; }; const hint = spec.hint ? note(spec.hint) : null; - // 157-2 산근 = 보기 둘(표 · 호표별 카드) — 머리 단추로 바꿈 - if (tab === "sg") { - const redraw = (): void => show("sg"); + // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표 + if (isGroupTab(tab)) { + const redraw = (): void => show(tab); return void panel.replaceChildren( - ...renderSgPanel({ spec, hint, focus, goto, makeTable, redraw }), + ...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }), ); } const table = makeTable(); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 90935c418..04670f20d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -264,7 +264,14 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { const rows: DataRow[] = []; const links: Array<{ tab: TabId; key: string } | null> = []; (sheets.machine_blocks || []).forEach((b: Row, n: number) => { - rows.push({ key: b.key, name: `제 ${n + 1} 호표 ${b.name}`, spec: b.spec, unit: b.unit }); + rows.push({ + key: b.key, + name: `제 ${n + 1} 호표 ${b.name}`, + no: n + 1, + title: b.name, // 171-3 묶음 카드 머리 + spec: b.spec, + unit: b.unit, + }); links.push(null); for (const ln of b.lines) { const at = (part: string): number | null => (ln.kind === part ? ln.price : null); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts new file mode 100644 index 000000000..957d62c6d --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Groups.ts + * B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 · + * [표로 보기] = 공용 표 그대로. + * + * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 + * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) + * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 + * ========================================================================== */ + +import type { DataRow, DataTable } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_elements"; +import { + clampColumns, + createGroupCards, + type GroupCard, + type GroupFoot, + type GroupLine, + type GroupParts, +} from "@ui/ui_template_group_cards"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { rowIndex, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs"; + +export type GroupView = "cards" | "table"; + +/** 묶음 카드로 보이는 탭 */ +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 flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards"); + +type Saved = Record | null; + +const saved = (name: "b09-group-view" | "b09-group-cols"): Saved => { + try { + return readState>(name); + } catch { + return null; + } +}; + +/** 고른 보기 — 모르는 값 · 못 읽음 = 카드 */ +export const readGroupView = (tab: TabId, kept: Saved = saved("b09-group-view")): GroupView => + kept?.[tab] === "table" ? "table" : "cards"; + +/** 고른 가로 열 수 — 1~4 · 모르는 값 = 2 */ +export const readGroupCols = (tab: TabId, kept: Saved = saved("b09-group-cols")): number => + clampColumns(kept?.[tab]); + +export function rememberGroup( + name: "b09-group-view" | "b09-group-cols", + tab: TabId, + value: unknown, +): void { + try { + writeState(name, { ...(saved(name) ?? {}), [tab]: value }); + } catch { + // 기억 못 해도 이 화면 안에서는 고른 값 그대로 + } +} + +/* ── 표 줄 → 묶음 ─────────────────────────────────────────────────────── */ + +const num = (v: unknown): number | null => (typeof v === "number" ? v : null); +const str = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); +const parts = (r: DataRow): GroupParts => [num(r.labor), num(r.material), num(r.expense)]; +/** 들여쓰기 · 「ㄴ」 뗀 이름 */ +const bare = (v: unknown): string => str(v).replace(/^\s*(ㄴ\s+)?/, ""); +const isFoot = (key: string): boolean => /\|(합계|총계)$/.test(key); + +/** 탭마다 머리 · 몸 줄 칸 */ +function headOf(tab: TabId, r: DataRow, at: number, spec: TabTable): Omit { + const tip = (key: string) => spec.tip?.(at, key) ?? null; + if (tab === "sg") + return { + key: str(r.key), + at, + no: str(r.kind), + name: str(r.name ?? r.text), + spec: str(r.note), + specTip: tip("note"), + unit: str(r.unit), + }; + return { + key: str(r.key), + at, + no: `제 ${str(r.no)} 호표`, + name: str(r.title ?? r.name), + spec: str(r.spec), + specTip: tip("spec"), + unit: str(r.unit), + }; +} + +function lineOf(tab: TabId, r: DataRow, at: number, spec: TabTable, first: string): GroupLine { + const tip = (key: string) => spec.tip?.(at, key) ?? null; + const base = { + at, + sum: num(r.sum), + parts: parts(r), + note: str(r.note), + noteTip: tip("note"), + link: !!spec.link?.(at, first), + }; + if (tab === "sg") return { ...base, name: bare(r.text), tag: str(r.kind) }; + return { + ...base, + name: bare(r.name), + spec: str(r.spec), + specTip: tip("spec"), + qty: num(r.qty), + unit: str(r.unit), + }; +} + +const footOf = (r: DataRow, at: number, spec: TabTable): GroupFoot => ({ + at, + parts: parts(r), + sum: num(r.sum), + note: str(r.note), + tip: spec.tip?.(at, "sum") ?? spec.tip?.(at, "note") ?? null, +}); + +/** 탭 표 → 묶음 카드(줄 차례 · 표 줄 번호 그대로) */ +export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] { + const first = spec.columns[0]?.key ?? ""; + const cards: GroupCard[] = []; + const byKey = new Map(); + spec.rows.forEach((r, at) => { + const key = str(r.key); + const group = byKey.get(key.split("|")[0]); + if (!group) { + const made: GroupCard = { ...headOf(tab, r, at, spec), lines: [] }; + byKey.set(key.split("|")[0], made); + return void cards.push(made); + } + if (isFoot(key)) group.foot = footOf(r, at, spec); + else group.lines.push(lineOf(tab, r, at, spec, first)); + }); + for (const c of cards) c.sum = c.foot?.sum ?? null; + return cards; +} + +/** 표 열과 같은 숫자 꼴(열 소수 자리) */ +function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string { + const c = spec.columns.find((col) => col.key === key); + const f = new Intl.NumberFormat("ko-KR", { + minimumFractionDigits: c?.trim ? 0 : (c?.decimals ?? 0), + maximumFractionDigits: c?.decimals ?? 0, + }); + return (v) => (v === null || v === undefined || !Number.isFinite(v) ? "" : f.format(v)); +} + +/* ── 그리기 ───────────────────────────────────────────────────────────── */ + +/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ +const chosen = new Map(); + +/** 묶음 탭 — 머리(안내 + 보기 단추) · 고른 보기(카드 · 표) */ +export function renderGroupPanel(o: { + tab: TabId; + spec: TabTable; + hint: HTMLElement | null; + focus?: string; + goto: (link: Link | null) => void; + makeTable: () => DataTable; + redraw: () => void; +}): HTMLElement[] { + const view = chosen.get(o.tab) ?? readGroupView(o.tab); + const toggle = createButton({ + label: viewLabel(view), + variant: "ghost", + size: "sm", + onClick: () => { + chosen.set(o.tab, flipView(view)); + rememberGroup("b09-group-view", o.tab, flipView(view)); + o.redraw(); + }, + }); + toggle.dataset.b09GroupView = view; + const bar = document.createElement("div"); + bar.className = "b09-estimation__bar"; + bar.append(...(o.hint ? [o.hint] : []), toggle); + const at = o.focus === undefined ? -1 : rowIndex(o.spec.rows, o.focus); + if (view === "cards") { + const first = o.spec.columns[0]?.key ?? ""; + const cards = createGroupCards({ + groups: tabGroups(o.tab, o.spec), + columns: readGroupCols(o.tab), + onColumns: (n) => rememberGroup("b09-group-cols", o.tab, n), + onPick: (i) => o.goto(o.spec.link?.(i, first) ?? null), + focus: at, + money: numberFormat(o.spec, "sum"), + qty: numberFormat(o.spec, "qty"), + }); + cards.dataset.b09Table = o.tab; + return [bar, cards]; + } + const table = o.makeTable(); + if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); + return [bar, table.root]; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 0330e005d..e50dfece3 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -86,6 +86,8 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { rows.push({ key: b.id, name: `제 ${b.no} 호표 ${b.name}`, + no: b.no, + title: b.name, // 171-3 묶음 카드 머리 spec: b.spec, spec_raw: b.spec_raw, unit: b.unit, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts deleted file mode 100644 index 3c0571811..000000000 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* ============================================================================= - * B09_Estimation_UI_Tabs_Sangeun_Cards.ts - * B09 단가산출근거 탭 보기 둘(PLAN 157-2) — 보기 A = 표(`buildSg` 그대로) · 보기 B = 호표별 카드. - * - * 카드 = `buildSg` 가 낸 같은 줄을 호표(줄 키 앞머리)마다 묶어 그림 — 값 · 글은 표와 같음 - * 머리 = 산근 n호표 · 공종 · 단위 · 합계 · 몸 = 산출내역 · 합계 · 노 · 재 · 경 · 비고 줄(가름줄 없이 간격) - * 탭 머리 단추 = 보기 바꿈 · 고른 보기 = localStorage(`b09-sg-view`) · 못 읽으면 표 - * 줄 누름 = 표와 같은 이동(`spec.link`) · 이동해 온 줄 = 스크롤 + 강조 - * ========================================================================== */ - -import type { DataRow, DataTable } from "@ui/ui_template_data_table"; -import { formatCell } from "@ui/ui_template_data_table"; -import { createButton, createCard } from "@ui/ui_template_elements"; -import { rowIndex, type Link, type TabTable } from "./B09_Estimation_UI_Tabs"; - -export type SgView = "table" | "cards"; - -export const SG_VIEW_KEY = "b09-sg-view"; - -/** 단추 글 = 바꿀 보기 이름 */ -export const sgViewLabel = (view: SgView): string => - view === "cards" ? "표로 보기" : "호표별 보기"; - -export const flipSgView = (view: SgView): SgView => (view === "cards" ? "table" : "cards"); - -type Store = Pick; - -function localStore(): Store | null { - try { - return localStorage; - } catch { - return null; - } -} - -/** 고른 보기 — 못 읽거나 모르는 값 = 표 */ -export function readSgView(store: Store | null = localStore()): SgView { - try { - return store?.getItem(SG_VIEW_KEY) === "cards" ? "cards" : "table"; - } catch { - return "table"; - } -} - -export function writeSgView(view: SgView, store: Store | null = localStore()): void { - try { - store?.setItem(SG_VIEW_KEY, view); - } catch { - // 기억 못 해도 이 화면 안에서는 바뀐 보기 그대로 - } -} - -/** 카드 하나 = 호표 한 블록 — 줄 번호(`at`)는 표 줄 번호(이동 · 강조) */ -export interface SgCard { - key: string; - at: number; - head: DataRow; - lines: Array<{ at: number; row: DataRow }>; -} - -/** 표 줄 → 호표 카드(줄 키 `호표키|…` 앞머리로 묶음 · 줄은 그 객체 그대로) */ -export function sgCards(rows: DataRow[]): SgCard[] { - const cards: SgCard[] = []; - const byKey = new Map(); - rows.forEach((row, at) => { - const key = String(row.key ?? "").split("|")[0]; - const card = byKey.get(key); - if (card) return void card.lines.push({ at, row }); - const made: SgCard = { key, at, head: row, lines: [] }; - byKey.set(key, made); - cards.push(made); - }); - return cards; -} - -/* ── 그리기 ───────────────────────────────────────────────────────────── */ - -const MONEY = ["sum", "labor", "material", "expense"] as const; -const LABEL: Record = { - kind: "공종", - text: "산출내역", - sum: "합계", - labor: "노무비", - material: "재료비", - expense: "경비", - note: "비고 · 까닭", -}; - -/** 표 열과 같은 숫자 꼴(열 소수 자리) */ -function formats(spec: TabTable): Record { - return Object.fromEntries( - spec.columns - .filter((c) => c.kind === "number") - .map((c) => [ - c.key, - new Intl.NumberFormat("ko-KR", { - minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0), - maximumFractionDigits: c.decimals ?? 0, - }), - ]), - ); -} - -function span(className: string, value: string): HTMLSpanElement { - const s = document.createElement("span"); - s.className = className; - s.textContent = value; - return s; -} - -function line( - row: DataRow, - fmt: Record, - className: string, - tip?: string | null, -): HTMLElement { - const div = document.createElement("div"); - div.className = className; - div.append( - span("b09-sg-card__kind", String(row.kind ?? "")), - span("b09-sg-card__text", String(row.text ?? "").trimStart()), - ...MONEY.map((k) => span("b09-sg-card__num", formatCell(row[k], fmt[k] ?? null, "")[0])), - span("b09-sg-card__note", String(row.note ?? "")), - ); - if (tip) (div.lastElementChild as HTMLElement).title = tip; - return div; -} - -function cardsView( - spec: TabTable, - goto: (link: Link | null) => void, - focus: string | undefined, -): HTMLElement { - const fmt = formats(spec); - const host = document.createElement("div"); - host.className = "b09-sg-cards"; - host.dataset.b09Table = "sg"; - const tip = (at: number) => spec.tip?.(at, "note"); - const clickable = (el: HTMLElement, at: number): void => { - el.dataset.i = String(at); - if (spec.link?.(at, "kind")) el.classList.add("is-clickable"); - }; - for (const c of sgCards(spec.rows)) { - const head = document.createElement("div"); - head.className = "b09-sg-card__head"; - const no = String(c.head.kind ?? "").replace(/^제\s*(\d+)\s*호표$/, "산근 $1호표"); - head.append( - span("b09-sg-card__no", no), - span("b09-sg-card__name", String(c.head.name ?? c.head.text ?? "")), - span("b09-sg-card__unit", String(c.head.unit ?? "")), - span( - "b09-sg-card__total", - `합계 ${formatCell(c.lines[c.lines.length - 1]?.row.sum, fmt.sum ?? null, "")[0]}`, - ), - ); - const sub = line({ ...c.head, kind: "" }, fmt, "b09-sg-card__line b09-sg-card__sub", tip(c.at)); - const cols = line(LABEL, {}, "b09-sg-card__line b09-sg-card__cols"); - const lines = c.lines.map(({ at, row }) => { - const cls = spec.rowClass?.(at) ? " is-title" : ""; - const el = line(row, fmt, `b09-sg-card__line${cls}`, tip(at)); - clickable(el, at); - return el; - }); - const card = createCard({ body: [head, cols, sub, ...lines] }); - card.classList.add("b09-sg-card"); - clickable(card, c.at); - host.append(card); - } - host.addEventListener("click", (event) => { - const el = (event.target as Element).closest("[data-i]"); - if (el && host.contains(el)) goto(spec.link?.(Number(el.dataset.i), "kind") ?? null); - }); - const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); - if (at >= 0) - requestAnimationFrame(() => { - const el = host.querySelector(`[data-i="${at}"]`); - el?.classList.add("is-focus"); - el?.scrollIntoView({ block: "center" }); - }); - return host; -} - -/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ -let chosen: SgView | null = null; - -/** 산근 탭 — 머리(안내 + 보기 단추) · 고른 보기(표 · 카드) */ -export function renderSgPanel(o: { - spec: TabTable; - hint: HTMLElement | null; - focus?: string; - goto: (link: Link | null) => void; - makeTable: () => DataTable; - redraw: () => void; -}): HTMLElement[] { - const view = chosen ?? readSgView(); - const toggle = createButton({ - label: sgViewLabel(view), - variant: "ghost", - size: "sm", - onClick: () => { - chosen = flipSgView(view); - writeSgView(chosen); - o.redraw(); - }, - }); - toggle.dataset.b09SgView = view; - const bar = document.createElement("div"); - bar.className = "b09-estimation__bar"; - bar.append(...(o.hint ? [o.hint] : []), toggle); - if (view === "cards") return [bar, cardsView(o.spec, o.goto, o.focus)]; - const table = o.makeTable(); - if (o.focus !== undefined) { - const index = rowIndex(o.spec.rows, o.focus); - if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); - } - return [bar, table.root]; -} diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py new file mode 100644 index 000000000..104b84165 --- /dev/null +++ b/resources/tester/helper_b09_group_cards.py @@ -0,0 +1,94 @@ +"""171 묶음 카드 시험 도우미 — TS 소스를 이어 붙여 Node(타입 지움)로 돌림 · 작은 가짜 DOM. + +시험 파일 아님(test_ 머리 없음) — test_171_2 · test_171_3 · test_157_2 가 씀. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +COMMON = "ui_template/ui_template_group_cards.ts" +TABS = "B09_Estimation/B09_Estimation_UI_Tabs.ts" +GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts" + +# 기억(b_page_state) 대신 Map · 단추(createButton) 대신 가짜 button · 157-3 원문번호 풍선(raw) 짝 +STUBS = r""" +const kept = new Map(); +const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null); +const writeState = (name, value) => kept.set(name, JSON.stringify(value)); +const createButton = (o) => { + const b = document.createElement("button"); + b.className = `ui-btn ui-btn--${o.variant}`; + b.textContent = o.label; + if (o.onClick) b.addEventListener("click", o.onClick); + return b; +}; +""" + +RAW = r""" +const raw = (shown, codes) => (codes && codes !== shown ? `원문번호 ${String(codes)}` : null); +const SOURCES = []; +const openQuotes = () => {}; +""" + +FAKE_DOM = r""" +class E { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; this.parent = null; this.dataset = {}; this.attrs = {}; + this.cls = new Set(); this.textContent = ""; this.title = ""; this.on = {}; + const props = {}; + this.style = { props, setProperty: (k, v) => { props[k] = v; } }; + const self = this; + this.classList = { + add: (...c) => c.forEach((x) => self.cls.add(x)), + remove: (...c) => c.forEach((x) => self.cls.delete(x)), + contains: (c) => self.cls.has(c), + toggle: (c, force) => { + const on = force === undefined ? !self.cls.has(c) : force; + on ? self.cls.add(c) : self.cls.delete(c); + return on; + }, + }; + } + 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); } } + 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); } + scrollIntoView() { this.scrolled = true; } + matches(sel) { + if (sel.startsWith(".")) return this.cls.has(sel.slice(1)); + const m = sel.match(/^\[data-([\w-]+)(?:="(.*)")?\]$/); + const k = m ? m[1].replace(/-(\w)/g, (_, c) => c.toUpperCase()) : ""; + if (m) return m[2] === undefined ? k in this.dataset : this.dataset[k] === m[2]; + return this.tagName === sel.toUpperCase(); + } + closest(sel) { for (let e = this; e; e = e.parent) if (e.matches(sel)) return e; return null; } + all(sel, out = []) { for (const c of this.children) { if (c.matches(sel)) out.push(c); c.all(sel, out); } return out; } + querySelector(sel) { return this.all(sel)[0] ?? null; } + contains(x) { for (let e = x; e; e = e.parent) if (e === this) return true; return false; } + get text() { return this.textContent + this.children.map((c) => c.text).join(""); } + click() { const event = { target: this }; for (let e = this; e; e = e.parent) (e.on.click ?? []).forEach((f) => f(event)); } +} +globalThis.document = { createElement: (t) => new E(t) }; +globalThis.requestAnimationFrame = (f) => f(); +""" + + +def run(files, js_body, prelude=""): + """`files` 차례로 이어 붙인 소스(import 지움) + 가짜 DOM + `js_body` → stdout JSON.""" + src = "".join(IMPORTS.sub("", (ROOT / f).read_text("utf-8")) for f in files) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=FAKE_DOM + STUBS + prelude + 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) diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index bbc188944..81dd451f8 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -1,13 +1,11 @@ """157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」. -- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 줄 객체 · 같은 차례 -- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 표(A) +- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 값 · 같은 차례(171-3 공용 묶음 카드로 갈음) +- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본) - 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip) """ import json -import re -import subprocess import sys from pathlib import Path @@ -16,23 +14,13 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B09_Estimation.B09_Estimation_Sangeun import spec_text # noqa: E402 +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402 -IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) B09 = ROOT / "B09_Estimation" def _node(js_body): - src = "".join( - IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8")) - for n in ("UI_Tabs", "UI_Tabs_Sangeun", "UI_Tabs_Sangeun_Cards") - ) - 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) + return run([COMMON, TABS, "B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", GROUPS], js_body) def _block(key, no, spec="", spec_raw=""): @@ -58,51 +46,45 @@ SHEETS = {"sg": [_block("SG:A", 1), _block("SG:B", 2)]} def test_157_2_두_보기_같은_값(): out = _node( f"\nconst t = buildSg({{ sheets: {json.dumps(SHEETS, ensure_ascii=False)} }});" - "\nconst cards = sgCards(t.rows);" - "\nconst flat = cards.flatMap((c) => [c.head, ...c.lines.map((l) => l.row)]);" - "\nconst ats = cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at)]);" - "\nconsole.log(JSON.stringify({ n: t.rows.length, keys: cards.map((c) => c.key)," - " same: flat.length === t.rows.length && flat.every((r, i) => r === t.rows[i])," - " ats, heads: cards.map((c) => [c.head.kind, c.head.name, c.head.unit])," - " links: cards.map((c) => c.lines.map((l) => t.link(l.at, 'kind')?.key ?? null)) }));" + "\nconst cards = tabGroups('sg', t);" + "\nconst ats = cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at), c.foot.at]);" + "\nconsole.log(JSON.stringify({ n: t.rows.length, keys: cards.map((c) => c.key), ats," + " sums: cards.flatMap((c) => c.lines.map((l) => [l.sum, t.rows[l.at].sum]))," + " heads: cards.map((c) => [c.no, c.name, c.unit, c.sum])," + " links: cards.map((c) => c.lines.map((l) => (l.link ? t.link(l.at, 'kind').key : null))) }));" ) assert out["keys"] == ["SG:A", "SG:B"] - assert out["same"] is True # 카드 줄 = 표 줄 객체 그대로 · 같은 차례 - assert out["ats"] == list(range(out["n"])) - assert out["heads"] == [["제 1 호표", "산근1", "m3"], ["제 2 호표", "산근2", "m3"]] - # 머리 1 · 노무 1 · 중기 3 · 총계 1 — 줄 누름 이동은 표와 같은 link - assert out["links"][0] == [None, "LB2", "EQ1", "EQ1", "EQ1", None] + assert out["ats"] == list(range(out["n"])) # 카드 줄 = 표 줄 번호 그대로 · 같은 차례 + assert all(a == b for a, b in out["sums"]) + assert out["heads"] == [["제 1 호표", "산근1", "m3", 8328], ["제 2 호표", "산근2", "m3", 8328]] + # 머리 줄 1 · 노무 1 · 중기 3 — 줄 누름 이동은 표와 같은 link + assert out["links"][0] == [None, "LB2", "EQ1", "EQ1", "EQ1"] def test_157_2_단추_바꿈(): out = _node( - "\nconsole.log(JSON.stringify([sgViewLabel('table'), sgViewLabel('cards')," - " flipSgView('table'), flipSgView(flipSgView('table'))]));" + "\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')," + " flipView('table'), flipView(flipView('table'))]));" ) assert out == ["호표별 보기", "표로 보기", "cards", "table"] -def test_157_2_기억_실패_때_표(): +def test_157_2_기억_실패_때_카드(): out = _node( - "\nconst bad = { getItem: () => { throw new Error('막힘'); }," - " setItem: () => { throw new Error('막힘'); } };" - "\nconst mem = new Map();" - "\nconst ok = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v) };" - "\nwriteSgView('cards', bad);" - "\nconst junk = { getItem: () => 'x', setItem: () => {} };" - "\nconst a = [readSgView(bad), readSgView(null), readSgView(junk), readSgView(ok)];" - "\nwriteSgView('cards', ok);" - "\nconsole.log(JSON.stringify([...a, readSgView(ok), mem.get(SG_VIEW_KEY)]));" + "\nconst a = [readGroupView('sg', null), readGroupView('sg', { sg: 'x' })," + " readGroupView('sg', { dg: 'table' }), readGroupView('sg', { sg: 'table' })];" + "\nconsole.log(JSON.stringify(a));" ) - assert out == ["table", "table", "table", "table", "cards", "cards"] + assert out == ["cards", "cards", "cards", "table"] def test_157_2_화면_잇기(): shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert 'tab === "sg"' in shell and "renderSgPanel(" in shell - cards = (B09 / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts").read_text("utf-8") - assert "createCard(" in cards and "createButton(" in cards - css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert "isGroupTab(tab)" in shell and "renderGroupPanel(" in shell + assert not ( + B09 / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts" + ).exists() # 171-2 공용 양식으로 갈음 + css = (ROOT / "ui_template" / "ui_template_group_cards.css").read_text("utf-8") assert "white-space: pre-wrap" in css # 긴 산출내역 글 = 카드 안 줄바꿈 diff --git a/resources/tester/test_171_2_group_cards.py b/resources/tester/test_171_2_group_cards.py new file mode 100644 index 000000000..679ea754d --- /dev/null +++ b/resources/tester/test_171_2_group_cards.py @@ -0,0 +1,154 @@ +"""171-2 묶음 카드 공용 양식(`ui_template_group_cards`) — Node(타입 지움) + 작은 가짜 DOM. + +- 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 이름 · 규격 · 수량 · 단위 · 합계 · 비고 / 합계 줄 = 노무 · 재료 · 경비 · 계 +- 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 오른쪽 · 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음) +- 가로 열 수 1~4(기본 2 · 고르면 알림) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) +- 157-2 산근 카드 파일 = 이 양식으로 갈음(지움) +""" + +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, run # noqa: E402 + +GROUPS_JS = """ +const mk = (n) => ({ + key: `G${n}`, at: n * 10, no: `제 ${n} 호표`, name: `묶음${n}`, spec: "규격", specTip: "원문번호 1", + unit: "m3", sum: 1234.5, + lines: [ + { at: n * 10 + 1, name: "석공", spec: "", qty: 0.072, unit: "인", sum: 18643.3, + parts: [18643.3, null, null], note: "노무3", link: true }, + { at: n * 10 + 2, name: "인용", tag: "품셈" }, + ], + foot: { at: n * 10 + 3, parts: [100, 20, 3], sum: 123, note: "빈 줄 1", tip: "빈 줄 — 모래" }, +}); +const picks = []; +const cols = []; +const make = (count, extra = {}) => createGroupCards({ + groups: Array.from({ length: count }, (_, i) => mk(i + 1)), + onPick: (at) => picks.push(at), onColumns: (n) => cols.push(n), ...extra, +}); +""" + + +def _run(body): + return run([COMMON], GROUPS_JS + body) + + +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] + + +def test_카드_머리_몸_합계_줄(): + out = _run( + "\nconst root = make(1);" + "\nconst box = root.querySelector('.ui-group-card');" + "\nconst head = box.querySelector('.ui-group-card__head');" + "\nconst tds = box.querySelector('tbody').children[0].children;" + "\nconst ths = box.querySelector('thead').children[0].children;" + "\nconsole.log(JSON.stringify({" + " head: head.children.map((c) => [c.className, c.textContent, c.title])," + " ths: ths.map((c) => [c.textContent, c.className])," + " tds: tds.map((c) => [c.textContent, c.className, c.title])," + " tag: box.querySelector('.ui-group-card__tag').textContent," + " foot: box.querySelector('.ui-group-card__foot').children.map((c) => c.textContent)," + " link: box.querySelector('tbody').children.map((r) => r.classList.contains('is-link'))," + " cols: root.querySelector('.ui-group-cards__grid').style.props['--group-cols']," + " summary: root.all('.ui-btn').map((b) => b.textContent) }));" + ) + assert out["head"] == [ + ["ui-group-card__no", "제 1 호표", ""], + ["ui-group-card__title", "묶음1", ""], + ["ui-group-card__spec", "규격", "원문번호 1"], + ["ui-group-card__unit", "m3", ""], + ["ui-group-card__total", "1,234.5", ""], + ] + assert out["ths"] == [ + ["이름", ""], ["규격", ""], ["수량", "is-num"], ["단위", ""], ["합계", "is-num"], ["비고", ""], + ] # fmt: skip + # 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 칸 = 오른쪽(is-num) + assert out["tds"] == [ + ["석공", "ui-group-card__name", ""], + ["", "", ""], + ["0.1", "is-num", ""], + ["인", "", ""], + ["18,643.3", "is-num", "노무 18,643.3 · 재료 - · 경비 -"], + ["노무3", "ui-group-card__note", ""], + ] + assert out["tag"] == "품셈" + 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 이하 = 요약 단추 없음 + + +def test_가로_열수_고름_알림_누름_이동(): + out = _run( + "\nconst root = make(2, { columns: 3 });" + "\nconst grid = root.querySelector('.ui-group-cards__grid');" + "\nconst before = grid.style.props['--group-cols'];" + "\nroot.querySelector('[data-group-cols=\"4\"]').click();" + "\nroot.querySelector('[data-at=\"21\"]').click();" + "\nroot.querySelector('[data-at=\"20\"]').click();" + "\nconsole.log(JSON.stringify({ before, after: grid.style.props['--group-cols'], cols," + " pressed: root.all('[data-group-cols]').map((b) => b.attrs['aria-pressed']), picks }));" + ) + assert out["before"] == "3" and out["after"] == "4" + assert out["cols"] == [4] + assert out["pressed"] == ["false", "false", "false", "true"] + assert out["picks"] == [21, 20] # 줄 · 머리 누름 = 표 줄 번호 그대로(갈 곳은 부르는 쪽 link) + + +def test_묶음_12_넘으면_요약_보기(): + 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\"]');" + "\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," + " foot: cards.every((c) => c.querySelector('.ui-group-card__foot'))," + " focus: [focused.classList.contains('is-focus'), !!focused.scrolled] }));" + ) + # 이동해 온 줄(5번째 묶음 줄) = 그 묶음만 펼침 · 강조 · 스크롤 + assert out["first"] == [True] * 4 + [False] + [True] * 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 # 접어도 합계 줄은 보임(몸만 숨김) + + +def test_양식_글자_간격_숫자(): + css = (ROOT / "ui_template" / "ui_template_group_cards.css").read_text("utf-8") + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") + assert "--text-body-sm: 14px;" in theme + assert "font-size: var(--text-body-sm);" in css # 14px 한 벌 + assert [ln.strip() for ln in css.splitlines() if "font-size" in ln] == [ + "font-size: var(--text-body-sm);", + "font-size: inherit;", + ] + assert "padding: 1px var(--spacing-4);" in css # 줄 간격 좁게 + 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 + src = (ROOT / COMMON).read_text("utf-8") + assert "createCard" not in src + assert not (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts").exists() diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py new file mode 100644 index 000000000..98d98d0d2 --- /dev/null +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -0,0 +1,176 @@ +"""171-3 B09 묶음 탭 셋 = 묶음 카드 기본 · [표로 보기] = 표 그대로 · 보기 기억(탭마다). + +- 일위대가표 · 중기사용료 · 단가산출근거 — 표 줄을 호표마다 묶음(값 · 풍선 · 누름 이동 = 표와 같음) +- 기억 = `b_page_state` 두 줄(`b09-group-view` · `b09-group-cols`) · 탭마다 따로 · 모르는 값 = 카드 · 2 +- 집계표 넷 · 설계내역서 · 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 COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +B09 = "B09_Estimation/B09_Estimation_UI_Tabs_" + +DG = { + "id": "DG1", "no": 1, "name": "찰쌓기", "spec": "기계=굴착기 0.6㎥", "spec_raw": "기계=0201-0070", + "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, + "blank": 1, "blank_names": ["모래"], +} # fmt: skip + +MB = { + "key": "EQ1", "name": "굴착기", "spec": "0.6㎥", "unit": "hr", + "lines": [ + {"kind": "expense", "name": "손료", "spec": "", "qty": 1, "unit": "hr", "price": 13041, + "amount": 13041, "ref": None}, + {"kind": "labor", "name": "운전사", "spec": "", "qty": 1, "unit": "인", "price": 55700, + "amount": 55700, "ref": {"key": "LB9"}}, + ], + "sum": 68741, "labor": 55700, "material": 0, "expense": 13041, +} # fmt: skip + +SG = { + "key": "SG:A", "no": 1, "name": "뒷채움", "unit": "m3", "spec": "규격=150", "spec_raw": "규격=150", + "head": ["뒷채움 / m3", "산림품셈 13-3-2 적용"], + "lines": [ + {"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"}, "why": None, + "labor": 2979.8, "material": 0.0, "expense": 0.0, "text": ["보통인부 : 165,545 * 0.018 = 2,979.8"]}, + ], + "labor": 2979, "material": 0, "expense": 0, "sum": 2979, "why": None, "foot": "계 : 2,979", +} # fmt: skip + +# 카드 → 판정 거리(머리 · 몸 줄 · 합계 · 표 줄 번호 · 값이 표와 같은가 · 누름 이동) +DUMP = """ +const dump = (tab, t) => { + const cards = tabGroups(tab, t); + const first = t.columns[0].key; + return { + n: t.rows.length, + heads: cards.map((c) => [c.no, c.name, c.spec, c.specTip, c.unit, c.sum]), + lines: cards.flatMap((c) => c.lines.map((l) => [l.at, l.tag ?? null, l.name, l.qty ?? null, l.sum, l.parts, l.note])), + foot: cards.map((c) => [c.foot.at, c.foot.parts, c.foot.sum, c.foot.note, c.foot.tip]), + ats: cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at), c.foot.at]), + same: cards.every((c) => c.lines.every((l) => l.sum === (t.rows[l.at].sum ?? null))), + links: cards.flatMap((c) => c.lines.map((l) => (l.link ? t.link(l.at, first) : null))), + }; +}; +""" + + +def _tab(files, build, sheets, tab): + body = f"\nconsole.log(JSON.stringify(dump('{tab}', {build}({{ sheets: {json.dumps(sheets, ensure_ascii=False)} }}))));" + prelude = "" if any("Sangeun" in f for f in files) else RAW # raw 짝 = 산근 파일에 있음 + return run([COMMON, TABS, *files, GROUPS], DUMP + body, prelude) + + +def test_일위대가표_카드(): + out = _tab([f"{B09}Labor.ts"], "buildDaegaBlocks", {"daega_blocks": [DG]}, "dg") + assert out["heads"] == [ + ["제 1 호표", "찰쌓기", "기계=굴착기 0.6㎥", "원문번호 기계=0201-0070", "m2", 22807] + ] + assert out["lines"] == [ + [1, None, "건설표준품셈 7-1-2", None, None, [None, None, None], "품셈 인용"], + [2, None, "석공", 0.072, 18643.3, [18643.3, None, None], "노무3"], + [3, None, "뒷채움", 0.5, 4164, [3439, 269, 456], "산근 1호표"], + ] + assert out["foot"] == [[4, [22082, 269, 456], 22807, "빈 줄 1", "빈 줄 — 모래"]] + assert out["ats"] == list(range(out["n"])) and out["same"] is True + assert out["links"] == [None, {"tab": "labor", "key": "L00005"}, {"tab": "sg", "key": "SG1"}] + + +def test_중기사용료_카드(): + out = _tab([f"{B09}Base.ts"], "buildMachineBlocks", {"machine_blocks": [MB]}, "machine_blocks") + assert out["heads"] == [["제 1 호표", "굴착기", "0.6㎥", None, "hr", 68741]] + assert out["lines"] == [ + [1, None, "손료", 1, 13041, [None, None, 13041], ""], + [2, None, "운전사", 1, 55700, [55700, None, None], ""], + ] + assert out["foot"] == [[3, [55700, 0, 13041], 68741, "", None]] + assert out["ats"] == list(range(out["n"])) and out["same"] is True + assert out["links"] == [None, {"tab": "basis", "key": "LB9"}] + + +def test_단가산출근거_카드(): + out = _tab([f"{B09}Sangeun.ts"], "buildSg", {"sg": [SG]}, "sg") + assert out["heads"] == [["제 1 호표", "뒷채움", "규격=150", None, "m3", 2979]] + assert out["lines"] == [ + [1, "", "산림품셈 13-3-2 적용", None, None, [None, None, None], ""], + [2, "노무", "보통인부 : 165,545 * 0.018 = 2,979.8", None, 2979.8, [2979.8, 0, 0], ""], + ] + assert out["foot"][0][:3] == [3, [2979, 0, 0], 2979] + assert out["ats"] == list(range(out["n"])) and out["same"] is True + assert out["links"] == [None, {"tab": "labor", "key": "LB2"}] + + +def test_보기_기억_탭마다(): + out = run( + [COMMON, TABS, GROUPS], + "\nconst a = [readGroupView('dg'), readGroupCols('dg')];" + "\nrememberGroup('b09-group-view', 'dg', 'table');" + "\nrememberGroup('b09-group-cols', 'sg', 3);" + "\nconst b = [readGroupView('dg'), readGroupView('sg'), readGroupView('machine_blocks')," + " readGroupCols('sg'), readGroupCols('dg'), readGroupCols('dg', { dg: 9 })];" + "\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept)," + " tabs: GROUP_TABS, other: ['bill', 'sum_material', 'sum_labor', 'sum_expense'," + " 'sum_machine', 'dg_list'].map(isGroupTab) }));", + ) + assert out["a"] == ["cards", 2] # 기본 = 카드 · 가로 2 + assert out["b"] == ["table", "cards", "cards", 3, 2, 2] + assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + assert json.loads(out["kept"]["b09-group-cols"]) == {"sg": 3} + assert out["tabs"] == ["dg", "machine_blocks", "sg"] + assert out["other"] == [False] * 6 # 집계표 넷 · 설계내역서 · 목록표 = 대상 밖 + + +def test_화면_보기_바꿈_누름_이동(): + out = run( + [COMMON, TABS, f"{B09}Labor.ts", GROUPS], + f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" + "\nconst gone = []; let redraws = 0; const focused = [];" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: (l) => gone.push(l)," + " redraw: () => redraws++, makeTable: () => ({ root: document.createElement('table')," + " focusRow: (i) => focused.push(i) }) };" + "\nconst [bar, view] = renderGroupPanel(o);" + "\nconst label = bar.children[0].textContent;" + "\nview.querySelector('[data-at=\"2\"]').click();" + "\nview.querySelector('[data-at=\"0\"]').click();" + "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" + "\nbar.children[0].click();" + "\nconst [bar2, view2] = renderGroupPanel(o);" + "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, redraws," + " label2: bar2.children[0].textContent, tag2: view2.tagName, focused," + " kept: Object.fromEntries(kept) }));", + RAW, + ) + 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["redraws"] == 1 + assert out["label2"] == "호표별 보기" and out["tag2"] == "TABLE" and out["focused"] == [3] + assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + + +def test_잇기(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (isGroupTab(tab)) {" in shell and "renderGroupPanel({ tab, spec," in shell + state = (ROOT / "A00_Common" / "b_page_state.ts").read_text("utf-8") + assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state + assert '"b09-group-cols": { bucket: "pref", scope: "global" },' in state + b08 = "".join(p.read_text("utf-8") for p in (ROOT / "B08_Quantity").glob("*.ts")) + assert "createGroupCards" not in b08 # B08 = 대상 밖 diff --git a/ui_template/ui_template_group_cards.css b/ui_template/ui_template_group_cards.css new file mode 100644 index 000000000..a2e6f033f --- /dev/null +++ b/ui_template/ui_template_group_cards.css @@ -0,0 +1,153 @@ +/* 묶음 카드 공용 양식(171-2) — 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽. 색은 테마 변수만. */ + +.ui-group-cards { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body-sm); + line-height: 1.35; +} + +.ui-group-cards__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.ui-group-cards__cols { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); +} + +.ui-group-cards__label, +.ui-group-cards__count { + color: var(--color-text-secondary); +} + +.ui-group-cards__actions { + display: flex; + gap: var(--spacing-4); + margin-left: auto; +} + +.ui-group-cards__grid { + display: grid; + grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr)); + align-items: start; + gap: var(--spacing-8); +} + +.ui-group-card { + min-width: 0; + padding: var(--spacing-4) var(--spacing-8); + background-color: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); +} + +.ui-group-card__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: var(--spacing-8); + padding: 2px 0; + border-bottom: 1px solid var(--color-border); + font-weight: var(--font-weight-bold); +} + +.ui-group-card__no { + color: var(--color-accent); +} + +.ui-group-card__spec, +.ui-group-card__unit { + color: var(--color-text-secondary); + font-weight: normal; +} + +.ui-group-card__total { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +.ui-group-card__body { + 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; + font-size: inherit; +} + +.ui-group-card__lines th, +.ui-group-card__lines td { + padding: 1px var(--spacing-4); + text-align: left; + vertical-align: top; +} + +.ui-group-cards .ui-group-card__lines th { + color: var(--color-text-secondary); + font-weight: normal; + white-space: nowrap; +} + +.ui-group-card__lines .is-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ui-group-card__name, +.ui-group-card__note { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.ui-group-card__note { + color: var(--color-text-secondary); +} + +.ui-group-card__tag { + margin-right: var(--spacing-4); + color: var(--color-accent); +} + +.ui-group-card__lines tr.is-link { + cursor: pointer; +} + +.ui-group-card__lines tr.is-link:hover, +.ui-group-card .is-focus { + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.ui-group-card__foot { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + column-gap: var(--spacing-8); + padding: 2px var(--spacing-4); + border-top: 1px solid var(--color-border); + font-variant-numeric: tabular-nums; +} + +.ui-group-card__part, +.ui-group-card__foot-note { + color: var(--color-text-secondary); +} + +.ui-group-card__sum { + font-weight: var(--font-weight-bold); +} diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts new file mode 100644 index 000000000..086ce3552 --- /dev/null +++ b/ui_template/ui_template_group_cards.ts @@ -0,0 +1,249 @@ +/* ============================================================================= + * ui_template_group_cards.ts + * 묶음 카드 공용 양식(PLAN 171-2) — 여러 줄이 한 묶음인 엑셀 꼴 표(호표 블록)를 카드로. + * + * 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) / + * 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선) + * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) + * 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) + * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조 + * ========================================================================== */ + +import "./ui_template_group_cards.css"; +import { createButton } from "./ui_template_elements"; + +/** 비목 셋 — 노무 · 재료 · 경비 */ +export type GroupParts = [number | null, number | null, number | null]; + +export interface GroupLine { + at: number; + name: string; + /** 이름 앞 작은 꼬리표(예 산근 공종 「노무 · 중기」) */ + tag?: string; + spec?: string; + qty?: number | null; + unit?: string; + sum?: number | null; + parts?: GroupParts | null; + note?: string; + /** 규격 · 비고 칸 풍선(원문번호 · 까닭) */ + specTip?: string | null; + noteTip?: string | null; + /** 누르면 이동하는 줄 */ + link?: boolean; +} + +export interface GroupFoot { + at: number; + parts: GroupParts; + sum: number | null; + note?: string; + tip?: string | null; +} + +export interface GroupCard { + key: string; + at: number; + no: string; + name: string; + spec?: string; + specTip?: string | null; + unit?: string; + sum?: number | null; + lines: GroupLine[]; + foot?: GroupFoot | null; +} + +export interface GroupCardsOptions { + groups: GroupCard[]; + /** 가로 열 수(1~4 · 모르는 값 = 2) */ + columns?: unknown; + onColumns?: (columns: number) => void; + onPick?: (at: number) => void; + /** 이동해 온 줄(표 줄 번호) */ + focus?: number; + money?: (value: number | null | undefined) => string; + qty?: (value: number | null | undefined) => string; + /** 머리 띠 끝 단추(예 [표로 보기]) */ + actions?: HTMLElement[]; +} + +export const GROUP_SUMMARY_OVER = 12; +export const GROUP_COLUMNS = [1, 2, 3, 4] as const; +export const PART_LABELS = ["노무", "재료", "경비"] as const; + +/** 가로 열 수 — 1~4 · 모르는 값 = 2 */ +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); + +/** 줄 비목 풍선 — 「노무 1,234 · 재료 0 · 경비 56」 · 셋 다 빈칸 = 없음 */ +export function partsTip( + parts: GroupParts | null | undefined, + money: (value: number | null | undefined) => string = plain, +): string | null { + if (!parts || parts.every((v) => v === null || v === undefined)) return null; + return parts.map((v, i) => `${PART_LABELS[i]} ${money(v) || "-"}`).join(" · "); +} + +function span(className: string, value: string, tip?: string | null): HTMLSpanElement { + const s = document.createElement("span"); + s.className = className; + s.textContent = value; + if (tip) s.title = tip; + return s; +} + +function cell(tag: "td" | "th", className: string, value: string, tip?: string | null) { + const c = document.createElement(tag); + c.className = className; + c.textContent = value; + if (tip) c.title = tip; + return c; +} + +const COLS = ["이름", "규격", "수량", "단위", "합계", "비고"]; +const NUM_COL = new Set([2, 4]); + +function linesTable(g: GroupCard, o: Required>) { + const table = document.createElement("table"); + table.className = "ui-group-card__lines"; + const head = document.createElement("tr"); + head.append(...COLS.map((c, i) => cell("th", NUM_COL.has(i) ? "is-num" : "", c))); + const thead = document.createElement("thead"); + thead.append(head); + const tbody = document.createElement("tbody"); + for (const ln of g.lines) { + const tr = document.createElement("tr"); + tr.dataset.at = String(ln.at); + if (ln.link) tr.classList.add("is-link"); + const name = cell("td", "ui-group-card__name", ln.name); + if (ln.tag) name.prepend(span("ui-group-card__tag", ln.tag)); + tr.append( + name, + cell("td", "", ln.spec ?? "", ln.specTip), + cell("td", "is-num", o.qty(ln.qty)), + cell("td", "", ln.unit ?? ""), + cell("td", "is-num", o.money(ln.sum), partsTip(ln.parts, o.money)), + cell("td", "ui-group-card__note", ln.note ?? "", ln.noteTip), + ); + tbody.append(tr); + } + table.append(thead, tbody); + return table; +} + +function footRow(f: GroupFoot, money: (v: number | null | undefined) => string): HTMLElement { + const foot = document.createElement("div"); + foot.className = "ui-group-card__foot"; + foot.dataset.at = String(f.at); + f.parts.forEach((v, i) => + foot.append(span("ui-group-card__part", `${PART_LABELS[i]} ${money(v)}`)), + ); + foot.append(span("ui-group-card__sum", `계 ${money(f.sum)}`, f.tip)); + if (f.note) foot.append(span("ui-group-card__foot-note", f.note, f.tip)); + return foot; +} + +function card(g: GroupCard, o: Required>): HTMLElement { + const box = document.createElement("article"); + box.className = "ui-group-card"; + box.dataset.key = g.key; + const head = document.createElement("div"); + head.className = "ui-group-card__head"; + head.dataset.at = String(g.at); + head.append( + span("ui-group-card__no", g.no), + span("ui-group-card__title", g.name), + span("ui-group-card__spec", g.spec ?? "", g.specTip), + span("ui-group-card__unit", g.unit ?? ""), + span("ui-group-card__total", o.money(g.sum ?? g.foot?.sum)), + ); + const body = document.createElement("div"); + body.className = "ui-group-card__body"; + if (g.lines.length) body.append(linesTable(g, o)); + box.append(head, body); + if (g.foot) box.append(footRow(g.foot, o.money)); + return box; +} + +/** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */ +export function createGroupCards(opts: GroupCardsOptions): HTMLElement { + const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain }; + const root = document.createElement("div"); + root.className = "ui-group-cards"; + const grid = document.createElement("div"); + grid.className = "ui-group-cards__grid"; + let columns = clampColumns(opts.columns); + 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"; + const pick = document.createElement("div"); + pick.className = "ui-btn-group ui-group-cards__cols"; + pick.append(span("ui-group-cards__label", "가로")); + const colButtons = GROUP_COLUMNS.map((n) => { + const b = createButton({ + label: String(n), + variant: n === columns ? "filled" : "ghost", + size: "sm", + onClick: () => { + columns = n; + grid.style.setProperty("--group-cols", String(n)); + colButtons.forEach((x, i) => { + x.classList.toggle("ui-btn--filled", GROUP_COLUMNS[i] === n); + x.classList.toggle("ui-btn--ghost", GROUP_COLUMNS[i] !== n); + x.setAttribute("aria-pressed", String(GROUP_COLUMNS[i] === n)); + }); + opts.onColumns?.(n); + }, + }); + b.setAttribute("aria-pressed", String(n === columns)); + b.dataset.groupCols = String(n); + return b; + }); + 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 ?? [])); + bar.append(tail); + root.append(bar, grid); + + 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); + el?.classList.add("is-focus"); + requestAnimationFrame(() => el?.scrollIntoView({ block: "center" })); + } + return root; +}