From 26fcebeb46c6a793d7c6f18d00d69a3b8a809548 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 15:53:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=84=A4=EC=A0=95):=20334-2=20=E2=98=B0?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=84=A4=EC=A0=95=204=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=94=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=F0=9F=8F=A0=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20+=20=EC=B9=B4=EB=93=9C=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EA=B8=88=20=EC=B9=B8=20=EA=B0=95=EC=A1=B0=20=C2=B7=20?= =?UTF-8?q?M02=20=E2=86=92=20M02=20=EC=9D=B4=EB=8F=99=EC=9D=80=20=EB=96=A0?= =?UTF-8?q?=EB=82=98=EA=B8=B0=20=EB=AC=BC=EC=9D=8C=20=EB=A8=BC=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 A00_Common/m_settings_nav: settingLinks = 대시보드 카드 역할 규칙 한 벌 · mountSettingsDrawer = ☰ 진행 단계 자리 - B01 시스템 설정 카드가 settingLinks 로만 그림(화면 그대로) - M01 · M02 page 한 줄씩(M02 = 진입 open · company 이어 실음) - test_b01_settings_cards 가 옮긴 함수 소스를 같이 실음(기대값 그대로) · test_225 뒷부분 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/m_settings_nav.ts | 98 +++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Page.ts | 38 ++---- M01_MasterData/M01_MasterData_UI_Page.ts | 2 + .../M02_MasterTemplete_UI_Page.ts | 2 + resources/tester/test_225_drawer_shortcuts.py | 115 +++++++++++++++++- resources/tester/test_b01_settings_cards.py | 7 +- 6 files changed, 228 insertions(+), 34 deletions(-) create mode 100644 A00_Common/m_settings_nav.ts diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts new file mode 100644 index 000000000..432177a8e --- /dev/null +++ b/A00_Common/m_settings_nav.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * m_settings_nav.ts + * 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는 + * 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`). + * ========================================================================== */ + +import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createStepBar } from "@ui/ui_template_workflow_layout"; +import { setDrawerSteps } from "./app_shell"; +import { currentRoute, navigateTo, takeLeaveGuard } from "./router"; + +interface SettingUser { + role?: string; + is_master?: boolean; + company_id?: number | null; +} + +/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */ +export interface SettingLink { + key: string; + title: string; + desc: string; + route: RoutePath; + query?: Record; +} + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +const ICONS: Record = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" }; + +/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] { + // 시스템 관리자는 고른 회사를 해시에 실음(29-1) + const company: Record = + user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; + const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({ + key: open, + title, + desc, + route: ROUTES.M02_MASTER_TEMPLATE, + query: { open, root, ...company }, + }); + // 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙 + const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback); + if (user.role === "SYSTEM_ADMIN") { + const desc = L("B01_Dashboard_PermMasterWrite"); + return [ + { + key: "m01", + title: L("B01_Dashboard_MasterElements"), + desc: L("B01_Dashboard_PermSourceRead"), + route: ROUTES.M01_MASTER_DATA, + }, + m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), + m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), + m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), + ]; + } + // 회사 수정 = 회사 관리자 · 회사 마스터(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(" · "); + return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)]; +} + +/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */ +export function mountSettingsDrawer( + user: SettingUser, + activeKey: string, + companyId: number | string | null, +): void { + const links = settingLinks(user, companyId); + const bar = createStepBar( + links.map((link) => link.title), + links.findIndex((link) => link.key === activeKey), + { + orientation: "vertical", + icons: links.map((link) => ICONS[link.key] ?? ""), + routes: links.map((link) => link.route), + homeButton: true, + onStepClick: (index) => void go(links[index]), + }, + ); + setDrawerSteps(bar); +} + +async function go(link: SettingLink): Promise { + const guard = link.route === currentRoute() ? takeLeaveGuard() : null; + if (guard && !(await guard())) return; + navigateTo(link.route, link.query); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index a30713238..82515ecfc 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -11,6 +11,7 @@ import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createStepBar } from "@ui/ui_template_workflow_layout"; import { setDrawerSteps } from "../A00_Common/app_shell"; +import { settingLinks } from "../A00_Common/m_settings_nav"; import { currentRoute, navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, @@ -164,40 +165,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement { return card; } -/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__setting-grid"; - // 시스템 관리자는 고른 회사를 해시에 실음(29-1) - const company: Record = - user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; - const m02 = (title: string, open: string, root: string, desc: string): HTMLElement => - settingCard(title, desc, () => - navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }), - ); - // 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙 - const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback); - 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_CardStructCombo"), "구조물조합", comboRoot("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(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)); + wrap.append( + ...settingLinks(user, companyId).map((link) => + settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)), + ), + ); return wrap; } diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 26525e3d2..c43c90a4a 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -19,6 +19,7 @@ import { import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { ROUTES } from "@config/config_frontend"; +import { mountSettingsDrawer } from "../A00_Common/m_settings_nav"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; @@ -54,6 +55,7 @@ export async function renderM01MasterData(root: HTMLElement): Promise { } root.innerHTML = ""; root.append(buildPage()); + mountSettingsDrawer(user, "m01", null); // ☰ 시스템 설정 바로가기(334) } function buildPage(): HTMLElement { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 95f647195..c44e253e8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -13,6 +13,7 @@ import { ROUTES } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; +import { mountSettingsDrawer } from "../A00_Common/m_settings_nav"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; @@ -238,6 +239,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise false, show: () => {}, dispose: () => setActiveLayer("system") }; diff --git a/resources/tester/test_225_drawer_shortcuts.py b/resources/tester/test_225_drawer_shortcuts.py index d4cb2d4b9..c7fdd1550 100644 --- a/resources/tester/test_225_drawer_shortcuts.py +++ b/resources/tester/test_225_drawer_shortcuts.py @@ -1,8 +1,10 @@ """PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2). ① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐 + settingLinks 역할 셋 · 설정 ☰ 지금 칸(진입 인자) ② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) · - 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) + 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) · M01 ☰ 다섯 · 강조 · 「도면 양식」 → open=도면 · + M02 → M02 이동은 떠나기 물음 먼저(가드 자리에 물음 창을 심어 확인 · 취소 = 머묾) """ from __future__ import annotations @@ -19,6 +21,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5177") # sub2 화면 서버(OWNERS 자기 줄) LAYOUT = ROOT / "ui_template" / "ui_template_workflow_layout.ts" +NAV = ROOT / "A00_Common" / "m_settings_nav.ts" STEPS = ["파일입력", "전처리", "종단설계"] WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"] @@ -81,6 +84,58 @@ def test_전처리_잠금은_길_기준(): assert _step_bar("USER", titles) == [False, False, False] +def _settings(user: dict, active: str, company) -> dict: + """settingLinks 목록 + ☰ 에 맡긴 단계 막대(이름 · 지금 칸 · 아이콘 · 🏠)""" + src = NAV.read_text(encoding="utf-8") + icons = re.search(r"^const ICONS.*$", src, re.M).group(0) + script = ( + "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," + " M02_MASTER_TEMPLATE: 'm02-master-template' };\n" + "const L = (k: string) => k;\nlet bar: any = null;\n" + "const createStepBar = (titles: string[], active: number, o: any) =>" + " ({ titles, active, icons: o.icons, home: o.homeButton, routes: o.routes });\n" + "const setDrawerSteps = (b: any) => { bar = b; };\nconst go = () => {};\n" + + icons + + "\n" + + _fn(src, "settingLinks") + + "\n" + + _fn(src, "mountSettingsDrawer") + + "\nconst [user, active, company] = JSON.parse(process.argv[1]);" + "\nmountSettingsDrawer(user, active, company);" + "\nconsole.log(JSON.stringify({ links: settingLinks(user, company).map((l) =>" + " [l.key, l.route, l.query ?? null]), bar }));" + ) + return _node(script, json.dumps([user, active, company])) + + +M02 = "m02-master-template" + + +def test_설정_바로가기_역할_셋_지금_칸(): + admin = _settings({"role": "SYSTEM_ADMIN", "company_id": 1}, "옵션", "3") + assert admin["links"] == [ + ["m01", "m01-master-data", None], + ["구조물조합", M02, {"open": "구조물조합", "root": "personal", "company": "3"}], + ["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}], + ["도면", M02, {"open": "도면", "root": "master", "company": "3"}], + ] + bar = admin["bar"] + assert bar["active"] == 2 and bar["home"] is True + assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"] + assert _settings({"role": "SYSTEM_ADMIN"}, "m01", None)["bar"]["active"] == 0 + # 일위대가조합 = 강조 없음 + assert _settings({"role": "SYSTEM_ADMIN"}, "일위대가조합", None)["bar"]["active"] == -1 + # 그 밖 역할 = 🏠 + 구조물 조합 하나(회사 인자 안 실음) + company = _settings({"role": "ADMIN", "company_id": 1}, "구조물조합", "3") + assert company["links"] == [["구조물조합", M02, {"open": "구조물조합", "root": "personal"}]] + assert company["bar"]["active"] == 0 + assert _settings({"role": "USER", "is_master": True}, "구조물조합", None)["links"][0][2] == { + "open": "구조물조합", + "root": "company", + } + assert _settings({"role": "USER"}, "구조물조합", None)["links"][0][2]["root"] == "personal" + + # ── 실제 브라우저 ───────────────────────────────────────────── @@ -157,3 +212,61 @@ def test_대시보드_구역_바로가기(who, count): pass assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top) browser.close() + + +ACTIVE = "document.querySelector('.app-drawer__steps .is-active .ui-workflow-layout__step-label')?.textContent" +HASH = "decodeURIComponent(location.hash)" + + +def _drawer_click(page, label: str): + _open_drawer(page) + page.click(f".app-drawer__steps .ui-workflow-layout__step:has-text('{label}')") + + +@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") +def test_설정_4_페이지_바로가기(): + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + page = _page(browser, "시스템 관리자", "m01-master-data") + page.wait_for_selector(".m01-master", timeout=60000) + _open_drawer(page) + assert page.evaluate(DRAWER_LABELS) == [ + "대시보드", + "마스터 데이터", + "구조물 조합", + "구조물 옵션", + "도면 양식", + ] + assert page.evaluate(ACTIVE) == "마스터 데이터" + page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('도면 양식')") + page.wait_for_function("decodeURIComponent(location.hash).includes('open=도면')") + assert M02 in page.evaluate(HASH) + page.wait_for_selector(".m02-master", timeout=60000) + page.wait_for_load_state("networkidle") + _open_drawer(page) + assert page.evaluate(ACTIVE) == "도면 양식" + page.click(".app-burger") # 닫음 + + # M02 → M02 — 저장 안 한 고침 대신 가드 자리에 앱의 물음 창을 심음 + page.evaluate( + """async () => { + const url = (p) => performance.getEntriesByType('resource') + .map((e) => e.name).find((n) => n.includes(p)); + const router = await import(url('/router.ts')); + const ui = await import(url('/ui_template_elements.ts')); + router.setLeaveGuard(() => ui.showConfirmDialog('저장 안 한 고침')); + }""" + ) + before = page.evaluate(HASH) + _drawer_click(page, "구조물 옵션") + page.wait_for_selector(".ui-confirm") + page.click(".ui-confirm__actions button >> nth=0") # 취소 = 머묾 + page.wait_for_timeout(300) + assert page.evaluate(HASH) == before + _drawer_click(page, "구조물 옵션") + page.wait_for_selector(".ui-confirm") + page.click(".ui-confirm__actions button >> nth=1") # 버림 = 이동 + page.wait_for_function("decodeURIComponent(location.hash).includes('open=옵션')") + browser.close() diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 004a0013d..7798ddfd2 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -17,6 +17,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] PAGE = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts" +NAV = ROOT / "A00_Common" / "m_settings_nav.ts" # 카드 규칙 한 벌(334) def _text(path: Path) -> str: @@ -24,9 +25,9 @@ def _text(path: Path) -> str: def _fn(src: str, name: str) -> str: - found = re.search(rf"^function {name}\([\s\S]*?^}}", src, re.M) + found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M) assert found, f"{name} 를 못 찾음" - return found.group(0) + return found.group(0).removeprefix("export ") def _cards(user: dict, company_id: int | None = None) -> list: @@ -46,6 +47,8 @@ def _cards(user: dict, company_id: int | None = None) -> list: "const document = { createElement: () => mk() };\n" + _fn(src, "settingCard") + "\n" + + _fn(_text(NAV), "settingLinks") + + "\n" + _fn(src, "buildSystemSettingsPanel") + "\nconst user = JSON.parse(process.argv[1]);" + "\nconst panel = buildSystemSettingsPanel(user, JSON.parse(process.argv[2]));"