diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 243720e8c..3d0c9971a 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -2,12 +2,12 @@ import { ROUTES } from "@config/config_frontend"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createButton, - createSelectField, createTag, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createMapContextMenu, type MapContextMenu } from "@ui/ui_template_context_menu"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { navigateTo } from "../A00_Common/router"; @@ -200,30 +200,46 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) return wrap; } -/** 회사 고르기(시스템 관리자만) — 고르면 저장 뒤 대시보드를 다시 읽음 */ -function buildCompanyPicker(state: DashboardState): HTMLElement { - const field = createSelectField({ - label: L("B01_Dashboard_PickCompany"), - options: state.allCompanies.map((c) => ({ value: String(c.id), text: c.name })), - value: String(state.adminCompanyId ?? ""), - compact: true, - onChange: (value) => { - sessionStorage.setItem(ADMIN_COMPANY_KEY, value); - window.dispatchEvent(new HashChangeEvent("hashchange")); - }, +/** 회사 메뉴 — 공용 작은 메뉴 하나를 알약 모두가 같이 씀 */ +let companyMenu: MapContextMenu | null = null; + +/** 회사 알약(시스템 관리자만) — 누르면 등록된 회사 메뉴 · 고르면 저장 뒤 대시보드를 다시 읽음 */ +function companyPill(state: DashboardState): HTMLElement { + const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-"; + const pill = document.createElement("button"); + pill.type = "button"; + pill.className = "ui-tag ui-tag--accent b01-dashboard__company-pill"; + pill.title = L("B01_Dashboard_PickCompany"); + pill.textContent = `${name} ▾`; + pill.addEventListener("click", (event) => { + // 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음 + event.stopPropagation(); + if (!companyMenu?.element.isConnected) { + companyMenu = createMapContextMenu("b01-dashboard"); + document.body.append(companyMenu.element); + } + const box = pill.getBoundingClientRect(); + companyMenu.open( + box.left, + box.bottom + 4, + state.allCompanies.map((c): [string, () => void] => [ + c.name, + () => { + sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id)); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }, + ]), + ); }); - field.root.classList.add("b01-dashboard__company-pick"); - // 제목 줄 누름 = 접힘 — 고르기 누름은 올려 보내지 않음 - field.root.addEventListener("click", (event) => event.stopPropagation()); - return field.root; + return pill; } /** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */ function buildSystemSettingsSection(state: DashboardState): HTMLElement { const { user } = state; - const picker = user.role === "SYSTEM_ADMIN" ? [buildCompanyPicker(state)] : []; + const pill = user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : []; const panel = buildSystemSettingsPanel(user, state.adminCompanyId); - const card = section(L("B01_Dashboard_SystemSettings"), panel, true, picker); + const card = section(L("B01_Dashboard_SystemSettings"), panel, true, pill); card.querySelector(".ui-card__body")?.classList.add("ui-collapsible"); card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title"); attachCollapsible(card); @@ -238,23 +254,31 @@ function buildPage(state: DashboardState): HTMLElement { grid.className = "b01-dashboard__grid"; if (state.user.role === "SYSTEM_ADMIN") { - // 시스템 설정은 리소스 현황 아래(29-1) · 그 아래 컨테이너는 고른 회사 것 + // 시스템 설정은 리소스 현황 아래(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), section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ + companyPill(state), createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), buildTempUploadSection(), section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ + companyPill(state), createButton({ label: "+", onClick: () => openAddMemberModal(undefined, state.adminCompanyId), }), ]), - section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), + section( + L("B01_Dashboard_JoinRequests"), + joinRequestTable(state.allJoinRequests, true), + true, + [companyPill(state)], + ), section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [ + companyPill(state), createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 0e082f38a..f2c2a0a3b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -68,12 +68,47 @@ font-size: var(--text-caption); } -/* 회사 고르기 — 시스템 설정 제목 줄 오른쪽 · 라벨과 고르기를 한 줄로 */ -.ui-field.b01-dashboard__company-pick { - flex-direction: row; - align-items: center; - gap: var(--spacing-8); - margin: 0; +/* 회사 알약 — 회사를 따라 바뀌는 컨테이너 제목 오른쪽 · 누르면 회사 메뉴 */ +.b01-dashboard__company-pill { + border: 0; + font: inherit; + font-size: var(--text-caption); + cursor: pointer; +} + +.b01-dashboard__context-menu { + position: fixed; + z-index: var(--z-modal); + display: flex; + flex-direction: column; + min-width: 160px; + max-height: 60vh; + overflow-y: auto; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.b01-dashboard__context-menu[hidden] { + display: none; +} + +.b01-dashboard__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: inherit; + font: inherit; + font-size: var(--text-body-sm); + text-align: left; + cursor: pointer; +} + +.b01-dashboard__context-menu-item:hover { + background: var(--color-surface-sunken); } /* 접히는 제목 줄 — 단추 칸을 접힘 표시 바로 왼쪽으로 */ diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index b36822cc2..f51ccef1c 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -113,26 +113,32 @@ def test_모든_역할_한_줄_통째로_접힘(): assert "StructureManage" not in src and "buildStructurePanel" not in src assert "top-row" not in src sect = _fn(src, "buildSystemSettingsSection") - assert 'L("B01_Dashboard_SystemSettings"), panel, true, picker)' in sect # 한 줄 통째 + assert 'L("B01_Dashboard_SystemSettings"), panel, true, pill)' 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) -def test_회사_고르기_시스템_관리자만(): +def test_회사_알약_시스템_관리자만(): src = _text(PAGE) sect = _fn(src, "buildSystemSettingsSection") - assert 'user.role === "SYSTEM_ADMIN" ? [buildCompanyPicker(state)] : []' in sect - picker = _fn(src, "buildCompanyPicker") - assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, value)" in picker - assert "event.stopPropagation()" in picker # 고르기 누름이 접힘으로 새지 않음 + assert 'user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : []' in sect + pill = _fn(src, "companyPill") + assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id))" in pill + assert "event.stopPropagation()" in pill # 알약 누름이 접힘으로 새지 않음 + assert 'createMapContextMenu("b01-dashboard")' in pill # 공용 작은 메뉴 + assert '"ui-tag ui-tag--accent b01-dashboard__company-pill"' in pill # 공용 알약 모양 assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키 load = src.split("async function loadRoleData")[1].split("\n}\n")[0] for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)", "fetchCompanyProjects(companyId)"): # fmt: skip assert call in load, call build = _fn(src, "buildPage") + # 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약 + sys_part = build.split('} else if (state.user.role === "ADMIN")')[0] + assert sys_part.count("companyPill(state)") == 4 + assert "companyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1] assert "companyTable(picked, state.user)" in build assert "openAddMemberModal(undefined, state.adminCompanyId)" in build diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 63b1d25d9..48ad7110d 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -145,7 +145,7 @@ export const ui_locales_b3 = { B01_Dashboard_PermCompanyRead: ["회사 읽기", "Company read"], B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"], B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"], - B01_Dashboard_PickCompany: ["회사", "Company"], + B01_Dashboard_PickCompany: ["회사 고르기", "Pick company"], /* --- B08·B09 빈 화면 (PLAN 7-3) --- */ B08_Quantity_Rebuilding: [