diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 8f257690f..0d95b131d 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -118,10 +118,11 @@ function buildBrand(): HTMLButtonElement { /* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠. * 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */ -let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null; +let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null; +/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */ export function setDrawerSteps(node: HTMLElement | null): void { - drawerSteps = node?.parentElement ? { node, home: node.parentElement } : null; + drawerSteps = node ? { node, home: node.parentElement } : null; } const NAV_ITEMS: [keyof typeof ui_locales, string][] = [ @@ -166,7 +167,8 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { burger.setAttribute("aria-expanded", String(open)); if (!drawerSteps) return; if (open) steps.append(drawerSteps.node); - else if (steps.contains(drawerSteps.node)) drawerSteps.home.append(drawerSteps.node); + else if (drawerSteps.home && steps.contains(drawerSteps.node)) + drawerSteps.home.append(drawerSteps.node); }; burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); scrim.addEventListener("click", () => setOpen(false)); diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 4278b4777..a30713238 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -9,7 +9,9 @@ import { } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; -import { navigateTo } from "../A00_Common/router"; +import { createStepBar } from "@ui/ui_template_workflow_layout"; +import { setDrawerSteps } from "../A00_Common/app_shell"; +import { currentRoute, navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, fetchAllUsers, @@ -231,6 +233,38 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement { return card; } +/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */ +const SECTION_ICONS: [Parameters[0], string][] = [ + ["B01_Dashboard_Resources", "📊"], + ["B01_Dashboard_SystemSettings", "⚙️"], + ["B01_Dashboard_Projects", "🗂️"], + ["B01_Temp_Section", "📦"], + ["B01_Dashboard_Users", "👥"], + ["B01_Dashboard_Members", "👥"], + ["B01_Dashboard_JoinRequests", "📨"], + ["B01_Dashboard_Companies", "🏢"], + ["B01_Dashboard_Company", "🏢"], + ["B01_Dashboard_Profile", "👤"], + ["B01_Account_Section_Security", "🔒"], + ["B01_Dashboard_AuditLogs", "📜"], +]; + +/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */ +function mountSectionShortcuts(grid: HTMLElement): void { + // 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음 + if (currentRoute() !== ROUTES.B01_ACCOUNT) return; + const sections = [...grid.children] as HTMLElement[]; + const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? ""); + const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? ""); + const bar = createStepBar(titles, -1, { + orientation: "vertical", + icons, + routes: titles, + onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }), + }); + setDrawerSteps(bar); +} + function buildPage(state: DashboardState): HTMLElement { // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. const page = document.createElement("div"); @@ -311,6 +345,7 @@ function buildPage(state: DashboardState): HTMLElement { grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); } page.append(grid); + mountSectionShortcuts(grid); page.append(buildKnowledgeChat()); const layout = createGeneralLayout({ pageClass: "b01-dashboard", diff --git a/resources/tester/test_225_drawer_shortcuts.py b/resources/tester/test_225_drawer_shortcuts.py new file mode 100644 index 000000000..d4cb2d4b9 --- /dev/null +++ b/resources/tester/test_225_drawer_shortcuts.py @@ -0,0 +1,159 @@ +"""PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2). + +① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐 +② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) · + 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import urllib.request +from pathlib import Path + +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" +STEPS = ["파일입력", "전처리", "종단설계"] +WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"] + + +def _fn(src: str, name: str) -> str: + found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M) + assert found, f"{name} 를 못 찾음" + return found.group(0).removeprefix("export ") + + +def _node(script: str, *args: str): + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, *args], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +FAKE_DOM = ( + "const mk = (): any => { const cls = new Set(); return {" + " children: [] as any[], textContent: '', title: '', disabled: false, on: null as any," + " set className(v: string) { cls.clear(); v.split(' ').filter(Boolean).forEach((c) => cls.add(c)); }," + " get className() { return [...cls].join(' '); }," + " classList: { add: (...c: string[]) => c.forEach((x) => cls.add(x))," + " remove: (c: string) => cls.delete(c), contains: (c: string) => cls.has(c)," + " toggle: (c: string, on: boolean) => (on ? cls.add(c) : cls.delete(c)) }," + " setAttribute() {}, append(...c: any[]) { this.children.push(...c); }," + " addEventListener(_: string, f: any) { this.on = f; } }; };\n" + "const document = { createElement: () => mk() };\n" +) + + +def _step_bar(role: str, routes: list[str]) -> list[bool]: + """역할 · 길 목록으로 그린 단계 단추의 꺼짐 — 역할 물음이 끝난 뒤""" + src = _fn(LAYOUT.read_text(encoding="utf-8"), "createStepBar") + script = ( + FAKE_DOM + + "const ROUTES = { B01_ACCOUNT: 'b01-account', B04_PREPROCESS: 'b04-preprocess' };\n" + "const t = (k: string) => k;\nconst navigateTo = () => {};\n" + "const getDashboardRole = async () => process.argv[1];\n" + + src.replace("import.meta.env.DEV", "false") + + "\nconst bar = createStepBar(JSON.parse(process.argv[2]), -1," + " { routes: JSON.parse(process.argv[3]) });" + "\nawait new Promise((r) => setTimeout(r, 0));" + "\nconsole.log(JSON.stringify(bar.children.map((b: any) => b.disabled)));" + ) + return _node(script, role, json.dumps(STEPS), json.dumps(routes)) + + +def test_전처리_잠금은_길_기준(): + # 프로젝트 진행 단계 — 그대로(전처리만 시스템 관리자 전용) + assert _step_bar("ADMIN", WF_ROUTES) == [False, True, False] + assert _step_bar("USER", WF_ROUTES) == [False, True, False] + assert _step_bar("SYSTEM_ADMIN", WF_ROUTES) == [False, False, False] + # 대시보드 구역 바로가기 — 둘째 칸이 회사 관리자 · 일반에서 안 꺼짐 + titles = ["시스템 설정", "프로젝트", "임시 보관함"] + assert _step_bar("ADMIN", titles) == [False, False, False] + assert _step_bar("USER", titles) == [False, False, False] + + +# ── 실제 브라우저 ───────────────────────────────────────────── + + +def _up() -> bool: + try: + urllib.request.urlopen(BASE, timeout=3) + return True + except Exception: + return False + + +def _cred(who: str) -> dict: + if who == "시스템 관리자": + return json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + accounts = json.loads((ROOT / "tmp" / "verify_accounts.json").read_text(encoding="utf-8")) + return {k: accounts[who][k] for k in ("email", "password")} + + +def _page(browser, who: str, route: str, size=(1280, 700)): + ctx = browser.new_context(viewport={"width": size[0], "height": size[1]}) + r = ctx.request.post(f"{BASE}/api/auth/login/request", data=_cred(who), timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + page = ctx.new_page() + page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded") + return page + + +DRAWER_LABELS = ( + "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step-label')]" + ".map((e) => e.textContent.trim())" +) +DRAWER_DISABLED = ( + "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step')]" + ".map((e) => e.disabled)" +) +SECTION_TITLES = ( + "[...document.querySelector('.b01-dashboard__grid').children]" + ".map((s) => s.querySelector('.ui-card__title').textContent.trim())" +) +DRAWER_OPEN = "!!document.querySelector('.is-drawer-open')" + + +def _open_drawer(page): + page.click(".app-burger") + page.wait_for_selector(".app-drawer__steps .ui-workflow-layout__step") + + +@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") +@pytest.mark.parametrize("who, count", [("시스템 관리자", 10), ("회사관리자", 8), ("일반", 6)]) +def test_대시보드_구역_바로가기(who, count): + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + page = _page(browser, who, "b01-account") + page.wait_for_selector(".b01-dashboard__grid", timeout=60000) + page.wait_for_load_state("networkidle") + titles = page.evaluate(SECTION_TITLES) + assert len(titles) == count, titles + _open_drawer(page) + assert page.evaluate(DRAWER_LABELS) == titles + assert page.evaluate(DRAWER_DISABLED) == [False] * count # 둘째 칸 안 꺼짐 + if who == "시스템 관리자": + page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('보안')") + assert not page.evaluate(DRAWER_OPEN) + top = ( + "[...document.querySelector('.b01-dashboard__grid').children]" + ".find((s) => s.querySelector('.ui-card__title').textContent.trim() === '보안')" + ".getBoundingClientRect().top" + ) + try: # 부드러운 굴림이 멎을 때까지 + page.wait_for_function(f"() => Math.abs({top} - 52) <= 2", timeout=5000) + except Exception: + pass + assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top) + browser.close() diff --git a/ui_template/ui_template_general_layout.css b/ui_template/ui_template_general_layout.css index f5533249e..1cdc97764 100644 --- a/ui_template/ui_template_general_layout.css +++ b/ui_template/ui_template_general_layout.css @@ -117,3 +117,8 @@ html.is-bm-route .ui-general-layout__header { padding-left: max(0px, calc(var(--app-burger-inset) + var(--spacing-8) - (100vw - 100%) / 2)); } + +/* ☰ 바로가기(334) — 맨 위로 굴린 구역 제목이 ☰ 자리에 깔리지 않게 비킴 */ +html.is-bm-route .ui-general-block__section { + scroll-margin-top: var(--app-burger-inset); +} diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 50b68bd70..bcee03cd3 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -116,7 +116,7 @@ export function createStepBar( button.classList.toggle("is-enabled", isEnabled); button.disabled = !isEnabled; - if (index === 1) { + if (options?.routes?.[index] === ROUTES.B04_PREPROCESS) { void getDashboardRole() .then((role) => { if (role !== "SYSTEM_ADMIN") {