import { ROUTES, type RoutePath } from "@config/config_frontend"; import { isBlank } from "@util/common_util_validate"; import { navigateTo } from "../A00_Common/router"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createCard, createInputField, createLineChart, createTag, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { addCompanyMember, changePassword, createCompany, fetchAllCompanies, fetchAllJoinRequests, fetchAllUsers, fetchAuditLogs, fetchCompanyJoinRequests, fetchCompanyMembers, fetchCompanyProjects, fetchDashboardMe, fetchSystemResources, fetchUserCompany, fetchUserProjects, joinCompany, processJoinRequest, removeCompanyMember, searchCompanies, updateUserProfile, type AuditLog, type CompanyInfo, type DashboardUser, type JoinRequest, type Member, type ProjectItem, type ResourceData, } from "./B01_Dashboard_Api_Fetch"; import "./B01_Dashboard_UI_Style.css"; const PASSWORD_MIN_LENGTH = 8; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } interface DashboardState { user: DashboardUser; userProjects: ProjectItem[]; companyProjects: ProjectItem[]; company: CompanyInfo | null; members: Member[]; joinRequests: JoinRequest[]; allCompanies: CompanyInfo[]; allUsers: DashboardUser[]; allJoinRequests: JoinRequest[]; auditLogs: AuditLog[]; resources: ResourceData | null; } export async function renderB01Dashboard(root: HTMLElement): Promise { showLoadingOverlay(); try { const user = await fetchDashboardMe(); const state: DashboardState = { user, userProjects: [], companyProjects: [], company: null, members: [], joinRequests: [], allCompanies: [], allUsers: [], allJoinRequests: [], auditLogs: [], resources: null, }; await loadRoleData(state); root.append(buildPage(state)); } catch (error) { showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error"); } finally { hideLoadingOverlay(); } } async function loadRoleData(state: DashboardState): Promise { if (state.user.role === "SYSTEM_ADMIN") { const [companies, users, requests, logs, resources] = await Promise.all([ fetchAllCompanies(), fetchAllUsers(), fetchAllJoinRequests(), fetchAuditLogs(), fetchSystemResources(), ]); state.allCompanies = companies; state.allUsers = users; state.allJoinRequests = requests; state.auditLogs = logs; state.resources = resources; return; } const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]); state.userProjects = projects; state.company = company; if (state.user.role === "ADMIN") { const [members, requests, companyProjects] = await Promise.all([ fetchCompanyMembers(), fetchCompanyJoinRequests(), fetchCompanyProjects(), ]); state.members = members; state.joinRequests = requests; state.companyProjects = companyProjects; } } function buildPage(state: DashboardState): HTMLElement { const page = document.createElement("div"); page.className = "b01-dashboard"; page.append(buildHeader(state.user)); const grid = document.createElement("div"); grid.className = "b01-dashboard__grid"; if (state.user.role === "SYSTEM_ADMIN") { grid.append( section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies)), section(L("B01_Dashboard_Users"), userTable(state.allUsers)), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true)), section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), ); } else { grid.append( section( L("B01_Dashboard_Projects"), projectTable(state.user.role === "ADMIN" ? state.companyProjects : state.userProjects), true, [ createButton({ label: L("B01_Dashboard_NewProject"), onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), }), ], ), section(L("B01_Dashboard_Company"), buildCompanyPanel(state)), ); if (state.user.role === "ADMIN") { grid.append( section(L("B01_Dashboard_Members"), memberTable(state.members), false, [ createButton({ label: L("B01_Dashboard_AddMember"), variant: "ghost", onClick: () => openAddMemberModal(), }), ]), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false)), ); } } grid.append( section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), section(L("B01_Account_Section_Security"), buildSecurityForm()), ); page.append(grid); return page; } function buildHeader(user: DashboardUser): HTMLElement { const header = document.createElement("header"); header.className = "b01-dashboard__header"; const text = document.createElement("div"); const title = document.createElement("h1"); title.className = "b01-dashboard__title"; title.textContent = L("B01_Dashboard_Title"); const subtitle = document.createElement("p"); subtitle.className = "b01-dashboard__subtitle"; subtitle.textContent = L("B01_Dashboard_Subtitle"); text.append(title, subtitle); const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral"); tag.classList.add("b01-dashboard__role"); header.append(text, tag); return header; } function roleLabel(role: DashboardUser["role"]): string { if (role === "SYSTEM_ADMIN") return L("B01_Dashboard_Role_SystemAdmin"); if (role === "ADMIN") return L("B01_Dashboard_Role_Admin"); return L("B01_Dashboard_Role_User"); } function section( title: string, body: HTMLElement, wide = false, actions: HTMLElement[] = [], ): HTMLElement { const actionRow = document.createElement("div"); actionRow.className = "b01-dashboard__actions"; actionRow.append(...actions); const cardBody = actions.length ? [actionRow, body] : [body]; const card = createCard({ title, body: cardBody, raised: true }); card.classList.add("b01-dashboard__section"); if (wide) card.classList.add("b01-dashboard__section--wide"); return card; } function table(headers: string[], rows: HTMLElement[][]): HTMLElement { if (!rows.length) { const empty = document.createElement("p"); empty.className = "b01-dashboard__empty"; empty.textContent = L("Common_Status_Empty"); return empty; } const wrap = document.createElement("div"); wrap.className = "b01-dashboard__table-wrap"; const tableEl = document.createElement("table"); tableEl.className = "b01-dashboard__table"; const thead = document.createElement("thead"); const headRow = document.createElement("tr"); for (const header of headers) { const th = document.createElement("th"); th.textContent = header; headRow.append(th); } thead.append(headRow); const tbody = document.createElement("tbody"); for (const row of rows) { const tr = document.createElement("tr"); for (const cell of row) { const td = document.createElement("td"); td.append(cell); tr.append(td); } tbody.append(tr); } tableEl.append(thead, tbody); wrap.append(tableEl); return wrap; } function text(value: unknown): HTMLElement { const span = document.createElement("span"); span.textContent = value == null || value === "" ? "-" : String(value); return span; } function projectTable(projects: ProjectItem[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Project"), L("B01_Dashboard_Table_Region"), L("B01_Dashboard_Table_Progress"), L("B01_Dashboard_Table_Workflow"), L("B01_Dashboard_Table_Updated"), ], projects.map((project) => [ text(project.name), text(project.region), text(`${project.progress_percent}%`), workflow(project.workflow_stage), text(formatDate(project.updated_at)), ]), ); } function workflow(activeStage: number): HTMLElement { const routes: RoutePath[] = [ ROUTES.B03_FILE_INPUT, ROUTES.B04_WF1_SURFACE, ROUTES.B05_WF2_ROUTE, ROUTES.B06_WF3_PROFILE_CROSS, ROUTES.B07_WF4_DESIGN_DETAIL, ROUTES.B08_WF5_QUANTITY, ROUTES.B09_WF6_ESTIMATION, ]; const box = document.createElement("div"); box.className = "b01-dashboard__workflow"; routes.forEach((route, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "b01-dashboard__step"; button.textContent = `B${String(index + 3).padStart(2, "0")}`; const enabled = index + 1 <= Math.max(activeStage, 1); button.disabled = !enabled; if (enabled) { button.classList.add("is-enabled"); button.addEventListener("click", () => navigateTo(route)); } box.append(button); }); return box; } function buildCompanyPanel(state: DashboardState): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__actions"; if (!state.company) { wrap.append( createTag(L("B01_Dashboard_NoCompany"), "warning"), createButton({ label: L("B01_Dashboard_CreateCompany"), onClick: () => openCreateCompanyModal(), }), createButton({ label: L("B01_Dashboard_FindCompany"), variant: "ghost", onClick: () => openFindCompanyModal(), }), ); return wrap; } wrap.append( createTag(`${state.company.name} (${state.user.status})`, "success"), text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), ); return wrap; } function memberTable(members: Member[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), L("B01_Dashboard_Table_Name"), L("B01_Dashboard_Table_Position"), L("B01_Dashboard_Table_Department"), L("B01_Dashboard_Table_Role"), L("B01_Dashboard_Table_Action"), ], members.map((member) => [ text(member.email), text(member.name), text(member.position), text(member.department), text(member.role), createButton({ label: L("B01_Dashboard_RemoveMember"), variant: "ghost", onClick: () => onB01_Member_Remove_Click(member.id), }), ]), ); } function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), ...(systemMode ? [L("B01_Dashboard_Table_Company")] : []), L("B01_Dashboard_Table_Requested"), L("B01_Dashboard_Table_Status"), L("B01_Dashboard_Table_Action"), ], requests.map((request) => [ text(request.user_email), ...(systemMode ? [text(request.company_name)] : []), text(formatDate(request.requested_at)), text(request.status), actionPair( () => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode), () => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode), ), ]), ); } function actionPair(approve: () => void, reject: () => void): HTMLElement { const row = document.createElement("div"); row.className = "b01-dashboard__actions"; row.append( createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }), createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }), ); return row; } function buildResourcePanel(resources: ResourceData | null): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__resource-panel"; wrap.append(buildResourceMetrics(resources), buildResourceChart(resources)); return wrap; } function buildResourceMetrics(resources: ResourceData | null): HTMLElement { const values = resources?.current; const grid = document.createElement("div"); grid.className = "b01-dashboard__metric-grid"; const items = [ [L("B01_Dashboard_Metric_Cpu"), percent(values?.cpu_usage_percent)], [L("B01_Dashboard_Metric_Memory"), percent(values?.memory_usage_percent)], [L("B01_Dashboard_Metric_Disk"), percent(values?.disk_usage_percent)], [L("B01_Dashboard_Metric_ActiveUsers"), values?.active_user_count ?? 0], [L("B01_Dashboard_Metric_ActiveProjects"), values?.active_project_count ?? 0], ]; for (const [label, value] of items) { const box = document.createElement("div"); box.className = "b01-dashboard__metric"; const labelEl = document.createElement("span"); labelEl.className = "b01-dashboard__metric-label"; labelEl.textContent = String(label); const valueEl = document.createElement("span"); valueEl.className = "b01-dashboard__metric-value"; valueEl.textContent = String(value); box.append(labelEl, valueEl); grid.append(box); } return grid; } function formatChartTime(iso: string | null | undefined): string { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function buildResourceChart(resources: ResourceData | null): HTMLElement { const history = resources?.history ?? []; const chart = createLineChart({ ariaLabel: L("B01_Dashboard_Resources_ChartAria"), xLabels: history.map((p) => formatChartTime(p.timestamp)), series: [ { name: L("B01_Dashboard_Metric_Cpu"), colorIndex: 0, values: history.map((p) => p.cpu_usage_percent), }, { name: L("B01_Dashboard_Metric_Memory"), colorIndex: 1, values: history.map((p) => p.memory_usage_percent), }, { name: L("B01_Dashboard_Metric_Disk"), colorIndex: 2, values: history.map((p) => p.disk_usage_percent), }, ], }); const caption = document.createElement("p"); caption.className = "b01-dashboard__chart-caption"; caption.textContent = L("B01_Dashboard_Resources_ChartCaption"); const box = document.createElement("div"); box.className = "b01-dashboard__chart"; box.append(caption, chart); return box; } function companyTable(companies: CompanyInfo[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Company"), L("B01_Dashboard_Field_BusinessNumber"), L("B01_Dashboard_Table_Status"), ], companies.map((company) => [ text(company.name), text(company.business_registration_number), text(company.business_status), ]), ); } function userTable(users: DashboardUser[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), L("B01_Dashboard_Table_Name"), L("B01_Dashboard_Table_Role"), L("B01_Dashboard_Table_Status"), ], users.map((user) => [text(user.email), text(user.name), text(user.role), text(user.status)]), ); } function auditLogTable(logs: AuditLog[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), L("B01_Dashboard_Table_Action"), L("B01_Dashboard_Table_Updated"), ], logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), ); } function buildProfileForm(user: DashboardUser): HTMLElement { const name = createInputField({ label: L("B01_Account_Field_Name"), value: user.name, required: true, }); const position = createInputField({ label: L("B01_Dashboard_Table_Position"), value: user.position ?? "", }); const department = createInputField({ label: L("B01_Dashboard_Table_Department"), value: user.department ?? "", }); const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" }); const grid = document.createElement("div"); grid.className = "b01-dashboard__form-grid"; grid.append(name.root, position.root, department.root, phone.root); const save = createButton({ label: L("B01_Dashboard_SaveProfile"), onClick: async function onB01_Profile_Save_Click() { name.setError(); if (isBlank(name.input.value)) { name.setError(L("Common_Msg_RequiredField")); return; } await runRequest(() => updateUserProfile({ name: name.input.value.trim(), position: position.input.value.trim() || null, department: department.input.value.trim() || null, phone: phone.input.value.trim() || null, }), ); }, }); const wrap = document.createElement("div"); wrap.append(grid, save); return wrap; } function buildSecurityForm(): HTMLElement { const currentPassword = createInputField({ label: L("B01_Account_Field_CurrentPw"), type: "password", required: true, }); const newPassword = createInputField({ label: L("B01_Account_Field_NewPw"), type: "password", required: true, }); const confirmPassword = createInputField({ label: L("B01_Account_Field_ConfirmPw"), type: "password", required: true, }); const grid = document.createElement("div"); grid.className = "b01-dashboard__form-grid"; grid.append(currentPassword.root, newPassword.root, confirmPassword.root); const save = createButton({ label: L("B01_Account_Save_Password"), variant: "ghost", onClick: async function onB01_Password_Save_Click() { currentPassword.setError(); newPassword.setError(); confirmPassword.setError(); const currentValue = currentPassword.input.value; const nextValue = newPassword.input.value; const confirmValue = confirmPassword.input.value; if (isBlank(currentValue) || isBlank(nextValue) || isBlank(confirmValue)) { currentPassword.setError(isBlank(currentValue) ? L("Common_Msg_RequiredField") : undefined); newPassword.setError(isBlank(nextValue) ? L("Common_Msg_RequiredField") : undefined); confirmPassword.setError(isBlank(confirmValue) ? L("Common_Msg_RequiredField") : undefined); return; } if (nextValue.length < PASSWORD_MIN_LENGTH) { newPassword.setError(L("B01_Account_Error_PwLength")); return; } if (nextValue !== confirmValue) { confirmPassword.setError(L("B01_Account_Error_PwMismatch")); return; } await runRequest(() => changePassword({ current_password: currentValue, new_password: nextValue, new_password_confirm: confirmValue, logout_all: false, }), ); currentPassword.input.value = ""; newPassword.input.value = ""; confirmPassword.input.value = ""; }, }); const wrap = document.createElement("div"); wrap.append(grid, save); return wrap; } function openCreateCompanyModal(): void { const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true }); const number = createInputField({ label: L("B01_Dashboard_Field_BusinessNumber"), required: true, }); const address = createInputField({ label: L("B01_Dashboard_Field_Address") }); const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") }); openModal( L("B01_Dashboard_Modal_CreateCompany"), [name.root, number.root, address.root, owner.root], async () => { if (isBlank(name.input.value) || isBlank(number.input.value)) return; await runRequest(() => createCompany({ name: name.input.value.trim(), business_registration_number: number.input.value.trim(), business_address: address.input.value.trim() || null, business_owner: owner.input.value.trim() || null, }), ); }, ); } function openFindCompanyModal(): void { const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true }); const results = document.createElement("div"); results.className = "b01-dashboard__actions"; const search = createButton({ label: L("Common_Btn_Search"), variant: "ghost", onClick: async function onB01_Company_Search_Click() { results.innerHTML = ""; const companies = await searchCompanies(query.input.value.trim()); for (const company of companies) { results.append( createButton({ label: `${company.name} ${L("B01_Dashboard_JoinCompany")}`, variant: "ghost", onClick: () => runRequest(() => joinCompany(company.id)), }), ); } }, }); openModal( L("B01_Dashboard_Modal_FindCompany"), [query.root, search, results], async () => undefined, ); } function openAddMemberModal(): void { const email = createInputField({ label: L("B01_Dashboard_Field_MemberEmail"), type: "email", required: true, }); openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => { if (isBlank(email.input.value)) return; await runRequest(() => addCompanyMember(email.input.value.trim())); }); } function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise): void { const modal = document.createElement("div"); modal.className = "b01-dashboard__modal"; const panel = document.createElement("div"); panel.className = "b01-dashboard__modal-panel"; const heading = document.createElement("h3"); heading.className = "b01-dashboard__modal-title"; heading.textContent = title; const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; actions.append( createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: () => modal.remove(), }), createButton({ label: L("Common_Btn_Confirm"), onClick: async () => { await onConfirm(); modal.remove(); window.dispatchEvent(new HashChangeEvent("hashchange")); }, }), ); panel.append(heading, ...body, actions); modal.append(panel); document.body.append(modal); } async function onB01_Member_Remove_Click(userId: number): Promise { await runRequest(() => removeCompanyMember(userId)); window.dispatchEvent(new HashChangeEvent("hashchange")); } async function onB01_JoinRequest_Process_Click( requestId: number, action: "APPROVE" | "REJECT", systemMode: boolean, ): Promise { await runRequest(() => systemMode && action === "APPROVE" ? import("./B01_Dashboard_Api_Fetch").then((api) => api.systemApproveJoinRequest(requestId)) : processJoinRequest(requestId, action), ); window.dispatchEvent(new HashChangeEvent("hashchange")); } async function runRequest(action: () => Promise): Promise { showLoadingOverlay(); try { await action(); showToast(L("B01_Dashboard_Saved"), "success"); } catch (error) { showToast(error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error"); } finally { hideLoadingOverlay(); } } function formatDate(value?: string | null): string { return value ? value.slice(0, 10) : "-"; } function percent(value?: number | null): string { return value == null ? "-" : `${Math.round(value)}%`; }