260707_4_대시보드 완료
This commit is contained in:
@@ -109,6 +109,9 @@ my-project/
|
|||||||
│ # ※ B01·B02 본문 완성 / B03~B11 헤더·셸만 (본문은 0_old 참고 후 구체화)
|
│ # ※ B01·B02 본문 완성 / B03~B11 헤더·셸만 (본문은 0_old 참고 후 구체화)
|
||||||
├── B01_Dashboard/ # 로그인 후 01: 대시보드 (역할별 정보 표시)
|
├── B01_Dashboard/ # 로그인 후 01: 대시보드 (역할별 정보 표시)
|
||||||
│ ├── B01_Dashboard_UI_Page.ts # 역할별 섹션 조건부 렌더링
|
│ ├── 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_UI_Style.css # 프로젝트 테이블, 워크플로우, 팝업 스타일
|
||||||
│ ├── B01_Dashboard_Api_Fetch.ts # dashboard API 클라이언트
|
│ ├── B01_Dashboard_Api_Fetch.ts # dashboard API 클라이언트
|
||||||
│ ├── B01_Dashboard_Schema.py # 요청/응답 Pydantic 검증
|
│ ├── B01_Dashboard_Schema.py # 요청/응답 Pydantic 검증
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface DashboardUser {
|
|||||||
|
|
||||||
export interface ProjectItem {
|
export interface ProjectItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
company_id?: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
region?: string | null;
|
region?: string | null;
|
||||||
road_type?: string | null;
|
road_type?: string | null;
|
||||||
@@ -204,6 +205,10 @@ export function createCompany(payload: CreateCompanyRequest): Promise<unknown> {
|
|||||||
return request("/dashboard/user/company/create", { method: "POST", body: body(payload) });
|
return request("/dashboard/user/company/create", { method: "POST", body: body(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSystemCompany(payload: CreateCompanyRequest): Promise<unknown> {
|
||||||
|
return request("/dashboard/admin/companies", { method: "POST", body: body(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
export function joinCompany(companyId: number): Promise<unknown> {
|
export function joinCompany(companyId: number): Promise<unknown> {
|
||||||
return request("/dashboard/user/company/join", {
|
return request("/dashboard/user/company/join", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
|
|||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
await cursor.execute(
|
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
|
FROM projects WHERE user_id = %s AND deleted_at IS NULL
|
||||||
ORDER BY updated_at DESC, created_at DESC""",
|
ORDER BY updated_at DESC, created_at DESC""",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
@@ -94,7 +95,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
|
|||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
await cursor.execute(
|
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
|
u.name AS owner_name, u.email AS owner_email
|
||||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||||
WHERE p.company_id = %s AND p.deleted_at IS NULL
|
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()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
await cursor.execute(
|
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
|
u.name AS owner_name, u.email AS owner_email
|
||||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||||
WHERE p.deleted_at IS NULL
|
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
|
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]:
|
async def join_company(user_id: int, company_id: int) -> dict[str, Any]:
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .B01_Dashboard_Repository import (
|
|||||||
count_company_admins,
|
count_company_admins,
|
||||||
create_company,
|
create_company,
|
||||||
create_project_automation,
|
create_project_automation,
|
||||||
|
create_system_company,
|
||||||
delete_project_automation,
|
delete_project_automation,
|
||||||
get_dashboard_me,
|
get_dashboard_me,
|
||||||
get_project,
|
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:
|
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||||
if session["role"] == "SYSTEM_ADMIN":
|
if session["role"] == "SYSTEM_ADMIN":
|
||||||
return True
|
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:
|
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}")
|
@router.delete("/projects/{project_id}")
|
||||||
async def dashboard_delete_project(
|
async def dashboard_delete_project(
|
||||||
project_id: str,
|
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"])):
|
if not await soft_delete_project(project_id, int(session["user_id"])):
|
||||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||||
return {"status": "success"}
|
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()}
|
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")
|
@router.get("/admin/users")
|
||||||
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
||||||
_ = session
|
_ = session
|
||||||
@@ -250,7 +277,7 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
|||||||
async def system_change_role(
|
async def system_change_role(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
payload: ChangeUserRoleRequest,
|
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)
|
target = await get_user_admin_target(user_id)
|
||||||
if not target:
|
if not target:
|
||||||
@@ -259,17 +286,6 @@ async def system_change_role(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다."
|
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):
|
if not await change_user_role(user_id, payload.role):
|
||||||
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>): 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<void> {
|
||||||
|
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 = `<strong>${item.name}</strong> (${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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { isBlank } from "@util/common_util_validate";
|
||||||
import { navigateTo } from "../A00_Common/router";
|
import { navigateTo } from "../A00_Common/router";
|
||||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
@@ -6,16 +6,13 @@ import {
|
|||||||
createButton,
|
createButton,
|
||||||
createCard,
|
createCard,
|
||||||
createInputField,
|
createInputField,
|
||||||
createLineChart,
|
|
||||||
createTag,
|
createTag,
|
||||||
hideLoadingOverlay,
|
hideLoadingOverlay,
|
||||||
showLoadingOverlay,
|
showLoadingOverlay,
|
||||||
showToast,
|
showToast,
|
||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import {
|
import {
|
||||||
addCompanyMember,
|
|
||||||
changePassword,
|
changePassword,
|
||||||
createCompany,
|
|
||||||
fetchAllCompanies,
|
fetchAllCompanies,
|
||||||
fetchAllJoinRequests,
|
fetchAllJoinRequests,
|
||||||
fetchAllProjects,
|
fetchAllProjects,
|
||||||
@@ -28,10 +25,7 @@ import {
|
|||||||
fetchSystemResources,
|
fetchSystemResources,
|
||||||
fetchUserCompany,
|
fetchUserCompany,
|
||||||
fetchUserProjects,
|
fetchUserProjects,
|
||||||
joinCompany,
|
|
||||||
processJoinRequest,
|
processJoinRequest,
|
||||||
removeCompanyMember,
|
|
||||||
searchCompanies,
|
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
type AuditLog,
|
type AuditLog,
|
||||||
type CompanyInfo,
|
type CompanyInfo,
|
||||||
@@ -41,6 +35,25 @@ import {
|
|||||||
type ProjectItem,
|
type ProjectItem,
|
||||||
type ResourceData,
|
type ResourceData,
|
||||||
} from "./B01_Dashboard_Api_Fetch";
|
} 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";
|
import "./B01_Dashboard_UI_Style.css";
|
||||||
|
|
||||||
const PASSWORD_MIN_LENGTH = 8;
|
const PASSWORD_MIN_LENGTH = 8;
|
||||||
@@ -83,6 +96,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
|||||||
resources: null,
|
resources: null,
|
||||||
};
|
};
|
||||||
await loadRoleData(state);
|
await loadRoleData(state);
|
||||||
|
root.innerHTML = "";
|
||||||
root.append(buildPage(state));
|
root.append(buildPage(state));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error");
|
showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error");
|
||||||
@@ -93,7 +107,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
|||||||
|
|
||||||
async function loadRoleData(state: DashboardState): Promise<void> {
|
async function loadRoleData(state: DashboardState): Promise<void> {
|
||||||
if (state.user.role === "SYSTEM_ADMIN") {
|
if (state.user.role === "SYSTEM_ADMIN") {
|
||||||
const [companies, users, requests, logs, resources, projects] = await Promise.all([
|
const results = await Promise.allSettled([
|
||||||
fetchAllCompanies(),
|
fetchAllCompanies(),
|
||||||
fetchAllUsers(),
|
fetchAllUsers(),
|
||||||
fetchAllJoinRequests(),
|
fetchAllJoinRequests(),
|
||||||
@@ -101,22 +115,31 @@ async function loadRoleData(state: DashboardState): Promise<void> {
|
|||||||
fetchSystemResources(7),
|
fetchSystemResources(7),
|
||||||
fetchAllProjects(),
|
fetchAllProjects(),
|
||||||
]);
|
]);
|
||||||
state.allCompanies = companies;
|
state.allCompanies = results[0].status === "fulfilled" ? results[0].value : [];
|
||||||
state.allUsers = users;
|
state.allUsers = results[1].status === "fulfilled" ? results[1].value : [];
|
||||||
state.allJoinRequests = requests;
|
state.allJoinRequests = results[2].status === "fulfilled" ? results[2].value : [];
|
||||||
state.auditLogs = logs;
|
state.auditLogs = results[3].status === "fulfilled" ? results[3].value : [];
|
||||||
state.resources = resources;
|
state.resources = results[4].status === "fulfilled" ? results[4].value : null;
|
||||||
state.allProjects = projects;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]);
|
const [projects, company] = await Promise.all([
|
||||||
|
fetchUserProjects().catch(() => []),
|
||||||
|
fetchUserCompany().catch(() => null),
|
||||||
|
]);
|
||||||
state.userProjects = projects;
|
state.userProjects = projects;
|
||||||
state.company = company;
|
state.company = company;
|
||||||
if (state.user.role === "ADMIN") {
|
if (state.user.role === "ADMIN") {
|
||||||
const [members, requests, companyProjects] = await Promise.all([
|
const [members, requests, companyProjects] = await Promise.all([
|
||||||
fetchCompanyMembers(),
|
fetchCompanyMembers().catch(() => []),
|
||||||
fetchCompanyJoinRequests(),
|
fetchCompanyJoinRequests().catch(() => []),
|
||||||
fetchCompanyProjects(),
|
fetchCompanyProjects().catch(() => []),
|
||||||
]);
|
]);
|
||||||
state.members = members;
|
state.members = members;
|
||||||
state.joinRequests = requests;
|
state.joinRequests = requests;
|
||||||
@@ -124,6 +147,40 @@ async function loadRoleData(state: DashboardState): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function buildPage(state: DashboardState): HTMLElement {
|
||||||
const page = document.createElement("div");
|
const page = document.createElement("div");
|
||||||
page.className = "b01-dashboard";
|
page.className = "b01-dashboard";
|
||||||
@@ -133,28 +190,40 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
grid.className = "b01-dashboard__grid";
|
grid.className = "b01-dashboard__grid";
|
||||||
|
|
||||||
if (state.user.role === "SYSTEM_ADMIN") {
|
if (state.user.role === "SYSTEM_ADMIN") {
|
||||||
// 순서: 리소스 현황(최상단), 프로젝트, 사용자 관리(사용자), 가입요청(1행 전체), 회사관리(회사목록), 시스템 로그(감사로그), 기본정보(프로필), 보안
|
|
||||||
grid.append(
|
grid.append(
|
||||||
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
|
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
|
||||||
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects), true),
|
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), 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, [
|
|
||||||
createButton({
|
createButton({
|
||||||
label: L("B01_Dashboard_NewProject"),
|
label: "+",
|
||||||
onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER),
|
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({
|
createButton({
|
||||||
label: L("B01_Dashboard_AddMember"),
|
label: "+",
|
||||||
variant: "ghost",
|
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(),
|
onClick: () => openAddMemberModal(),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -162,12 +231,10 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
|
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자 (USER)
|
|
||||||
// 순서: 프로젝트, 회사관리(회사패널), 기본정보(프로필), 보안
|
|
||||||
grid.append(
|
grid.append(
|
||||||
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects), true, [
|
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
|
||||||
createButton({
|
createButton({
|
||||||
label: L("B01_Dashboard_NewProject"),
|
label: "+",
|
||||||
onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER),
|
onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -175,7 +242,6 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본정보 (프로필) & 보안 순서
|
|
||||||
grid.append(
|
grid.append(
|
||||||
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
|
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
|
||||||
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
||||||
@@ -207,22 +273,6 @@ function roleLabel(role: DashboardUser["role"]): string {
|
|||||||
return L("B01_Dashboard_Role_User");
|
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 {
|
function table(headers: string[], rows: HTMLElement[][]): HTMLElement {
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
@@ -263,7 +313,7 @@ function text(value: unknown): HTMLElement {
|
|||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectTable(projects: ProjectItem[]): HTMLElement {
|
function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement {
|
||||||
return table(
|
return table(
|
||||||
[
|
[
|
||||||
L("B01_Dashboard_Table_Project"),
|
L("B01_Dashboard_Table_Project"),
|
||||||
@@ -271,14 +321,49 @@ function projectTable(projects: ProjectItem[]): HTMLElement {
|
|||||||
L("B01_Dashboard_Table_Progress"),
|
L("B01_Dashboard_Table_Progress"),
|
||||||
L("B01_Dashboard_Table_Workflow"),
|
L("B01_Dashboard_Table_Workflow"),
|
||||||
L("B01_Dashboard_Table_Updated"),
|
L("B01_Dashboard_Table_Updated"),
|
||||||
|
L("B01_Dashboard_Table_Action"),
|
||||||
],
|
],
|
||||||
projects.map((project) => [
|
projects.map((project) => {
|
||||||
text(project.name),
|
const actCell = document.createElement("div");
|
||||||
text(project.region),
|
actCell.className = "b01-dashboard__actions";
|
||||||
text(`${project.progress_percent}%`),
|
|
||||||
workflow(project.workflow_stage),
|
if (canEditProject(currentUser, project)) {
|
||||||
text(formatDate(project.updated_at)),
|
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;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function memberTable(members: Member[]): HTMLElement {
|
function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement {
|
||||||
return table(
|
return table(
|
||||||
[
|
[
|
||||||
L("B01_Dashboard_Table_Email"),
|
L("B01_Dashboard_Table_Email"),
|
||||||
@@ -346,18 +431,47 @@ function memberTable(members: Member[]): HTMLElement {
|
|||||||
L("B01_Dashboard_Table_Role"),
|
L("B01_Dashboard_Table_Role"),
|
||||||
L("B01_Dashboard_Table_Action"),
|
L("B01_Dashboard_Table_Action"),
|
||||||
],
|
],
|
||||||
members.map((member) => [
|
members.map((member) => {
|
||||||
text(member.email),
|
const actionsEl = document.createElement("div");
|
||||||
text(member.name),
|
actionsEl.className = "b01-dashboard__actions";
|
||||||
text(member.position),
|
|
||||||
text(member.department),
|
actionsEl.append(
|
||||||
text(member.role),
|
createButton({
|
||||||
createButton({
|
label: L("Common_Btn_Edit"),
|
||||||
label: L("B01_Dashboard_RemoveMember"),
|
variant: "ghost",
|
||||||
variant: "ghost",
|
onClick: () => openEditUserModal(currentUser, member),
|
||||||
onClick: () => onB01_Member_Remove_Click(member.id),
|
}),
|
||||||
}),
|
);
|
||||||
]),
|
|
||||||
|
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;
|
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 {
|
function companyTable(companies: CompanyInfo[]): HTMLElement {
|
||||||
return table(
|
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(
|
return table(
|
||||||
[
|
[
|
||||||
L("B01_Dashboard_Table_Email"),
|
L("B01_Dashboard_Table_Email"),
|
||||||
L("B01_Dashboard_Table_Name"),
|
L("B01_Dashboard_Table_Name"),
|
||||||
L("B01_Dashboard_Table_Role"),
|
L("B01_Dashboard_Table_Role"),
|
||||||
L("B01_Dashboard_Table_Status"),
|
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;
|
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>): 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<void> {
|
|
||||||
await runRequest(() => removeCompanyMember(userId));
|
|
||||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onB01_JoinRequest_Process_Click(
|
async function onB01_JoinRequest_Process_Click(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
action: "APPROVE" | "REJECT",
|
action: "APPROVE" | "REJECT",
|
||||||
@@ -825,7 +700,3 @@ async function runRequest(action: () => Promise<unknown>): Promise<void> {
|
|||||||
function formatDate(value?: string | null): string {
|
function formatDate(value?: string | null): string {
|
||||||
return value ? value.slice(0, 10) : "-";
|
return value ? value.slice(0, 10) : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function percent(value?: number | null): string {
|
|
||||||
return value == null ? "-" : `${Math.round(value)}%`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -664,6 +664,41 @@ export const ui_locales = {
|
|||||||
"결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.",
|
"결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.",
|
||||||
"Check payment and document status, and download results.",
|
"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<string, LocaleEntry>;
|
} as const satisfies Record<string, LocaleEntry>;
|
||||||
|
|
||||||
export type LocaleKey = keyof typeof ui_locales;
|
export type LocaleKey = keyof typeof ui_locales;
|
||||||
|
|||||||
Reference in New Issue
Block a user