diff --git a/.agent/FOLLOWUP_dashboard_fixes.md b/.agent/complete_and_old/FOLLOWUP_dashboard_fixes.md similarity index 100% rename from .agent/FOLLOWUP_dashboard_fixes.md rename to .agent/complete_and_old/FOLLOWUP_dashboard_fixes.md diff --git a/.agent/plan_dashboard_management.md b/.agent/complete_and_old/plan_dashboard_management.md similarity index 100% rename from .agent/plan_dashboard_management.md rename to .agent/complete_and_old/plan_dashboard_management.md diff --git a/.agent/validation_dashboard_management.md b/.agent/complete_and_old/validation_dashboard_management.md similarity index 100% rename from .agent/validation_dashboard_management.md rename to .agent/complete_and_old/validation_dashboard_management.md diff --git a/.agent/structure.md b/.agent/structure.md index 6581641..0155110 100644 --- a/.agent/structure.md +++ b/.agent/structure.md @@ -109,6 +109,9 @@ my-project/ │ # ※ B01·B02 본문 완성 / B03~B11 헤더·셸만 (본문은 0_old 참고 후 구체화) ├── B01_Dashboard/ # 로그인 후 01: 대시보드 (역할별 정보 표시) │ ├── B01_Dashboard_UI_Page.ts # 역할별 섹션 조건부 렌더링 +│ ├── B01_Dashboard_UI_Modals.ts # 프로젝트, 사용자, 자동화 제어 모달 모음 +│ ├── B01_Dashboard_UI_Helper.ts # 역할별 접근 권한 확인 헬퍼 +│ ├── B01_Dashboard_UI_Resources.ts # CPU/Memory/Disk 리소스 렌더링 컴포넌트 │ ├── B01_Dashboard_UI_Style.css # 프로젝트 테이블, 워크플로우, 팝업 스타일 │ ├── B01_Dashboard_Api_Fetch.ts # dashboard API 클라이언트 │ ├── B01_Dashboard_Schema.py # 요청/응답 Pydantic 검증 diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index b64b219..44ea5a1 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -16,6 +16,7 @@ export interface DashboardUser { export interface ProjectItem { id: string; + company_id?: number | null; name: string; region?: string | null; road_type?: string | null; @@ -204,6 +205,10 @@ export function createCompany(payload: CreateCompanyRequest): Promise { return request("/dashboard/user/company/create", { method: "POST", body: body(payload) }); } +export function createSystemCompany(payload: CreateCompanyRequest): Promise { + return request("/dashboard/admin/companies", { method: "POST", body: body(payload) }); +} + export function joinCompany(companyId: number): Promise { return request("/dashboard/user/company/join", { method: "POST", diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index dc74bf2..5360b45 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -82,7 +82,8 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT id, name, region, status, updated_at, created_at + """SELECT id, company_id, name, region, road_type, project_year, + estimated_length_m, memo, status, updated_at, created_at FROM projects WHERE user_id = %s AND deleted_at IS NULL ORDER BY updated_at DESC, created_at DESC""", (user_id,), @@ -94,7 +95,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT p.id, p.name, p.region, p.status, p.updated_at, p.created_at, + """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, + p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, u.name AS owner_name, u.email AS owner_email FROM projects p LEFT JOIN users u ON u.id = p.user_id WHERE p.company_id = %s AND p.deleted_at IS NULL @@ -108,7 +110,8 @@ async def list_all_projects() -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT p.id, p.name, p.region, p.status, p.updated_at, p.created_at, + """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, + p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, u.name AS owner_name, u.email AS owner_email FROM projects p LEFT JOIN users u ON u.id = p.user_id WHERE p.deleted_at IS NULL @@ -254,6 +257,37 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: raise +async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + actor_id, + ), + ) + company_id = cursor.lastrowid + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", + (actor_id, company_id), + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + async def join_company(user_id: int, company_id: int) -> dict[str, Any]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index ccd74f4..d44021c 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -13,6 +13,7 @@ from .B01_Dashboard_Repository import ( count_company_admins, create_company, create_project_automation, + create_system_company, delete_project_automation, get_dashboard_me, get_project, @@ -78,7 +79,9 @@ def _same_company(session: dict[str, Any], company_id: int | None) -> bool: def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: if session["role"] == "SYSTEM_ADMIN": return True - return _same_company(session, project.get("company_id")) + if session["role"] == "ADMIN": + return _same_company(session, project.get("company_id")) + return False def _can_manage_automation(session: dict[str, Any], project: dict[str, Any]) -> bool: @@ -227,8 +230,23 @@ async def dashboard_update_project( @router.delete("/projects/{project_id}") async def dashboard_delete_project( project_id: str, - session: dict[str, Any] = Depends(require_system_admin), + session: dict[str, Any] = Depends(verify_session), ): + project = await get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + + can_del = False + if session["role"] == "SYSTEM_ADMIN": + can_del = True + # 나중에 ADMIN도 소유 프로젝트 삭제 허용할 수 있으므로 주석 처리 + # elif session["role"] == "ADMIN": + # if int(project.get("user_id") or 0) == int(session["user_id"]): + # can_del = True + + if not can_del: + raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.") + if not await soft_delete_project(project_id, int(session["user_id"])): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} @@ -240,6 +258,15 @@ async def system_companies(session: dict[str, Any] = Depends(require_system_admi return {"status": "success", "companies": await list_all_companies()} +@router.post("/admin/companies") +async def system_create_company( + payload: CreateCompanyRequest, + session: dict[str, Any] = Depends(require_system_admin), +): + result = await create_company(int(session["user_id"]), payload.model_dump()) + return {"status": "success", **result} + + @router.get("/admin/users") async def system_users(session: dict[str, Any] = Depends(require_system_admin)): _ = session @@ -250,7 +277,7 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)): async def system_change_role( user_id: int, payload: ChangeUserRoleRequest, - session: dict[str, Any] = Depends(verify_session), + session: dict[str, Any] = Depends(require_system_admin), ): target = await get_user_admin_target(user_id) if not target: @@ -259,17 +286,6 @@ async def system_change_role( raise HTTPException( status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다." ) - if session["role"] == "ADMIN": - if not _same_company(session, target.get("company_id")): - raise HTTPException(status_code=403, detail="같은 회사 사용자만 변경할 수 있습니다.") - if target["role"] == "ADMIN" and payload.role == "USER": - admins = await count_company_admins(int(target["company_id"])) - if admins <= 1: - raise HTTPException( - status_code=409, detail="회사의 마지막 관리자는 변경할 수 없습니다." - ) - elif session["role"] != "SYSTEM_ADMIN": - raise HTTPException(status_code=403, detail="역할 변경 권한이 없습니다.") if not await change_user_role(user_id, payload.role): raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") return {"status": "success"} diff --git a/B01_Dashboard/B01_Dashboard_UI_Helper.ts b/B01_Dashboard/B01_Dashboard_UI_Helper.ts new file mode 100644 index 0000000..735d7b3 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Helper.ts @@ -0,0 +1,35 @@ +import type { DashboardUser, ProjectItem, Member } from "./B01_Dashboard_Api_Fetch"; + +export function canEditProject(user: DashboardUser, project: ProjectItem): boolean { + if (user.role === "SYSTEM_ADMIN") return true; + if (user.role === "ADMIN") return user.company_id !== null; + return false; // USER는 수정 불가 +} + +export function canDeleteProject(user: DashboardUser): boolean { + // SYSTEM_ADMIN만 가능 (ADMIN 프로젝트 삭제는 나중을 위해 주석 처리) + return user.role === "SYSTEM_ADMIN"; + // return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; +} + +export function canAddUser(user: DashboardUser): boolean { + return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; +} + +export function canChangeRole(user: DashboardUser, targetUser: Member | DashboardUser): boolean { + // 역할 변경은 오직 SYSTEM_ADMIN만 가능 + return user.role === "SYSTEM_ADMIN"; +} + +export function canDeleteUser(user: DashboardUser, targetUser: Member | DashboardUser): boolean { + if (user.role === "SYSTEM_ADMIN") return true; + // ADMIN은 본인 회사의 멤버만 삭제 가능 + return user.role === "ADMIN" && user.company_id !== null; +} + +export function canManageAutomation( + user: DashboardUser, + projectOrAutomation: ProjectItem | { company_id?: number | null }, +): boolean { + return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts new file mode 100644 index 0000000..237d686 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -0,0 +1,427 @@ +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + showToast, + showLoadingOverlay, + hideLoadingOverlay, +} from "@ui/ui_template_elements"; +import { + updateProject, + deleteProject, + changeUserRole, + updateDashboardUser, + removeCompanyMember, + fetchProjectAutomations, + createProjectAutomation, + updateProjectAutomation, + deleteProjectAutomation, + executeProjectAutomation, + createCompany, + joinCompany, + searchCompanies, + addCompanyMember, + type DashboardUser, + type ProjectItem, + type Member, + type AutomationItem, + type CompanyInfo, +} from "./B01_Dashboard_Api_Fetch"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +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 () => { + showLoadingOverlay(); + try { + await onConfirm(); + modal.remove(); + window.dispatchEvent(new HashChangeEvent("hashchange")); + } catch (error) { + showToast( + error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), + "error", + ); + } finally { + hideLoadingOverlay(); + } + }, + }), + ); + panel.append(heading, ...body, actions); + modal.append(panel); + document.body.append(modal); +} + +export function openEditProjectModal(user: DashboardUser, project: ProjectItem): void { + const isUserOnly = user.role === "USER"; + + const name = createInputField({ + label: L("B01_Dashboard_Table_Project"), + value: project.name, + required: true, + }); + const region = createInputField({ + label: L("B01_Dashboard_Table_Region"), + value: project.region ?? "", + }); + const roadType = createInputField({ label: "임도 종류", value: project.road_type ?? "" }); + const year = createInputField({ + label: "사업 연도", + type: "number", + value: String(project.project_year ?? ""), + }); + const length = createInputField({ + label: "예상 연장 (m)", + type: "number", + value: String(project.estimated_length_m ?? ""), + }); + const memo = createInputField({ label: "비고", value: project.memo ?? "" }); + + if (isUserOnly) { + name.input.disabled = true; + region.input.disabled = true; + roadType.input.disabled = true; + year.input.disabled = true; + length.input.disabled = true; + memo.input.disabled = true; + } + + openModal( + L("B01_Dashboard_EditProject"), + [name.root, region.root, roadType.root, year.root, length.root, memo.root], + async () => { + await updateProject(project.id, { + name: name.input.value.trim(), + region: region.input.value.trim() || null, + road_type: roadType.input.value.trim() || null, + project_year: year.input.value ? Number(year.input.value) : null, + estimated_length_m: length.input.value ? Number(length.input.value) : null, + memo: memo.input.value.trim() || null, + status: project.status, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + }, + ); +} + +export function openDeleteProjectModal(project: ProjectItem): void { + const warning = document.createElement("p"); + warning.className = "b01-dashboard__modal-text"; + warning.textContent = L("B01_Dashboard_Confirm_DeleteProject"); + + openModal(L("B01_Dashboard_DeleteProject"), [warning], async () => { + await deleteProject(project.id); + showToast(L("B01_Dashboard_Saved"), "success"); + }); +} + +export function openEditUserModal(user: DashboardUser, target: Member | DashboardUser): void { + const name = createInputField({ + label: L("B01_Dashboard_Table_Name"), + value: target.name, + required: true, + }); + const position = createInputField({ + label: L("B01_Dashboard_Table_Position"), + value: target.position ?? "", + }); + const department = createInputField({ + label: L("B01_Dashboard_Table_Department"), + value: target.department ?? "", + }); + + const phoneVal = (target as DashboardUser).phone || ""; + const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal }); + + if (user.role === "ADMIN") { + // ADMIN은 직책(position) / 부서(department)만 수정 정보 가능 + name.input.disabled = true; + phone.input.disabled = true; + } else if (user.role === "USER" && user.id !== target.id) { + name.input.disabled = true; + position.input.disabled = true; + department.input.disabled = true; + phone.input.disabled = true; + } + + openModal( + L("B01_Dashboard_EditUser"), + [name.root, position.root, department.root, phone.root], + async () => { + await updateDashboardUser(target.id, { + name: name.input.value.trim(), + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + phone: phone.input.value.trim() || null, + status: target.status, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + }, + ); +} + +export function openChangeRoleModal(target: Member | DashboardUser): void { + const select = document.createElement("select"); + select.className = "ui-input-field__input"; + + const roles = ["USER", "ADMIN"]; + roles.forEach((r) => { + const opt = document.createElement("option"); + opt.value = r; + opt.textContent = r; + opt.selected = target.role === r; + select.append(opt); + }); + + const wrap = document.createElement("div"); + wrap.className = "ui-input-field"; + const label = document.createElement("label"); + label.className = "ui-input-field__label"; + label.textContent = L("B01_Dashboard_Table_Role"); + wrap.append(label, select); + + openModal(L("B01_Dashboard_ChangeRole"), [wrap], async () => { + await changeUserRole(target.id, select.value); + showToast(L("B01_Dashboard_Saved"), "success"); + }); +} + +export function openDeleteUserModal(target: Member | DashboardUser): void { + const warning = document.createElement("p"); + warning.className = "b01-dashboard__modal-text"; + warning.textContent = L("B01_Dashboard_Confirm_DeleteUser"); + + openModal(L("B01_Dashboard_DeleteUser"), [warning], async () => { + await removeCompanyMember(target.id); + showToast(L("B01_Dashboard_Saved"), "success"); + }); +} + +export 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 (!name.input.value.trim() || !number.input.value.trim()) return; + await 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, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + }, + ); +} + +export 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: async () => { + showLoadingOverlay(); + try { + await joinCompany(company.id); + showToast(L("B01_Dashboard_Saved"), "success"); + } catch (e) { + showToast("요청 실패", "error"); + } finally { + hideLoadingOverlay(); + } + }, + }), + ); + } + }, + }); + openModal( + L("B01_Dashboard_Modal_FindCompany"), + [query.root, search, results], + async () => undefined, + ); +} + +export 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 (!email.input.value.trim()) return; + await addCompanyMember(email.input.value.trim()); + showToast(L("B01_Dashboard_Saved"), "success"); + }); +} + +export async function openAutomationModal(project: ProjectItem): Promise { + const modal = document.createElement("div"); + modal.className = "b01-dashboard__modal"; + const panel = document.createElement("div"); + panel.className = "b01-dashboard__modal-panel b01-dashboard__modal-panel--wide"; + + const heading = document.createElement("h3"); + heading.className = "b01-dashboard__modal-title"; + heading.textContent = `${project.name} - ${L("B01_Dashboard_AutomationLogic")}`; + + const listWrap = document.createElement("div"); + listWrap.className = "b01-dashboard__automation-list"; + + const refreshList = async () => { + listWrap.innerHTML = L("Common_Status_Loading"); + try { + const data = await fetchProjectAutomations(project.id); + listWrap.innerHTML = ""; + if (data.length === 0) { + listWrap.textContent = L("Common_Status_Empty"); + return; + } + data.forEach((item) => { + const itemEl = document.createElement("div"); + itemEl.className = "b01-dashboard__automation-item"; + + const info = document.createElement("div"); + info.innerHTML = `${item.name} (${item.logic_type}) - status: ${item.status}`; + + const actions = document.createElement("div"); + actions.className = "b01-dashboard__actions"; + + const runBtn = createButton({ + label: L("B01_Dashboard_ExecuteAutomation"), + variant: "ghost", + onClick: async () => { + await executeProjectAutomation(item.id); + showToast("실행 성공", "success"); + refreshList(); + }, + }); + + const editBtn = createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => { + modal.remove(); + openEditAutomationModal(project, item); + }, + }); + + const delBtn = createButton({ + label: L("Common_Btn_Delete"), + variant: "danger", + onClick: () => { + modal.remove(); + openDeleteAutomationModal(project, item); + }, + }); + + actions.append(runBtn, editBtn, delBtn); + itemEl.append(info, actions); + listWrap.append(itemEl); + }); + } catch (e) { + listWrap.textContent = "로딩 에러"; + } + }; + + const createBtn = createButton({ + label: L("B01_Dashboard_CreateAutomation"), + onClick: () => { + modal.remove(); + openCreateAutomationModal(project); + }, + }); + + const closeBtn = createButton({ + label: L("Common_Btn_Close"), + variant: "ghost", + onClick: () => modal.remove(), + }); + + panel.append(heading, createBtn, listWrap, closeBtn); + modal.append(panel); + document.body.append(modal); + + await refreshList(); +} + +function openCreateAutomationModal(project: ProjectItem): void { + const name = createInputField({ label: "자동화 로직명", required: true }); + const type = createInputField({ label: "로직 타입", required: true }); + + openModal(L("B01_Dashboard_CreateAutomation"), [name.root, type.root], async () => { + await createProjectAutomation(project.id, { + name: name.input.value.trim(), + logic_type: type.input.value.trim(), + config_json: {}, + status: "DRAFT", + }); + showToast(L("B01_Dashboard_Saved"), "success"); + setTimeout(() => openAutomationModal(project), 300); + }); +} + +function openEditAutomationModal(project: ProjectItem, item: AutomationItem): void { + const name = createInputField({ label: "자동화 로직명", value: item.name, required: true }); + const type = createInputField({ label: "로직 타입", value: item.logic_type, required: true }); + + openModal(L("B01_Dashboard_EditAutomation"), [name.root, type.root], async () => { + await updateProjectAutomation(item.id, { + name: name.input.value.trim(), + logic_type: type.input.value.trim(), + config_json: item.config_json, + status: item.status, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + setTimeout(() => openAutomationModal(project), 300); + }); +} + +function openDeleteAutomationModal(project: ProjectItem, item: AutomationItem): void { + const warning = document.createElement("p"); + warning.textContent = L("B01_Dashboard_DeleteAutomation") + "?"; + + openModal(L("B01_Dashboard_DeleteAutomation"), [warning], async () => { + await deleteProjectAutomation(item.id); + showToast(L("B01_Dashboard_Saved"), "success"); + setTimeout(() => openAutomationModal(project), 300); + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 322d080..cdd9619 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -1,4 +1,4 @@ -import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { ROUTES } 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"; @@ -6,16 +6,13 @@ import { createButton, createCard, createInputField, - createLineChart, createTag, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { - addCompanyMember, changePassword, - createCompany, fetchAllCompanies, fetchAllJoinRequests, fetchAllProjects, @@ -28,10 +25,7 @@ import { fetchSystemResources, fetchUserCompany, fetchUserProjects, - joinCompany, processJoinRequest, - removeCompanyMember, - searchCompanies, updateUserProfile, type AuditLog, type CompanyInfo, @@ -41,6 +35,25 @@ import { type ProjectItem, type ResourceData, } from "./B01_Dashboard_Api_Fetch"; +import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; +import { + canEditProject, + canDeleteProject, + canChangeRole, + canDeleteUser, + canManageAutomation, +} from "./B01_Dashboard_UI_Helper"; +import { + openEditProjectModal, + openDeleteProjectModal, + openEditUserModal, + openChangeRoleModal, + openDeleteUserModal, + openAutomationModal, + openCreateCompanyModal, + openFindCompanyModal, + openAddMemberModal, +} from "./B01_Dashboard_UI_Modals"; import "./B01_Dashboard_UI_Style.css"; const PASSWORD_MIN_LENGTH = 8; @@ -83,6 +96,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise { resources: null, }; await loadRoleData(state); + root.innerHTML = ""; root.append(buildPage(state)); } catch (error) { showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error"); @@ -93,7 +107,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise { async function loadRoleData(state: DashboardState): Promise { if (state.user.role === "SYSTEM_ADMIN") { - const [companies, users, requests, logs, resources, projects] = await Promise.all([ + const results = await Promise.allSettled([ fetchAllCompanies(), fetchAllUsers(), fetchAllJoinRequests(), @@ -101,22 +115,31 @@ async function loadRoleData(state: DashboardState): Promise { fetchSystemResources(7), fetchAllProjects(), ]); - state.allCompanies = companies; - state.allUsers = users; - state.allJoinRequests = requests; - state.auditLogs = logs; - state.resources = resources; - state.allProjects = projects; + state.allCompanies = results[0].status === "fulfilled" ? results[0].value : []; + state.allUsers = results[1].status === "fulfilled" ? results[1].value : []; + state.allJoinRequests = results[2].status === "fulfilled" ? results[2].value : []; + state.auditLogs = results[3].status === "fulfilled" ? results[3].value : []; + state.resources = results[4].status === "fulfilled" ? results[4].value : null; + state.allProjects = results[5].status === "fulfilled" ? results[5].value : []; + + results.forEach((res, idx) => { + if (res.status === "rejected") { + console.error(`System Admin API failed at index ${idx}:`, res.reason); + } + }); return; } - const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]); + const [projects, company] = await Promise.all([ + fetchUserProjects().catch(() => []), + fetchUserCompany().catch(() => null), + ]); state.userProjects = projects; state.company = company; if (state.user.role === "ADMIN") { const [members, requests, companyProjects] = await Promise.all([ - fetchCompanyMembers(), - fetchCompanyJoinRequests(), - fetchCompanyProjects(), + fetchCompanyMembers().catch(() => []), + fetchCompanyJoinRequests().catch(() => []), + fetchCompanyProjects().catch(() => []), ]); state.members = members; state.joinRequests = requests; @@ -124,6 +147,40 @@ async function loadRoleData(state: DashboardState): Promise { } } +function buildSectionHeader(titleText: string, actionButtons: HTMLElement[] = []): HTMLElement { + const header = document.createElement("div"); + header.className = "b01-dashboard__section-header"; + header.style.display = "flex"; + header.style.justifyContent = "space-between"; + header.style.alignItems = "center"; + header.style.marginBottom = "var(--spacing-16)"; + + const title = document.createElement("h3"); + title.className = "ui-card__title"; + title.style.margin = "0"; + title.textContent = titleText; + + const actions = document.createElement("div"); + actions.className = "b01-dashboard__actions"; + actions.append(...actionButtons); + + header.append(title, actions); + return header; +} + +function section( + title: string, + body: HTMLElement, + wide = false, + actions: HTMLElement[] = [], +): HTMLElement { + const header = buildSectionHeader(title, actions); + const card = createCard({ body: [header, body], raised: true }); + card.classList.add("b01-dashboard__section"); + if (wide) card.classList.add("b01-dashboard__section--wide"); + return card; +} + function buildPage(state: DashboardState): HTMLElement { const page = document.createElement("div"); page.className = "b01-dashboard"; @@ -133,28 +190,40 @@ function buildPage(state: DashboardState): HTMLElement { grid.className = "b01-dashboard__grid"; if (state.user.role === "SYSTEM_ADMIN") { - // 순서: 리소스 현황(최상단), 프로젝트, 사용자 관리(사용자), 가입요청(1행 전체), 회사관리(회사목록), 시스템 로그(감사로그), 기본정보(프로필), 보안 grid.append( section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), - section(L("B01_Dashboard_Projects"), projectTable(state.allProjects), true), - section(L("B01_Dashboard_Users"), userTable(state.allUsers), true), - section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), - section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true), - section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), - ); - } else if (state.user.role === "ADMIN") { - // 순서: 프로젝트, 사용자 관리(멤버), 가입요청(1행 전체), 회사관리(회사패널), 기본정보(프로필), 보안 - grid.append( - section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects), true, [ + section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ createButton({ - label: L("B01_Dashboard_NewProject"), + label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), }), ]), - section(L("B01_Dashboard_Members"), memberTable(state.members), true, [ + section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ createButton({ - label: L("B01_Dashboard_AddMember"), - variant: "ghost", + label: "+", + onClick: () => openAddMemberModal(), + }), + ]), + section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), + section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true, [ + createButton({ + label: "+", + onClick: () => openCreateCompanyModal(), + }), + ]), + section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), + ); + } else if (state.user.role === "ADMIN") { + grid.append( + section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [ + createButton({ + label: "+", + onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), + }), + ]), + section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ + createButton({ + label: "+", onClick: () => openAddMemberModal(), }), ]), @@ -162,12 +231,10 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), ); } else { - // 일반 사용자 (USER) - // 순서: 프로젝트, 회사관리(회사패널), 기본정보(프로필), 보안 grid.append( - section(L("B01_Dashboard_Projects"), projectTable(state.userProjects), true, [ + section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [ createButton({ - label: L("B01_Dashboard_NewProject"), + label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), }), ]), @@ -175,7 +242,6 @@ function buildPage(state: DashboardState): HTMLElement { ); } - // 기본정보 (프로필) & 보안 순서 grid.append( section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), section(L("B01_Account_Section_Security"), buildSecurityForm()), @@ -207,22 +273,6 @@ function roleLabel(role: DashboardUser["role"]): string { 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"); @@ -263,7 +313,7 @@ function text(value: unknown): HTMLElement { return span; } -function projectTable(projects: ProjectItem[]): HTMLElement { +function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement { return table( [ L("B01_Dashboard_Table_Project"), @@ -271,14 +321,49 @@ function projectTable(projects: ProjectItem[]): HTMLElement { L("B01_Dashboard_Table_Progress"), L("B01_Dashboard_Table_Workflow"), L("B01_Dashboard_Table_Updated"), + L("B01_Dashboard_Table_Action"), ], - projects.map((project) => [ - text(project.name), - text(project.region), - text(`${project.progress_percent}%`), - workflow(project.workflow_stage), - text(formatDate(project.updated_at)), - ]), + projects.map((project) => { + const actCell = document.createElement("div"); + actCell.className = "b01-dashboard__actions"; + + if (canEditProject(currentUser, project)) { + actCell.append( + createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditProjectModal(currentUser, project), + }), + ); + } + if (canDeleteProject(currentUser)) { + actCell.append( + createButton({ + label: L("Common_Btn_Delete"), + variant: "danger", + onClick: () => openDeleteProjectModal(project), + }), + ); + } + if (canManageAutomation(currentUser, project)) { + actCell.append( + createButton({ + label: L("B01_Dashboard_AutomationLogic"), + variant: "ghost", + onClick: () => openAutomationModal(project), + }), + ); + } + + return [ + text(project.name), + text(project.region), + text(`${project.progress_percent}%`), + workflow(project.workflow_stage), + text(formatDate(project.updated_at)), + actCell, + ]; + }), ); } @@ -336,7 +421,7 @@ function buildCompanyPanel(state: DashboardState): HTMLElement { return wrap; } -function memberTable(members: Member[]): HTMLElement { +function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), @@ -346,18 +431,47 @@ function memberTable(members: Member[]): HTMLElement { 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), - }), - ]), + members.map((member) => { + const actionsEl = document.createElement("div"); + actionsEl.className = "b01-dashboard__actions"; + + actionsEl.append( + createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditUserModal(currentUser, member), + }), + ); + + if (canChangeRole(currentUser, member)) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_ChangeRole"), + variant: "ghost", + onClick: () => openChangeRoleModal(member), + }), + ); + } + + if (canDeleteUser(currentUser, member)) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_RemoveMember"), + variant: "danger", + onClick: () => openDeleteUserModal(member), + }), + ); + } + + return [ + text(member.email), + text(member.name), + text(member.position), + text(member.department), + text(member.role), + actionsEl, + ]; + }), ); } @@ -393,169 +507,6 @@ function actionPair(approve: () => void, reject: () => void): HTMLElement { 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 ""; - try { - 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())}`; - } catch { - return ""; - } -} - -function buildResourceChart(resources: ResourceData | null): HTMLElement { - const box = document.createElement("div"); - box.className = "b01-dashboard__chart"; - - const caption = document.createElement("p"); - caption.className = "b01-dashboard__chart-caption"; - caption.textContent = L("B01_Dashboard_Resources_ChartCaption"); - box.append(caption); - - const chartWrap = document.createElement("div"); - chartWrap.className = "b01-dashboard__chart-content"; - box.append(chartWrap); - - const history = resources?.history ?? []; - - // 7일간의 고정 타임라인 생성 (4시간 간격, 총 43개 포인트) - const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] = - []; - const now = new Date(); - - // 12시간 단위(정오 또는 자정)로 올림 정렬하여 기준점 계산 - const baseDate = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - now.getHours() >= 12 ? 24 : 12, - 0, - 0, - 0, - ); - - for (let i = 0; i <= 42; i++) { - // 7일 전부터 현재 정렬 시점까지 4시간 단위 버킷 생성 - const time = new Date(baseDate.getTime() - (42 - i) * 4 * 60 * 60 * 1000); - points.push({ timestamp: time, cpu: null, mem: null, disk: null }); - } - - // DB에서 불러온 계측 이력 매핑 - history.forEach((h) => { - if (!h.timestamp) return; - // DB에서 넘어오는 ISO 문자열을 UTC 기준으로 정확히 매핑하기 위해 Z를 보완하여 파싱 - const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z"; - const hTime = new Date(tsStr); - - // 가장 가까운 4시간 단위 버킷 매핑 (±2시간 내) - let closestIdx = -1; - let minDiff = Infinity; - - points.forEach((p, idx) => { - const diff = Math.abs(p.timestamp.getTime() - hTime.getTime()); - if (diff < minDiff && diff <= 2 * 60 * 60 * 1000) { - minDiff = diff; - closestIdx = idx; - } - }); - - if (closestIdx !== -1) { - const p = points[closestIdx]; - p.cpu = h.cpu_usage_percent; - p.mem = h.memory_usage_percent; - p.disk = h.disk_usage_percent; - } - }); - - // xLabels 생성 (Midnight: MM/DD, 그 외는 빈칸) - const xLabels = points.map((p) => { - const hours = p.timestamp.getHours(); - if (hours === 0) { - return `${p.timestamp.getMonth() + 1}/${p.timestamp.getDate()}`; - } - return ""; - }); - - let resizeTimeout: number | null = null; - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const width = Math.floor(entry.contentRect.width); - if (width <= 0) continue; - - if (resizeTimeout) { - window.cancelAnimationFrame(resizeTimeout); - } - - resizeTimeout = window.requestAnimationFrame(() => { - chartWrap.innerHTML = ""; - const chart = createLineChart({ - ariaLabel: L("B01_Dashboard_Resources_ChartAria"), - xLabels, - width: width, - height: 140, - series: [ - { - name: L("B01_Dashboard_Metric_Cpu"), - colorIndex: 0, - values: points.map((p) => p.cpu), - }, - { - name: L("B01_Dashboard_Metric_Memory"), - colorIndex: 1, - values: points.map((p) => p.mem), - }, - { - name: L("B01_Dashboard_Metric_Disk"), - colorIndex: 2, - values: points.map((p) => p.disk), - }, - ], - }); - chartWrap.append(chart); - }); - } - }); - - observer.observe(box); - - return box; -} - function companyTable(companies: CompanyInfo[]): HTMLElement { return table( [ @@ -571,15 +522,39 @@ function companyTable(companies: CompanyInfo[]): HTMLElement { ); } -function userTable(users: DashboardUser[]): HTMLElement { +function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), L("B01_Dashboard_Table_Name"), L("B01_Dashboard_Table_Role"), L("B01_Dashboard_Table_Status"), + L("B01_Dashboard_Table_Action"), ], - users.map((user) => [text(user.email), text(user.name), text(user.role), text(user.status)]), + users.map((user) => { + const actionsEl = document.createElement("div"); + actionsEl.className = "b01-dashboard__actions"; + + actionsEl.append( + createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditUserModal(currentUser, user), + }), + ); + + if (canChangeRole(currentUser, user)) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_ChangeRole"), + variant: "ghost", + onClick: () => openChangeRoleModal(user), + }), + ); + } + + return [text(user.email), text(user.name), text(user.role), text(user.status), actionsEl]; + }), ); } @@ -697,106 +672,6 @@ function buildSecurityForm(): HTMLElement { 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", @@ -825,7 +700,3 @@ async function runRequest(action: () => Promise): Promise { function formatDate(value?: string | null): string { return value ? value.slice(0, 10) : "-"; } - -function percent(value?: number | null): string { - return value == null ? "-" : `${Math.round(value)}%`; -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Resources.ts b/B01_Dashboard/B01_Dashboard_UI_Resources.ts new file mode 100644 index 0000000..08042d0 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Resources.ts @@ -0,0 +1,148 @@ +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createLineChart } from "@ui/ui_template_elements"; +import type { ResourceData } from "./B01_Dashboard_Api_Fetch"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +function percent(value?: number | null): string { + return value == null ? "-" : `${Math.round(value)}%`; +} + +export 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 buildResourceChart(resources: ResourceData | null): HTMLElement { + const box = document.createElement("div"); + box.className = "b01-dashboard__chart"; + + const caption = document.createElement("p"); + caption.className = "b01-dashboard__chart-caption"; + caption.textContent = L("B01_Dashboard_Resources_ChartCaption"); + box.append(caption); + + const chartWrap = document.createElement("div"); + chartWrap.className = "b01-dashboard__chart-content"; + box.append(chartWrap); + + const history = resources?.history ?? []; + const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] = + []; + const now = new Date(); + const baseDate = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + now.getHours() >= 12 ? 24 : 12, + 0, + 0, + 0, + ); + + for (let i = 0; i <= 42; i++) { + const time = new Date(baseDate.getTime() - (42 - i) * 4 * 60 * 60 * 1000); + points.push({ timestamp: time, cpu: null, mem: null, disk: null }); + } + + history.forEach((h) => { + if (!h.timestamp) return; + const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z"; + const hTime = new Date(tsStr); + let closestIdx = -1; + let minDiff = Infinity; + + points.forEach((p, idx) => { + const diff = Math.abs(p.timestamp.getTime() - hTime.getTime()); + if (diff < minDiff && diff <= 2 * 60 * 60 * 1000) { + minDiff = diff; + closestIdx = idx; + } + }); + + if (closestIdx !== -1) { + const p = points[closestIdx]; + p.cpu = h.cpu_usage_percent; + p.mem = h.memory_usage_percent; + p.disk = h.disk_usage_percent; + } + }); + + const xLabels = points.map((p) => { + const hours = p.timestamp.getHours(); + return hours === 0 ? `${p.timestamp.getMonth() + 1}/${p.timestamp.getDate()}` : ""; + }); + + let resizeTimeout: number | null = null; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const width = Math.floor(entry.contentRect.width); + if (width <= 0) continue; + + if (resizeTimeout) { + window.cancelAnimationFrame(resizeTimeout); + } + + resizeTimeout = window.requestAnimationFrame(() => { + chartWrap.innerHTML = ""; + const chart = createLineChart({ + ariaLabel: L("B01_Dashboard_Resources_ChartAria"), + xLabels, + width: width, + height: 140, + series: [ + { + name: L("B01_Dashboard_Metric_Cpu"), + colorIndex: 0, + values: points.map((p) => p.cpu), + }, + { + name: L("B01_Dashboard_Metric_Memory"), + colorIndex: 1, + values: points.map((p) => p.mem), + }, + { + name: L("B01_Dashboard_Metric_Disk"), + colorIndex: 2, + values: points.map((p) => p.disk), + }, + ], + }); + chartWrap.append(chart); + }); + } + }); + + observer.observe(box); + return box; +} diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index f604155..58b69af 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -664,6 +664,41 @@ export const ui_locales = { "결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.", "Check payment and document status, and download results.", ], + + // 프로젝트 관리 + B01_Dashboard_EditProject: ["프로젝트 수정", "Edit Project"], + B01_Dashboard_DeleteProject: ["프로젝트 삭제", "Delete Project"], + + // 사용자 관리 + B01_Dashboard_AddUser: ["사용자 추가", "Add User"], + B01_Dashboard_EditUser: ["사용자 수정", "Edit User"], + B01_Dashboard_DeleteUser: ["사용자 삭제", "Delete User"], + B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"], + B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"], + + // 자동화 로직 + B01_Dashboard_AutomationLogic: ["자동화 설계 로직", "Automation Logic"], + B01_Dashboard_CreateAutomation: ["자동화 생성", "Create Automation"], + B01_Dashboard_EditAutomation: ["자동화 수정", "Edit Automation"], + B01_Dashboard_DeleteAutomation: ["자동화 삭제", "Delete Automation"], + B01_Dashboard_ExecuteAutomation: ["자동화 실행", "Execute Automation"], + + // 확인 메시지 + B01_Dashboard_Confirm_DeleteProject: [ + "프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.", + "Delete this project? This cannot be undone.", + ], + B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"], + B01_Dashboard_Confirm_LastAdmin: [ + "회사의 유일한 관리자는 삭제할 수 없습니다.", + "Cannot delete the last admin of the company.", + ], + B01_Dashboard_CannotChangeToSystemAdmin: [ + "시스템 관리자로 변경할 수 없습니다.", + "Cannot change role to System Admin.", + ], + B01_Dashboard_AutomationLogic: ["자동화 설계 로직", "Automation Logic"], + B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"], } as const satisfies Record; export type LocaleKey = keyof typeof ui_locales;