/* ============================================================================= * B01_Dashboard_UI_Shell.ts * 대시보드 셸(546) — 위 머리 48 + 왼쪽 메뉴 210 + 세부 페이지 자리 + 오른쪽 아래 챗봇 단추. * 머리 = AISLO · 회사 이름(시스템 관리자 = 눌러 회사 전환) · 찾기(자리만) · [?] · 알림 · 사용자 메뉴 * 페이지 = 등록부(`B01_Dashboard_UI_Views.ts`) · 주소 `?view=` 가 바뀌면 셸은 두고 페이지만 갈아 끼움 * (라우터 MountedPage — 같은 주소 안 이동 = show) · 옛 ☰ 서랍 · 구역 접기 없음 * ========================================================================== */ import { LANG_STORAGE_KEY, ROUTES, THEME_STORAGE_KEY } from "@config/config_frontend"; import { createHelpButton } from "@ui/ui_template_help"; 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, 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, 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"; function iconButton(name: IconName, label: string, onClick?: () => void): HTMLButtonElement { const b = document.createElement("button"); b.type = "button"; b.className = "b01-shell__icon-btn"; b.title = b.ariaLabel = label; b.append(icon(name)); if (onClick) b.addEventListener("click", onClick); else b.disabled = true; return b; } function menuItem(name: IconName, label: string, onClick: () => void): HTMLButtonElement { const b = document.createElement("button"); b.type = "button"; b.className = "b01-shell__menu-item"; const text = document.createElement("span"); text.textContent = label; b.append(icon(name, 16), text); b.addEventListener("click", onClick); return b; } function toggleTheme(): void { const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); localStorage.setItem(THEME_STORAGE_KEY, next); } /** 로그아웃 — 옛 ☰ 와 같음(서버 로그아웃 → 이 브라우저 자료 지움 → 홈으로 새로고침) */ async function logoutNow(): Promise { const { logout } = await import("../A06_Login/A06_Login_Api_Fetch"); await logout().catch(() => undefined); // 세션이 이미 만료된 경우 등은 무시 const { wipeAccountCache } = await import("../A00_Common/b_project_switch"); await wipeAccountCache(); history.replaceState(null, "", `#/${ROUTES.A01_HOME}`); window.location.reload(); } /** 사용자 메뉴 — 이름 · 역할 · 내 정보 · 화면(밝음 · 어두움 · 언어 · 터치) · 설정 · 홈페이지 · 로그아웃 */ function buildUserMenu(ctx: ViewCtx, roleText: string): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-shell__user"; const trigger = document.createElement("button"); trigger.type = "button"; trigger.className = "b01-shell__user-btn"; trigger.setAttribute("aria-haspopup", "menu"); trigger.setAttribute("aria-expanded", "false"); const avatar = document.createElement("span"); avatar.className = "b01-shell__avatar"; avatar.textContent = (ctx.me.name?.trim() || ctx.me.email).slice(0, 1); const name = document.createElement("span"); name.className = "b01-shell__user-name"; name.textContent = ctx.me.name?.trim() || ctx.me.email; trigger.append(avatar, name, icon("chevronDown", 14)); const menu = document.createElement("div"); menu.className = "b01-shell__menu"; menu.setAttribute("role", "menu"); menu.hidden = true; const who = document.createElement("div"); who.className = "b01-shell__menu-who"; const strong = document.createElement("strong"); strong.textContent = name.textContent; const sub = document.createElement("span"); sub.textContent = `${roleText} · ${ctx.me.email}`; who.append(strong, sub); const touch = document.createElement("div"); touch.className = "b01-shell__menu-touch"; const touchLabel = document.createElement("span"); touchLabel.textContent = "터치 모드"; touch.append(icon("hand", 16), touchLabel); const modes: [TouchMode, string][] = [ ["auto", "자동"], ["on", "켬"], ["off", "끔"], ]; for (const [mode, label] of modes) { const b = document.createElement("button"); b.type = "button"; b.textContent = label; b.dataset.touchMode = mode; b.setAttribute("aria-pressed", String(readTouchMode() === mode)); b.addEventListener("click", () => { setTouchMode(mode); for (const x of touch.querySelectorAll("button")) x.setAttribute("aria-pressed", String(x.dataset.touchMode === mode)); }); touch.append(b); } const setOpen = (open: boolean): void => { menu.hidden = !open; trigger.setAttribute("aria-expanded", String(open)); }; // 테마 줄 = 지금과 반대 화면 이름 · 바꾼 뒤 다시 씀 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")), theme, menuItem("world", currentLanguageIndex === 0 ? "English" : "한국어", () => { const next = currentLanguageIndex === 0 ? "en" : "ko"; setLanguage(next); localStorage.setItem(LANG_STORAGE_KEY, next); window.location.reload(); }), touch, menuItem("adjustments", "설정(마우스 · 단축키)", () => { void import("../A00_Common/app_settings").then((m) => m.openAppSettings()); }), menuItem("home", "홈페이지", () => navigateTo(ROUTES.A01_HOME)), menuItem("logout", "로그아웃", () => void logoutNow()), ); // 고른 뒤 닫힘(터치 모드 단추는 그대로) menu.addEventListener("click", (e) => { if ((e.target as Element).closest(".b01-shell__menu-item")) setOpen(false); }); trigger.addEventListener("click", () => setOpen(menu.hidden !== false)); const outside = (e: MouseEvent): void => { if (!wrap.isConnected) document.removeEventListener("click", outside); else if (!wrap.contains(e.target as Node)) setOpen(false); }; document.addEventListener("click", outside); wrap.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); wrap.append(trigger, menu); return wrap; } /** 회사 이름 — 시스템 관리자는 눌러 회사 고르기(옛 회사 알약) · 고르면 지금 페이지를 그 회사 것으로 다시 그림 */ function buildCompany( ctx: ViewCtx, companies: CompanyInfo[], onPick: (id: number) => void, ): HTMLElement { const nameOf = (): string => companies.find((c) => c.id === ctx.companyId)?.name ?? ctx.me.company_name ?? "-"; if (ctx.role !== "SYSTEM_ADMIN") { const span = document.createElement("span"); span.className = "b01-shell__org"; span.textContent = ctx.me.company_name ?? "회사 없음"; return span; } const b = document.createElement("button"); b.type = "button"; b.className = "b01-shell__org is-switch"; b.title = "회사 전환"; const label = document.createElement("span"); label.textContent = nameOf(); b.append(label, icon("chevronDown", 14)); b.addEventListener("click", () => openCompanyPicker(companies, ctx.companyId, (company) => { sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id)); onPick(company.id); label.textContent = nameOf(); }), ); 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, forest: boolean, ): Promise { const role = roleOf(me, forest); const companies = role === "SYSTEM_ADMIN" ? await fetchAllCompanies().catch(() => []) : []; const ctx: ViewCtx = { me, role, companyId: role === "SYSTEM_ADMIN" ? pickAdminCompany(me, companies) : (me.company_id ?? null), go: (view, params = {}) => { window.location.hash = viewHref(view, params); }, }; const roleText = role === "FOREST" ? "산림청" : roleLabel(me.role); const shell = document.createElement("div"); shell.className = "b01-shell b01-dashboard"; const header = document.createElement("header"); header.className = "b01-shell__head"; const brand = document.createElement("a"); brand.className = "b01-shell__brand"; brand.href = viewHref("home"); brand.textContent = "AISLO"; const search = document.createElement("input"); search.className = "b01-shell__search"; search.type = "search"; search.placeholder = "찾기 (준비 중)"; search.disabled = true; const spacer = document.createElement("span"); spacer.className = "b01-shell__spacer"; const help = createHelpButton(); help.classList.add("b01-shell__help-btn"); // 알림 = 처리할 일 수 · 누르면 홈(처리할 일 목록 자리) 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) => { ctx.companyId = id; show(); }), search, spacer, help, bell, buildUserMenu(ctx, roleText), ); const visible = VIEWS.filter((v) => v.roles.includes(role)); const nav = buildNav(visible); const main = document.createElement("main"); main.className = "b01-shell__main"; const body = document.createElement("div"); body.className = "b01-shell__body"; body.append(nav.root, main); shell.append(header, body, buildKnowledgeChat()); root.append(shell); let cleanup: (() => void) | null = null; let seq = 0; /** 주소 `?view=` 페이지를 새 자리에 그림 — 옛 페이지 정리 · 늦게 끝난 옛 mount 의 정리도 부름 */ const show = (): void => { cleanup?.(); cleanup = null; 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; main.replaceChildren(host); void Promise.resolve(view.mount(host, ctx)).then((done) => { if (typeof done !== "function") return; if (mine === seq) cleanup = done; else done(); }); }; show(); return { covers: (route) => route === ROUTES.B01_ACCOUNT, show, dispose: () => { seq++; cleanup?.(); cleanup = null; }, }; }