사요자 대시보드 추가
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
export interface DashboardUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
phone?: string | null;
|
||||
company_id?: number | null;
|
||||
company_name?: string | null;
|
||||
role: "SYSTEM_ADMIN" | "ADMIN" | "USER";
|
||||
is_master: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
name: string;
|
||||
region?: string | null;
|
||||
status?: string | null;
|
||||
owner_name?: string | null;
|
||||
workflow_stage: number;
|
||||
progress_percent: number;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
business_registration_number?: string | null;
|
||||
business_address?: string | null;
|
||||
business_owner?: string | null;
|
||||
business_status?: string | null;
|
||||
user_count?: number;
|
||||
project_count?: number;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: number;
|
||||
user_id: number;
|
||||
company_id: number;
|
||||
user_email: string;
|
||||
user_name?: string | null;
|
||||
company_name?: string | null;
|
||||
requested_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
email?: string | null;
|
||||
action: string;
|
||||
resource_type?: string | null;
|
||||
resource_id?: number | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ResourceData {
|
||||
current: {
|
||||
cpu_usage_percent: number | null;
|
||||
memory_usage_percent: number | null;
|
||||
disk_usage_percent: number | null;
|
||||
active_user_count: number;
|
||||
active_project_count: number;
|
||||
total_storage_mb: number;
|
||||
};
|
||||
history: unknown[];
|
||||
stats: ResourceData["current"];
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCompanyRequest {
|
||||
name: string;
|
||||
business_registration_number: string;
|
||||
business_address?: string | null;
|
||||
business_owner?: string | null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
const data = (await response.json()) as { detail?: string } & T;
|
||||
if (!response.ok) throw new Error(data.detail ?? "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
const body = (value: object): string => JSON.stringify(value);
|
||||
|
||||
export async function fetchDashboardMe(): Promise<DashboardUser> {
|
||||
const data = await request<{ user: DashboardUser }>("/dashboard/me");
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function updateUserProfile(payload: UpdateUserRequest): Promise<DashboardUser> {
|
||||
const data = await request<{ user: DashboardUser }>("/dashboard/me", {
|
||||
method: "PATCH",
|
||||
body: body(payload),
|
||||
});
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function fetchUserProjects(): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>("/dashboard/user/projects");
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
export async function fetchUserCompany(): Promise<CompanyInfo | null> {
|
||||
const data = await request<{ company: CompanyInfo | null }>("/dashboard/user/company");
|
||||
return data.company;
|
||||
}
|
||||
|
||||
export async function searchCompanies(query: string): Promise<CompanyInfo[]> {
|
||||
const data = await request<{ companies: CompanyInfo[] }>(
|
||||
`/dashboard/user/companies?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
return data.companies;
|
||||
}
|
||||
|
||||
export function createCompany(payload: CreateCompanyRequest): Promise<unknown> {
|
||||
return request("/dashboard/user/company/create", { method: "POST", body: body(payload) });
|
||||
}
|
||||
|
||||
export function joinCompany(companyId: number): Promise<unknown> {
|
||||
return request("/dashboard/user/company/join", {
|
||||
method: "POST",
|
||||
body: body({ company_id: companyId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyMembers(): Promise<Member[]> {
|
||||
const data = await request<{ members: Member[] }>("/dashboard/admin/members");
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export function addCompanyMember(email: string): Promise<unknown> {
|
||||
return request("/dashboard/admin/members", { method: "POST", body: body({ email }) });
|
||||
}
|
||||
|
||||
export function removeCompanyMember(userId: number): Promise<unknown> {
|
||||
return request(`/dashboard/admin/members/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchCompanyJoinRequests(): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests");
|
||||
return data.requests;
|
||||
}
|
||||
|
||||
export function processJoinRequest(
|
||||
requestId: number,
|
||||
action: "APPROVE" | "REJECT",
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/admin/join-requests/${requestId}`, {
|
||||
method: "PATCH",
|
||||
body: body({ action }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyProjects(): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects");
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
export async function fetchAllCompanies(): Promise<CompanyInfo[]> {
|
||||
const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies");
|
||||
return data.companies;
|
||||
}
|
||||
|
||||
export async function fetchAllUsers(): Promise<DashboardUser[]> {
|
||||
const data = await request<{ users: DashboardUser[] }>("/dashboard/admin/users");
|
||||
return data.users;
|
||||
}
|
||||
|
||||
export function changeUserRole(userId: number, role: string): Promise<unknown> {
|
||||
return request(`/dashboard/admin/users/${userId}/role`, {
|
||||
method: "PATCH",
|
||||
body: body({ role }),
|
||||
});
|
||||
}
|
||||
|
||||
export function assignUserToCompany(userId: number, companyId: number | null): Promise<unknown> {
|
||||
return request(`/dashboard/admin/users/${userId}/company`, {
|
||||
method: "PATCH",
|
||||
body: body({ company_id: companyId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAllJoinRequests(): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests-all");
|
||||
return data.requests;
|
||||
}
|
||||
|
||||
export function systemApproveJoinRequest(requestId: number): Promise<unknown> {
|
||||
return request(`/dashboard/admin/join-requests/${requestId}/approve`, { method: "PATCH" });
|
||||
}
|
||||
|
||||
export async function fetchAuditLogs(): Promise<AuditLog[]> {
|
||||
const data = await request<{ items: AuditLog[] }>("/dashboard/admin/audit-logs");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function fetchSystemResources(period = "24h"): Promise<ResourceData> {
|
||||
return request<ResourceData>(`/dashboard/admin/resources?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"""B01_Dashboard aiomysql Raw SQL 저장소."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
|
||||
def _role(value: str | None) -> str:
|
||||
return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER")
|
||||
|
||||
|
||||
def _stage_from_status(status: str | None) -> tuple[int, int]:
|
||||
order = [
|
||||
("FILE_UPLOADED", 1),
|
||||
("WF1", 2),
|
||||
("WF2", 3),
|
||||
("WF3", 4),
|
||||
("WF4", 5),
|
||||
("WF5", 6),
|
||||
("WF6", 7),
|
||||
("DONE", 7),
|
||||
("CONFIRMED", 7),
|
||||
]
|
||||
value = status or "NEW"
|
||||
stage = 0
|
||||
for token, idx in order:
|
||||
if token in value:
|
||||
stage = max(stage, idx)
|
||||
return stage, round(stage / 7 * 100)
|
||||
|
||||
|
||||
def _project_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
stage, progress = _stage_from_status(row.get("status"))
|
||||
return {**row, "workflow_stage": stage, "progress_percent": progress}
|
||||
|
||||
|
||||
async def get_dashboard_me(user_id: int) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
|
||||
u.company_id, u.role, u.is_master, u.status, u.last_login,
|
||||
u.auth_expires_at, c.name AS company_name
|
||||
FROM users u LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE u.id = %s AND u.deleted_at IS NULL""",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
row["role"] = _role(row.get("role"))
|
||||
row["is_master"] = bool(row.get("is_master"))
|
||||
return row
|
||||
|
||||
|
||||
async def update_user_profile(user_id: int, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET name = %s, position = %s, department = %s, phone = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(
|
||||
data["name"],
|
||||
data.get("position"),
|
||||
data.get("department"),
|
||||
data.get("phone"),
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(user_id)
|
||||
|
||||
|
||||
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
|
||||
FROM projects WHERE user_id = %s AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC, created_at DESC""",
|
||||
(user_id,),
|
||||
)
|
||||
return [_project_row(row) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
ORDER BY p.updated_at DESC, p.created_at DESC""",
|
||||
(company_id,),
|
||||
)
|
||||
return [_project_row(row) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
|
||||
if company_id is None:
|
||||
return None
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_address,
|
||||
c.business_owner, c.business_status,
|
||||
COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
|
||||
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
|
||||
WHERE c.id = %s AND c.deleted_at IS NULL
|
||||
GROUP BY c.id""",
|
||||
(company_id,),
|
||||
)
|
||||
return await cursor.fetchone()
|
||||
|
||||
|
||||
async def search_companies(query: str) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
pattern = f"%{query}%"
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, name, business_registration_number, business_status
|
||||
FROM companies
|
||||
WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s)
|
||||
ORDER BY name LIMIT 20""",
|
||||
(pattern, pattern),
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def create_company(user_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, master_user_id, created_by, status)
|
||||
VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""",
|
||||
(
|
||||
data["name"],
|
||||
data["business_registration_number"],
|
||||
data.get("business_address"),
|
||||
data.get("business_owner"),
|
||||
user_id,
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
company_id = cursor.lastrowid
|
||||
await cursor.execute(
|
||||
"""UPDATE users
|
||||
SET company_id = %s, role = 'ADMIN', is_master = TRUE,
|
||||
status = 'ACTIVE',
|
||||
auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH)
|
||||
WHERE id = %s""",
|
||||
(company_id, user_id),
|
||||
)
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""",
|
||||
(user_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:
|
||||
try:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""INSERT INTO join_requests (user_id, company_id, status)
|
||||
VALUES (%s, %s, 'PENDING')
|
||||
ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP,
|
||||
reviewed_by = NULL, reviewed_at = NULL""",
|
||||
(user_id, company_id),
|
||||
)
|
||||
request_id = cursor.lastrowid
|
||||
await cursor.execute(
|
||||
"UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,)
|
||||
)
|
||||
await connection.commit()
|
||||
return {"join_request_id": request_id, "status": "PENDING"}
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def list_company_members(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 id, email, name, position, department, role, is_master, status
|
||||
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
||||
(company_id,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
row["role"] = _role(row.get("role"))
|
||||
row["is_master"] = bool(row.get("is_master"))
|
||||
return rows
|
||||
|
||||
|
||||
async def add_company_member(company_id: int, email: str) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id FROM users
|
||||
WHERE email = %s AND deleted_at IS NULL FOR UPDATE""",
|
||||
(email.lower(),),
|
||||
)
|
||||
user = await cursor.fetchone()
|
||||
if not user or user["company_id"] == company_id:
|
||||
await connection.rollback()
|
||||
return None
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s""",
|
||||
(company_id, user["id"]),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(user["id"])
|
||||
|
||||
|
||||
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
||||
(user_id, company_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]:
|
||||
where = "WHERE jr.company_id = %s" if company_id else ""
|
||||
params = (company_id,) if company_id else ()
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status,
|
||||
u.email AS user_email, u.name AS user_name, c.name AS company_name
|
||||
FROM join_requests jr
|
||||
JOIN users u ON u.id = jr.user_id
|
||||
JOIN companies c ON c.id = jr.company_id
|
||||
{where}
|
||||
ORDER BY jr.requested_at DESC LIMIT 100""",
|
||||
params,
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def process_join_request(
|
||||
request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None
|
||||
) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await connection.begin()
|
||||
where = "id = %s AND status = 'PENDING'"
|
||||
params: tuple[Any, ...] = (request_id,)
|
||||
if company_id is not None:
|
||||
where += " AND company_id = %s"
|
||||
params = (request_id, company_id)
|
||||
await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
await connection.rollback()
|
||||
return False
|
||||
status = "APPROVED" if approved else "REJECTED"
|
||||
await cursor.execute(
|
||||
"""UPDATE join_requests SET status = %s, reviewed_by = %s,
|
||||
reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""",
|
||||
(status, reviewer_id, request_id),
|
||||
)
|
||||
if approved:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE'
|
||||
WHERE id = %s""",
|
||||
(row["company_id"], row["user_id"]),
|
||||
)
|
||||
else:
|
||||
await cursor.execute(
|
||||
"UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],)
|
||||
)
|
||||
await connection.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def list_all_companies() -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
|
||||
c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
|
||||
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
|
||||
WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC"""
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def list_all_users() -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT u.id, u.email, u.name, u.role, u.status, u.company_id,
|
||||
c.name AS company_name, u.last_login
|
||||
FROM users u LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC LIMIT 200"""
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
row["role"] = _role(row.get("role"))
|
||||
return rows
|
||||
|
||||
|
||||
async def change_user_role(user_id: int, role: str) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id))
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def assign_user_company(user_id: int, company_id: int | None) -> bool:
|
||||
status = "ACTIVE" if company_id else "NO_COMPANY"
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE users SET company_id = %s, status = %s WHERE id = %s",
|
||||
(company_id, status, user_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute("SELECT COUNT(*) AS total FROM system_audit_logs")
|
||||
total = (await cursor.fetchone())["total"]
|
||||
await cursor.execute(
|
||||
"""SELECT l.id, l.user_id, u.email, l.action, l.resource_type,
|
||||
l.resource_id, l.timestamp
|
||||
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id
|
||||
ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""",
|
||||
(limit, offset),
|
||||
)
|
||||
return {"total": total, "items": list(await cursor.fetchall())}
|
||||
|
||||
|
||||
async def get_system_resources() -> dict[str, Any]:
|
||||
total, used, _free = shutil.disk_usage(".")
|
||||
disk_percent = used / total * 100 if total else 0
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT
|
||||
(SELECT COUNT(*) FROM sessions
|
||||
WHERE expires_at > CURRENT_TIMESTAMP) AS active_user_count,
|
||||
(SELECT COUNT(*) FROM projects WHERE deleted_at IS NULL) AS active_project_count"""
|
||||
)
|
||||
stats = await cursor.fetchone()
|
||||
current = {
|
||||
"cpu_usage_percent": None,
|
||||
"memory_usage_percent": None,
|
||||
"disk_usage_percent": round(disk_percent, 2),
|
||||
"active_user_count": stats["active_user_count"],
|
||||
"active_project_count": stats["active_project_count"],
|
||||
"total_storage_mb": round(used / 1024 / 1024, 2),
|
||||
}
|
||||
await cursor.execute(
|
||||
"""SELECT timestamp, cpu_usage_percent, memory_usage_percent, disk_usage_percent,
|
||||
active_user_count, active_project_count, total_storage_mb
|
||||
FROM system_resources
|
||||
WHERE timestamp >= %s ORDER BY timestamp""",
|
||||
(datetime.utcnow() - timedelta(hours=24),),
|
||||
)
|
||||
return {"current": current, "history": list(await cursor.fetchall()), "stats": current}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""B01_Dashboard 역할별 대시보드 API."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
|
||||
from .B01_Dashboard_Repository import (
|
||||
add_company_member,
|
||||
assign_user_company,
|
||||
change_user_role,
|
||||
create_company,
|
||||
get_dashboard_me,
|
||||
get_system_resources,
|
||||
get_user_company,
|
||||
join_company,
|
||||
list_all_companies,
|
||||
list_all_users,
|
||||
list_audit_logs,
|
||||
list_company_members,
|
||||
list_company_projects,
|
||||
list_join_requests,
|
||||
list_user_projects,
|
||||
process_join_request,
|
||||
remove_company_member,
|
||||
search_companies,
|
||||
update_user_profile,
|
||||
)
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AssignCompanyRequest,
|
||||
ChangeUserRoleRequest,
|
||||
CreateCompanyRequest,
|
||||
JoinCompanyRequest,
|
||||
ProcessJoinRequest,
|
||||
UpdateUserRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
|
||||
|
||||
|
||||
async def require_company_admin(
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]:
|
||||
raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.")
|
||||
return session
|
||||
|
||||
|
||||
def _require_company_id(session: dict[str, Any]) -> int:
|
||||
company_id = session.get("company_id")
|
||||
if company_id is None:
|
||||
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
||||
return int(company_id)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def dashboard_me(session: dict[str, Any] = Depends(verify_session)):
|
||||
user = await get_dashboard_me(int(session["user_id"]))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="사용자 정보를 찾을 수 없습니다.")
|
||||
return {"status": "success", "user": user}
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def patch_dashboard_me(
|
||||
payload: UpdateUserRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
user = await update_user_profile(int(session["user_id"]), payload.model_dump())
|
||||
return {"status": "success", "user": user}
|
||||
|
||||
|
||||
@router.get("/user/projects")
|
||||
async def user_projects(session: dict[str, Any] = Depends(verify_session)):
|
||||
return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))}
|
||||
|
||||
|
||||
@router.get("/user/company")
|
||||
async def user_company(session: dict[str, Any] = Depends(verify_session)):
|
||||
return {"status": "success", "company": await get_user_company(session.get("company_id"))}
|
||||
|
||||
|
||||
@router.get("/user/companies")
|
||||
async def user_company_search(
|
||||
q: str = Query(min_length=1, max_length=100),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
_ = session
|
||||
return {"status": "success", "companies": await search_companies(q.strip())}
|
||||
|
||||
|
||||
@router.post("/user/company/create")
|
||||
async def user_company_create(
|
||||
payload: CreateCompanyRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
result = await create_company(int(session["user_id"]), payload.model_dump())
|
||||
return {"status": "success", **result}
|
||||
|
||||
|
||||
@router.post("/user/company/join")
|
||||
async def user_company_join(
|
||||
payload: JoinCompanyRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
result = await join_company(int(session["user_id"]), payload.company_id)
|
||||
return {"status": "success", **result}
|
||||
|
||||
|
||||
@router.get("/admin/members")
|
||||
async def admin_members(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
return {
|
||||
"status": "success",
|
||||
"members": await list_company_members(_require_company_id(session)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/members")
|
||||
async def admin_add_member(
|
||||
payload: AddMemberRequest,
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
member = await add_company_member(_require_company_id(session), payload.email)
|
||||
if not member:
|
||||
raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.")
|
||||
return {"status": "success", "member": member}
|
||||
|
||||
|
||||
@router.delete("/admin/members/{user_id}")
|
||||
async def admin_remove_member(
|
||||
user_id: int, session: dict[str, Any] = Depends(require_company_admin)
|
||||
):
|
||||
if not await remove_company_member(_require_company_id(session), user_id):
|
||||
raise HTTPException(status_code=404, detail="제거할 팀원을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/join-requests")
|
||||
async def admin_join_requests(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
return {"status": "success", "requests": await list_join_requests(_require_company_id(session))}
|
||||
|
||||
|
||||
@router.patch("/admin/join-requests/{request_id}")
|
||||
async def admin_process_join_request(
|
||||
request_id: int,
|
||||
payload: ProcessJoinRequest,
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
changed = await process_join_request(
|
||||
request_id,
|
||||
int(session["user_id"]),
|
||||
payload.action == "APPROVE",
|
||||
_require_company_id(session),
|
||||
)
|
||||
if not changed:
|
||||
raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/projects")
|
||||
async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
return {
|
||||
"status": "success",
|
||||
"projects": await list_company_projects(_require_company_id(session)),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/companies")
|
||||
async def system_companies(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "companies": await list_all_companies()}
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "users": await list_all_users()}
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}/role")
|
||||
async def system_change_role(
|
||||
user_id: int,
|
||||
payload: ChangeUserRoleRequest,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = session
|
||||
if not await change_user_role(user_id, payload.role):
|
||||
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}/company")
|
||||
async def system_assign_company(
|
||||
user_id: int,
|
||||
payload: AssignCompanyRequest,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = session
|
||||
if not await assign_user_company(user_id, payload.company_id):
|
||||
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/join-requests-all")
|
||||
async def system_join_requests(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "requests": await list_join_requests()}
|
||||
|
||||
|
||||
@router.patch("/admin/join-requests/{request_id}/approve")
|
||||
async def system_approve_join_request(
|
||||
request_id: int,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
if not await process_join_request(request_id, int(session["user_id"]), True):
|
||||
raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/audit-logs")
|
||||
async def system_audit_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = session
|
||||
return {"status": "success", **await list_audit_logs(limit, (page - 1) * limit)}
|
||||
|
||||
|
||||
@router.get("/admin/resources")
|
||||
async def system_resources(
|
||||
period: str = Query("24h"),
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
_ = period
|
||||
_ = session
|
||||
return {"status": "success", **await get_system_resources()}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""B01_Dashboard 요청 스키마."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
position: str | None = Field(default=None, max_length=100)
|
||||
department: str | None = Field(default=None, max_length=100)
|
||||
phone: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class CreateCompanyRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
business_registration_number: str = Field(min_length=1, max_length=20)
|
||||
business_address: str | None = Field(default=None, max_length=500)
|
||||
business_owner: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class JoinCompanyRequest(BaseModel):
|
||||
company_id: int = Field(gt=0)
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
|
||||
|
||||
class ProcessJoinRequest(BaseModel):
|
||||
action: str = Field(pattern="^(APPROVE|REJECT)$")
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ChangeUserRoleRequest(BaseModel):
|
||||
role: str = Field(pattern="^(SYSTEM_ADMIN|ADMIN|USER)$")
|
||||
|
||||
|
||||
class AssignCompanyRequest(BaseModel):
|
||||
company_id: int | None = Field(default=None, gt=0)
|
||||
@@ -0,0 +1,612 @@
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
createInputField,
|
||||
createTag,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import {
|
||||
addCompanyMember,
|
||||
createCompany,
|
||||
fetchAllCompanies,
|
||||
fetchAllJoinRequests,
|
||||
fetchAllUsers,
|
||||
fetchAuditLogs,
|
||||
fetchCompanyJoinRequests,
|
||||
fetchCompanyMembers,
|
||||
fetchCompanyProjects,
|
||||
fetchDashboardMe,
|
||||
fetchSystemResources,
|
||||
fetchUserCompany,
|
||||
fetchUserProjects,
|
||||
joinCompany,
|
||||
processJoinRequest,
|
||||
removeCompanyMember,
|
||||
searchCompanies,
|
||||
updateUserProfile,
|
||||
type AuditLog,
|
||||
type CompanyInfo,
|
||||
type DashboardUser,
|
||||
type JoinRequest,
|
||||
type Member,
|
||||
type ProjectItem,
|
||||
type ResourceData,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import "./B01_Dashboard_UI_Style.css";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
user: DashboardUser;
|
||||
userProjects: ProjectItem[];
|
||||
companyProjects: ProjectItem[];
|
||||
company: CompanyInfo | null;
|
||||
members: Member[];
|
||||
joinRequests: JoinRequest[];
|
||||
allCompanies: CompanyInfo[];
|
||||
allUsers: DashboardUser[];
|
||||
allJoinRequests: JoinRequest[];
|
||||
auditLogs: AuditLog[];
|
||||
resources: ResourceData | null;
|
||||
}
|
||||
|
||||
export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const user = await fetchDashboardMe();
|
||||
const state: DashboardState = {
|
||||
user,
|
||||
userProjects: [],
|
||||
companyProjects: [],
|
||||
company: null,
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
allCompanies: [],
|
||||
allUsers: [],
|
||||
allJoinRequests: [],
|
||||
auditLogs: [],
|
||||
resources: null,
|
||||
};
|
||||
await loadRoleData(state);
|
||||
root.append(buildPage(state));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoleData(state: DashboardState): Promise<void> {
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
const [companies, users, requests, logs, resources] = await Promise.all([
|
||||
fetchAllCompanies(),
|
||||
fetchAllUsers(),
|
||||
fetchAllJoinRequests(),
|
||||
fetchAuditLogs(),
|
||||
fetchSystemResources(),
|
||||
]);
|
||||
state.allCompanies = companies;
|
||||
state.allUsers = users;
|
||||
state.allJoinRequests = requests;
|
||||
state.auditLogs = logs;
|
||||
state.resources = resources;
|
||||
return;
|
||||
}
|
||||
const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]);
|
||||
state.userProjects = projects;
|
||||
state.company = company;
|
||||
if (state.user.role === "ADMIN") {
|
||||
const [members, requests, companyProjects] = await Promise.all([
|
||||
fetchCompanyMembers(),
|
||||
fetchCompanyJoinRequests(),
|
||||
fetchCompanyProjects(),
|
||||
]);
|
||||
state.members = members;
|
||||
state.joinRequests = requests;
|
||||
state.companyProjects = companyProjects;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPage(state: DashboardState): HTMLElement {
|
||||
const page = document.createElement("div");
|
||||
page.className = "b01-dashboard";
|
||||
page.append(buildHeader(state.user));
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__grid";
|
||||
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Resources"), buildResourceMetrics(state.resources), true),
|
||||
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies)),
|
||||
section(L("B01_Dashboard_Users"), userTable(state.allUsers)),
|
||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true)),
|
||||
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
|
||||
);
|
||||
} else {
|
||||
grid.append(
|
||||
section(
|
||||
L("B01_Dashboard_Projects"),
|
||||
projectTable(state.user.role === "ADMIN" ? state.companyProjects : state.userProjects),
|
||||
true,
|
||||
[
|
||||
createButton({
|
||||
label: L("B01_Dashboard_NewProject"),
|
||||
onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER),
|
||||
}),
|
||||
],
|
||||
),
|
||||
section(L("B01_Dashboard_Company"), buildCompanyPanel(state)),
|
||||
);
|
||||
if (state.user.role === "ADMIN") {
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Members"), memberTable(state.members), false, [
|
||||
createButton({
|
||||
label: L("B01_Dashboard_AddMember"),
|
||||
variant: "ghost",
|
||||
onClick: () => openAddMemberModal(),
|
||||
}),
|
||||
]),
|
||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
grid.append(section(L("B01_Dashboard_Profile"), buildProfileForm(state.user), true));
|
||||
page.append(grid);
|
||||
return page;
|
||||
}
|
||||
|
||||
function buildHeader(user: DashboardUser): HTMLElement {
|
||||
const header = document.createElement("header");
|
||||
header.className = "b01-dashboard__header";
|
||||
const text = document.createElement("div");
|
||||
const title = document.createElement("h1");
|
||||
title.className = "b01-dashboard__title";
|
||||
title.textContent = L("B01_Dashboard_Title");
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "b01-dashboard__subtitle";
|
||||
subtitle.textContent = L("B01_Dashboard_Subtitle");
|
||||
text.append(title, subtitle);
|
||||
const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral");
|
||||
tag.classList.add("b01-dashboard__role");
|
||||
header.append(text, tag);
|
||||
return header;
|
||||
}
|
||||
|
||||
function roleLabel(role: DashboardUser["role"]): string {
|
||||
if (role === "SYSTEM_ADMIN") return L("B01_Dashboard_Role_SystemAdmin");
|
||||
if (role === "ADMIN") return L("B01_Dashboard_Role_Admin");
|
||||
return L("B01_Dashboard_Role_User");
|
||||
}
|
||||
|
||||
function section(
|
||||
title: string,
|
||||
body: HTMLElement,
|
||||
wide = false,
|
||||
actions: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b01-dashboard__actions";
|
||||
actionRow.append(...actions);
|
||||
const cardBody = actions.length ? [actionRow, body] : [body];
|
||||
const card = createCard({ title, body: cardBody, raised: true });
|
||||
card.classList.add("b01-dashboard__section");
|
||||
if (wide) card.classList.add("b01-dashboard__section--wide");
|
||||
return card;
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: HTMLElement[][]): HTMLElement {
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b01-dashboard__empty";
|
||||
empty.textContent = L("Common_Status_Empty");
|
||||
return empty;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__table-wrap";
|
||||
const tableEl = document.createElement("table");
|
||||
tableEl.className = "b01-dashboard__table";
|
||||
const thead = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const header of headers) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = header;
|
||||
headRow.append(th);
|
||||
}
|
||||
thead.append(headRow);
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
for (const cell of row) {
|
||||
const td = document.createElement("td");
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
tableEl.append(thead, tbody);
|
||||
wrap.append(tableEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function text(value: unknown): HTMLElement {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null || value === "" ? "-" : String(value);
|
||||
return span;
|
||||
}
|
||||
|
||||
function projectTable(projects: ProjectItem[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Project"),
|
||||
L("B01_Dashboard_Table_Region"),
|
||||
L("B01_Dashboard_Table_Progress"),
|
||||
L("B01_Dashboard_Table_Workflow"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
],
|
||||
projects.map((project) => [
|
||||
text(project.name),
|
||||
text(project.region),
|
||||
text(`${project.progress_percent}%`),
|
||||
workflow(project.workflow_stage),
|
||||
text(formatDate(project.updated_at)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function workflow(activeStage: number): HTMLElement {
|
||||
const routes: RoutePath[] = [
|
||||
ROUTES.B03_FILE_INPUT,
|
||||
ROUTES.B04_WF1_SURFACE,
|
||||
ROUTES.B05_WF2_ROUTE,
|
||||
ROUTES.B06_WF3_PROFILE_CROSS,
|
||||
ROUTES.B07_WF4_DESIGN_DETAIL,
|
||||
ROUTES.B08_WF5_QUANTITY,
|
||||
ROUTES.B09_WF6_ESTIMATION,
|
||||
];
|
||||
const box = document.createElement("div");
|
||||
box.className = "b01-dashboard__workflow";
|
||||
routes.forEach((route, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b01-dashboard__step";
|
||||
button.textContent = `B${String(index + 3).padStart(2, "0")}`;
|
||||
const enabled = index + 1 <= Math.max(activeStage, 1);
|
||||
button.disabled = !enabled;
|
||||
if (enabled) {
|
||||
button.classList.add("is-enabled");
|
||||
button.addEventListener("click", () => navigateTo(route));
|
||||
}
|
||||
box.append(button);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__actions";
|
||||
if (!state.company) {
|
||||
wrap.append(
|
||||
createTag(L("B01_Dashboard_NoCompany"), "warning"),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_CreateCompany"),
|
||||
onClick: () => openCreateCompanyModal(),
|
||||
}),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_FindCompany"),
|
||||
variant: "ghost",
|
||||
onClick: () => openFindCompanyModal(),
|
||||
}),
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
wrap.append(
|
||||
createTag(`${state.company.name} (${state.user.status})`, "success"),
|
||||
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function memberTable(members: Member[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Name"),
|
||||
L("B01_Dashboard_Table_Position"),
|
||||
L("B01_Dashboard_Table_Department"),
|
||||
L("B01_Dashboard_Table_Role"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
members.map((member) => [
|
||||
text(member.email),
|
||||
text(member.name),
|
||||
text(member.position),
|
||||
text(member.department),
|
||||
text(member.role),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_RemoveMember"),
|
||||
variant: "ghost",
|
||||
onClick: () => onB01_Member_Remove_Click(member.id),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
...(systemMode ? [L("B01_Dashboard_Table_Company")] : []),
|
||||
L("B01_Dashboard_Table_Requested"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
requests.map((request) => [
|
||||
text(request.user_email),
|
||||
...(systemMode ? [text(request.company_name)] : []),
|
||||
text(formatDate(request.requested_at)),
|
||||
text(request.status),
|
||||
actionPair(
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode),
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function actionPair(approve: () => void, reject: () => void): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b01-dashboard__actions";
|
||||
row.append(
|
||||
createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }),
|
||||
createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
function 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 companyTable(companies: CompanyInfo[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Company"),
|
||||
L("B01_Dashboard_Field_BusinessNumber"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
],
|
||||
companies.map((company) => [
|
||||
text(company.name),
|
||||
text(company.business_registration_number),
|
||||
text(company.business_status),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function userTable(users: DashboardUser[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Name"),
|
||||
L("B01_Dashboard_Table_Role"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
],
|
||||
users.map((user) => [text(user.email), text(user.name), text(user.role), text(user.status)]),
|
||||
);
|
||||
}
|
||||
|
||||
function auditLogTable(logs: AuditLog[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
],
|
||||
logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]),
|
||||
);
|
||||
}
|
||||
|
||||
function buildProfileForm(user: DashboardUser): HTMLElement {
|
||||
const name = createInputField({
|
||||
label: L("B01_Account_Field_Name"),
|
||||
value: user.name,
|
||||
required: true,
|
||||
});
|
||||
const position = createInputField({
|
||||
label: L("B01_Dashboard_Table_Position"),
|
||||
value: user.position ?? "",
|
||||
});
|
||||
const department = createInputField({
|
||||
label: L("B01_Dashboard_Table_Department"),
|
||||
value: user.department ?? "",
|
||||
});
|
||||
const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" });
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__form-grid";
|
||||
grid.append(name.root, position.root, department.root, phone.root);
|
||||
const save = createButton({
|
||||
label: L("B01_Dashboard_SaveProfile"),
|
||||
onClick: async function onB01_Profile_Save_Click() {
|
||||
name.setError();
|
||||
if (isBlank(name.input.value)) {
|
||||
name.setError(L("Common_Msg_RequiredField"));
|
||||
return;
|
||||
}
|
||||
await runRequest(() =>
|
||||
updateUserProfile({
|
||||
name: name.input.value.trim(),
|
||||
position: position.input.value.trim() || null,
|
||||
department: department.input.value.trim() || null,
|
||||
phone: phone.input.value.trim() || null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const wrap = document.createElement("div");
|
||||
wrap.append(grid, save);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function 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(
|
||||
requestId: number,
|
||||
action: "APPROVE" | "REJECT",
|
||||
systemMode: boolean,
|
||||
): Promise<void> {
|
||||
await runRequest(() =>
|
||||
systemMode && action === "APPROVE"
|
||||
? import("./B01_Dashboard_Api_Fetch").then((api) => api.systemApproveJoinRequest(requestId))
|
||||
: processJoinRequest(requestId, action),
|
||||
);
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
}
|
||||
|
||||
async function runRequest(action: () => Promise<unknown>): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await action();
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
return value ? value.slice(0, 10) : "-";
|
||||
}
|
||||
|
||||
function percent(value?: number | null): string {
|
||||
return value == null ? "-" : `${Math.round(value)}%`;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
.b01-dashboard {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-40) var(--spacing-24) var(--spacing-64);
|
||||
}
|
||||
|
||||
.b01-dashboard__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-24);
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--spacing-32);
|
||||
}
|
||||
|
||||
.b01-dashboard__title {
|
||||
font-size: var(--text-heading);
|
||||
}
|
||||
|
||||
.b01-dashboard__subtitle {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.b01-dashboard__role {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b01-dashboard__section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__section--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.b01-dashboard__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-dashboard__table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
|
||||
.b01-dashboard__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b01-dashboard__table th,
|
||||
.b01-dashboard__table td {
|
||||
padding: var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__table th {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-dashboard__table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b01-dashboard__workflow {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b01-dashboard__step {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-icons);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.b01-dashboard__step.is-enabled {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric {
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric-label {
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric-value {
|
||||
display: block;
|
||||
margin-top: var(--spacing-8);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-subheading);
|
||||
font-family: var(--font-display);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.b01-dashboard__form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__form-grid .ui-field:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.b01-dashboard__modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-24);
|
||||
background: rgba(38, 17, 74, 0.24);
|
||||
}
|
||||
|
||||
.b01-dashboard__modal-panel {
|
||||
width: min(560px, 100%);
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b01-dashboard__modal-title {
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b01-dashboard__header,
|
||||
.b01-dashboard__grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.b01-dashboard__section {
|
||||
margin-top: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b01-dashboard__form-grid,
|
||||
.b01-dashboard__metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user