- 셸 = 위 머리 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>
277 lines
11 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|