diff --git a/B01_Dashboard/B01_Dashboard_UI_Shell.ts b/B01_Dashboard/B01_Dashboard_UI_Shell.ts index 41f5aa0d4..69836fcd5 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Shell.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Shell.ts @@ -12,12 +12,19 @@ import { icon, type IconName } from "@ui/ui_template_icons"; import { currentLanguageIndex, setLanguage } from "@ui/ui_template_locale"; import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context"; import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router"; -import { fetchAllCompanies, type CompanyInfo, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { + fetchAllCompanies, + fetchCompanyJoinRequests, + type CompanyInfo, + type DashboardUser, +} from "./B01_Dashboard_Api_Fetch"; +import { fetchTempBatches } from "./B01_Dashboard_Api_Temp"; +import { fetchAssignedPlans } from "./B01_Dashboard_UI_AssignedPlans"; import { roleLabel } from "./B01_Dashboard_UI_Common"; import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick"; import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat"; import { ADMIN_COMPANY_KEY, pickAdminCompany } from "./B01_Dashboard_UI_Legacy"; -import { buildNav, viewHref } from "./B01_Dashboard_UI_Nav"; +import { buildNav, viewHref, type DashboardNav } from "./B01_Dashboard_UI_Nav"; import { roleOf, viewFor, VIEWS, type ViewCtx } from "./B01_Dashboard_UI_Views"; import "./B01_Dashboard_UI_Style_Shell.css"; @@ -116,14 +123,19 @@ function buildUserMenu(ctx: ViewCtx, roleText: string): HTMLElement { menu.hidden = !open; trigger.setAttribute("aria-expanded", String(open)); }; - const dark = document.documentElement.getAttribute("data-theme") === "dark"; + // 테마 줄 = 지금과 반대 화면 이름 · 바꾼 뒤 다시 씀 + const themeItem = (): HTMLButtonElement => { + const dark = document.documentElement.getAttribute("data-theme") === "dark"; + return menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => { + toggleTheme(); + theme.replaceWith((theme = themeItem())); + }); + }; + let theme = themeItem(); menu.append( who, menuItem("userCircle", "내 정보", () => ctx.go("me")), - menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => { - toggleTheme(); - setOpen(false); - }), + theme, menuItem("world", currentLanguageIndex === 0 ? "English" : "한국어", () => { const next = currentLanguageIndex === 0 ? "en" : "ko"; setLanguage(next); @@ -183,6 +195,39 @@ function buildCompany( return b; } +/** 보관함 만료 임박 = 7 일 안 */ +const EXPIRE_SOON_MS = 7 * 24 * 3600 * 1000; + +/** 메뉴 배지 · 알림 수 — 가입 요청(회사 관리자 · 시스템 관리자) · 받은 예정노선 미연결 · 업데이트 · + * 보관함 묶음 수(만료 임박은 처리할 일) · 처리할 일 = 빨강 배지 합 · 실패한 것은 0 */ +async function refreshBadges(ctx: ViewCtx, nav: DashboardNav, bell: HTMLElement): Promise { + const admin = ctx.role === "ADMIN" || ctx.role === "SYSTEM_ADMIN"; + const [joins, plans, temp] = await Promise.all([ + admin + ? fetchCompanyJoinRequests(ctx.role === "SYSTEM_ADMIN" ? ctx.companyId : undefined).catch( + () => [], + ) + : [], + ctx.role === "FOREST" ? [] : fetchAssignedPlans().catch(() => []), + fetchTempBatches() + .then((r) => r.batches) + .catch(() => []), + ]); + const newPlans = plans.filter((p) => !p.linked_project_id || p.update_available).length; + const now = Date.now(); + const expiring = temp.filter( + (b) => b.expires_at && Date.parse(b.expires_at) - now < EXPIRE_SOON_MS, + ).length; + nav.setBadge("people", joins.length, true); + nav.setBadge("assigned", newPlans, true); + nav.setBadge("temp", temp.length); + const todo = joins.length + newPlans + expiring; + const badge = bell.querySelector(".b01-shell__badge")!; + badge.hidden = todo <= 0; + badge.textContent = String(todo); + bell.title = bell.ariaLabel = `처리할 일 ${todo} 건`; +} + export async function mountDashboardShell( root: HTMLElement, me: DashboardUser, @@ -218,8 +263,12 @@ export async function mountDashboardShell( spacer.className = "b01-shell__spacer"; const help = createHelpButton(); help.classList.add("b01-shell__help-btn"); - const bell = iconButton("bell", "알림 (준비 중)"); - bell.classList.add("b01-shell__bell"); + // 알림 = 처리할 일 수 · 누르면 홈(처리할 일 목록 자리) + const bell = iconButton("bell", "처리할 일", () => ctx.go("home")); + const bellBadge = document.createElement("span"); + bellBadge.className = "b01-shell__badge is-alert"; + bellBadge.hidden = true; + bell.append(bellBadge); header.append( brand, buildCompany(ctx, companies, (id) => { @@ -252,6 +301,7 @@ export async function mountDashboardShell( const mine = ++seq; const view = viewFor(currentQuery().get("view"), role); nav.setActive(view.group === "hidden" && view.key === "project" ? "projects" : view.key); + void refreshBadges(ctx, nav, bell); const host = document.createElement("div"); host.className = "b01-shell__page"; host.dataset.view = view.key; diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css b/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css index 3c46cdb83..52ed0b1a3 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css @@ -22,6 +22,10 @@ --color-border: var(--b01-line); --color-primary: var(--b01-accent); --color-primary-text: var(--b01-accent-text); + --color-text: var(--b01-text); + --color-text-body: var(--b01-text); + --color-text-secondary: var(--b01-muted); + --color-text-muted: var(--b01-muted); min-height: 100dvh; display: grid; diff --git a/resources/tester/test_546_b01_shell.py b/resources/tester/test_546_b01_shell.py index baf8cfe74..c59c1f5dd 100644 --- a/resources/tester/test_546_b01_shell.py +++ b/resources/tester/test_546_b01_shell.py @@ -104,3 +104,16 @@ def test_셸_한_벌(): assert ':root[data-theme="dark"] .b01-shell {' in css icons = _text(ROOT / "ui_template" / "ui_template_icons.ts") assert "MIT License" in icons and "Paweł Kuna" in icons + + +def test_메뉴_배지_알림(): + shell = _text(B01 / "B01_Dashboard_UI_Shell.ts") + badges = shell.split("async function refreshBadges")[1].split("\n}\n")[0] + assert 'nav.setBadge("people", joins.length, true);' in badges # 가입 요청 = 빨강 + assert "!p.linked_project_id || p.update_available" in badges # 받은 예정노선 새로 · 업데이트 + assert 'nav.setBadge("temp", temp.length);' in badges + assert "const todo = joins.length + newPlans + expiring;" in badges # 알림 = 처리할 일 합 + assert "void refreshBadges(ctx, nav, bell);" in shell # 페이지 바뀔 때마다 다시 셈 + css = _text(B01 / "B01_Dashboard_UI_Style_Shell.css") + assert "@media (max-width: 1024px)" in css # 작은 화면 = 아이콘 레일 + assert "html:has(.b01-shell) .app-header" in css # 옛 ☰ 걷음