Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3

This commit is contained in:
2026-10-26 18:09:03 +09:00
10 changed files with 1320 additions and 395 deletions
+286
View File
@@ -0,0 +1,286 @@
/* =============================================================================
* B01_Dashboard_UI_Legacy.ts
* 옛 한 페이지 대시보드의 구역 부품을 세부 페이지에 그대로 얹음(546-1) — 기능 빠짐 0.
* 새 모양 페이지가 등록부(`B01_Dashboard_UI_Views.ts`) 줄을 갈아 끼우면 여기 그 구역은 지움.
* 자료 = 열 때마다 역할별로 다시 읽음(옛 화면이 주소 바뀔 때마다 다시 읽던 것과 같음).
* ========================================================================== */
import { navigateTo } from "../A00_Common/router";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { createButton, hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import {
fetchAllCompanies,
fetchAllUsers,
fetchAuditLogs,
fetchCompanyJoinRequests,
fetchCompanyMembers,
fetchCompanyProjects,
fetchSystemResources,
fetchUserCompany,
fetchUserProjects,
type AuditLog,
type CompanyInfo,
type DashboardUser,
type JoinRequest,
type Member,
type ProjectItem,
type ResourceData,
} from "./B01_Dashboard_Api_Fetch";
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
import {
assignedPlanTable,
fetchAssignedPlans,
type AssignedPlan,
} from "./B01_Dashboard_UI_AssignedPlans";
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
import { L } from "./B01_Dashboard_UI_Common";
import {
buildCompanyPanel,
companyTable,
joinRequestTable,
memberTable,
} from "./B01_Dashboard_UI_Company";
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
export interface DashboardState {
user: DashboardUser;
userProjects: ProjectItem[];
companyProjects: ProjectItem[];
allProjects: ProjectItem[];
company: CompanyInfo | null;
members: Member[];
joinRequests: JoinRequest[];
allCompanies: CompanyInfo[];
allUsers: DashboardUser[];
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
resources: ResourceData | null;
/** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */
assignedPlans: AssignedPlan[];
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
adminCompanyId: number | null;
}
/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */
export const ADMIN_COMPANY_KEY = "aislo.adminCompany";
/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */
export function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null {
const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY));
if (companies.some((c) => c.id === saved)) return saved;
return user.company_id ?? companies[0]?.id ?? null;
}
async function loadRoleData(state: DashboardState): Promise<void> {
if (state.user.role === "SYSTEM_ADMIN") {
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
state.allCompanies = await fetchAllCompanies().catch(() => []);
const companyId = pickAdminCompany(state.user, state.allCompanies);
state.adminCompanyId = companyId;
const results = await Promise.allSettled([
fetchAllUsers(companyId),
fetchCompanyJoinRequests(companyId),
fetchAuditLogs(),
fetchSystemResources(7),
fetchCompanyProjects(companyId),
]);
state.allUsers = results[0].status === "fulfilled" ? results[0].value : [];
state.allJoinRequests = results[1].status === "fulfilled" ? results[1].value : [];
state.auditLogs = results[2].status === "fulfilled" ? results[2].value : [];
state.resources = results[3].status === "fulfilled" ? results[3].value : null;
state.allProjects = results[4].status === "fulfilled" ? results[4].value : [];
results.forEach((res, idx) => {
if (res.status === "rejected") {
console.error(`System Admin API failed at index ${idx}:`, res.reason);
}
});
return;
}
const [projects, company, assigned] = await Promise.all([
fetchUserProjects().catch(() => []),
fetchUserCompany().catch(() => null),
fetchAssignedPlans().catch(() => []),
]);
state.userProjects = projects;
state.company = company;
state.assignedPlans = assigned;
if (state.user.role === "ADMIN") {
const [members, requests, companyProjects] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyJoinRequests().catch(() => []),
fetchCompanyProjects().catch(() => []),
]);
state.members = members;
state.joinRequests = requests;
state.companyProjects = companyProjects;
}
}
/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */
function settingCard(title: string, desc: string, go: () => void): HTMLElement {
const card = document.createElement("button");
card.type = "button";
card.className = "b01-dashboard__setting-card";
const head = document.createElement("strong");
head.className = "b01-dashboard__setting-title";
head.textContent = title;
const sub = document.createElement("span");
sub.className = "b01-dashboard__setting-desc";
sub.textContent = desc;
card.append(head, sub);
card.addEventListener("click", go);
return card;
}
/** 설계 기본값 카드 — 카드 규칙은 `settingLinks` 한 벌(334) · 계산 자리 = 창(504-2) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid";
wrap.append(
...settingLinks(user, companyId).map((link) =>
settingCard(link.title, link.desc, () =>
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query),
),
),
);
return wrap;
}
const addButton = (onClick: () => void): HTMLElement[] => [createButton({ label: "+", onClick })];
/** 열쇠별 옛 구역 — 역할 갈래는 옛 `buildPage` 그대로 */
const SECTIONS: Record<string, (s: DashboardState) => HTMLElement[]> = {
projects: (s) => {
const list =
s.user.role === "SYSTEM_ADMIN"
? s.allProjects
: s.user.role === "ADMIN"
? s.companyProjects
: s.userProjects;
return [
section(
L("B01_Dashboard_Projects"),
projectTable(list, s.user),
true,
addButton(() => void openNewProjectPicker()),
),
];
},
assigned: (s) => [section(L("DE_Assigned_Title"), assignedPlanTable(s.assignedPlans), true)],
temp: () => [buildTempUploadSection()],
people: (s) =>
s.user.role === "SYSTEM_ADMIN"
? [
section(
L("B01_Dashboard_Users"),
userTable(s.allUsers, s.user),
true,
addButton(() => openAddMemberModal(undefined, s.adminCompanyId)),
),
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.allJoinRequests, true), true),
]
: [
section(
L("B01_Dashboard_Members"),
memberTable(s.members, s.user),
true,
addButton(() => openAddMemberModal()),
),
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.joinRequests, false), true),
],
company: (s) =>
s.user.role === "SYSTEM_ADMIN"
? [
section(
L("B01_Dashboard_Companies"),
companyTable(
s.allCompanies.filter((c) => c.id === s.adminCompanyId),
s.user,
),
true,
),
]
: [section(L("B01_Dashboard_Company"), buildCompanyPanel(s), true)],
companyDefaults: (s) => [
section(
L("B01_Dashboard_SystemSettings"),
buildSystemSettingsPanel(s.user, s.adminCompanyId),
true,
),
],
sysOverview: (s) => [
section(L("B01_Dashboard_Resources"), buildResourcePanel(s.resources), true),
],
sysCompanies: (s) => [
section(
L("B01_Dashboard_Companies"),
companyTable(s.allCompanies, s.user),
true,
addButton(() => openCreateCompanyModal()),
),
],
sysUsers: (s) => [
section(
L("B01_Dashboard_Users"),
userTable(s.allUsers, s.user),
true,
addButton(() => openAddMemberModal(undefined, s.adminCompanyId)),
),
],
sysDefaults: (s) => [
section(
L("B01_Dashboard_SystemSettings"),
buildSystemSettingsPanel(s.user, s.adminCompanyId),
true,
),
],
sysLogs: (s) => [section(L("B01_Dashboard_AuditLogs"), auditLogTable(s.auditLogs), true)],
me: (s) => [
section(L("B01_Dashboard_Profile"), buildProfileForm(s.user), true),
section(L("B01_Account_Section_Security"), buildSecurityForm(), true),
],
};
export type LegacyKey = keyof typeof SECTIONS;
/** 옛 구역을 host 에 얹음 — 자료를 다 읽은 뒤 그림 · 그 사이 떠났으면(host 떨어짐) 안 그림 */
export async function mountLegacy(
key: LegacyKey,
host: HTMLElement,
me: DashboardUser,
): Promise<void> {
const state: DashboardState = {
user: me,
userProjects: [],
companyProjects: [],
allProjects: [],
company: null,
members: [],
joinRequests: [],
allCompanies: [],
allUsers: [],
allJoinRequests: [],
auditLogs: [],
resources: null,
assignedPlans: [],
adminCompanyId: null,
};
showLoadingOverlay();
try {
await loadRoleData(state);
} finally {
hideLoadingOverlay();
}
if (!host.isConnected) return;
const grid = document.createElement("div");
grid.className = "b01-dashboard__grid";
grid.append(...SECTIONS[key](state));
host.append(grid);
}
+107
View File
@@ -0,0 +1,107 @@
/* =============================================================================
* B01_Dashboard_UI_Nav.ts
* 대시보드 왼쪽 메뉴(546) — 묶음(작업 · 회사 관리 · 시스템 관리 · 도움) · 권한 없는 항목 · 빈 묶음 숨김
* 항목 = 주소 링크(뒤로 가기 · 새 탭) · 지금 위치 강조 · 오른쪽 수 배지 · 작은 화면 = 아이콘 레일(CSS)
* 도움 묶음 페이지(가이드 · 챗봇)도 여기 — 창을 여는 일만 함
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { icon } from "@ui/ui_template_icons";
import { createHelpButton } from "@ui/ui_template_help";
import type { View, ViewGroup } from "./B01_Dashboard_UI_Views";
const GROUPS: [ViewGroup, string][] = [
["work", "작업"],
["company", "회사 관리"],
["system", "시스템 관리"],
["help", "도움"],
];
export const viewHref = (key: string, params: Record<string, string> = {}): string =>
`#/${ROUTES.B01_ACCOUNT}?${new URLSearchParams({ view: key, ...params })}`;
export interface DashboardNav {
root: HTMLElement;
setActive: (key: string) => void;
/** 수 배지 — 0 이하면 숨김 · alert = 빨강(처리할 일) */
setBadge: (key: string, count: number, alert?: boolean) => void;
}
/** views = 이 역할에 보이는 것만 받음 */
export function buildNav(views: View[]): DashboardNav {
const root = document.createElement("nav");
root.className = "b01-shell__nav";
root.setAttribute("aria-label", "대시보드 메뉴");
const items = new Map<string, HTMLAnchorElement>();
for (const [group, label] of GROUPS) {
const inGroup = views.filter((v) => v.group === group);
if (!inGroup.length) continue;
const head = document.createElement("div");
head.className = "b01-shell__nav-group";
head.textContent = label;
root.append(head);
for (const view of inGroup) {
const a = document.createElement("a");
a.className = "b01-shell__nav-item";
a.href = viewHref(view.key);
a.title = view.title;
a.dataset.view = view.key;
const text = document.createElement("span");
text.className = "b01-shell__nav-label";
text.textContent = view.title;
const badge = document.createElement("span");
badge.className = "b01-shell__badge";
badge.hidden = true;
a.append(icon(view.icon ?? "home"), text, badge);
root.append(a);
items.set(view.key, a);
}
}
return {
root,
setActive: (key) => {
for (const [k, a] of items) {
a.classList.toggle("is-active", k === key);
if (k === key) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}
},
setBadge: (key, count, alert = false) => {
const badge = items.get(key)?.querySelector<HTMLElement>(".b01-shell__badge");
if (!badge) return;
badge.hidden = count <= 0;
badge.textContent = String(count);
badge.classList.toggle("is-alert", alert);
},
};
}
function helpCard(text: string, action: HTMLElement): HTMLElement {
const card = document.createElement("div");
card.className = "b01-shell__card b01-shell__help";
const p = document.createElement("p");
p.textContent = text;
card.append(p, action);
return card;
}
/** 사용자 가이드 — 위 머리 [?] 와 같은 창을 엶 */
export function mountGuide(host: HTMLElement): void {
const button = createHelpButton();
host.append(helpCard("대시보드 사용법을 그림과 함께 봅니다.", button));
button.click();
}
/** 지식 챗봇 — 오른쪽 아래 둥근 단추 창을 엶 */
export function mountChat(host: HTMLElement): void {
const toggle = (): HTMLButtonElement | null =>
document.querySelector<HTMLButtonElement>(".b01-knowledge-chat__toggle");
const open = (): void => {
const t = toggle();
if (t?.getAttribute("aria-expanded") !== "true") t?.click();
};
const button = createButton({ label: "챗봇 열기", variant: "filled", onClick: open });
host.append(helpCard("임도 · 사방 법령과 기술 자료를 물어봅니다.", button));
open();
}
+19 -355
View File
@@ -1,368 +1,32 @@
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createButton,
createTag,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createHelpButton } from "@ui/ui_template_help";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "../A00_Common/app_shell";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { currentRoute, navigateTo } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
fetchAuditLogs,
fetchCompanyJoinRequests,
fetchCompanyMembers,
fetchCompanyProjects,
fetchDashboardMe,
fetchSystemResources,
fetchUserCompany,
fetchUserProjects,
type AuditLog,
type CompanyInfo,
type DashboardUser,
type JoinRequest,
type Member,
type ProjectItem,
type ResourceData,
} from "./B01_Dashboard_Api_Fetch";
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
import { L, roleLabel } from "./B01_Dashboard_UI_Common";
import {
buildCompanyPanel,
companyTable,
joinRequestTable,
memberTable,
} from "./B01_Dashboard_UI_Company";
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import {
assignedPlanTable,
fetchAssignedPlans,
type AssignedPlan,
} from "./B01_Dashboard_UI_AssignedPlans";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
/* =============================================================================
* B01_Dashboard_UI_Page.ts
* 대시보드 입구 — 나(me) · 산림청 판정을 읽고 셸(`B01_Dashboard_UI_Shell.ts`)을 세움(546).
* 세부 페이지는 등록부(`B01_Dashboard_UI_Views.ts`) · 옛 구역 부품은 `B01_Dashboard_UI_Legacy.ts`.
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import type { MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchDashboardMe } from "./B01_Dashboard_Api_Fetch";
import { L } from "./B01_Dashboard_UI_Common";
import { mountDashboardShell } from "./B01_Dashboard_UI_Shell";
import "./B01_Dashboard_UI_Style.css";
import "./B01_Dashboard_UI_Style_Company.css";
export interface DashboardState {
user: DashboardUser;
userProjects: ProjectItem[];
companyProjects: ProjectItem[];
allProjects: ProjectItem[];
company: CompanyInfo | null;
members: Member[];
joinRequests: JoinRequest[];
allCompanies: CompanyInfo[];
allUsers: DashboardUser[];
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
resources: ResourceData | null;
/** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */
assignedPlans: AssignedPlan[];
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
adminCompanyId: number | null;
}
export type { DashboardState } from "./B01_Dashboard_UI_Legacy";
/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */
const ADMIN_COMPANY_KEY = "aislo.adminCompany";
/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */
function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null {
const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY));
if (companies.some((c) => c.id === saved)) return saved;
return user.company_id ?? companies[0]?.id ?? null;
}
export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
export async function renderB01Dashboard(root: HTMLElement): Promise<MountedPage | void> {
showLoadingOverlay();
try {
const user = await fetchDashboardMe();
const state: DashboardState = {
user,
userProjects: [],
companyProjects: [],
allProjects: [],
company: null,
members: [],
joinRequests: [],
allCompanies: [],
allUsers: [],
allJoinRequests: [],
auditLogs: [],
resources: null,
assignedPlans: [],
adminCompanyId: null,
};
await loadRoleData(state);
const [user, session] = await Promise.all([
fetchDashboardMe(),
fetchSessionUser().catch(() => null),
]);
root.innerHTML = "";
root.append(buildPage(state));
return await mountDashboardShell(root, user, Boolean(session?.is_forest_service));
} catch (error) {
showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error");
} finally {
hideLoadingOverlay();
}
}
async function loadRoleData(state: DashboardState): Promise<void> {
if (state.user.role === "SYSTEM_ADMIN") {
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
state.allCompanies = await fetchAllCompanies().catch(() => []);
const companyId = pickAdminCompany(state.user, state.allCompanies);
state.adminCompanyId = companyId;
const results = await Promise.allSettled([
fetchAllUsers(companyId),
fetchCompanyJoinRequests(companyId),
fetchAuditLogs(),
fetchSystemResources(7),
fetchCompanyProjects(companyId),
]);
state.allUsers = results[0].status === "fulfilled" ? results[0].value : [];
state.allJoinRequests = results[1].status === "fulfilled" ? results[1].value : [];
state.auditLogs = results[2].status === "fulfilled" ? results[2].value : [];
state.resources = results[3].status === "fulfilled" ? results[3].value : null;
state.allProjects = results[4].status === "fulfilled" ? results[4].value : [];
results.forEach((res, idx) => {
if (res.status === "rejected") {
console.error(`System Admin API failed at index ${idx}:`, res.reason);
}
});
return;
}
const [projects, company, assigned] = await Promise.all([
fetchUserProjects().catch(() => []),
fetchUserCompany().catch(() => null),
fetchAssignedPlans().catch(() => []),
]);
state.userProjects = projects;
state.company = company;
state.assignedPlans = assigned;
if (state.user.role === "ADMIN") {
const [members, requests, companyProjects] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyJoinRequests().catch(() => []),
fetchCompanyProjects().catch(() => []),
]);
state.members = members;
state.joinRequests = requests;
state.companyProjects = companyProjects;
}
}
/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */
function settingCard(title: string, desc: string, go: () => void): HTMLElement {
const card = document.createElement("button");
card.type = "button";
card.className = "b01-dashboard__setting-card";
const head = document.createElement("strong");
head.className = "b01-dashboard__setting-title";
head.textContent = title;
const sub = document.createElement("span");
sub.className = "b01-dashboard__setting-desc";
sub.textContent = desc;
card.append(head, sub);
card.addEventListener("click", go);
return card;
}
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) · 계산 자리 = 창(504-2) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid";
wrap.append(
...settingLinks(user, companyId).map((link) =>
settingCard(link.title, link.desc, () =>
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query),
),
),
);
return wrap;
}
/** 회사 알약(시스템 관리자만) — 컨테이너 제목 바로 옆 · 누르면 조회 후 고르기 · 고르면 저장 뒤 다시 읽음 */
function withCompanyPill(card: HTMLElement, state: DashboardState): HTMLElement {
const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-";
const pill = document.createElement("button");
pill.type = "button";
pill.className = "ui-tag ui-tag--accent b01-dashboard__company-pill";
pill.title = L("B01_Dashboard_PickCompany");
pill.textContent = `${name} ▾`;
pill.addEventListener("click", (event) => {
// 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음
event.stopPropagation();
openCompanyPicker(state.allCompanies, state.adminCompanyId, (company) => {
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id));
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
});
card.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill);
return card;
}
/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */
function buildSystemSettingsSection(state: DashboardState): HTMLElement {
const { user } = state;
const panel = buildSystemSettingsPanel(user, state.adminCompanyId);
const card = section(L("B01_Dashboard_SystemSettings"), panel, true);
if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);
card.querySelector(".ui-card__body")?.classList.add("ui-collapsible");
card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title");
attachCollapsible(card);
return card;
}
/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */
const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
["B01_Dashboard_Resources", "📊"],
["B01_Dashboard_SystemSettings", "⚙️"],
["B01_Dashboard_Projects", "🗂️"],
["DE_Assigned_Title", "📬"],
["B01_Temp_Section", "📦"],
["B01_Dashboard_Users", "👥"],
["B01_Dashboard_Members", "👥"],
["B01_Dashboard_JoinRequests", "📨"],
["B01_Dashboard_Companies", "🏢"],
["B01_Dashboard_Company", "🏢"],
["B01_Dashboard_Profile", "👤"],
["B01_Account_Section_Security", "🔒"],
["B01_Dashboard_AuditLogs", "📜"],
];
/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */
function mountSectionShortcuts(grid: HTMLElement): void {
// 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음
if (currentRoute() !== ROUTES.B01_ACCOUNT) return;
const sections = [...grid.children] as HTMLElement[];
const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? "");
const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? "");
const bar = createStepBar(titles, -1, {
orientation: "vertical",
icons,
routes: titles,
onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }),
});
setDrawerSteps(bar);
}
/** 받은 예정노선 구역 — 받은 것이 있을 때만 */
function assignedPlanSection(state: DashboardState): HTMLElement[] {
if (!state.assignedPlans.length) return [];
return [section(L("DE_Assigned_Title"), assignedPlanTable(state.assignedPlans), true)];
}
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
const grid = document.createElement("div");
grid.className = "b01-dashboard__grid";
if (state.user.role === "SYSTEM_ADMIN") {
// 시스템 설정은 리소스 현황 아래(29-1) · 회사 알약이 붙은 컨테이너는 고른 회사 것
const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId);
grid.append(
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
buildSystemSettingsSection(state),
withCompanyPill(
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
state,
),
buildTempUploadSection(),
withCompanyPill(
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
createButton({
label: "+",
onClick: () => openAddMemberModal(undefined, state.adminCompanyId),
}),
]),
state,
),
withCompanyPill(
section(
L("B01_Dashboard_JoinRequests"),
joinRequestTable(state.allJoinRequests, true),
true,
),
state,
),
withCompanyPill(
section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
]),
state,
),
);
} else if (state.user.role === "ADMIN") {
// 시스템 설정은 맨 위 한 줄(27-3a)
grid.append(
buildSystemSettingsSection(state),
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
buildTempUploadSection(),
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
createButton({ label: "+", onClick: () => openAddMemberModal() }),
]),
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false), true),
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
);
} else {
grid.append(
buildSystemSettingsSection(state),
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
// 바로 아래에 둔다(2026-08-08 사용자 지시).
buildTempUploadSection(),
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
);
}
grid.append(
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
section(L("B01_Account_Section_Security"), buildSecurityForm()),
);
// 시스템 로그는 맨 아래 (2026-09-06 사용자 지시).
if (state.user.role === "SYSTEM_ADMIN") {
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
}
page.append(grid);
mountSectionShortcuts(grid);
page.append(buildKnowledgeChat());
const layout = createGeneralLayout({
pageClass: "b01-dashboard",
title: L("B01_Dashboard_Title"),
subtitle: L("B01_Dashboard_Subtitle"),
content: page,
});
// 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다).
const tag = createTag(
roleLabel(state.user.role),
state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral",
);
// [?] 사용자 가이드(538-2) = 배지 왼쪽 — 같은 자리 묶음
const head = document.createElement("span");
head.className = "b01-dashboard__role ui-help-head";
head.append(createHelpButton(), tag);
layout.root.querySelector(".ui-general-layout__header")?.append(head);
return layout.root;
}
+276
View File
@@ -0,0 +1,276 @@
/* =============================================================================
* 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;
},
};
}
@@ -0,0 +1,353 @@
/* 대시보드 셸(546) — 색 · 간격은 .b01-shell 안 변수만(프로젝트 화면 영향 0) · 간격 = 4 의 배수 */
.b01-shell {
--b01-accent: #1f7a4d;
--b01-accent-soft: #eaf5ee;
--b01-accent-text: #ffffff;
--b01-bg: #f6f7f9;
--b01-card: #ffffff;
--b01-line: #e6e8ec;
--b01-text: #1f2328;
--b01-muted: #6b7280;
--b01-hover: #f3f4f6;
--b01-alert: #e5484d;
--b01-radius: 10px;
--b01-head-h: 48px;
--b01-nav-w: 210px;
--b01-rail-w: 56px;
/* 옛 구역 부품(공용 카드 · 표 · 단추)도 셸 색을 따름 — 셸 안에서만 */
--color-bg: var(--b01-bg);
--color-surface: var(--b01-card);
--color-surface-raised: var(--b01-card);
--color-border: var(--b01-line);
--color-primary: var(--b01-accent);
--color-primary-text: var(--b01-accent-text);
min-height: 100dvh;
display: grid;
grid-template-rows: var(--b01-head-h) 1fr;
background: var(--b01-bg);
color: var(--b01-text);
}
:root[data-theme="dark"] .b01-shell {
--b01-accent: #3fae75;
--b01-accent-soft: #173326;
--b01-accent-text: #0e1512;
--b01-bg: #121417;
--b01-card: #1b1e23;
--b01-line: #2c3038;
--b01-text: #e6e8eb;
--b01-muted: #9aa1ab;
--b01-hover: #242830;
--b01-alert: #f06b6f;
}
/* 옛 ☰ 서랍 · 떠 있는 단추 — 대시보드에선 걷음(왼쪽 메뉴 · 사용자 메뉴가 맡음) */
html:has(.b01-shell) .app-header {
display: none;
}
/* 위 머리 */
.b01-shell__head {
position: sticky;
top: 0;
z-index: var(--z-header);
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
background: var(--b01-card);
border-bottom: 1px solid var(--b01-line);
}
.b01-shell__brand {
font-weight: 800;
letter-spacing: 0.6px;
color: var(--b01-text);
text-decoration: none;
}
.b01-shell__org {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: none;
color: var(--b01-muted);
font: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.b01-shell__org.is-switch {
cursor: pointer;
}
.b01-shell__org.is-switch:hover {
background: var(--b01-hover);
color: var(--b01-text);
}
.b01-shell__search {
width: 240px;
padding: 4px 12px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: var(--b01-bg);
color: var(--b01-text);
font: inherit;
}
.b01-shell__spacer {
flex: 1;
}
.b01-shell__icon-btn {
position: relative;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: var(--b01-card);
color: var(--b01-muted);
cursor: pointer;
}
.b01-shell__icon-btn:disabled {
cursor: default;
}
.b01-shell__icon-btn:not(:disabled):hover {
background: var(--b01-hover);
color: var(--b01-text);
}
.b01-shell__icon-btn .b01-shell__badge {
position: absolute;
top: -6px;
right: -6px;
margin: 0;
}
.ui-icon {
display: inline-grid;
place-items: center;
flex: none;
}
/* 사용자 메뉴 */
.b01-shell__user {
position: relative;
}
.b01-shell__user-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 8px;
background: none;
color: var(--b01-text);
font: inherit;
cursor: pointer;
}
.b01-shell__user-btn:hover {
background: var(--b01-hover);
}
.b01-shell__avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--b01-accent-soft);
color: var(--b01-accent);
font-weight: 700;
font-size: 12px;
}
.b01-shell__user-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-shell__menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 240px;
padding: 4px;
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12);
}
.b01-shell__menu[hidden] {
display: none;
}
.b01-shell__menu-who {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px 12px;
border-bottom: 1px solid var(--b01-line);
margin-bottom: 4px;
}
.b01-shell__menu-who span {
color: var(--b01-muted);
font-size: 12px;
}
.b01-shell__menu-item,
.b01-shell__menu-touch {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: none;
color: var(--b01-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.b01-shell__menu-item:hover {
background: var(--b01-hover);
}
.b01-shell__menu-touch {
cursor: default;
}
.b01-shell__menu-touch > span:nth-child(2) {
flex: 1;
}
.b01-shell__menu-touch button {
padding: 2px 8px;
border: 1px solid var(--b01-line);
border-radius: 12px;
background: var(--b01-card);
color: var(--b01-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.b01-shell__menu-touch button[aria-pressed="true"] {
border-color: var(--b01-accent);
background: var(--b01-accent-soft);
color: var(--b01-accent);
}
/* 몸통 — 왼쪽 메뉴 + 페이지 */
.b01-shell__body {
display: grid;
grid-template-columns: var(--b01-nav-w) minmax(0, 1fr);
}
.b01-shell__nav {
position: sticky;
top: var(--b01-head-h);
height: calc(100dvh - var(--b01-head-h));
overflow-y: auto;
padding: 8px;
background: var(--b01-card);
border-right: 1px solid var(--b01-line);
}
.b01-shell__nav-group {
margin: 12px 8px 4px;
color: var(--b01-muted);
font-size: 12px;
}
.b01-shell__nav-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 8px;
border-radius: 8px;
color: var(--b01-text);
text-decoration: none;
}
.b01-shell__nav-item:hover {
background: var(--b01-hover);
}
.b01-shell__nav-item.is-active {
background: var(--b01-accent-soft);
color: var(--b01-accent);
font-weight: 600;
}
.b01-shell__nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-shell__badge {
min-width: 20px;
padding: 0 4px;
border-radius: 8px;
background: var(--b01-hover);
color: var(--b01-muted);
font-size: 11px;
font-weight: 600;
line-height: 16px;
text-align: center;
}
.b01-shell__badge.is-alert {
background: var(--b01-alert);
color: #fff;
}
.b01-shell__badge[hidden] {
display: none;
}
.b01-shell__main {
min-width: 0;
padding: 20px 24px 32px;
}
/* 도움 페이지 카드 */
.b01-shell__card {
padding: 16px;
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
}
.b01-shell__help {
display: flex;
align-items: center;
gap: 16px;
max-width: 640px;
}
.b01-shell__help p {
flex: 1;
margin: 0;
}
/* 작은 화면 — 메뉴는 아이콘 레일 · 머리 찾기 칸 · 이름 숨김 */
@media (max-width: 1024px) {
.b01-shell__body {
grid-template-columns: var(--b01-rail-w) minmax(0, 1fr);
}
.b01-shell__nav-group {
height: 1px;
margin: 8px 4px;
overflow: hidden;
background: var(--b01-line);
color: transparent;
}
.b01-shell__nav-item {
position: relative;
justify-content: center;
padding: 0;
}
.b01-shell__nav-label {
display: none;
}
.b01-shell__nav-item .b01-shell__badge {
position: absolute;
top: 0;
right: 0;
}
.b01-shell__search,
.b01-shell__user-name {
display: none;
}
}
+81
View File
@@ -0,0 +1,81 @@
/* =============================================================================
* B01_Dashboard_UI_Views.ts
* 대시보드 세부 페이지 등록부(546) — 한 줄 = 한 페이지 · 주소 `#/b01-account?view=<열쇠>&…`.
* key = 주소 열쇠 · title = 메뉴 · 머리 글 · group = 메뉴 묶음(hidden = 메뉴에 안 보임 · 주소로만)
* roles = 보이는 역할(없는 역할은 메뉴에서 숨고 주소로 와도 홈) · icon = ui_template_icons 이름
* mount(host, ctx) = host 에 그림 · 정리 함수를 돌려주면 떠날 때 부름(비동기 가능)
* ctx = { me, role, companyId, go(view, params?) } — companyId = 시스템 관리자는 머리에서 고른 회사
* 새 페이지는 자기 줄만 갈아 끼움 · 옛 구역(`mountLegacy`)은 갈아 끼우면 지움.
* ========================================================================== */
import type { IconName } from "@ui/ui_template_icons";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
export type ViewGroup = "work" | "company" | "system" | "help" | "hidden";
export interface ViewCtx {
me: DashboardUser;
role: ViewRole;
companyId: number | null;
go: (view: string, params?: Record<string, string>) => void;
}
type Cleanup = (() => void) | void;
export interface View {
key: string;
title: string;
group: ViewGroup;
roles: readonly ViewRole[];
icon?: IconName;
mount: (host: HTMLElement, ctx: ViewCtx) => Cleanup | Promise<Cleanup>;
}
export const DEFAULT_VIEW = "home";
const ALL: readonly ViewRole[] = ["USER", "ADMIN", "FOREST", "SYSTEM_ADMIN"];
const legacy =
(key: LegacyKey) =>
(host: HTMLElement, ctx: ViewCtx): Promise<void> =>
mountLegacy(key, host, ctx.me);
// prettier-ignore
export const VIEWS: View[] = [
// 작업
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") },
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") },
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") },
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: legacy("assigned") },
{ key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: legacy("temp") },
// 회사 관리
{ key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: legacy("people") },
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: legacy("company") },
{ key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: legacy("companyDefaults") },
// 시스템 관리
{ key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: legacy("sysOverview") },
{ key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: legacy("sysCompanies") },
{ key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: legacy("sysUsers") },
{ key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: legacy("sysDefaults") },
{ key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: legacy("sysLogs") },
// 도움
{ key: "guide", title: "사용자 가이드", group: "help", roles: ALL, icon: "book", mount: mountGuide },
{ key: "chat", title: "지식 챗봇", group: "help", roles: ALL, icon: "chatbot", mount: mountChat },
// 사용자 메뉴
{ key: "me", title: "내 정보", group: "hidden", roles: ALL, icon: "userCircle", mount: legacy("me") },
];
/** 역할 가르기 — 옛 B01 갈래(시스템 관리자 · 회사 관리자 · 그 밖) + 산림청 */
export function roleOf(me: DashboardUser, forest: boolean): ViewRole {
if (me.role === "SYSTEM_ADMIN" || me.role === "ADMIN") return me.role;
return forest ? "FOREST" : "USER";
}
/** 주소 열쇠 → 페이지 · 없거나 권한 없으면 홈 */
export function viewFor(key: string | null, role: ViewRole): View {
const found = VIEWS.find((v) => v.key === key && v.roles.includes(role));
return found ?? VIEWS.find((v) => v.key === DEFAULT_VIEW)!;
}