From 50abcb894deb76a9e0c22b7a93c3189d70e0e0e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 12:53:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1(27-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「구조물 관리」 컨테이너 삭제 · 「시스템 설정」을 모든 역할 맨 위 한 줄 · 통째로 접힘(attachCollapsible) - 안은 작은 카드(제목 + 한 줄 설명) 공용 양식 하나 · 역할로 숨김 - 시스템 관리자: 마스터 데이터 · 일위대가 조합 · 구조물 조합 · 구조물 옵션 · 도면 양식 - 회사 관리자 · 개인: 일위대가 조합 · 구조물 조합(마스터 읽기 · 회사 수정|읽기 · 개인 수정) - 카드 누르면 #/m02-master-template?open=…&root=… · 마스터 데이터는 M01 - 시험 test_b01_settings_cards 새로 · test_m02_layer_screen 옛 대시보드 카드 시험 뺌 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- B01_Dashboard/B01_Dashboard_UI_Page.ts | 93 ++++++++++------- B01_Dashboard/B01_Dashboard_UI_Style.css | 42 ++++++-- resources/tester/test_b01_settings_cards.py | 108 ++++++++++++++++++++ resources/tester/test_m02_layer_screen.py | 16 +-- ui_template/ui_template_locale_b3.ts | 6 ++ ui_template/ui_template_locale_m2.ts | 8 +- 6 files changed, 209 insertions(+), 64 deletions(-) create mode 100644 resources/tester/test_b01_settings_cards.py diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index b526078f..bf73c045 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -1,4 +1,5 @@ import { ROUTES } from "@config/config_frontend"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createButton, createTag, @@ -130,39 +131,63 @@ async function loadRoleData(state: DashboardState): Promise { } } -function buildSystemSettingsPanel(): HTMLElement { +/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */ +function settingCard(title: string, desc: string, go: () => void): HTMLElement { + const card = document.createElement("button"); + card.type = "button"; + card.className = "b01-dashboard__setting-card"; + const head = document.createElement("strong"); + head.className = "b01-dashboard__setting-title"; + head.textContent = title; + const sub = document.createElement("span"); + sub.className = "b01-dashboard__setting-desc"; + sub.textContent = desc; + card.append(head, sub); + card.addEventListener("click", go); + return card; +} + +/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +function buildSystemSettingsPanel(user: DashboardUser): HTMLElement { const wrap = document.createElement("div"); - wrap.className = "b01-dashboard__actions"; + wrap.className = "b01-dashboard__setting-grid"; + const m02 = (title: string, open: string, root: string, desc: string): HTMLElement => + settingCard(title, desc, () => navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root })); + if (user.role === "SYSTEM_ADMIN") { + const desc = L("B01_Dashboard_PermMasterWrite"); + wrap.append( + settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () => + navigateTo(ROUTES.M01_MASTER_DATA), + ), + m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", "master", desc), + m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc), + m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), + m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), + ); + return wrap; + } + // 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙) + const companyAdmin = user.role === "ADMIN" || user.is_master; + const root = companyAdmin ? "company" : "personal"; + const desc = [ + L("B01_Dashboard_PermMasterRead"), + L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), + L("B01_Dashboard_PermPersonalWrite"), + ].join(" · "); wrap.append( - createButton({ - label: L("B01_Dashboard_MasterElements"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.M01_MASTER_DATA), - }), - createButton({ - label: L("B01_Dashboard_MasterTemplate"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.M02_MASTER_TEMPLATE), - }), + m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", root, desc), + m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc), ); return wrap; } -/** 구조물 관리 카드 — 개인용은 모두 · 회사용은 회사 관리자와 시스템 관리자(21-0) */ -function buildStructurePanel(state: DashboardState): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b01-dashboard__actions"; - const go = (layer: "company" | "personal", label: string): HTMLElement => - createButton({ - label, - variant: "ghost", - onClick: () => navigateTo(ROUTES.M02_MASTER_TEMPLATE, { layer }), - }); - if (state.user.role === "ADMIN" || state.user.role === "SYSTEM_ADMIN") { - wrap.append(go("company", L("B01_Dashboard_StructureCompany"))); - } - wrap.append(go("personal", L("B01_Dashboard_StructurePersonal"))); - return wrap; +/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */ +function buildSystemSettingsSection(user: DashboardUser): HTMLElement { + const card = section(L("B01_Dashboard_SystemSettings"), buildSystemSettingsPanel(user), true); + card.querySelector(".ui-card__body")?.classList.add("ui-collapsible"); + card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title"); + attachCollapsible(card); + return card; } function buildPage(state: DashboardState): HTMLElement { @@ -171,22 +196,16 @@ function buildPage(state: DashboardState): HTMLElement { const grid = document.createElement("div"); grid.className = "b01-dashboard__grid"; + // 시스템 설정은 모든 역할 맨 위 한 줄(27-3a) + grid.append(buildSystemSettingsSection(state.user)); if (state.user.role === "SYSTEM_ADMIN") { - // 리소스 현황 오른쪽에 시스템 설정 — 앞으로 다른 설정 단추도 이 칸에 듦 (2026-09-15 브레인 Z01). - const topRow = document.createElement("div"); - topRow.className = "b01-dashboard__top-row"; - topRow.append( - section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources)), - section(L("B01_Dashboard_SystemSettings"), buildSystemSettingsPanel()), - ); grid.append( - topRow, + section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), buildTempUploadSection(), - section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)), section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), @@ -201,7 +220,6 @@ function buildPage(state: DashboardState): HTMLElement { createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), buildTempUploadSection(), - section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)), section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), @@ -216,7 +234,6 @@ function buildPage(state: DashboardState): HTMLElement { // 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록 // 바로 아래에 둔다(2026-08-08 사용자 지시). buildTempUploadSection(), - section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)), section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), ); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index ceab81c6..87d02e70 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -31,12 +31,41 @@ grid-column: 1 / -1; } -/* 리소스 현황 | 시스템 설정 — 설정 칸은 단추만 들어 좁게. */ -.b01-dashboard__top-row { - grid-column: 1 / -1; +/* 시스템 설정 — 작은 카드(제목 + 한 줄 설명) 한 양식 */ +.b01-dashboard__setting-grid { display: grid; - grid-template-columns: minmax(0, 3fr) minmax(0, 1fr); - gap: var(--spacing-24); + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-8); +} + +.b01-dashboard__setting-card { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.b01-dashboard__setting-card:hover, +.b01-dashboard__setting-card:focus-visible { + border-color: var(--color-royal-amethyst); + outline: none; +} + +.b01-dashboard__setting-title { + font-size: var(--text-body); + font-weight: var(--font-weight-medium); +} + +.b01-dashboard__setting-desc { + color: var(--color-text-secondary); + font-size: var(--text-caption); } .b01-dashboard__actions { @@ -288,8 +317,7 @@ @media (max-width: 860px) { .b01-dashboard__header, - .b01-dashboard__grid, - .b01-dashboard__top-row { + .b01-dashboard__grid { display: block; } diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py new file mode 100644 index 00000000..8f80b046 --- /dev/null +++ b/resources/tester/test_b01_settings_cards.py @@ -0,0 +1,108 @@ +"""B01 대시보드 「시스템 설정」 컨테이너 시험(27-3) — 화면 조작은 ORCA. + +· 역할별 카드(제목 · 설명 · 이동 해시)를 Node 로 바로 돌림 — 가짜 DOM +· 모든 역할 맨 위 한 줄 · 통째로 접힘 · 「구조물 관리」 컨테이너 없음 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PAGE = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _fn(src: str, name: str) -> str: + found = re.search(rf"^function {name}\([\s\S]*?^}}", src, re.M) + assert found, f"{name} 를 못 찾음" + return found.group(0) + + +def _cards(user: dict) -> list: + src = _text(PAGE) + script = ( + "interface DashboardUser { role: string; is_master: boolean }\n" + "const ROUTES = { M01_MASTER_DATA: 'm01-master-data', M02_MASTER_TEMPLATE: 'm02-master-template' };\n" + "const L = (k: string) => k;\n" + "let went = '';\n" + "const navigateTo = (r: string, q?: Record) =>" + " { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n" + "const mk = (): any => ({ children: [] as any[], textContent: '', className: '', on: null as any," + " append(...c: any[]) { this.children.push(...c); }," + " addEventListener(_: string, f: any) { this.on = f; } });\n" + "const document = { createElement: () => mk() };\n" + + _fn(src, "settingCard") + + "\n" + + _fn(src, "buildSystemSettingsPanel") + + "\nconst user = JSON.parse(process.argv[1]);" + + "\nconsole.log(JSON.stringify(buildSystemSettingsPanel(user).children.map((c: any) => {" + + " c.on(); return [c.children[0].textContent, c.children[1].textContent," + + " decodeURIComponent(went)]; })));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(user)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +M02 = "m02-master-template" +READ_CO = ( + "B01_Dashboard_PermMasterRead · B01_Dashboard_PermCompanyRead · B01_Dashboard_PermPersonalWrite" +) +WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_시스템_관리자_카드_다섯(): + m = "B01_Dashboard_PermMasterWrite" + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}) == [ + ["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"], + ["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master"], + ["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master"], + ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master"], + ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master"], + ] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_회사_관리자_개인_카드_둘(): + company = [ + ["B01_Dashboard_CardUnitCombo", WRITE_CO, f"{M02}?open=일위대가&root=company"], + ["B01_Dashboard_CardStructCombo", WRITE_CO, f"{M02}?open=구조물조합&root=company"], + ] + assert _cards({"role": "ADMIN", "is_master": False}) == company + assert _cards({"role": "USER", "is_master": True}) == company # 회사 마스터 = 회사 수정 + assert _cards({"role": "USER", "is_master": False}) == [ + ["B01_Dashboard_CardUnitCombo", READ_CO, f"{M02}?open=일위대가&root=personal"], + ["B01_Dashboard_CardStructCombo", READ_CO, f"{M02}?open=구조물조합&root=personal"], + ] + + +def test_모든_역할_맨_위_통째로_접힘(): + src = _text(PAGE) + build = _fn(src, "buildPage") + # 역할 갈래 앞에서 한 번 — 모든 역할 · 프로젝트 위 + assert build.index("grid.append(buildSystemSettingsSection(state.user))") < build.index( + 'if (state.user.role === "SYSTEM_ADMIN")' + ) + assert "StructureManage" not in src and "buildStructurePanel" not in src + assert "top-row" not in src + sect = _fn(src, "buildSystemSettingsSection") + assert "buildSystemSettingsPanel(user), true)" in sect # 한 줄 통째 + assert '"ui-collapsible"' in sect and '"ui-collapsible__title"' in sect + assert "attachCollapsible(card)" in sect + config = _text(ROOT / "config" / "config_frontend.ts") + assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config) diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 0da4aa4d..69a66cfd 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -2,7 +2,7 @@ · 권한 규칙(`accessFor`)을 Node 로 바로 돌림 — 시스템 SYSTEM_ADMIN · 회사 ADMIN/is_master · 개인 본인 · 회사 없음 -· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어 +· 주소 인자를 라우트에서 떼는지 · 층 길 접두어 """ from __future__ import annotations @@ -71,20 +71,6 @@ def test_주소_인자를_라우트에서_뗌(): assert 'value === "company" || value === "personal" ? value : "system"' in layer -def test_대시보드_카드_역할별_단추(): - page = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts") - assert ( - page.count('section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state))') == 3 - ) - body = page.split("function buildStructurePanel")[1].split("function buildPage")[0] - # 개인용은 모두 · 회사용은 ADMIN · SYSTEM_ADMIN 만 - assert 'state.user.role === "ADMIN" || state.user.role === "SYSTEM_ADMIN"' in body - assert body.index('go("company"') < body.index('go("personal"') - assert "ROUTES.M02_MASTER_TEMPLATE, { layer }" in body - config = _text(ROOT / "config" / "config_frontend.ts") - assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config) - - def test_층_길_접두어와_고정_풀림(): api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 9ba996d8..24684799 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -139,6 +139,12 @@ export const ui_locales_b3 = { /* --- B01 시스템 설정 --- */ B01_Dashboard_SystemSettings: ["시스템 설정", "System Settings"], B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"], + B01_Dashboard_PermSourceRead: ["원문 원천 · 읽기", "Source data · read"], + B01_Dashboard_PermMasterRead: ["마스터 읽기", "Master read"], + B01_Dashboard_PermMasterWrite: ["마스터 수정", "Master edit"], + B01_Dashboard_PermCompanyRead: ["회사 읽기", "Company read"], + B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"], + B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"], /* --- B08·B09 빈 화면 (PLAN 7-3) --- */ B08_Quantity_Rebuilding: [ diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index bff3311c..005fa7d1 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -4,7 +4,10 @@ * ========================================================================== */ export const ui_locales_m2 = { - B01_Dashboard_MasterTemplate: ["마스터 템플릿", "Master Templates"], + B01_Dashboard_CardUnitCombo: ["일위대가 조합", "Unit cost combos"], + B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"], + B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"], + B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"], M02_Title: ["마스터 템플릿", "Master Templates"], M02_KindTable: ["구조물 조합", "Structure combos"], M02_KindDrawing: ["도면 양식", "Drawing templates"], @@ -57,9 +60,6 @@ export const ui_locales_m2 = { M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"], M02_StructureNone: ["없음", "None"], M02_Back: ["돌아가기", "Back"], - B01_Dashboard_StructureManage: ["구조물 관리", "Structure management"], - B01_Dashboard_StructureCompany: ["회사용", "Company"], - B01_Dashboard_StructurePersonal: ["개인용", "Personal"], M02_TitleCompany: ["구조물 관리(회사)", "Structure management (company)"], M02_TitlePersonal: ["구조물 관리(개인)", "Structure management (personal)"], M02_ReadOnlyCompany: [