Revert "feat(B01): 대시보드 항목별 탭 — 해시 탭 줄 · 개요 · 프로젝트 · 회사 · 내 계정 · 시스템 (92-1·92-2)"
This reverts commit 2a2c4d0f62.
This commit is contained in:
@@ -187,7 +187,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (!user.company_id && user.role !== "SYSTEM_ADMIN") {
|
||||
navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" });
|
||||
navigateTo(ROUTES.B01_ACCOUNT);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -12,9 +12,6 @@ export interface DashboardUser {
|
||||
role: "SYSTEM_ADMIN" | "ADMIN" | "USER";
|
||||
is_master: boolean;
|
||||
status: string;
|
||||
/** 마지막 로그인 · 이메일 재인증 만료일 — GET /me 가 주고, 사용자 목록은 last_login 만 준다. */
|
||||
last_login?: string | null;
|
||||
auth_expires_at?: string | null;
|
||||
/** 서버가 하드 삭제 모드인지 (`PROJECT_DELETE_HARD_ENABLED`). GET /me에서만 채워진다. */
|
||||
project_delete_hard?: boolean;
|
||||
}
|
||||
@@ -68,7 +65,6 @@ export interface ProjectItem {
|
||||
workflow_stage: number;
|
||||
workflow_state?: WorkflowState;
|
||||
updated_at?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyInfo {
|
||||
@@ -81,7 +77,6 @@ export interface CompanyInfo {
|
||||
logo_asset_id?: number | null;
|
||||
user_count?: number;
|
||||
project_count?: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
@@ -498,32 +493,6 @@ export async function fetchAuditLogs(): Promise<AuditLog[]> {
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/** 시스템 로그 한 쪽 — 전체 건수와 함께(쪽 넘김). */
|
||||
export async function fetchAuditLogPage(
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<{ total: number; items: AuditLog[] }> {
|
||||
return request(`/dashboard/admin/audit-logs?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
/** 고객 문의 한 줄 — `/security/admin/support` 가 주는 칸 중 화면이 쓰는 것. */
|
||||
export interface SupportRequest {
|
||||
id: number;
|
||||
company_name?: string | null;
|
||||
user_name?: string | null;
|
||||
user_email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
status: "NEW" | "REVIEWED" | "RESOLVED" | "DELETED";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function fetchSupportRequests(): Promise<SupportRequest[]> {
|
||||
const { getAdminCollection } = await import("../A09_Security/A09_Security_Api_Fetch");
|
||||
const data = await getAdminCollection("support");
|
||||
return (data.items as SupportRequest[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
export async function fetchSystemResources(days = 30): Promise<ResourceData> {
|
||||
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,16 @@ import {
|
||||
openEditUserModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { formatDate, formatTime, L, stackedCell } from "./B01_Dashboard_UI_Common";
|
||||
import { createPagedTable } from "./B01_Dashboard_UI_Paging";
|
||||
import {
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
formatDate,
|
||||
formatTime,
|
||||
L,
|
||||
stackedCell,
|
||||
} from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement {
|
||||
return createPagedTable(
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Name"),
|
||||
@@ -22,7 +27,7 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
(user: DashboardUser) => {
|
||||
users.map((user) => {
|
||||
const actionsEl = document.createElement("div");
|
||||
actionsEl.className = "b01-dashboard__actions";
|
||||
|
||||
@@ -65,32 +70,9 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H
|
||||
text(user.status),
|
||||
actionsEl,
|
||||
];
|
||||
},
|
||||
users,
|
||||
).root;
|
||||
}
|
||||
|
||||
/** 전체 사용자(시스템 탭) — 소속 · 마지막 로그인까지 읽기만 · 관리는 회사 탭 */
|
||||
export function allUserTable(users: DashboardUser[]): HTMLElement {
|
||||
return createPagedTable(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Name"),
|
||||
L("B01_Sy_Affiliation"),
|
||||
L("B01_Dashboard_Table_Role"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
L("B01_Sy_LastLogin"),
|
||||
],
|
||||
(user: DashboardUser) => [
|
||||
text(user.email),
|
||||
text(user.name),
|
||||
text(user.company_name),
|
||||
text(user.role),
|
||||
text(user.status),
|
||||
stackedCell(formatDate(user.last_login), formatTime(user.last_login)),
|
||||
],
|
||||
users,
|
||||
).root;
|
||||
}),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
/** 무엇에 한 일인지 — 표에 저장된 대상 종류·번호를 사람이 읽는 말로. */
|
||||
@@ -131,5 +113,6 @@ export function auditLogTable(logs: AuditLog[]): HTMLElement {
|
||||
// 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다.
|
||||
stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)),
|
||||
]),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchCompanyAssets,
|
||||
processJoinRequest,
|
||||
setCompanyLogo,
|
||||
type CompanyInfo,
|
||||
@@ -18,18 +19,8 @@ import {
|
||||
openFindCompanyModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import type { DashboardState } from "./B01_Dashboard_UI_Page";
|
||||
import { actionPair, text } from "@ui/ui_template_general_blocks";
|
||||
import { formatDate, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
import { createPagedTable } from "./B01_Dashboard_UI_Paging";
|
||||
|
||||
/** 라벨 + 값 한 칸 — 회사 정보 격자 */
|
||||
function infoItem(label: string, value: unknown): HTMLElement[] {
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = label;
|
||||
const detail = document.createElement("dd");
|
||||
detail.textContent = value == null || value === "" ? "-" : String(value);
|
||||
return [term, detail];
|
||||
}
|
||||
import { actionPair, table, text } from "@ui/ui_template_general_blocks";
|
||||
import { DASHBOARD_VISIBLE_ROWS, formatDate, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -49,23 +40,16 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
const company = state.company;
|
||||
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
|
||||
const info = document.createElement("dl");
|
||||
info.className = "b01-dashboard__info";
|
||||
info.append(
|
||||
...infoItem(L("B01_Dashboard_Table_Company"), company.name),
|
||||
...infoItem(L("B01_Dashboard_Field_BusinessNumber"), company.business_registration_number),
|
||||
...infoItem(L("B01_Co_Owner"), company.business_owner),
|
||||
...infoItem(L("B01_Co_Status"), company.business_status),
|
||||
...infoItem(L("B01_Dashboard_Field_Address"), company.business_address),
|
||||
...infoItem(L("B01_Co_Members"), company.user_count ?? 0),
|
||||
...infoItem(L("B01_Co_Projects"), company.project_count ?? 0),
|
||||
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}`),
|
||||
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
|
||||
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
|
||||
);
|
||||
const box = document.createElement("div");
|
||||
box.append(info, wrap);
|
||||
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
|
||||
if (state.user.role !== "USER") {
|
||||
if (state.user.role !== "USER" && state.company) {
|
||||
const company = state.company;
|
||||
wrap.append(
|
||||
createButton({
|
||||
label: "회사 정보 수정",
|
||||
@@ -73,39 +57,41 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
onClick: () => openEditCompanyModal(company),
|
||||
}),
|
||||
);
|
||||
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
|
||||
box.append(
|
||||
createAssetField(
|
||||
"회사 로고 (도면 표제란)",
|
||||
"LOGO",
|
||||
state.assets,
|
||||
company.logo_asset_id ?? null,
|
||||
company.id,
|
||||
state.user,
|
||||
{ onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) },
|
||||
).root,
|
||||
);
|
||||
}
|
||||
return box;
|
||||
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
|
||||
if (state.user.role !== "USER") {
|
||||
const company = state.company;
|
||||
const slot = document.createElement("div");
|
||||
void fetchCompanyAssets(company.id).then((assets) => {
|
||||
slot.append(
|
||||
createAssetField(
|
||||
"회사 로고 (도면 표제란)",
|
||||
"LOGO",
|
||||
assets,
|
||||
company.logo_asset_id ?? null,
|
||||
company.id,
|
||||
state.user,
|
||||
{ onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) },
|
||||
).root,
|
||||
);
|
||||
});
|
||||
wrap.append(slot);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 일반 사용자는 읽기만(`readOnly`) — 관리 열 없음 */
|
||||
export function memberTable(
|
||||
members: Member[],
|
||||
currentUser: DashboardUser,
|
||||
readOnly = false,
|
||||
): HTMLElement {
|
||||
return createPagedTable(
|
||||
export function memberTable(members: Member[], currentUser: DashboardUser): 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"),
|
||||
...(readOnly ? [] : [L("B01_Dashboard_Table_Action")]),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
(member: Member) => {
|
||||
members.map((member) => {
|
||||
const actionsEl = document.createElement("div");
|
||||
actionsEl.className = "b01-dashboard__actions";
|
||||
|
||||
@@ -143,15 +129,15 @@ export function memberTable(
|
||||
text(member.position),
|
||||
text(member.department),
|
||||
text(member.role),
|
||||
...(readOnly ? [] : [actionsEl]),
|
||||
actionsEl,
|
||||
];
|
||||
},
|
||||
members,
|
||||
).root;
|
||||
}),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement {
|
||||
return createPagedTable(
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
...(systemMode ? [L("B01_Dashboard_Table_Company")] : []),
|
||||
@@ -159,7 +145,7 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean):
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
(request: JoinRequest) => [
|
||||
requests.map((request) => [
|
||||
text(request.user_email),
|
||||
...(systemMode ? [text(request.company_name)] : []),
|
||||
text(formatDate(request.requested_at)),
|
||||
@@ -168,9 +154,9 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean):
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode),
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode),
|
||||
),
|
||||
],
|
||||
requests,
|
||||
).root;
|
||||
]),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
|
||||
@@ -195,24 +181,18 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT
|
||||
});
|
||||
return button;
|
||||
};
|
||||
return createPagedTable(
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Company"),
|
||||
L("B01_Dashboard_Field_BusinessNumber"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
L("B01_Co_Members"),
|
||||
L("B01_Co_Projects"),
|
||||
L("B01_Co_Registered"),
|
||||
"로고",
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
(company: CompanyInfo) => [
|
||||
companies.map((company) => [
|
||||
text(company.name),
|
||||
text(company.business_registration_number),
|
||||
text(company.business_status),
|
||||
text(company.user_count ?? 0),
|
||||
text(company.project_count ?? 0),
|
||||
text(formatDate(company.created_at)),
|
||||
logoCell(company),
|
||||
user
|
||||
? createButton({
|
||||
@@ -221,9 +201,9 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT
|
||||
onClick: () => openEditCompanyModal(company),
|
||||
})
|
||||
: text(""),
|
||||
],
|
||||
companies,
|
||||
).root;
|
||||
]),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function onB01_JoinRequest_Process_Click(
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 「회사」 탭(92장) — 회사 정보 · 팀원(일반도 읽기) · 가입 요청 · 로고 · 자산.
|
||||
* 시스템 관리자는 탭 줄 오른쪽 알약으로 고른 회사 것.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { section, text } from "@ui/ui_template_general_blocks";
|
||||
import { companyAssetFileUrl, type CompanyAsset } from "./B01_Dashboard_Api_Fetch";
|
||||
import { userTable } from "./B01_Dashboard_UI_Admin";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import { buildCompanyPanel, joinRequestTable, memberTable } from "./B01_Dashboard_UI_Company";
|
||||
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
|
||||
import type { DashboardState } from "./B01_Dashboard_UI_Page";
|
||||
import { createPagedTable } from "./B01_Dashboard_UI_Paging";
|
||||
|
||||
function assetCells(asset: CompanyAsset): HTMLElement[] {
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "b01-dashboard__asset-thumb";
|
||||
preview.src = companyAssetFileUrl(asset.id);
|
||||
preview.alt = asset.label;
|
||||
return [
|
||||
text(L(asset.kind === "LOGO" ? "B01_Co_AssetLogo" : "B01_Co_AssetSignature")),
|
||||
preview,
|
||||
text(asset.label),
|
||||
text(asset.user_name ?? L("B01_Co_AssetShared")),
|
||||
];
|
||||
}
|
||||
|
||||
function assetTable(assets: CompanyAsset[]): HTMLElement {
|
||||
return createPagedTable(
|
||||
[
|
||||
L("B01_Co_AssetKind"),
|
||||
L("B01_Co_AssetPreview"),
|
||||
L("B01_Co_AssetName"),
|
||||
L("B01_Co_AssetOwner"),
|
||||
],
|
||||
assetCells,
|
||||
assets,
|
||||
).root;
|
||||
}
|
||||
|
||||
export function buildCompanyTab(state: DashboardState): HTMLElement[] {
|
||||
const { user } = state;
|
||||
const system = user.role === "SYSTEM_ADMIN";
|
||||
const manager = user.role !== "USER";
|
||||
const sections = [
|
||||
section(
|
||||
L("B01_Dashboard_Company"),
|
||||
buildCompanyPanel(state),
|
||||
true,
|
||||
system
|
||||
? [
|
||||
createButton({
|
||||
label: `+ ${L("B01_Co_NewCompany")}`,
|
||||
onClick: () => openCreateCompanyModal(),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
];
|
||||
if (!state.company) return sections;
|
||||
sections.push(
|
||||
system
|
||||
? section(L("B01_Dashboard_Users"), userTable(state.allUsers, user), true, [
|
||||
createButton({
|
||||
label: "+",
|
||||
onClick: () => openAddMemberModal(undefined, state.adminCompanyId),
|
||||
}),
|
||||
])
|
||||
: section(
|
||||
L("B01_Dashboard_Members"),
|
||||
memberTable(state.members, user, !manager),
|
||||
true,
|
||||
manager ? [createButton({ label: "+", onClick: () => openAddMemberModal() })] : [],
|
||||
),
|
||||
);
|
||||
if (manager) {
|
||||
sections.push(
|
||||
section(
|
||||
L("B01_Dashboard_JoinRequests"),
|
||||
joinRequestTable(system ? state.allJoinRequests : state.joinRequests, system),
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
sections.push(section(L("B01_Co_Assets"), assetTable(state.assets), true));
|
||||
return sections;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 「개요」 탭(92장) — 로그인 직후 첫 화면. 이미 받는 자료의 숫자만 · 누르면 해당 탭.
|
||||
* ========================================================================== */
|
||||
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import type { ProjectItem, ResourceData } from "./B01_Dashboard_Api_Fetch";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import type { DashboardState } from "./B01_Dashboard_UI_Page";
|
||||
import { summarizeProjects } from "./B01_Dashboard_UI_ProjectStats";
|
||||
import { goTab, type DashboardTab } from "./B01_Dashboard_UI_Tabs";
|
||||
|
||||
/** 숫자 칸 하나 — 누르면 `tab`(덧인자 `extra`)으로 */
|
||||
function tile(
|
||||
label: string,
|
||||
value: string | number,
|
||||
tab: DashboardTab,
|
||||
extra: Record<string, string> = {},
|
||||
alert = false,
|
||||
): HTMLElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b01-dashboard__metric b01-dashboard__metric--link";
|
||||
button.classList.toggle("b01-dashboard__metric--alert", alert);
|
||||
button.dataset.b01Tile = label;
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "b01-dashboard__metric-label";
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement("span");
|
||||
valueEl.className = "b01-dashboard__metric-value";
|
||||
valueEl.textContent = String(value);
|
||||
button.append(labelEl, valueEl);
|
||||
button.addEventListener("click", () => goTab(tab, extra));
|
||||
return button;
|
||||
}
|
||||
|
||||
function grid(tiles: HTMLElement[]): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__overview-grid";
|
||||
wrap.append(...tiles);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 소제목 + 숫자 칸 묶음 */
|
||||
function block(title: string, tiles: HTMLElement[]): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__overview-block";
|
||||
const heading = document.createElement("p");
|
||||
heading.className = "b01-dashboard__overview-title";
|
||||
heading.textContent = title;
|
||||
wrap.append(heading, grid(tiles));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function percent(value?: number | null): string {
|
||||
return value == null ? "-" : `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
function resourceTiles(resources: ResourceData | null): HTMLElement[] {
|
||||
const now = resources?.current;
|
||||
return [
|
||||
tile(L("B01_Dashboard_Metric_Cpu"), percent(now?.cpu_usage_percent), "system"),
|
||||
tile(L("B01_Dashboard_Metric_Memory"), percent(now?.memory_usage_percent), "system"),
|
||||
tile(L("B01_Dashboard_Metric_Disk"), percent(now?.disk_usage_percent), "system"),
|
||||
tile(L("B01_Dashboard_Metric_ActiveUsers"), now?.active_user_count ?? 0, "system"),
|
||||
tile(L("B01_Dashboard_Metric_ActiveProjects"), now?.active_project_count ?? 0, "system"),
|
||||
];
|
||||
}
|
||||
|
||||
/** 가입 대기 수 — 시스템 관리자는 고른 회사 것 */
|
||||
function joinPending(state: DashboardState): number {
|
||||
return (state.user.role === "SYSTEM_ADMIN" ? state.allJoinRequests : state.joinRequests).length;
|
||||
}
|
||||
|
||||
/** 개요 컨테이너들 — `projects` 는 역할별 프로젝트 목록(프로젝트 탭과 같은 목록) */
|
||||
export function buildOverview(state: DashboardState, projects: ProjectItem[]): HTMLElement[] {
|
||||
const sum = summarizeProjects(projects);
|
||||
const steps = workflowSteps();
|
||||
const status = [
|
||||
tile(L("B01_Ov_Total"), sum.total, "projects"),
|
||||
tile(L("B01_Ov_Failed"), sum.failed, "projects", { filter: "failed" }, sum.failed > 0),
|
||||
tile(L("B01_Ov_Stale"), sum.stale, "projects", { filter: "stale" }, sum.stale > 0),
|
||||
];
|
||||
const stages = sum.byStep.map((count, index) =>
|
||||
tile(steps[index] ?? String(index + 1), count, "projects", { filter: `stage-${index}` }),
|
||||
);
|
||||
const todo: HTMLElement[] = [];
|
||||
if (state.user.role !== "USER")
|
||||
todo.push(tile(L("B01_Ov_JoinPending"), joinPending(state), "company"));
|
||||
todo.push(tile(L("B01_Ov_TempBox"), state.tempCount, "projects"));
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "b01-dashboard__overview";
|
||||
body.append(grid(status), block(L("B01_Ov_ByStage"), stages));
|
||||
const sections = [
|
||||
section(L("B01_Ov_ProjectStatus"), body, true),
|
||||
section(L("B01_Ov_Todo"), grid(todo), true),
|
||||
];
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
sections.push(section(L("B01_Ov_Resources"), grid(resourceTiles(state.resources)), true));
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
createButton,
|
||||
createTag,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
@@ -8,66 +9,57 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { currentQuery, navigateTo } from "../A00_Common/router";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
fetchAllUsers,
|
||||
fetchAuditLogPage,
|
||||
fetchCompanyAssets,
|
||||
fetchAuditLogs,
|
||||
fetchCompanyJoinRequests,
|
||||
fetchCompanyMembers,
|
||||
fetchCompanyProjects,
|
||||
fetchDashboardMe,
|
||||
fetchSupportRequests,
|
||||
fetchSystemResources,
|
||||
fetchUserCompany,
|
||||
fetchUserProjects,
|
||||
type AuditLog,
|
||||
type CompanyAsset,
|
||||
type CompanyInfo,
|
||||
type DashboardUser,
|
||||
type JoinRequest,
|
||||
type Member,
|
||||
type ProjectItem,
|
||||
type ResourceData,
|
||||
type SupportRequest,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { fetchTempBatches } from "./B01_Dashboard_Api_Temp";
|
||||
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
|
||||
import { L, roleLabel } from "./B01_Dashboard_UI_Common";
|
||||
import { buildCompanyTab } from "./B01_Dashboard_UI_CompanyTab";
|
||||
import {
|
||||
buildCompanyPanel,
|
||||
companyTable,
|
||||
joinRequestTable,
|
||||
memberTable,
|
||||
} from "./B01_Dashboard_UI_Company";
|
||||
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
|
||||
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
|
||||
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
|
||||
import { buildOverview } from "./B01_Dashboard_UI_Overview";
|
||||
import { buildAccountTab } from "./B01_Dashboard_UI_Profile";
|
||||
import { buildProjectsTab } from "./B01_Dashboard_UI_ProjectsTab";
|
||||
import { AUDIT_PAGE_SIZE, buildSystemTab } from "./B01_Dashboard_UI_System";
|
||||
import { buildTabBar, resolveTab, type DashboardTab } from "./B01_Dashboard_UI_Tabs";
|
||||
import { projectTable } from "./B01_Dashboard_UI_Projects";
|
||||
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
||||
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
|
||||
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
|
||||
import "./B01_Dashboard_UI_Style.css";
|
||||
|
||||
export interface DashboardState {
|
||||
user: DashboardUser;
|
||||
/** 해시 `?tab=` 로 고른 탭 (없거나 역할에 없으면 개요) */
|
||||
tab: DashboardTab;
|
||||
userProjects: ProjectItem[];
|
||||
companyProjects: ProjectItem[];
|
||||
allProjects: ProjectItem[];
|
||||
company: CompanyInfo | null;
|
||||
/** 회사 자산(로고 · 서명) — 회사 탭에서만 받음 */
|
||||
assets: CompanyAsset[];
|
||||
members: Member[];
|
||||
joinRequests: JoinRequest[];
|
||||
allCompanies: CompanyInfo[];
|
||||
allUsers: DashboardUser[];
|
||||
/** 시스템 탭 · 모든 회사의 사용자 */
|
||||
systemUsers: DashboardUser[];
|
||||
allJoinRequests: JoinRequest[];
|
||||
auditLogs: AuditLog[];
|
||||
auditTotal: number;
|
||||
supportRequests: SupportRequest[];
|
||||
resources: ResourceData | null;
|
||||
/** 개요 · 임시 보관함 묶음 수 */
|
||||
tempCount: number;
|
||||
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 정보가 이 회사 것 */
|
||||
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
|
||||
adminCompanyId: number | null;
|
||||
}
|
||||
|
||||
@@ -87,26 +79,20 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
const user = await fetchDashboardMe();
|
||||
const state: DashboardState = {
|
||||
user,
|
||||
tab: resolveTab(user.role, currentQuery().get("tab")),
|
||||
userProjects: [],
|
||||
companyProjects: [],
|
||||
allProjects: [],
|
||||
company: null,
|
||||
assets: [],
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
allCompanies: [],
|
||||
allUsers: [],
|
||||
systemUsers: [],
|
||||
allJoinRequests: [],
|
||||
auditLogs: [],
|
||||
auditTotal: 0,
|
||||
supportRequests: [],
|
||||
resources: null,
|
||||
tempCount: 0,
|
||||
adminCompanyId: null,
|
||||
};
|
||||
await loadTabData(state);
|
||||
await loadRoleData(state);
|
||||
root.innerHTML = "";
|
||||
root.append(buildPage(state));
|
||||
} catch (error) {
|
||||
@@ -116,76 +102,48 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 역할별 프로젝트 목록 — 개요 · 프로젝트 탭이 같은 목록을 씀 */
|
||||
function projectsOf(state: DashboardState): ProjectItem[] {
|
||||
if (state.user.role === "SYSTEM_ADMIN") return state.allProjects;
|
||||
return state.user.role === "ADMIN" ? state.companyProjects : state.userProjects;
|
||||
}
|
||||
|
||||
/** 고른 탭이 쓰는 자료만 받음(92-1) — 실패한 한 줄은 빈 값으로 두고 나머지는 그림 */
|
||||
async function loadTabData(state: DashboardState): Promise<void> {
|
||||
const { user, tab } = state;
|
||||
const system = user.role === "SYSTEM_ADMIN";
|
||||
const manager = user.role !== "USER";
|
||||
const jobs: Promise<unknown>[] = [];
|
||||
const load = <T>(job: Promise<T>, fallback: T, set: (value: T) => void): void => {
|
||||
jobs.push(
|
||||
job
|
||||
.catch((error) => {
|
||||
console.error("[B01] 자료 읽기 실패", error);
|
||||
return fallback;
|
||||
})
|
||||
.then(set),
|
||||
);
|
||||
};
|
||||
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
|
||||
if (system) {
|
||||
async function loadRoleData(state: DashboardState): Promise<void> {
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
|
||||
state.allCompanies = await fetchAllCompanies().catch(() => []);
|
||||
state.adminCompanyId = pickAdminCompany(user, state.allCompanies);
|
||||
}
|
||||
// 시스템 관리자만 회사 번호를 실음 — 나머지는 서버가 세션 회사를 씀
|
||||
const scoped = system ? state.adminCompanyId : undefined;
|
||||
const companyId = pickAdminCompany(state.user, state.allCompanies);
|
||||
state.adminCompanyId = companyId;
|
||||
const results = await Promise.allSettled([
|
||||
fetchAllUsers(companyId),
|
||||
fetchCompanyJoinRequests(companyId),
|
||||
fetchAuditLogs(),
|
||||
fetchSystemResources(7),
|
||||
fetchCompanyProjects(companyId),
|
||||
]);
|
||||
state.allUsers = results[0].status === "fulfilled" ? results[0].value : [];
|
||||
state.allJoinRequests = results[1].status === "fulfilled" ? results[1].value : [];
|
||||
state.auditLogs = results[2].status === "fulfilled" ? results[2].value : [];
|
||||
state.resources = results[3].status === "fulfilled" ? results[3].value : null;
|
||||
state.allProjects = results[4].status === "fulfilled" ? results[4].value : [];
|
||||
|
||||
if (tab === "overview" || tab === "projects") {
|
||||
if (system) load(fetchCompanyProjects(scoped), [], (v) => (state.allProjects = v));
|
||||
else if (manager) load(fetchCompanyProjects(), [], (v) => (state.companyProjects = v));
|
||||
else load(fetchUserProjects(), [], (v) => (state.userProjects = v));
|
||||
}
|
||||
if (tab === "overview") {
|
||||
load(
|
||||
fetchTempBatches().then((r) => r.batches.length),
|
||||
0,
|
||||
(v) => (state.tempCount = v),
|
||||
);
|
||||
if (manager) {
|
||||
load(fetchCompanyJoinRequests(scoped), [], (v) =>
|
||||
system ? (state.allJoinRequests = v) : (state.joinRequests = v),
|
||||
);
|
||||
}
|
||||
if (system) load(fetchSystemResources(7), null, (v) => (state.resources = v));
|
||||
}
|
||||
if (tab === "company") {
|
||||
if (system) {
|
||||
state.company = state.allCompanies.find((c) => c.id === state.adminCompanyId) ?? null;
|
||||
load(fetchAllUsers(scoped), [], (v) => (state.allUsers = v));
|
||||
load(fetchCompanyJoinRequests(scoped), [], (v) => (state.allJoinRequests = v));
|
||||
} else {
|
||||
load(fetchUserCompany(), null, (v) => (state.company = v));
|
||||
load(fetchCompanyMembers(), [], (v) => (state.members = v));
|
||||
if (manager) load(fetchCompanyJoinRequests(), [], (v) => (state.joinRequests = v));
|
||||
}
|
||||
load(fetchCompanyAssets(scoped), [], (v) => (state.assets = v));
|
||||
}
|
||||
if (tab === "system") {
|
||||
load(fetchSystemResources(7), null, (v) => (state.resources = v));
|
||||
load(fetchAuditLogPage(1, AUDIT_PAGE_SIZE), { total: 0, items: [] }, (v) => {
|
||||
state.auditLogs = v.items;
|
||||
state.auditTotal = v.total;
|
||||
results.forEach((res, idx) => {
|
||||
if (res.status === "rejected") {
|
||||
console.error(`System Admin API failed at index ${idx}:`, res.reason);
|
||||
}
|
||||
});
|
||||
load(fetchAllUsers(null), [], (v) => (state.systemUsers = v));
|
||||
load(fetchSupportRequests(), [], (v) => (state.supportRequests = v));
|
||||
return;
|
||||
}
|
||||
const [projects, company] = await Promise.all([
|
||||
fetchUserProjects().catch(() => []),
|
||||
fetchUserCompany().catch(() => null),
|
||||
]);
|
||||
state.userProjects = projects;
|
||||
state.company = company;
|
||||
if (state.user.role === "ADMIN") {
|
||||
const [members, requests, companyProjects] = await Promise.all([
|
||||
fetchCompanyMembers().catch(() => []),
|
||||
fetchCompanyJoinRequests().catch(() => []),
|
||||
fetchCompanyProjects().catch(() => []),
|
||||
]);
|
||||
state.members = members;
|
||||
state.joinRequests = requests;
|
||||
state.companyProjects = companyProjects;
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */
|
||||
@@ -239,72 +197,118 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 회사 알약(시스템 관리자만) — 탭 줄 오른쪽 하나 · 누르면 조회 후 고르기 · 고르면 저장 뒤 같은 탭을 다시 읽음 */
|
||||
function buildCompanyPill(state: DashboardState): HTMLElement {
|
||||
/** 회사 알약(시스템 관리자만) — 컨테이너 제목 바로 옆 · 누르면 조회 후 고르기 · 고르면 저장 뒤 다시 읽음 */
|
||||
function withCompanyPill(card: HTMLElement, state: DashboardState): HTMLElement {
|
||||
const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-";
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "ui-tag ui-tag--accent b01-dashboard__company-pill";
|
||||
pill.title = L("B01_Dashboard_PickCompany");
|
||||
pill.textContent = `${name} ▾`;
|
||||
pill.addEventListener("click", () => {
|
||||
pill.addEventListener("click", (event) => {
|
||||
// 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음
|
||||
event.stopPropagation();
|
||||
openCompanyPicker(state.allCompanies, state.adminCompanyId, (company) => {
|
||||
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id));
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
});
|
||||
return pill;
|
||||
card.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 시스템 설정 컨테이너 — 모든 역할 · 모든 탭 위 한 줄 통째 · 제목 눌러 통째로 접힘 */
|
||||
/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */
|
||||
function buildSystemSettingsSection(state: DashboardState): HTMLElement {
|
||||
const { user } = state;
|
||||
const panel = buildSystemSettingsPanel(user, state.adminCompanyId);
|
||||
const card = section(L("B01_Dashboard_SystemSettings"), panel, true);
|
||||
if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);
|
||||
card.querySelector(".ui-card__body")?.classList.add("ui-collapsible");
|
||||
card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title");
|
||||
attachCollapsible(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 탭 하나의 컨테이너들 */
|
||||
function tabSections(state: DashboardState): HTMLElement[] {
|
||||
switch (state.tab) {
|
||||
case "projects":
|
||||
return buildProjectsTab(state, projectsOf(state));
|
||||
case "company":
|
||||
return buildCompanyTab(state);
|
||||
case "account":
|
||||
return buildAccountTab(state.user);
|
||||
case "system":
|
||||
return buildSystemTab(state);
|
||||
default:
|
||||
return buildOverview(state, projectsOf(state));
|
||||
}
|
||||
}
|
||||
|
||||
/** 회사 따라 바뀌는 탭(개요 · 프로젝트 · 회사)에서만 알약 — 계정 · 시스템 탭은 회사와 무관 */
|
||||
const COMPANY_TABS: readonly DashboardTab[] = ["overview", "projects", "company"];
|
||||
|
||||
function buildPage(state: DashboardState): HTMLElement {
|
||||
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
||||
const page = document.createElement("div");
|
||||
page.className = "b01-dashboard__page";
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__grid";
|
||||
grid.append(...tabSections(state));
|
||||
|
||||
const pill =
|
||||
state.user.role === "SYSTEM_ADMIN" && COMPANY_TABS.includes(state.tab)
|
||||
? buildCompanyPill(state)
|
||||
: null;
|
||||
// 시스템 설정은 모든 탭 위 한 줄(27-3a) · 그 아래 탭 줄
|
||||
page.append(
|
||||
buildSystemSettingsSection(state),
|
||||
buildTabBar(state.tab, state.user.role, pill),
|
||||
grid,
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
// 시스템 설정은 리소스 현황 아래(29-1) · 회사 알약이 붙은 컨테이너는 고른 회사 것
|
||||
const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId);
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
|
||||
buildSystemSettingsSection(state),
|
||||
withCompanyPill(
|
||||
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
state,
|
||||
),
|
||||
buildTempUploadSection(),
|
||||
withCompanyPill(
|
||||
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
|
||||
createButton({
|
||||
label: "+",
|
||||
onClick: () => openAddMemberModal(undefined, state.adminCompanyId),
|
||||
}),
|
||||
]),
|
||||
state,
|
||||
),
|
||||
withCompanyPill(
|
||||
section(
|
||||
L("B01_Dashboard_JoinRequests"),
|
||||
joinRequestTable(state.allJoinRequests, true),
|
||||
true,
|
||||
),
|
||||
state,
|
||||
),
|
||||
withCompanyPill(
|
||||
section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
|
||||
]),
|
||||
state,
|
||||
),
|
||||
);
|
||||
} else if (state.user.role === "ADMIN") {
|
||||
// 시스템 설정은 맨 위 한 줄(27-3a)
|
||||
grid.append(
|
||||
buildSystemSettingsSection(state),
|
||||
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
buildTempUploadSection(),
|
||||
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||
]),
|
||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false), true),
|
||||
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
|
||||
);
|
||||
} else {
|
||||
grid.append(
|
||||
buildSystemSettingsSection(state),
|
||||
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
|
||||
// 바로 아래에 둔다(2026-08-08 사용자 지시).
|
||||
buildTempUploadSection(),
|
||||
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
|
||||
);
|
||||
}
|
||||
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
|
||||
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
||||
);
|
||||
// 시스템 로그는 맨 아래 (2026-09-06 사용자 지시).
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
|
||||
}
|
||||
page.append(grid);
|
||||
page.append(buildKnowledgeChat());
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b01-dashboard",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 쪽 넘김 표(92장) — 전용 탭에선 4줄 제한을 풀고 쪽으로 넘김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { table } from "@ui/ui_template_general_blocks";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export const PAGE_SIZE = 10;
|
||||
|
||||
/** 「‹ 이전 · 1 / 3 쪽 · 다음 ›」 줄 — 쪽이 하나뿐이면 건수만 */
|
||||
export function buildPager(
|
||||
page: number,
|
||||
pages: number,
|
||||
total: number,
|
||||
onPage: (page: number) => void,
|
||||
): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b01-dashboard__pager";
|
||||
const count = document.createElement("span");
|
||||
count.className = "b01-dashboard__pager-count";
|
||||
count.textContent = L("B01_Pg_Total").replace("{count}", String(total));
|
||||
bar.append(count);
|
||||
if (pages <= 1) return bar;
|
||||
const label = document.createElement("span");
|
||||
label.className = "b01-dashboard__pager-page";
|
||||
label.textContent = L("B01_Pg_Page")
|
||||
.replace("{page}", String(page))
|
||||
.replace("{pages}", String(pages));
|
||||
const prev = createButton({
|
||||
label: L("Common_Btn_Prev"),
|
||||
variant: "ghost",
|
||||
disabled: page <= 1,
|
||||
onClick: () => onPage(page - 1),
|
||||
});
|
||||
const next = createButton({
|
||||
label: L("Common_Btn_Next"),
|
||||
variant: "ghost",
|
||||
disabled: page >= pages,
|
||||
onClick: () => onPage(page + 1),
|
||||
});
|
||||
bar.append(prev, label, next);
|
||||
return bar;
|
||||
}
|
||||
|
||||
export interface PagedTable<T> {
|
||||
root: HTMLElement;
|
||||
/** 목록을 바꿔 첫 쪽부터 다시 그림(찾기 · 거르기) */
|
||||
setItems: (items: T[]) => void;
|
||||
}
|
||||
|
||||
/** 받아 둔 목록을 `pageSize` 줄씩 나눠 그리는 표 */
|
||||
export function createPagedTable<T>(
|
||||
headers: string[],
|
||||
rowOf: (item: T) => HTMLElement[],
|
||||
items: T[],
|
||||
pageSize = PAGE_SIZE,
|
||||
): PagedTable<T> {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b01-dashboard__paged";
|
||||
let current = items;
|
||||
let page = 1;
|
||||
const draw = (): void => {
|
||||
const pages = Math.max(1, Math.ceil(current.length / pageSize));
|
||||
page = Math.min(page, pages);
|
||||
const slice = current.slice((page - 1) * pageSize, page * pageSize);
|
||||
root.replaceChildren(
|
||||
table(headers, slice.map(rowOf)),
|
||||
buildPager(page, pages, current.length, (next) => {
|
||||
page = next;
|
||||
draw();
|
||||
}),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
return {
|
||||
root,
|
||||
setItems: (next) => {
|
||||
current = next;
|
||||
page = 1;
|
||||
draw();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import { createButton, createInputField } from "@ui/ui_template_elements";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import {
|
||||
changePassword,
|
||||
fetchCompanyAssets,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
type DashboardUser,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { buildUserFields, formatDate, formatTime, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
@@ -109,33 +108,3 @@ export function buildSecurityForm(): HTMLElement {
|
||||
wrap.append(grid, save);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 계정 정보 — 마지막 로그인 · 이메일 인증 만료일(`/me` 가 줌) */
|
||||
export function buildAccountInfo(user: DashboardUser): HTMLElement {
|
||||
const info = document.createElement("dl");
|
||||
info.className = "b01-dashboard__info";
|
||||
const when = (value?: string | null): string =>
|
||||
value ? `${formatDate(value)} ${formatTime(value)}` : "-";
|
||||
const rows: [string, string][] = [
|
||||
[L("B01_Ac_LastLogin"), when(user.last_login)],
|
||||
[L("B01_Ac_AuthExpires"), formatDate(user.auth_expires_at)],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = label;
|
||||
const detail = document.createElement("dd");
|
||||
detail.textContent = value;
|
||||
info.append(term, detail);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/** 「내 계정」 탭 — 기본정보(+ 계정 정보 · 서명) · 보안 */
|
||||
export function buildAccountTab(user: DashboardUser): HTMLElement[] {
|
||||
const profile = document.createElement("div");
|
||||
profile.append(buildAccountInfo(user), buildProfileForm(user));
|
||||
return [
|
||||
section(L("B01_Dashboard_Profile"), profile),
|
||||
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 프로젝트 목록 계산(92장) — 개요 숫자와 프로젝트 탭 거르기가 같은 규칙을 씀.
|
||||
* 화면 부품을 부르지 않는 순수 함수만 둠(시험이 바로 불러 씀).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ProjectItem, WorkflowStageState } from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
/** 워크플로 단계 수 — 파일 입력 · 지형 · 노선 · 종횡단 · 상세 · 수량 · 견적 */
|
||||
export const STEP_COUNT = 7;
|
||||
|
||||
/** 거르기 값 — 해시 `filter=` 로도 들어옴 */
|
||||
export type ProjectFilter = "all" | "failed" | "stale" | `stage-${number}`;
|
||||
|
||||
/** 표 「단계」 열과 같은 값 — 지금 하는 단계 번호 */
|
||||
export function projectStep(project: ProjectItem): number {
|
||||
const step = project.workflow_state?.current_stage ?? project.workflow_stage;
|
||||
return Math.min(Math.max(step, 0), STEP_COUNT - 1);
|
||||
}
|
||||
|
||||
export function hasStageState(project: ProjectItem, state: WorkflowStageState["state"]): boolean {
|
||||
return (project.workflow_state?.stages ?? []).some((stage) => stage.state === state);
|
||||
}
|
||||
|
||||
export function parseFilter(raw: string | null): ProjectFilter {
|
||||
if (raw === "failed" || raw === "stale") return raw;
|
||||
const found = /^stage-(\d+)$/.exec(raw ?? "");
|
||||
return found && Number(found[1]) < STEP_COUNT ? (`stage-${Number(found[1])}` as const) : "all";
|
||||
}
|
||||
|
||||
export function matchesFilter(project: ProjectItem, filter: ProjectFilter): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "failed") return hasStageState(project, "FAILED");
|
||||
if (filter === "stale") return hasStageState(project, "STALE");
|
||||
return projectStep(project) === Number(filter.slice("stage-".length));
|
||||
}
|
||||
|
||||
/** 이름 · 지역 · 발주처 · 작성자 · 사업번호에 모든 단어가 들어간 프로젝트 */
|
||||
export function matchesQuery(project: ProjectItem, text: string): boolean {
|
||||
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return true;
|
||||
const hay = [
|
||||
project.name,
|
||||
project.region,
|
||||
project.client_org,
|
||||
project.owner_name,
|
||||
project.project_number,
|
||||
project.road_type,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return words.every((word) => hay.includes(word));
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
total: number;
|
||||
/** 단계 번호별 프로젝트 수 (길이 STEP_COUNT) */
|
||||
byStep: number[];
|
||||
failed: number;
|
||||
stale: number;
|
||||
}
|
||||
|
||||
export function summarizeProjects(projects: ProjectItem[]): ProjectSummary {
|
||||
const byStep = Array.from({ length: STEP_COUNT }, () => 0);
|
||||
for (const project of projects) byStep[projectStep(project)] += 1;
|
||||
return {
|
||||
total: projects.length,
|
||||
byStep,
|
||||
failed: projects.filter((p) => hasStageState(p, "FAILED")).length,
|
||||
stale: projects.filter((p) => hasStageState(p, "STALE")).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 진행 현황에 보일 단계 — 실패 → 재확인 필요 → 지금 단계 순 */
|
||||
export function progressStage(project: ProjectItem): WorkflowStageState | null {
|
||||
const stages = project.workflow_state?.stages ?? [];
|
||||
return (
|
||||
stages.find((stage) => stage.state === "FAILED") ??
|
||||
stages.find((stage) => stage.state === "STALE") ??
|
||||
stages.find((stage) => stage.stage_no === projectStep(project)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -1,81 +1,56 @@
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { text } from "@ui/ui_template_general_blocks";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { createStepBar } from "@ui/ui_template_workflow_layout";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import type { DashboardUser, ProjectItem } from "./B01_Dashboard_Api_Fetch";
|
||||
import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper";
|
||||
import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals";
|
||||
import { formatDate, L } from "./B01_Dashboard_UI_Common";
|
||||
import { createPagedTable } from "./B01_Dashboard_UI_Paging";
|
||||
import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
/** 프로젝트 표 머리 — 연도 · 도로 종류 · 연장 · 발주처 · 작성자 · 만든 날까지(92-2) */
|
||||
export function projectHeaders(): string[] {
|
||||
return [
|
||||
L("B01_Dashboard_Table_Project"),
|
||||
L("B01_Dashboard_Table_Region"),
|
||||
L("B01_Pj_Year"),
|
||||
L("B01_Pj_RoadType"),
|
||||
L("B01_Pj_Length"),
|
||||
L("B01_Pj_Client"),
|
||||
L("B01_Pj_Author"),
|
||||
// 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다.
|
||||
L("B01_Dashboard_Table_Workflow"),
|
||||
L("B01_Pj_Created"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
];
|
||||
}
|
||||
|
||||
function lengthText(meters?: number | null): string {
|
||||
return meters == null ? "-" : `${meters.toLocaleString("ko-KR")} m`;
|
||||
}
|
||||
|
||||
export function projectRow(project: ProjectItem, currentUser: DashboardUser): HTMLElement[] {
|
||||
const actCell = document.createElement("div");
|
||||
actCell.className = "b01-dashboard__actions";
|
||||
|
||||
if (canEditProject(currentUser, project)) {
|
||||
actCell.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Edit"),
|
||||
variant: "ghost",
|
||||
onClick: () => openEditProjectModal(currentUser, project),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (canDeleteProject(currentUser)) {
|
||||
actCell.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => openDeleteProjectModal(currentUser, project),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
text(project.name),
|
||||
text(project.region),
|
||||
text(project.project_year),
|
||||
text(project.road_type),
|
||||
text(lengthText(project.estimated_length_m)),
|
||||
text(project.client_org),
|
||||
text(project.owner_name),
|
||||
workflow(project),
|
||||
text(formatDate(project.created_at)),
|
||||
text(formatDate(project.updated_at)),
|
||||
actCell,
|
||||
];
|
||||
}
|
||||
|
||||
/** 쪽으로 넘기는 프로젝트 표 — 찾기 · 거르기는 프로젝트 탭이 `createPagedTable` 로 직접 씀 */
|
||||
export function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement {
|
||||
return createPagedTable(
|
||||
projectHeaders(),
|
||||
(p: ProjectItem) => projectRow(p, currentUser),
|
||||
projects,
|
||||
).root;
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Project"),
|
||||
L("B01_Dashboard_Table_Region"),
|
||||
// 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다.
|
||||
L("B01_Dashboard_Table_Workflow"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
projects.map((project) => {
|
||||
const actCell = document.createElement("div");
|
||||
actCell.className = "b01-dashboard__actions";
|
||||
|
||||
if (canEditProject(currentUser, project)) {
|
||||
actCell.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Edit"),
|
||||
variant: "ghost",
|
||||
onClick: () => openEditProjectModal(currentUser, project),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (canDeleteProject(currentUser)) {
|
||||
actCell.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => openDeleteProjectModal(currentUser, project),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
text(project.name),
|
||||
text(project.region),
|
||||
workflow(project),
|
||||
text(formatDate(project.updated_at)),
|
||||
actCell,
|
||||
];
|
||||
}),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
export function workflow(project: ProjectItem): HTMLElement {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 「프로젝트」 탭(92장) — 프로젝트 표(찾기 · 거르기 · 쪽 넘김) + 진행 현황 + 임시 보관함.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
type TagVariant,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { section, text } from "@ui/ui_template_general_blocks";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { currentQuery, navigateTo } from "../A00_Common/router";
|
||||
import type { ProjectItem, WorkflowStageState } from "./B01_Dashboard_Api_Fetch";
|
||||
import { formatDate, formatTime, L, stackedCell } from "./B01_Dashboard_UI_Common";
|
||||
import { createPagedTable } from "./B01_Dashboard_UI_Paging";
|
||||
import type { DashboardState } from "./B01_Dashboard_UI_Page";
|
||||
import { projectHeaders, projectRow } from "./B01_Dashboard_UI_Projects";
|
||||
import {
|
||||
matchesFilter,
|
||||
matchesQuery,
|
||||
parseFilter,
|
||||
progressStage,
|
||||
STEP_COUNT,
|
||||
type ProjectFilter,
|
||||
} from "./B01_Dashboard_UI_ProjectStats";
|
||||
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
|
||||
|
||||
const STATE_TAG: Record<WorkflowStageState["state"], TagVariant> = {
|
||||
NOT_STARTED: "neutral",
|
||||
IN_PROGRESS: "accent",
|
||||
COMPLETE: "success",
|
||||
FAILED: "danger",
|
||||
STALE: "warning",
|
||||
};
|
||||
|
||||
function filterOptions(): { value: string; text: string }[] {
|
||||
const steps = workflowSteps();
|
||||
return [
|
||||
{ value: "all", text: L("B01_Pj_FilterAll") },
|
||||
{ value: "failed", text: L("B01_Ov_Failed") },
|
||||
{ value: "stale", text: L("B01_Ov_Stale") },
|
||||
...Array.from({ length: STEP_COUNT }, (_, i) => ({
|
||||
value: `stage-${i}`,
|
||||
text: `${L("B01_Pj_Stage")} · ${steps[i]}`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function progressRow(project: ProjectItem): HTMLElement[] {
|
||||
const stage = progressStage(project);
|
||||
if (!stage)
|
||||
return [
|
||||
text(project.name),
|
||||
text(null),
|
||||
text(null),
|
||||
text(null),
|
||||
text(null),
|
||||
text(null),
|
||||
text(null),
|
||||
];
|
||||
const when = (value?: string | null): HTMLElement =>
|
||||
value ? stackedCell(formatDate(value), formatTime(value)) : text(null);
|
||||
return [
|
||||
text(project.name),
|
||||
text(workflowSteps()[stage.stage_no] ?? stage.stage_key),
|
||||
createTag(L(`B01_Pj_State_${stage.state}`), STATE_TAG[stage.state]),
|
||||
text(`${Math.round(stage.progress_percent)}%`),
|
||||
text(stage.message),
|
||||
when(stage.started_at),
|
||||
when(stage.completed_at),
|
||||
];
|
||||
}
|
||||
|
||||
function progressHeaders(): string[] {
|
||||
return [
|
||||
L("B01_Dashboard_Table_Project"),
|
||||
L("B01_Pj_Stage"),
|
||||
L("B01_Pj_State"),
|
||||
L("B01_Pj_Percent"),
|
||||
L("B01_Pj_Message"),
|
||||
L("B01_Pj_Started"),
|
||||
L("B01_Pj_Done"),
|
||||
];
|
||||
}
|
||||
|
||||
/** `projects` = 역할별 프로젝트 목록 · 거르기 처음 값은 해시 `filter=`(개요에서 눌러 올 때) */
|
||||
export function buildProjectsTab(state: DashboardState, projects: ProjectItem[]): HTMLElement[] {
|
||||
const user = state.user;
|
||||
const list = createPagedTable(
|
||||
projectHeaders(),
|
||||
(p: ProjectItem) => projectRow(p, user),
|
||||
projects,
|
||||
);
|
||||
const progress = createPagedTable(progressHeaders(), progressRow, projects);
|
||||
|
||||
let filter: ProjectFilter = parseFilter(currentQuery().get("filter"));
|
||||
const query = createInputField({
|
||||
label: L("B01_Pj_Search"),
|
||||
placeholder: L("B01_Pj_SearchHint"),
|
||||
});
|
||||
const filterField = createSelectField({
|
||||
label: L("B01_Pj_Filter"),
|
||||
options: filterOptions(),
|
||||
value: filter,
|
||||
onChange: (value) => {
|
||||
filter = parseFilter(value);
|
||||
apply();
|
||||
},
|
||||
});
|
||||
function apply(): void {
|
||||
const shown = projects.filter(
|
||||
(p) => matchesFilter(p, filter) && matchesQuery(p, query.input.value),
|
||||
);
|
||||
list.setItems(shown);
|
||||
progress.setItems(shown);
|
||||
}
|
||||
query.input.addEventListener("input", apply);
|
||||
apply();
|
||||
|
||||
const filters = document.createElement("div");
|
||||
filters.className = "b01-dashboard__filters";
|
||||
filters.append(query.root, filterField.root);
|
||||
const body = document.createElement("div");
|
||||
body.append(filters, list.root);
|
||||
|
||||
return [
|
||||
section(L("B01_Dashboard_Projects"), body, true, [
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
section(L("B01_Pj_Progress"), progress.root, true),
|
||||
buildTempUploadSection(),
|
||||
];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createLineChart } from "@ui/ui_template_elements";
|
||||
import type { ResourceData, ResourceHistoryPoint } from "./B01_Dashboard_Api_Fetch";
|
||||
import type { ResourceData } from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -10,34 +10,13 @@ function percent(value?: number | null): string {
|
||||
return value == null ? "-" : `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
export function buildResourcePanel(resources: ResourceData | null, days = 7): HTMLElement {
|
||||
export function buildResourcePanel(resources: ResourceData | null): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__resource-panel";
|
||||
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources, days));
|
||||
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 활성 사용자 · 활성 프로젝트 추이(92-2) — 같은 기간 선그래프 */
|
||||
export function buildActivityChart(resources: ResourceData | null, days = 7): HTMLElement {
|
||||
return buildTrendChart(resources, days, {
|
||||
caption: L("B01_Sy_TrendCaption"),
|
||||
ariaLabel: L("B01_Sy_ActiveTrend"),
|
||||
count: true,
|
||||
series: [
|
||||
{
|
||||
name: L("B01_Dashboard_Metric_ActiveUsers"),
|
||||
colorIndex: 0,
|
||||
pick: (h) => h.active_user_count,
|
||||
},
|
||||
{
|
||||
name: L("B01_Dashboard_Metric_ActiveProjects"),
|
||||
colorIndex: 1,
|
||||
pick: (h) => h.active_project_count,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
|
||||
const values = resources?.current;
|
||||
const grid = document.createElement("div");
|
||||
@@ -64,50 +43,13 @@ function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
|
||||
return grid;
|
||||
}
|
||||
|
||||
function buildResourceChart(resources: ResourceData | null, days: number): HTMLElement {
|
||||
return buildTrendChart(resources, days, {
|
||||
caption: L("B01_Dashboard_Resources_ChartCaption"),
|
||||
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
|
||||
series: [
|
||||
{ name: L("B01_Dashboard_Metric_Cpu"), colorIndex: 0, pick: (h) => h.cpu_usage_percent },
|
||||
{
|
||||
name: L("B01_Dashboard_Metric_Memory"),
|
||||
colorIndex: 1,
|
||||
pick: (h) => h.memory_usage_percent,
|
||||
},
|
||||
{ name: L("B01_Dashboard_Metric_Disk"), colorIndex: 2, pick: (h) => h.disk_usage_percent },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
interface TrendOptions {
|
||||
caption: string;
|
||||
ariaLabel: string;
|
||||
/** true = 사람 · 프로젝트 수(개수) 그래프 */
|
||||
count?: boolean;
|
||||
series: {
|
||||
name: string;
|
||||
colorIndex: 0 | 1 | 2 | 3;
|
||||
pick: (point: ResourceHistoryPoint) => number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** 기간(일)에 맞춘 시간 칸 — 7일은 1시간, 30일은 3시간, 90일은 12시간 */
|
||||
function trendBucketHours(days: number): number {
|
||||
return days <= 7 ? 1 : days <= 30 ? 3 : 12;
|
||||
}
|
||||
|
||||
function buildTrendChart(
|
||||
resources: ResourceData | null,
|
||||
days: number,
|
||||
options: TrendOptions,
|
||||
): HTMLElement {
|
||||
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 = options.caption;
|
||||
caption.textContent = L("B01_Dashboard_Resources_ChartCaption");
|
||||
box.append(caption);
|
||||
|
||||
const chartWrap = document.createElement("div");
|
||||
@@ -115,8 +57,8 @@ function buildTrendChart(
|
||||
box.append(chartWrap);
|
||||
|
||||
const history = resources?.history ?? [];
|
||||
const bucketHours = trendBucketHours(days);
|
||||
const bucketMs = bucketHours * 60 * 60 * 1000;
|
||||
const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] =
|
||||
[];
|
||||
const now = new Date();
|
||||
const baseDate = new Date(
|
||||
now.getFullYear(),
|
||||
@@ -128,45 +70,41 @@ function buildTrendChart(
|
||||
0,
|
||||
);
|
||||
|
||||
// 기간을 시간 칸으로 나눔. 칸이 넓으면 짧은 가동 구간이 한 칸에 뭉쳐 점 하나(선 없음)가
|
||||
// 되므로 7일은 1시간 단위를 쓴다.
|
||||
const count = (days * 24) / bucketHours;
|
||||
const times: Date[] = [];
|
||||
const values: (number | null)[][] = options.series.map(() => []);
|
||||
for (let i = 0; i <= count; i++) {
|
||||
times.push(new Date(baseDate.getTime() - (count - i) * bucketMs));
|
||||
for (const column of values) column.push(null);
|
||||
// 7일을 1시간 버킷으로 분할. 버킷이 넓으면 짧은 가동 구간이 한 칸에 뭉쳐
|
||||
// 점 하나(선 없음)가 되므로, 가동 시간대가 연속 버킷으로 이어지도록 1시간 단위 사용.
|
||||
const HOURS = 7 * 24;
|
||||
for (let i = 0; i <= HOURS; i++) {
|
||||
const time = new Date(baseDate.getTime() - (HOURS - i) * 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).getTime();
|
||||
const hTime = new Date(tsStr);
|
||||
let closestIdx = -1;
|
||||
let minDiff = Infinity;
|
||||
times.forEach((time, idx) => {
|
||||
const diff = Math.abs(time.getTime() - hTime);
|
||||
if (diff < minDiff && diff <= Math.max(30 * 60 * 1000, bucketMs / 2)) {
|
||||
|
||||
points.forEach((p, idx) => {
|
||||
const diff = Math.abs(p.timestamp.getTime() - hTime.getTime());
|
||||
if (diff < minDiff && diff <= 30 * 60 * 1000) {
|
||||
minDiff = diff;
|
||||
closestIdx = idx;
|
||||
}
|
||||
});
|
||||
if (closestIdx === -1) return;
|
||||
options.series.forEach((item, s) => {
|
||||
values[s][closestIdx] = item.pick(h);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// 날짜 이름표는 하루 간격(7일) · 3일(30일) · 10일(90일)
|
||||
const dayStep = days <= 7 ? 1 : days <= 30 ? 3 : 10;
|
||||
const xLabels = times.map((time, i) =>
|
||||
time.getHours() === 0 && Math.floor((i * bucketHours) / 24) % dayStep === 0
|
||||
? `${time.getMonth() + 1}/${time.getDate()}`
|
||||
: "",
|
||||
);
|
||||
const peak = Math.max(0, ...values.flat().map((v) => v ?? 0));
|
||||
// 백분율 그래프는 0~100% 고정 · 개수 그래프는 최댓값에 맞춰 4칸으로 나눔
|
||||
const yMax = options.count ? Math.max(4, Math.ceil((peak * 1.2) / 4) * 4) : 100;
|
||||
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) => {
|
||||
@@ -181,17 +119,27 @@ function buildTrendChart(
|
||||
resizeTimeout = window.requestAnimationFrame(() => {
|
||||
chartWrap.innerHTML = "";
|
||||
const chart = createLineChart({
|
||||
ariaLabel: options.ariaLabel,
|
||||
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
|
||||
xLabels,
|
||||
width: width,
|
||||
height: 140,
|
||||
yMax,
|
||||
yUnit: options.count ? "" : "%",
|
||||
series: options.series.map((item, s) => ({
|
||||
name: item.name,
|
||||
colorIndex: item.colorIndex,
|
||||
values: values[s],
|
||||
})),
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/* B01 대시보드 항목별 탭(92장) — 탭 줄 · 개요 · 프로젝트 · 회사 · 시스템. 색은 테마 변수만. */
|
||||
|
||||
.b01-dashboard__page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
/* 탭 줄 — B08 위 탭과 같은 꼴 · 회사 고르기 알약은 오른쪽 끝 */
|
||||
.b01-dashboard__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding-bottom: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-dashboard__tabs .b01-dashboard__company-pill {
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 개요 — 눌러서 탭으로 가는 숫자 칸 */
|
||||
.b01-dashboard__overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric--link {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__metric--link:hover,
|
||||
.b01-dashboard__metric--link:focus-visible {
|
||||
border-color: var(--color-royal-amethyst);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__metric--link .b01-dashboard__metric-value {
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.b01-dashboard__metric--alert .b01-dashboard__metric-value {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.b01-dashboard__overview-title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b01-dashboard__overview,
|
||||
.b01-dashboard__overview-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 찾기 · 거르기 줄 */
|
||||
.b01-dashboard__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-16);
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__filters .ui-field {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* 쪽 넘김 */
|
||||
.b01-dashboard__pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b01-dashboard__pager-count {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* 회사 정보 칸 */
|
||||
.b01-dashboard__info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
margin: 0 0 var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__info dt {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b01-dashboard__info dd {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
font-size: var(--text-body-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset-thumb {
|
||||
display: block;
|
||||
max-height: 32px;
|
||||
max-width: 96px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 시스템 — 기간 고르기 줄 */
|
||||
.b01-dashboard__period {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-dashboard__period .ui-btn[aria-pressed="true"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__inquiry-state {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.b01-dashboard__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.b01-dashboard__tabs .b01-dashboard__company-pill {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.b01-dashboard__ellipsis {
|
||||
display: inline-block;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 「시스템」 탭(92장 · 시스템 관리자만) — 리소스(7 · 30 · 90일) · 활성 추이 · 전체 회사 ·
|
||||
* 전체 사용자 · 시스템 로그(쪽 넘김) · 고객 문의. 모두 이미 있는 API.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, showToast, type TagVariant } from "@ui/ui_template_elements";
|
||||
import { section, text } from "@ui/ui_template_general_blocks";
|
||||
import {
|
||||
fetchAuditLogPage,
|
||||
fetchSystemResources,
|
||||
type AuditLog,
|
||||
type ResourceData,
|
||||
type SupportRequest,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { allUserTable, auditLogTable } from "./B01_Dashboard_UI_Admin";
|
||||
import { companyTable } from "./B01_Dashboard_UI_Company";
|
||||
import { formatDate, formatTime, L, stackedCell } from "./B01_Dashboard_UI_Common";
|
||||
import type { DashboardState } from "./B01_Dashboard_UI_Page";
|
||||
import { buildPager, createPagedTable } from "./B01_Dashboard_UI_Paging";
|
||||
import { buildActivityChart, buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
||||
|
||||
/** 리소스 기간(일) — 백엔드 최대 90 */
|
||||
export const RESOURCE_DAYS = [7, 30, 90] as const;
|
||||
/** 시스템 로그 한 쪽 줄 수 */
|
||||
export const AUDIT_PAGE_SIZE = 20;
|
||||
|
||||
const INQUIRY_TAG: Record<SupportRequest["status"], TagVariant> = {
|
||||
NEW: "warning",
|
||||
REVIEWED: "accent",
|
||||
RESOLVED: "success",
|
||||
DELETED: "neutral",
|
||||
};
|
||||
|
||||
/** 기간 고르기 + 리소스 현황 + 활성 추이 — 기간을 바꾸면 이 두 컨테이너만 새로 받음 */
|
||||
function buildResourceSections(initial: ResourceData | null): HTMLElement[] {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b01-dashboard__period";
|
||||
const panelHost = document.createElement("div");
|
||||
const trendHost = document.createElement("div");
|
||||
let days: number = RESOURCE_DAYS[0];
|
||||
let latest = 0;
|
||||
|
||||
const draw = (data: ResourceData | null): void => {
|
||||
panelHost.replaceChildren(buildResourcePanel(data, days));
|
||||
trendHost.replaceChildren(buildActivityChart(data, days));
|
||||
};
|
||||
const drawBar = (): void => {
|
||||
bar.replaceChildren(
|
||||
...RESOURCE_DAYS.map((value) => {
|
||||
const button = createButton({
|
||||
label: L("B01_Sy_Days").replace("{days}", String(value)),
|
||||
variant: value === days ? "filled" : "ghost",
|
||||
onClick: () => void pick(value),
|
||||
});
|
||||
button.setAttribute("aria-pressed", String(value === days));
|
||||
button.dataset.b01Days = String(value);
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
};
|
||||
const pick = async (value: number): Promise<void> => {
|
||||
if (value === days) return;
|
||||
days = value;
|
||||
drawBar();
|
||||
const mine = ++latest;
|
||||
try {
|
||||
const data = await fetchSystemResources(value);
|
||||
if (mine === latest) draw(data);
|
||||
} catch (error) {
|
||||
if (mine === latest) showToast(error instanceof Error ? error.message : "", "error");
|
||||
}
|
||||
};
|
||||
drawBar();
|
||||
draw(initial);
|
||||
const resource = section(L("B01_Dashboard_Resources"), panelHost, true);
|
||||
resource.querySelector(".ui-card__body")?.prepend(bar);
|
||||
return [resource, section(L("B01_Sy_ActiveTrend"), trendHost, true)];
|
||||
}
|
||||
|
||||
/** 시스템 로그 — 서버가 쪽으로 나눠 줌(전체 건수 포함) */
|
||||
function buildAuditSection(first: { total: number; items: AuditLog[] }): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
const draw = (page: number, data: { total: number; items: AuditLog[] }): void => {
|
||||
const pages = Math.max(1, Math.ceil(data.total / AUDIT_PAGE_SIZE));
|
||||
host.replaceChildren(
|
||||
auditLogTable(data.items),
|
||||
buildPager(page, pages, data.total, (next) => {
|
||||
fetchAuditLogPage(next, AUDIT_PAGE_SIZE)
|
||||
.then((result) => draw(next, result))
|
||||
.catch((error) => showToast(error instanceof Error ? error.message : "", "error"));
|
||||
}),
|
||||
);
|
||||
};
|
||||
draw(1, first);
|
||||
return section(L("B01_Dashboard_AuditLogs"), host, true);
|
||||
}
|
||||
|
||||
function inquiryTable(requests: SupportRequest[]): HTMLElement {
|
||||
return createPagedTable(
|
||||
[
|
||||
L("B01_Sy_Received"),
|
||||
L("B01_Sy_Sender"),
|
||||
L("B01_Dashboard_Table_Company"),
|
||||
L("B01_Sy_Subject"),
|
||||
L("B01_Pj_Message"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
],
|
||||
(request: SupportRequest) => {
|
||||
const message = document.createElement("span");
|
||||
message.className = "b01-dashboard__ellipsis";
|
||||
message.textContent = request.message;
|
||||
message.title = request.message;
|
||||
return [
|
||||
stackedCell(formatDate(request.created_at), formatTime(request.created_at)),
|
||||
text([request.user_name, request.user_email].filter(Boolean).join(" · ")),
|
||||
text(request.company_name),
|
||||
text(request.subject),
|
||||
message,
|
||||
createTag(L(`B01_Sy_Inquiry_${request.status}`), INQUIRY_TAG[request.status]),
|
||||
];
|
||||
},
|
||||
requests,
|
||||
).root;
|
||||
}
|
||||
|
||||
export function buildSystemTab(state: DashboardState): HTMLElement[] {
|
||||
return [
|
||||
...buildResourceSections(state.resources),
|
||||
section(L("B01_Sy_AllCompanies"), companyTable(state.allCompanies, state.user), true),
|
||||
section(L("B01_Sy_AllUsers"), allUserTable(state.systemUsers), true),
|
||||
buildAuditSection({ total: state.auditTotal, items: state.auditLogs }),
|
||||
section(L("B01_Sy_Inquiries"), inquiryTable(state.supportRequests), true),
|
||||
];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 대시보드 탭 줄(92장) — 한 주소(`#/b01-account`) 안에서 해시 `?tab=` 으로 나눔.
|
||||
* 라우터는 그대로 — 저장 뒤 해시로 다시 그려도 같은 탭이 남음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import "./B01_Dashboard_UI_Style_Tabs.css";
|
||||
|
||||
export type DashboardTab = "overview" | "projects" | "company" | "account" | "system";
|
||||
|
||||
const TAB_LABELS = {
|
||||
overview: "B01_Tab_Overview",
|
||||
projects: "B01_Tab_Projects",
|
||||
company: "B01_Tab_Company",
|
||||
account: "B01_Tab_Account",
|
||||
system: "B01_Tab_System",
|
||||
} as const;
|
||||
|
||||
/** 역할이 보는 탭 — 시스템은 시스템 관리자만 */
|
||||
export function tabsFor(role: DashboardUser["role"]): DashboardTab[] {
|
||||
const base: DashboardTab[] = ["overview", "projects", "company", "account"];
|
||||
return role === "SYSTEM_ADMIN" ? [...base, "system"] : base;
|
||||
}
|
||||
|
||||
/** 해시 값 → 탭 · 없거나 역할에 없는 값이면 첫 탭(개요) */
|
||||
export function resolveTab(role: DashboardUser["role"], raw: string | null): DashboardTab {
|
||||
return tabsFor(role).find((tab) => tab === raw) ?? "overview";
|
||||
}
|
||||
|
||||
/** 탭으로 이동 — 같은 주소라 hashchange 로 다시 그림 · `extra` = 거르기 같은 덧인자 */
|
||||
export function goTab(tab: DashboardTab, extra: Record<string, string> = {}): void {
|
||||
navigateTo(ROUTES.B01_ACCOUNT, { tab, ...extra });
|
||||
}
|
||||
|
||||
/** B08 위 탭과 같은 단추 줄 · `trailing` = 줄 오른쪽 끝에 붙는 것(회사 고르기 알약) */
|
||||
export function buildTabBar(
|
||||
active: DashboardTab,
|
||||
role: DashboardUser["role"],
|
||||
trailing: HTMLElement | null,
|
||||
): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b01-dashboard__tabs";
|
||||
bar.setAttribute("role", "tablist");
|
||||
bar.setAttribute("aria-label", L("B01_Tab_Aria"));
|
||||
for (const tab of tabsFor(role)) {
|
||||
const button = createButton({
|
||||
label: L(TAB_LABELS[tab]),
|
||||
variant: tab === active ? "filled" : "ghost",
|
||||
onClick: () => tab !== active && goTab(tab),
|
||||
});
|
||||
button.setAttribute("role", "tab");
|
||||
button.setAttribute("aria-selected", String(tab === active));
|
||||
button.dataset.b01Tab = tab;
|
||||
bar.append(button);
|
||||
}
|
||||
if (trailing) bar.append(trailing);
|
||||
return bar;
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function readableSources(
|
||||
/** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */
|
||||
export function buildNeedCompany(): HTMLElement {
|
||||
const go = createButton({ label: L("M02_NeedCompanyGo"), variant: "filled" });
|
||||
go.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" }));
|
||||
go.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT));
|
||||
return el("div", {
|
||||
className: "m02-layer-need",
|
||||
children: [el("p", { text: L("M02_NeedCompany") }), go],
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
"""B01 대시보드 항목별 탭 시험(92장) — 화면 조작은 ORCA.
|
||||
|
||||
· 탭 줄: 역할별 탭 수 · 해시 값 해석(없거나 모르는 값 = 개요) · 시스템 탭은 시스템 관리자만
|
||||
· 프로젝트 계산: 단계별 수 · 실패 · 재확인 · 거르기 · 찾기 · 진행 현황 단계 고르기
|
||||
· 진입 두 곳(회사 없음)이 회사 탭으로 · 같은 프로젝트 목록을 두 번 받지 않음
|
||||
· 새 문구 키가 사전에 있음 · 소스 700줄 이하
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B01 = ROOT / "B01_Dashboard"
|
||||
PAGE = B01 / "B01_Dashboard_UI_Page.ts"
|
||||
STATS = B01 / "B01_Dashboard_UI_ProjectStats.ts"
|
||||
TABS = B01 / "B01_Dashboard_UI_Tabs.ts"
|
||||
|
||||
NODE = ["node", "--experimental-strip-types", "--no-warnings"]
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _node(script: str, *args: str) -> object:
|
||||
result = subprocess.run(
|
||||
[*NODE, "--input-type=module", "-e", script, *args],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _stage(no: int, state: str, **extra: object) -> dict:
|
||||
return {"stage_no": no, "stage_key": f"S{no}", "state": state, "progress_percent": 0, **extra}
|
||||
|
||||
|
||||
def _project(name: str, current: int, states: tuple[str, ...] = (), **extra: object) -> dict:
|
||||
stages = [_stage(i, states[i] if i < len(states) else "NOT_STARTED") for i in range(7)]
|
||||
return {
|
||||
"id": name, "name": name, "workflow_stage": 0,
|
||||
"workflow_state": {"project_id": name, "current_stage": current, "stages": stages},
|
||||
**extra,
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
PROJECTS = [
|
||||
_project(
|
||||
"가-노선", 2, ("COMPLETE", "COMPLETE", "IN_PROGRESS"), region="강원", client_org="산림청"
|
||||
),
|
||||
_project("나-횡단", 3, ("COMPLETE", "COMPLETE", "COMPLETE", "FAILED"), owner_name="홍길동"),
|
||||
_project("다-상세", 4, ("COMPLETE", "STALE", "COMPLETE", "COMPLETE", "IN_PROGRESS")),
|
||||
_project("라-입력", 0, ()),
|
||||
{
|
||||
"id": "마",
|
||||
"name": "마-옛",
|
||||
"workflow_stage": 9,
|
||||
}, # 단계 정보 없는 옛 줄 · 단계 9 → 끝으로 눌림
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_프로젝트_계산():
|
||||
script = (
|
||||
f"import * as m from '{STATS.as_uri()}';\n"
|
||||
"const ps = JSON.parse(process.argv[1]);\n"
|
||||
"const names = (f, q = '') => ps.filter((p) => m.matchesFilter(p, f) && m.matchesQuery(p, q))"
|
||||
".map((p) => p.name);\n"
|
||||
"console.log(JSON.stringify({\n"
|
||||
" sum: m.summarizeProjects(ps),\n"
|
||||
" failed: names('failed'), stale: names('stale'), stage2: names('stage-2'),\n"
|
||||
" stage6: names('stage-6'), all: names('all').length,\n"
|
||||
" q1: names('all', '산림청'), q2: names('all', '강원 노선'), q3: names('all', '없는말'),\n"
|
||||
" filters: ['failed', 'stale', 'stage-3', 'stage-7', 'stage-x', null, 'all']"
|
||||
".map((v) => m.parseFilter(v)),\n"
|
||||
" prog: ps.map((p) => m.progressStage(p)?.stage_no ?? null),\n"
|
||||
"}));\n"
|
||||
)
|
||||
out = _node(script, json.dumps(PROJECTS))
|
||||
assert out["sum"] == {
|
||||
"total": 5,
|
||||
"byStep": [1, 0, 1, 1, 1, 0, 1], # 마-옛 는 9 → 6 으로 눌림
|
||||
"failed": 1,
|
||||
"stale": 1,
|
||||
}
|
||||
# 개요 숫자 = 프로젝트 탭 거르기 수
|
||||
assert out["failed"] == ["나-횡단"] and len(out["failed"]) == out["sum"]["failed"]
|
||||
assert out["stale"] == ["다-상세"] and len(out["stale"]) == out["sum"]["stale"]
|
||||
assert out["stage2"] == ["가-노선"] and out["stage6"] == ["마-옛"] and out["all"] == 5
|
||||
assert out["q1"] == ["가-노선"] and out["q2"] == ["가-노선"] and out["q3"] == []
|
||||
assert out["filters"] == ["failed", "stale", "stage-3", "all", "all", "all", "all"]
|
||||
# 진행 현황 단계 — 실패 → 재확인 → 지금 단계
|
||||
assert out["prog"] == [2, 3, 1, 0, None]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_탭_수와_해시_해석():
|
||||
src = _text(TABS)
|
||||
funcs = "\n".join(
|
||||
re.search(rf"^export function {name}[\s\S]*?^}}", src, re.M).group(0).replace("export ", "")
|
||||
for name in ("tabsFor", "resolveTab")
|
||||
)
|
||||
script = (
|
||||
"type Tab = 'overview' | 'projects' | 'company' | 'account' | 'system';\n"
|
||||
"type Role = 'SYSTEM_ADMIN' | 'ADMIN' | 'USER';\n"
|
||||
f"interface DashboardUser {{ role: Role }}\n{funcs}\n"
|
||||
"const roles: Role[] = ['SYSTEM_ADMIN', 'ADMIN', 'USER'];\n"
|
||||
"console.log(JSON.stringify({\n"
|
||||
" tabs: roles.map((r) => tabsFor(r)),\n"
|
||||
" pick: roles.map((r) => ['company', 'system', 'zzz', null].map((v) => resolveTab(r, v))),\n"
|
||||
"}));\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[*NODE, "--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
out = json.loads(result.stdout)
|
||||
base = ["overview", "projects", "company", "account"]
|
||||
assert out["tabs"] == [[*base, "system"], base, base] # 시스템 관리자 5 · 나머지 4
|
||||
assert out["pick"] == [
|
||||
["company", "system", "overview", "overview"],
|
||||
["company", "overview", "overview", "overview"], # 시스템 탭은 시스템 관리자만
|
||||
["company", "overview", "overview", "overview"],
|
||||
]
|
||||
assert "navigateTo(ROUTES.B01_ACCOUNT, { tab, ...extra })" in src # 라우터 안 바꿈 · 해시만
|
||||
|
||||
|
||||
def test_회사_없음_두_곳은_회사_탭으로():
|
||||
router = _text(ROOT / "A00_Common" / "router.ts")
|
||||
assert 'navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" })' in router
|
||||
layer = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Layer.ts")
|
||||
assert 'navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" })' in layer
|
||||
|
||||
|
||||
def test_탭마다_그_탭_자료만_받음():
|
||||
src = _text(PAGE)
|
||||
load = src.split("async function loadTabData")[1].split("\n}\n")[0]
|
||||
# 같은 프로젝트 목록을 두 번 받지 않음 — 회사 관리자는 회사 목록 하나
|
||||
assert load.count("fetchUserProjects()") == 1 and load.count("fetchCompanyProjects(") == 2
|
||||
assert 'if (tab === "overview" || tab === "projects")' in load
|
||||
for key in ("overview", "company", "system"):
|
||||
assert f'tab === "{key}"' in load
|
||||
# 고른 회사 인자 한 곳
|
||||
assert "const scoped = system ? state.adminCompanyId : undefined;" in load
|
||||
for call in ("fetchCompanyProjects(scoped)", "fetchAllUsers(scoped)",
|
||||
"fetchCompanyJoinRequests(scoped)"): # fmt: skip
|
||||
assert call in load, call
|
||||
# 시스템 탭 자료는 시스템 탭에서만
|
||||
sys_part = load.split('if (tab === "system")')[1]
|
||||
for call in ("fetchAuditLogPage(", "fetchSupportRequests()", "fetchAllUsers(null)"):
|
||||
assert call in sys_part, call
|
||||
assert "fetchSupportRequests" not in load.split('if (tab === "system")')[0]
|
||||
|
||||
|
||||
def test_설정_카드는_탭_줄_위_알약은_탭_줄_오른쪽_하나():
|
||||
src = _text(PAGE)
|
||||
build = src.split("function buildPage(")[1].split("\n}\n")[0]
|
||||
assert build.count("buildSystemSettingsSection(state)") == 1
|
||||
assert build.index("buildSystemSettingsSection(state)") < build.index("buildTabBar(")
|
||||
assert build.count("buildCompanyPill(state)") == 1
|
||||
assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키
|
||||
assert "withCompanyPill" not in src
|
||||
tab_bar = _text(TABS)
|
||||
assert "if (trailing) bar.append(trailing);" in tab_bar
|
||||
# 설정 카드 안 알약은 없어짐
|
||||
assert (
|
||||
"company-pill" not in src.split("function buildSystemSettingsSection")[1].split("\n}\n")[0]
|
||||
)
|
||||
|
||||
|
||||
def test_새_문구_키가_사전에_있음():
|
||||
locales = "".join(_text(p) for p in (ROOT / "ui_template").glob("ui_template_locale*.ts"))
|
||||
names = ("Tabs", "Overview", "ProjectsTab", "CompanyTab", "System", "Paging", "Profile",
|
||||
"Projects", "Company", "Admin", "Page") # fmt: skip
|
||||
for name in names:
|
||||
src = _text(B01 / f"B01_Dashboard_UI_{name}.ts")
|
||||
for key in set(re.findall(r'L\(\s*"(B01_[A-Za-z_]+)"', src)):
|
||||
assert f"{key}:" in locales, f"{name}: {key}"
|
||||
# 템플릿 글자로 만드는 키(상태 · 문의 상태)도 모두 있음
|
||||
for key in (
|
||||
*(
|
||||
f"B01_Pj_State_{s}"
|
||||
for s in ("NOT_STARTED", "IN_PROGRESS", "COMPLETE", "FAILED", "STALE")
|
||||
),
|
||||
*(f"B01_Sy_Inquiry_{s}" for s in ("NEW", "REVIEWED", "RESOLVED", "DELETED")),
|
||||
):
|
||||
assert f"{key}:" in locales, key
|
||||
|
||||
|
||||
def test_소스_700줄_이하():
|
||||
for path in [*B01.glob("B01_Dashboard_UI_*.ts"), *B01.glob("B01_Dashboard_UI_Style*.css")]:
|
||||
assert len(_text(path).splitlines()) <= 700, path.name
|
||||
@@ -99,9 +99,14 @@ def test_회사_관리자_개인_카드_하나():
|
||||
def test_모든_역할_한_줄_통째로_접힘():
|
||||
src = _text(PAGE)
|
||||
build = _fn(src, "buildPage")
|
||||
# 한 번만 · 모든 탭 위(탭 줄보다 앞) — 탭마다 다시 만들지 않음(92-1)
|
||||
assert build.count("buildSystemSettingsSection(state)") == 1
|
||||
assert build.index("buildSystemSettingsSection(state)") < build.index("buildTabBar(")
|
||||
# 세 갈래 모두 한 번씩 · 시스템 관리자는 리소스 현황 아래(29-1) · 프로젝트 위
|
||||
assert build.count("buildSystemSettingsSection(state)") == 3
|
||||
sys_part = build.split('} else if (state.user.role === "ADMIN")')[0]
|
||||
assert (
|
||||
sys_part.index('L("B01_Dashboard_Resources")')
|
||||
< sys_part.index("buildSystemSettingsSection(state)")
|
||||
< sys_part.index('L("B01_Dashboard_Projects")')
|
||||
)
|
||||
assert "StructureManage" not in src and "buildStructurePanel" not in src
|
||||
assert "top-row" not in src
|
||||
sect = _fn(src, "buildSystemSettingsSection")
|
||||
@@ -115,24 +120,25 @@ def test_모든_역할_한_줄_통째로_접힘():
|
||||
def test_회사_알약_시스템_관리자만():
|
||||
src = _text(PAGE)
|
||||
sect = _fn(src, "buildSystemSettingsSection")
|
||||
assert "company-pill" not in sect # 설정 카드 안 알약은 탭 줄 오른쪽으로 옮김(92-1)
|
||||
pill = _fn(src, "buildCompanyPill")
|
||||
assert 'if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);' in sect
|
||||
pill = _fn(src, "withCompanyPill")
|
||||
assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id))" in pill
|
||||
assert "event.stopPropagation()" in pill # 알약 누름이 접힘으로 새지 않음
|
||||
assert "openCompanyPicker(state.allCompanies" in pill # 조회 후 고르기(29-5)
|
||||
assert '"ui-tag ui-tag--accent b01-dashboard__company-pill"' in pill # 공용 알약 모양
|
||||
assert '.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill)' in pill
|
||||
assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키
|
||||
load = src.split("async function loadTabData")[1].split("\n}\n")[0]
|
||||
for call in ("fetchAllUsers(scoped)", "fetchCompanyJoinRequests(scoped)",
|
||||
"fetchCompanyProjects(scoped)"): # fmt: skip
|
||||
load = src.split("async function loadRoleData")[1].split("\n}\n")[0]
|
||||
for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)",
|
||||
"fetchCompanyProjects(companyId)"): # fmt: skip
|
||||
assert call in load, call
|
||||
assert "const scoped = system ? state.adminCompanyId : undefined;" in load
|
||||
build = _fn(src, "buildPage")
|
||||
# 탭 줄 오른쪽 하나 — 시스템 관리자 · 회사 따라 바뀌는 탭에서만
|
||||
assert build.count("buildCompanyPill(state)") == 1
|
||||
assert 'state.user.role === "SYSTEM_ADMIN" && COMPANY_TABS.includes(state.tab)' in build
|
||||
assert "buildTabBar(state.tab, state.user.role, pill)" in build
|
||||
tab_src = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_CompanyTab.ts")
|
||||
assert "openAddMemberModal(undefined, state.adminCompanyId)" in tab_src
|
||||
# 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약
|
||||
sys_part = build.split('} else if (state.user.role === "ADMIN")')[0]
|
||||
assert sys_part.count("withCompanyPill(") == 4
|
||||
assert "withCompanyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1]
|
||||
assert "companyTable(picked, state.user)" in build
|
||||
assert "openAddMemberModal(undefined, state.adminCompanyId)" in build
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
@@ -22,7 +22,6 @@ import { ui_locales_b4 } from "./ui_template_locale_b4";
|
||||
import { ui_locales_common } from "./ui_template_locale_common";
|
||||
import { ui_locales_a } from "./ui_template_locale_a";
|
||||
import { ui_locales_b1 } from "./ui_template_locale_b1";
|
||||
import { ui_locales_b1_tabs } from "./ui_template_locale_b1_tabs";
|
||||
import { ui_locales_b2 } from "./ui_template_locale_b2";
|
||||
import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
import { ui_locales_m1 } from "./ui_template_locale_m1";
|
||||
@@ -59,7 +58,6 @@ export const ui_locales = {
|
||||
...ui_locales_common,
|
||||
...ui_locales_a,
|
||||
...ui_locales_b1,
|
||||
...ui_locales_b1_tabs,
|
||||
...ui_locales_b2,
|
||||
...ui_locales_b3,
|
||||
...ui_locales_m1,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_b1_tabs.ts
|
||||
* B01 대시보드 항목별 탭 사전 (PLAN 92장) — 탭 줄 · 개요 · 프로젝트 · 회사 · 내 계정 · 시스템
|
||||
*
|
||||
* 형식: 키 → [한국어, 영어].
|
||||
* ========================================================================== */
|
||||
|
||||
export const ui_locales_b1_tabs = {
|
||||
/* --- 탭 줄 --- */
|
||||
B01_Tab_Aria: ["대시보드 항목", "Dashboard sections"],
|
||||
B01_Tab_Overview: ["개요", "Overview"],
|
||||
B01_Tab_Projects: ["프로젝트", "Projects"],
|
||||
B01_Tab_Company: ["회사", "Company"],
|
||||
B01_Tab_Account: ["내 계정", "My account"],
|
||||
B01_Tab_System: ["시스템", "System"],
|
||||
|
||||
/* --- 개요 --- */
|
||||
B01_Ov_ProjectStatus: ["프로젝트 현황", "Project status"],
|
||||
B01_Ov_Total: ["전체 프로젝트", "All projects"],
|
||||
B01_Ov_Failed: ["실패", "Failed"],
|
||||
B01_Ov_Stale: ["재확인 필요", "Needs recheck"],
|
||||
B01_Ov_ByStage: ["단계별 프로젝트 수", "Projects by stage"],
|
||||
B01_Ov_Todo: ["처리할 일", "To do"],
|
||||
B01_Ov_JoinPending: ["가입 대기", "Pending join requests"],
|
||||
B01_Ov_TempBox: ["임시 보관함", "Temp storage"],
|
||||
B01_Ov_Resources: ["리소스 요약", "Resource summary"],
|
||||
|
||||
/* --- 프로젝트 --- */
|
||||
B01_Pj_Search: ["찾기", "Search"],
|
||||
B01_Pj_SearchHint: ["이름 · 지역 · 발주처 · 작성자", "Name, region, client, author"],
|
||||
B01_Pj_Filter: ["거르기", "Filter"],
|
||||
B01_Pj_FilterAll: ["전체", "All"],
|
||||
B01_Pj_Year: ["연도", "Year"],
|
||||
B01_Pj_RoadType: ["도로 종류", "Road type"],
|
||||
B01_Pj_Length: ["연장", "Length"],
|
||||
B01_Pj_Client: ["발주처", "Client"],
|
||||
B01_Pj_Author: ["작성자", "Author"],
|
||||
B01_Pj_Created: ["만든 날", "Created"],
|
||||
B01_Pj_Progress: ["진행 현황", "Progress"],
|
||||
B01_Pj_Stage: ["단계", "Stage"],
|
||||
B01_Pj_State: ["상태", "State"],
|
||||
B01_Pj_Percent: ["진행률", "Progress %"],
|
||||
B01_Pj_Message: ["안내", "Message"],
|
||||
B01_Pj_Started: ["시작", "Started"],
|
||||
B01_Pj_Done: ["완료", "Completed"],
|
||||
B01_Pj_State_NOT_STARTED: ["대기", "Waiting"],
|
||||
B01_Pj_State_IN_PROGRESS: ["진행 중", "In progress"],
|
||||
B01_Pj_State_COMPLETE: ["완료", "Complete"],
|
||||
B01_Pj_State_FAILED: ["실패", "Failed"],
|
||||
B01_Pj_State_STALE: ["재확인 필요", "Needs recheck"],
|
||||
|
||||
/* --- 쪽 넘김 --- */
|
||||
B01_Pg_Total: ["총 {count}건", "{count} in total"],
|
||||
B01_Pg_Page: ["{page} / {pages} 쪽", "Page {page} / {pages}"],
|
||||
|
||||
/* --- 회사 --- */
|
||||
B01_Co_Owner: ["대표", "Representative"],
|
||||
B01_Co_Status: ["사업 상태", "Business status"],
|
||||
B01_Co_Members: ["인원", "Members"],
|
||||
B01_Co_Projects: ["프로젝트 수", "Projects"],
|
||||
B01_Co_Registered: ["등록일", "Registered"],
|
||||
B01_Co_Assets: ["로고 · 서명 자산", "Logo and signature assets"],
|
||||
B01_Co_AssetKind: ["종류", "Kind"],
|
||||
B01_Co_AssetPreview: ["미리보기", "Preview"],
|
||||
B01_Co_AssetName: ["이름", "Name"],
|
||||
B01_Co_AssetOwner: ["소유자", "Owner"],
|
||||
B01_Co_AssetLogo: ["로고", "Logo"],
|
||||
B01_Co_AssetSignature: ["서명", "Signature"],
|
||||
B01_Co_AssetShared: ["회사 공용", "Company"],
|
||||
B01_Co_NewCompany: ["새 회사", "New company"],
|
||||
|
||||
/* --- 내 계정 --- */
|
||||
B01_Ac_Info: ["계정 정보", "Account info"],
|
||||
B01_Ac_LastLogin: ["마지막 로그인", "Last login"],
|
||||
B01_Ac_AuthExpires: ["이메일 인증 만료일", "Email verification expires"],
|
||||
|
||||
/* --- 시스템 --- */
|
||||
B01_Sy_Period: ["기간", "Period"],
|
||||
B01_Sy_Days: ["{days}일", "{days} days"],
|
||||
B01_Sy_ActiveTrend: ["활성 추이", "Activity trend"],
|
||||
B01_Sy_TrendCaption: ["활성 사용자 · 활성 프로젝트 수", "Active users and active projects"],
|
||||
B01_Sy_AllCompanies: ["전체 회사", "All companies"],
|
||||
B01_Sy_AllUsers: ["전체 사용자", "All users"],
|
||||
B01_Sy_Affiliation: ["소속", "Company"],
|
||||
B01_Sy_LastLogin: ["마지막 로그인", "Last login"],
|
||||
B01_Sy_Inquiries: ["고객 문의", "Customer inquiries"],
|
||||
B01_Sy_Received: ["접수일", "Received"],
|
||||
B01_Sy_Subject: ["제목", "Subject"],
|
||||
B01_Sy_Sender: ["보낸 사람", "Sender"],
|
||||
B01_Sy_Inquiry_NEW: ["새 문의", "New"],
|
||||
B01_Sy_Inquiry_REVIEWED: ["확인함", "Reviewed"],
|
||||
B01_Sy_Inquiry_RESOLVED: ["처리 완료", "Resolved"],
|
||||
B01_Sy_Inquiry_DELETED: ["삭제됨", "Deleted"],
|
||||
} as const;
|
||||
Reference in New Issue
Block a user