- 메뉴 배지 = 가입 요청(빨강) · 받은 예정노선 미연결 · 업데이트(빨강) · 임시 보관함 묶음 수 — 페이지 바뀔 때마다 다시 셈 - 알림 종 = 처리할 일 수(가입 요청 + 새 예정노선 + 보관함 만료 7 일 안) · 누르면 홈 - 사용자 메뉴 테마 줄 = 바꾼 뒤 반대 화면 이름으로 다시 씀 - 셸 안 글 색 변수(--color-text 등)도 셸 색으로 — 어두운 화면 제목 보라 빠짐 - 시험 test_546_b01_shell 배지 · 레일 · ☰ 걷음 단정 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
13 KiB
TypeScript
327 lines
13 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,
|
|
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<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 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<void> {
|
|
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<HTMLElement>(".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<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", "처리할 일", () => 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;
|
|
},
|
|
};
|
|
}
|