diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 75d0905ad..5d81ea51c 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -9,6 +9,7 @@ import { } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, @@ -42,6 +43,12 @@ import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile" import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick"; import { projectTable } from "./B01_Dashboard_UI_Projects"; import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; +import { + buildSidePanel, + pickActiveKey, + rememberActiveKey, + type PanelItem, +} from "./B01_Dashboard_UI_SidePanel"; import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload"; import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat"; import "./B01_Dashboard_UI_Style.css"; @@ -229,87 +236,133 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement { return card; } -function buildPage(state: DashboardState): HTMLElement { - // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. - const page = document.createElement("div"); - - const grid = document.createElement("div"); - grid.className = "b01-dashboard__grid"; - - if (state.user.role === "SYSTEM_ADMIN") { - // 시스템 설정은 리소스 현황 아래(29-1) · 회사 알약이 붙은 컨테이너는 고른 회사 것 - const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId); - grid.append( - section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), - buildSystemSettingsSection(state), - withCompanyPill( - section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ - createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), - ]), - state, - ), - buildTempUploadSection(), - withCompanyPill( - section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ - createButton({ - label: "+", - onClick: () => openAddMemberModal(undefined, state.adminCompanyId), - }), - ]), - state, - ), - withCompanyPill( - section( - L("B01_Dashboard_JoinRequests"), - joinRequestTable(state.allJoinRequests, true), - true, - ), - state, - ), - withCompanyPill( - section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [ - createButton({ label: "+", onClick: () => openCreateCompanyModal() }), - ]), - state, - ), - ); - } else if (state.user.role === "ADMIN") { - // 시스템 설정은 맨 위 한 줄(27-3a) - grid.append( - buildSystemSettingsSection(state), - section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [ - createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), - ]), - buildTempUploadSection(), - section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ - createButton({ label: "+", onClick: () => openAddMemberModal() }), - ]), - section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false), true), - section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), - ); - } else { - grid.append( - buildSystemSettingsSection(state), - section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [ - createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), - ]), - // 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록 - // 바로 아래에 둔다(2026-08-08 사용자 지시). - buildTempUploadSection(), - section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), - ); - } - - grid.append( - section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), - section(L("B01_Account_Section_Security"), buildSecurityForm()), +/** 좌측 패널 항목 — 권한별 · 항목 하나 = 지금 컨테이너 그대로(누를 때 만듦) */ +function panelItems(state: DashboardState): PanelItem[] { + const one = (key: string, label: string, make: () => HTMLElement): PanelItem => ({ + key, + label, + build: () => [make()], + }); + const projectsAdd = [ + createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), + ]; + const myInfo: PanelItem = { + key: "profile", + label: L("B01_Panel_MyInfo"), + build: () => [ + section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), + section(L("B01_Account_Section_Security"), buildSecurityForm()), + ], + }; + const settings = one("settings", L("B01_Dashboard_SystemSettings"), () => + buildSystemSettingsSection(state), ); - // 시스템 로그는 맨 아래 (2026-09-06 사용자 지시). + const temp = one("temp", L("B01_Temp_Section"), () => buildTempUploadSection()); if (state.user.role === "SYSTEM_ADMIN") { - grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); + // 회사 알약이 붙은 컨테이너는 고른 회사 것 + const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId); + return [ + one("resources", L("B01_Dashboard_Resources"), () => + section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), + ), + settings, + one("projects", L("B01_Dashboard_Projects"), () => + withCompanyPill( + section( + L("B01_Dashboard_Projects"), + projectTable(state.allProjects, state.user), + true, + projectsAdd, + ), + state, + ), + ), + temp, + one("users", L("B01_Dashboard_Users"), () => + withCompanyPill( + section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ + createButton({ + label: "+", + onClick: () => openAddMemberModal(undefined, state.adminCompanyId), + }), + ]), + state, + ), + ), + one("requests", L("B01_Dashboard_JoinRequests"), () => + withCompanyPill( + section( + L("B01_Dashboard_JoinRequests"), + joinRequestTable(state.allJoinRequests, true), + true, + ), + state, + ), + ), + one("companies", L("B01_Dashboard_Companies"), () => + withCompanyPill( + section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [ + createButton({ label: "+", onClick: () => openCreateCompanyModal() }), + ]), + state, + ), + ), + myInfo, + one("logs", L("B01_Dashboard_AuditLogs"), () => + section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), + ), + ]; } - page.append(grid); - page.append(buildKnowledgeChat()); + const company = one("company", L("B01_Dashboard_Company"), () => + section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), + ); + if (state.user.role === "ADMIN") { + return [ + settings, + one("projects", L("B01_Dashboard_Projects"), () => + section( + L("B01_Dashboard_Projects"), + projectTable(state.companyProjects, state.user), + true, + projectsAdd, + ), + ), + temp, + one("members", L("B01_Dashboard_Members"), () => + section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ + createButton({ label: "+", onClick: () => openAddMemberModal() }), + ]), + ), + one("requests", L("B01_Dashboard_JoinRequests"), () => + section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false), true), + ), + company, + myInfo, + ]; + } + return [ + settings, + one("projects", L("B01_Dashboard_Projects"), () => + section( + L("B01_Dashboard_Projects"), + projectTable(state.userProjects, state.user), + true, + projectsAdd, + ), + ), + temp, + company, + myInfo, + ]; +} + +function buildPage(state: DashboardState): HTMLElement { + const items = panelItems(state); + const detail = document.createElement("div"); + detail.className = "b01-dashboard__grid"; + // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 본문은 고른 항목 하나. + const page = document.createElement("div"); + page.append(detail, buildKnowledgeChat()); const layout = createGeneralLayout({ pageClass: "b01-dashboard", title: L("B01_Dashboard_Title"), @@ -323,5 +376,23 @@ function buildPage(state: DashboardState): HTMLElement { ); tag.classList.add("b01-dashboard__role"); layout.root.querySelector(".ui-general-layout__header")?.append(tag); + + // B 그룹 공용 좌측 패널(M01 · B03 과 같은 오버레이) — 열리면 본문을 밀어냄 + const panel = buildSidePanel(items, pickActiveKey(items), (key) => show(key, true)); + const overlays = createWorkflowOverlays({ + title: L("B01_Panel_Title"), + optionsContent: panel.root, + showProjectName: false, + onOptionsOpenChange: (isOpen) => layout.root.classList.toggle("is-options-open", isOpen), + }); + layout.root.append(overlays.root); + function show(key: string, picked: boolean): void { + detail.replaceChildren(...(items.find((item) => item.key === key) ?? items[0]).build()); + panel.setActive(key); + rememberActiveKey(key); + // 좁은 화면(패널이 본문을 덮음)에서는 고르면 접음 + if (picked && window.matchMedia("(max-width: 860px)").matches) overlays.setTitleOpen(false); + } + show(pickActiveKey(items), false); return layout.root; } diff --git a/B01_Dashboard/B01_Dashboard_UI_SidePanel.ts b/B01_Dashboard/B01_Dashboard_UI_SidePanel.ts new file mode 100644 index 000000000..e574bf97c --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_SidePanel.ts @@ -0,0 +1,47 @@ +import { ROUTES } from "@config/config_frontend"; +import { currentQuery } from "../A00_Common/router"; + +/** 좌측 패널 항목 하나 — 누르면 우측 상세에 `build()` 결과만 보임 */ +export interface PanelItem { + key: string; + label: string; + build: () => HTMLElement[]; +} + +/** 해시 `?tab=` 에 남은 항목 — 없거나 이 권한에 없는 항목이면 첫 항목 */ +export function pickActiveKey(items: readonly PanelItem[]): string { + const saved = currentQuery().get("tab"); + return items.find((item) => item.key === saved)?.key ?? items[0].key; +} + +/** 고른 항목을 해시에 남김 — hashchange 를 일으키지 않음(다시 그리면 같은 항목으로 돌아옴) */ +export function rememberActiveKey(key: string): void { + history.replaceState(null, "", `#/${ROUTES.B01_ACCOUNT}?tab=${encodeURIComponent(key)}`); +} + +/** B 그룹 공용 좌측 패널 목록 모양(`ui-sidebar-*`) — 줄 하나 = 항목 하나 */ +export function buildSidePanel( + items: readonly PanelItem[], + activeKey: string, + onPick: (key: string) => void, +): { root: HTMLElement; setActive: (key: string) => void } { + const list = document.createElement("div"); + list.className = "ui-sidebar-list b01-dashboard__panel-list"; + const rows = new Map(); + for (const item of items) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "ui-sidebar-row b01-dashboard__panel-row"; + row.textContent = item.label; + row.addEventListener("click", () => onPick(item.key)); + rows.set(item.key, row); + list.append(row); + } + const setActive = (key: string): void => + rows.forEach((row, rowKey) => row.classList.toggle("is-active", rowKey === key)); + setActive(activeKey); + const root = document.createElement("div"); + root.className = "ui-sidebar-panel b01-dashboard__panel"; + root.append(list); + return { root, setActive }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 16efe22a4..b8dc6b8ac 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -683,3 +683,27 @@ outline: 2px solid var(--color-royal-amethyst, #6843a7); outline-offset: 2px; } + +/* 좌측 패널(공용 오버레이) — 열리면 본문을 밀어내고 접히면 손잡이 폭만 남김(B03 과 같은 값) */ +.b01-dashboard .ui-general-layout__inner { + transition: padding-left var(--transition-base); +} + +.b01-dashboard.is-options-open .ui-general-layout__inner { + padding-left: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48))); +} + +.b01-dashboard:not(.is-options-open) .ui-general-layout__inner { + padding-left: var(--spacing-24); +} + +@media (max-width: 860px) { + .b01-dashboard.is-options-open .ui-general-layout__inner { + padding-left: 0; + } +} + +.b01-dashboard__panel-list { + max-height: none; + margin-top: 0; +} diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index f5872516f..e0a81d7c5 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -98,14 +98,15 @@ def test_회사_관리자_개인_카드_하나(): def test_모든_역할_한_줄_통째로_접힘(): src = _text(PAGE) - build = _fn(src, "buildPage") + build = _fn(src, "panelItems") # 세 갈래 모두 한 번씩 · 시스템 관리자는 리소스 현황 아래(29-1) · 프로젝트 위 - assert build.count("buildSystemSettingsSection(state)") == 3 - sys_part = build.split('} else if (state.user.role === "ADMIN")')[0] + assert "buildSystemSettingsSection(state)" in build + assert len(re.findall(r"^\s+settings,$", build, re.M)) == 3 + sys_part = build.split('if (state.user.role === "ADMIN")')[0] assert ( - sys_part.index('L("B01_Dashboard_Resources")') - < sys_part.index("buildSystemSettingsSection(state)") - < sys_part.index('L("B01_Dashboard_Projects")') + sys_part.index('one("resources"') + < sys_part.index(" settings,") + < sys_part.index('one("projects"') ) assert "StructureManage" not in src and "buildStructurePanel" not in src assert "top-row" not in src @@ -132,11 +133,11 @@ def test_회사_알약_시스템_관리자만(): for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)", "fetchCompanyProjects(companyId)"): # fmt: skip assert call in load, call - build = _fn(src, "buildPage") + build = _fn(src, "panelItems") # 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약 - sys_part = build.split('} else if (state.user.role === "ADMIN")')[0] + sys_part = build.split('if (state.user.role === "ADMIN")')[0] assert sys_part.count("withCompanyPill(") == 4 - assert "withCompanyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1] + assert "withCompanyPill" not in build.split('if (state.user.role === "ADMIN")')[1] assert "companyTable(picked, state.user)" in build assert "openAddMemberModal(undefined, state.adminCompanyId)" in build diff --git a/resources/tester/test_b01_side_panel.py b/resources/tester/test_b01_side_panel.py new file mode 100644 index 000000000..3f1cd902b --- /dev/null +++ b/resources/tester/test_b01_side_panel.py @@ -0,0 +1,56 @@ +"""B01 대시보드 좌측 패널 시험(99-2) — 권한별 항목 · 해시 유지 · 공용 오버레이. 화면 조작은 ORCA.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B01 = ROOT / "B01_Dashboard" + + +def _text(name: str) -> str: + return (B01 / name).read_text(encoding="utf-8") + + +def _items(role: str) -> list[str]: + """panelItems 안 권한별 return 목록의 항목 이름(key) 차례.""" + body = re.search(r"^function panelItems\([\s\S]*?^}", _text("B01_Dashboard_UI_Page.ts"), re.M) + assert body + lists = re.findall(r"^( *)return \[\n([\s\S]*?)^\1\];", body.group(0), re.M) + part = lists[["SYSTEM_ADMIN", "ADMIN", "USER"].index(role)][1] + found = re.findall(r'one\(\s*"(\w+)"|^ {4,6}(settings|temp|company|myInfo),', part, re.M) + names = [a or b for a, b in found] + return ["profile" if n == "myInfo" else n for n in names] + + +def test_권한별_항목_차례(): + assert _items("SYSTEM_ADMIN") == [ + "resources", "settings", "projects", "temp", "users", "requests", + "companies", "profile", "logs", + ] # fmt: skip + assert _items("ADMIN") == [ + "settings", "projects", "temp", "members", "requests", "company", "profile", + ] # fmt: skip + assert _items("USER") == ["settings", "projects", "temp", "company", "profile"] + + +def test_고른_항목_해시에_남김(): + panel = _text("B01_Dashboard_UI_SidePanel.ts") + assert 'currentQuery().get("tab")' in panel + assert "history.replaceState" in panel # hashchange 를 안 일으킴 + page = _text("B01_Dashboard_UI_Page.ts") + assert "show(pickActiveKey(items), false)" in page # 다시 그려도 같은 항목 + assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in page + + +def test_공용_좌측_패널_부품(): + page = _text("B01_Dashboard_UI_Page.ts") + assert "createWorkflowOverlays" in page and "showProjectName: false" in page + assert "is-options-open" in page + panel = _text("B01_Dashboard_UI_SidePanel.ts") + assert "ui-sidebar-list" in panel and "ui-sidebar-row" in panel + # 옛 탭 줄 · 해시 탭 부품 없음 + assert not (B01 / "B01_Dashboard_UI_Tabs.ts").exists() + # 좁은 화면(≤860) — 고르면 접음 + assert "max-width: 860px" in page diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index bd455c923..5b863b99f 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -128,6 +128,8 @@ export const ui_locales_b1 = { B01_Temp_Delete_Failed: ["삭제에 실패했습니다.", "Failed to delete."], B01_Dashboard_Profile: ["기본정보", "Profile"], + B01_Panel_Title: ["대시보드", "Dashboard"], + B01_Panel_MyInfo: ["내 정보", "My info"], B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"], B01_Dashboard_Table_Project: ["프로젝트명", "Project"], B01_Dashboard_Table_Region: ["지역", "Region"],