Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Shell.ts
T
eomsangdonandClaude Opus 5.5 e505f4014d feat(B01): 대시보드 새 틀 — 셸 · 왼쪽 메뉴 · 세부 페이지 등록부 (546-1)
- 셸 = 위 머리 48(AISLO · 회사 이름 — 시스템 관리자 회사 전환 · 찾기 자리 · [?] · 알림 자리 · 사용자 메뉴) + 왼쪽 메뉴 210 + 챗봇 단추
- 등록부 B01_Dashboard_UI_Views.ts — { key, title, group, roles, icon, mount(host, ctx) } · 주소 ?view= · 같은 주소 안 이동 = 페이지만 갈아 끼움(MountedPage)
- 역할 넷(일반 · 회사 관리자 · 산림청 · 시스템 관리자) · 권한 없는 묶음 · 항목 숨김 · 권한 없는 열쇠 = 홈
- 열쇠마다 옛 구역 부품 그대로 얹음(B01_Dashboard_UI_Legacy.ts) — 기능 빠짐 0
- 옛 ☰ 서랍 · 구역 접기 · 회사 알약 걷음 — 테마 · 언어 · 터치 · 설정 · 로그아웃은 사용자 메뉴로
- 아이콘 = Tabler(MIT) 선 아이콘 몇 개(ui_template_icons.ts · 라이센스 글 함께)
- 시험 — test_b01_settings_cards 옛 한 페이지 단정(buildPage · 접힘 · 알약) 지움 · 셸 회사 고르기 단정으로 바꿈 · test_546_b01_shell 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:05:56 +09:00

277 lines
11 KiB
TypeScript

/* =============================================================================
* 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, type CompanyInfo, type DashboardUser } from "./B01_Dashboard_Api_Fetch";
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 { 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<void> {
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 dark = document.documentElement.getAttribute("data-theme") === "dark";
menu.append(
who,
menuItem("userCircle", "내 정보", () => ctx.go("me")),
menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => {
toggleTheme();
setOpen(false);
}),
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;
}
export async function mountDashboardShell(
root: HTMLElement,
me: DashboardUser,
forest: boolean,
): Promise<MountedPage> {
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", "알림 (준비 중)");
bell.classList.add("b01-shell__bell");
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);
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;
},
};
}