Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Page.ts
T
eomsangdonandClaude Sonnet 5.5 2a2c4d0f62 feat(B01): 대시보드 항목별 탭 — 해시 탭 줄 · 개요 · 프로젝트 · 회사 · 내 계정 · 시스템 (92-1·92-2)
- 해시 ?tab= 탭 줄(B08 단추 꼴) · 고른 탭 자료만 받음 · 설정 카드는 탭 줄 위 · 회사 고르기 알약은 탭 줄 오른쪽 하나
- 회사 없음 진입 두 곳(router · M02 Layer)은 ?tab=company · 회사 관리자 프로젝트 목록 한 번만 받음
- 개요 숫자 카드(단계별 · 실패 · 재확인 · 가입 대기 · 보관함 · 리소스) · 프로젝트 표 칸 보강 · 찾기 · 거르기 · 쪽 넘김 · 진행 현황
- 회사 정보 칸(사업자번호 · 대표 · 상태) · 팀원 읽기 · 자산 표 · 내 계정 마지막 로그인 · 시스템 리소스 7·30·90일 · 활성 추이 · 전체 회사 · 사용자 · 로그 쪽 · 고객 문의
- 시험 test_b01_dashboard_tabs 신규 · test_b01_settings_cards 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-08 13:58:34 +09:00

324 lines
12 KiB
TypeScript

import { ROUTES } from "@config/config_frontend";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createTag,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { currentQuery, navigateTo } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
fetchAuditLogPage,
fetchCompanyAssets,
fetchCompanyJoinRequests,
fetchCompanyMembers,
fetchCompanyProjects,
fetchDashboardMe,
fetchSupportRequests,
fetchSystemResources,
fetchUserCompany,
fetchUserProjects,
type AuditLog,
type CompanyAsset,
type CompanyInfo,
type DashboardUser,
type JoinRequest,
type Member,
type ProjectItem,
type ResourceData,
type SupportRequest,
} from "./B01_Dashboard_Api_Fetch";
import { fetchTempBatches } from "./B01_Dashboard_Api_Temp";
import { L, roleLabel } from "./B01_Dashboard_UI_Common";
import { buildCompanyTab } from "./B01_Dashboard_UI_CompanyTab";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { buildOverview } from "./B01_Dashboard_UI_Overview";
import { buildAccountTab } from "./B01_Dashboard_UI_Profile";
import { buildProjectsTab } from "./B01_Dashboard_UI_ProjectsTab";
import { AUDIT_PAGE_SIZE, buildSystemTab } from "./B01_Dashboard_UI_System";
import { buildTabBar, resolveTab, type DashboardTab } from "./B01_Dashboard_UI_Tabs";
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
import "./B01_Dashboard_UI_Style.css";
export interface DashboardState {
user: DashboardUser;
/** 해시 `?tab=` 로 고른 탭 (없거나 역할에 없으면 개요) */
tab: DashboardTab;
userProjects: ProjectItem[];
companyProjects: ProjectItem[];
allProjects: ProjectItem[];
company: CompanyInfo | null;
/** 회사 자산(로고 · 서명) — 회사 탭에서만 받음 */
assets: CompanyAsset[];
members: Member[];
joinRequests: JoinRequest[];
allCompanies: CompanyInfo[];
allUsers: DashboardUser[];
/** 시스템 탭 · 모든 회사의 사용자 */
systemUsers: DashboardUser[];
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
auditTotal: number;
supportRequests: SupportRequest[];
resources: ResourceData | null;
/** 개요 · 임시 보관함 묶음 수 */
tempCount: number;
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 정보가 이 회사 것 */
adminCompanyId: number | null;
}
/** 고른 회사 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> {
showLoadingOverlay();
try {
const user = await fetchDashboardMe();
const state: DashboardState = {
user,
tab: resolveTab(user.role, currentQuery().get("tab")),
userProjects: [],
companyProjects: [],
allProjects: [],
company: null,
assets: [],
members: [],
joinRequests: [],
allCompanies: [],
allUsers: [],
systemUsers: [],
allJoinRequests: [],
auditLogs: [],
auditTotal: 0,
supportRequests: [],
resources: null,
tempCount: 0,
adminCompanyId: null,
};
await loadTabData(state);
root.innerHTML = "";
root.append(buildPage(state));
} catch (error) {
showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error");
} finally {
hideLoadingOverlay();
}
}
/** 역할별 프로젝트 목록 — 개요 · 프로젝트 탭이 같은 목록을 씀 */
function projectsOf(state: DashboardState): ProjectItem[] {
if (state.user.role === "SYSTEM_ADMIN") return state.allProjects;
return state.user.role === "ADMIN" ? state.companyProjects : state.userProjects;
}
/** 고른 탭이 쓰는 자료만 받음(92-1) — 실패한 한 줄은 빈 값으로 두고 나머지는 그림 */
async function loadTabData(state: DashboardState): Promise<void> {
const { user, tab } = state;
const system = user.role === "SYSTEM_ADMIN";
const manager = user.role !== "USER";
const jobs: Promise<unknown>[] = [];
const load = <T>(job: Promise<T>, fallback: T, set: (value: T) => void): void => {
jobs.push(
job
.catch((error) => {
console.error("[B01] 자료 읽기 실패", error);
return fallback;
})
.then(set),
);
};
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
if (system) {
state.allCompanies = await fetchAllCompanies().catch(() => []);
state.adminCompanyId = pickAdminCompany(user, state.allCompanies);
}
// 시스템 관리자만 회사 번호를 실음 — 나머지는 서버가 세션 회사를 씀
const scoped = system ? state.adminCompanyId : undefined;
if (tab === "overview" || tab === "projects") {
if (system) load(fetchCompanyProjects(scoped), [], (v) => (state.allProjects = v));
else if (manager) load(fetchCompanyProjects(), [], (v) => (state.companyProjects = v));
else load(fetchUserProjects(), [], (v) => (state.userProjects = v));
}
if (tab === "overview") {
load(
fetchTempBatches().then((r) => r.batches.length),
0,
(v) => (state.tempCount = v),
);
if (manager) {
load(fetchCompanyJoinRequests(scoped), [], (v) =>
system ? (state.allJoinRequests = v) : (state.joinRequests = v),
);
}
if (system) load(fetchSystemResources(7), null, (v) => (state.resources = v));
}
if (tab === "company") {
if (system) {
state.company = state.allCompanies.find((c) => c.id === state.adminCompanyId) ?? null;
load(fetchAllUsers(scoped), [], (v) => (state.allUsers = v));
load(fetchCompanyJoinRequests(scoped), [], (v) => (state.allJoinRequests = v));
} else {
load(fetchUserCompany(), null, (v) => (state.company = v));
load(fetchCompanyMembers(), [], (v) => (state.members = v));
if (manager) load(fetchCompanyJoinRequests(), [], (v) => (state.joinRequests = v));
}
load(fetchCompanyAssets(scoped), [], (v) => (state.assets = v));
}
if (tab === "system") {
load(fetchSystemResources(7), null, (v) => (state.resources = v));
load(fetchAuditLogPage(1, AUDIT_PAGE_SIZE), { total: 0, items: [] }, (v) => {
state.auditLogs = v.items;
state.auditTotal = v.total;
});
load(fetchAllUsers(null), [], (v) => (state.systemUsers = v));
load(fetchSupportRequests(), [], (v) => (state.supportRequests = v));
}
await Promise.all(jobs);
}
/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */
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;
}
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid";
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
const m02 = (title: string, open: string, root: string, desc: string): HTMLElement =>
settingCard(title, desc, () =>
navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }),
);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
wrap.append(
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
navigateTo(ROUTES.M01_MASTER_DATA),
),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
);
return wrap;
}
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
const companyAdmin = user.role === "ADMIN" || user.is_master;
const root = companyAdmin ? "company" : "personal";
const desc = [
L("B01_Dashboard_PermMasterRead"),
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc));
return wrap;
}
/** 회사 알약(시스템 관리자만) — 탭 줄 오른쪽 하나 · 누르면 조회 후 고르기 · 고르면 저장 뒤 같은 탭을 다시 읽음 */
function buildCompanyPill(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", () => {
openCompanyPicker(state.allCompanies, state.adminCompanyId, (company) => {
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id));
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
});
return pill;
}
/** 시스템 설정 컨테이너 — 모든 역할 · 모든 탭 위 한 줄 통째 · 제목 눌러 통째로 접힘 */
function buildSystemSettingsSection(state: DashboardState): HTMLElement {
const { user } = state;
const panel = buildSystemSettingsPanel(user, state.adminCompanyId);
const card = section(L("B01_Dashboard_SystemSettings"), panel, true);
card.querySelector(".ui-card__body")?.classList.add("ui-collapsible");
card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title");
attachCollapsible(card);
return card;
}
/** 탭 하나의 컨테이너들 */
function tabSections(state: DashboardState): HTMLElement[] {
switch (state.tab) {
case "projects":
return buildProjectsTab(state, projectsOf(state));
case "company":
return buildCompanyTab(state);
case "account":
return buildAccountTab(state.user);
case "system":
return buildSystemTab(state);
default:
return buildOverview(state, projectsOf(state));
}
}
/** 회사 따라 바뀌는 탭(개요 · 프로젝트 · 회사)에서만 알약 — 계정 · 시스템 탭은 회사와 무관 */
const COMPANY_TABS: readonly DashboardTab[] = ["overview", "projects", "company"];
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
page.className = "b01-dashboard__page";
const grid = document.createElement("div");
grid.className = "b01-dashboard__grid";
grid.append(...tabSections(state));
const pill =
state.user.role === "SYSTEM_ADMIN" && COMPANY_TABS.includes(state.tab)
? buildCompanyPill(state)
: null;
// 시스템 설정은 모든 탭 위 한 줄(27-3a) · 그 아래 탭 줄
page.append(
buildSystemSettingsSection(state),
buildTabBar(state.tab, state.user.role, pill),
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",
);
tag.classList.add("b01-dashboard__role");
layout.root.querySelector(".ui-general-layout__header")?.append(tag);
return layout.root;
}