feat(B01): 대시보드 항목별 탭 — 해시 탭 줄 · 개요 · 프로젝트 · 회사 · 내 계정 · 시스템 (92-1·92-2)

- 해시 ?tab= 탭 줄(B08 단추 꼴) · 고른 탭 자료만 받음 · 설정 카드는 탭 줄 위 · 회사 고르기 알약은 탭 줄 오른쪽 하나
- 회사 없음 진입 두 곳(router · M02 Layer)은 ?tab=company · 회사 관리자 프로젝트 목록 한 번만 받음
- 개요 숫자 카드(단계별 · 실패 · 재확인 · 가입 대기 · 보관함 · 리소스) · 프로젝트 표 칸 보강 · 찾기 · 거르기 · 쪽 넘김 · 진행 현황
- 회사 정보 칸(사업자번호 · 대표 · 상태) · 팀원 읽기 · 자산 표 · 내 계정 마지막 로그인 · 시스템 리소스 7·30·90일 · 활성 추이 · 전체 회사 · 사용자 · 로그 쪽 · 고객 문의
- 시험 test_b01_dashboard_tabs 신규 · test_b01_settings_cards 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-08 13:58:34 +09:00
co-authored by Claude Sonnet 5.5
parent 85ff7f8bb8
commit 2a2c4d0f62
21 changed files with 1622 additions and 315 deletions
+1 -1
View File
@@ -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);
navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" });
return;
}
} catch {
+31
View File
@@ -12,6 +12,9 @@ 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;
}
@@ -65,6 +68,7 @@ export interface ProjectItem {
workflow_stage: number;
workflow_state?: WorkflowState;
updated_at?: string | null;
created_at?: string | null;
}
export interface CompanyInfo {
@@ -77,6 +81,7 @@ export interface CompanyInfo {
logo_asset_id?: number | null;
user_count?: number;
project_count?: number;
created_at?: string | null;
}
export interface Member {
@@ -493,6 +498,32 @@ 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)}`);
}
+30 -13
View File
@@ -7,16 +7,11 @@ import {
openEditUserModal,
} from "./B01_Dashboard_UI_Modals";
import { table, text } from "@ui/ui_template_general_blocks";
import {
DASHBOARD_VISIBLE_ROWS,
formatDate,
formatTime,
L,
stackedCell,
} from "./B01_Dashboard_UI_Common";
import { formatDate, formatTime, L, stackedCell } from "./B01_Dashboard_UI_Common";
import { createPagedTable } from "./B01_Dashboard_UI_Paging";
export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement {
return table(
return createPagedTable(
[
L("B01_Dashboard_Table_Email"),
L("B01_Dashboard_Table_Name"),
@@ -27,7 +22,7 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H
L("B01_Dashboard_Table_Status"),
L("B01_Dashboard_Table_Action"),
],
users.map((user) => {
(user: DashboardUser) => {
const actionsEl = document.createElement("div");
actionsEl.className = "b01-dashboard__actions";
@@ -70,9 +65,32 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H
text(user.status),
actionsEl,
];
}),
DASHBOARD_VISIBLE_ROWS,
);
},
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;
}
/** 무엇에 한 일인지 — 표에 저장된 대상 종류·번호를 사람이 읽는 말로. */
@@ -113,6 +131,5 @@ export function auditLogTable(logs: AuditLog[]): HTMLElement {
// 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다.
stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)),
]),
DASHBOARD_VISIBLE_ROWS,
);
}
+70 -50
View File
@@ -1,6 +1,5 @@
import { createButton, createTag } from "@ui/ui_template_elements";
import {
fetchCompanyAssets,
processJoinRequest,
setCompanyLogo,
type CompanyInfo,
@@ -19,8 +18,18 @@ import {
openFindCompanyModal,
} from "./B01_Dashboard_UI_Modals";
import type { DashboardState } from "./B01_Dashboard_UI_Page";
import { actionPair, table, text } from "@ui/ui_template_general_blocks";
import { DASHBOARD_VISIBLE_ROWS, formatDate, L, runRequest } from "./B01_Dashboard_UI_Common";
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];
}
export function buildCompanyPanel(state: DashboardState): HTMLElement {
const wrap = document.createElement("div");
@@ -40,16 +49,23 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
);
return wrap;
}
wrap.append(
createTag(`${state.company.name} (${state.user.status})`, "success"),
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
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),
);
const box = document.createElement("div");
box.append(info, wrap);
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
if (state.user.role !== "USER" && state.company) {
const company = state.company;
if (state.user.role !== "USER") {
wrap.append(
createButton({
label: "회사 정보 수정",
@@ -57,41 +73,39 @@ 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,
);
}
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (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;
return box;
}
export function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement {
return table(
/** 일반 사용자는 읽기만(`readOnly`) — 관리 열 없음 */
export function memberTable(
members: Member[],
currentUser: DashboardUser,
readOnly = false,
): HTMLElement {
return createPagedTable(
[
L("B01_Dashboard_Table_Email"),
L("B01_Dashboard_Table_Name"),
L("B01_Dashboard_Table_Position"),
L("B01_Dashboard_Table_Department"),
L("B01_Dashboard_Table_Role"),
L("B01_Dashboard_Table_Action"),
...(readOnly ? [] : [L("B01_Dashboard_Table_Action")]),
],
members.map((member) => {
(member: Member) => {
const actionsEl = document.createElement("div");
actionsEl.className = "b01-dashboard__actions";
@@ -129,15 +143,15 @@ export function memberTable(members: Member[], currentUser: DashboardUser): HTML
text(member.position),
text(member.department),
text(member.role),
actionsEl,
...(readOnly ? [] : [actionsEl]),
];
}),
DASHBOARD_VISIBLE_ROWS,
);
},
members,
).root;
}
export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement {
return table(
return createPagedTable(
[
L("B01_Dashboard_Table_Email"),
...(systemMode ? [L("B01_Dashboard_Table_Company")] : []),
@@ -145,7 +159,7 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean):
L("B01_Dashboard_Table_Status"),
L("B01_Dashboard_Table_Action"),
],
requests.map((request) => [
(request: JoinRequest) => [
text(request.user_email),
...(systemMode ? [text(request.company_name)] : []),
text(formatDate(request.requested_at)),
@@ -154,9 +168,9 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean):
() => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode),
() => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode),
),
]),
DASHBOARD_VISIBLE_ROWS,
);
],
requests,
).root;
}
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
@@ -181,18 +195,24 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT
});
return button;
};
return table(
return createPagedTable(
[
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"),
],
companies.map((company) => [
(company: CompanyInfo) => [
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({
@@ -201,9 +221,9 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT
onClick: () => openEditCompanyModal(company),
})
: text(""),
]),
DASHBOARD_VISIBLE_ROWS,
);
],
companies,
).root;
}
export async function onB01_JoinRequest_Process_Click(
@@ -0,0 +1,88 @@
/* =============================================================================
* 「회사」 탭(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;
}
+103
View File
@@ -0,0 +1,103 @@
/* =============================================================================
* 「개요」 탭(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;
}
+133 -137
View File
@@ -1,7 +1,6 @@
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createButton,
createTag,
hideLoadingOverlay,
showLoadingOverlay,
@@ -9,57 +8,66 @@ import {
} from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { navigateTo } from "../A00_Common/router";
import { currentQuery, navigateTo } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
fetchAuditLogs,
fetchAuditLogPage,
fetchCompanyAssets,
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 { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
import { fetchTempBatches } from "./B01_Dashboard_Api_Temp";
import { L, roleLabel } from "./B01_Dashboard_UI_Common";
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 { buildCompanyTab } from "./B01_Dashboard_UI_CompanyTab";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
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 { 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;
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
/** 개요 · 임시 보관함 묶음 수 */
tempCount: number;
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 정보가 이 회사 것 */
adminCompanyId: number | null;
}
@@ -79,20 +87,26 @@ 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 loadRoleData(state);
await loadTabData(state);
root.innerHTML = "";
root.append(buildPage(state));
} catch (error) {
@@ -102,48 +116,76 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
}
}
async function loadRoleData(state: DashboardState): Promise<void> {
if (state.user.role === "SYSTEM_ADMIN") {
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
state.allCompanies = await fetchAllCompanies().catch(() => []);
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 : [];
/** 역할별 프로젝트 목록 — 개요 · 프로젝트 탭이 같은 목록을 씀 */
function projectsOf(state: DashboardState): ProjectItem[] {
if (state.user.role === "SYSTEM_ADMIN") return state.allProjects;
return state.user.role === "ADMIN" ? state.companyProjects : state.userProjects;
}
results.forEach((res, idx) => {
if (res.status === "rejected") {
console.error(`System Admin API failed at index ${idx}:`, res.reason);
}
/** 고른 탭이 쓰는 자료만 받음(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) {
state.allCompanies = await fetchAllCompanies().catch(() => []);
state.adminCompanyId = pickAdminCompany(user, state.allCompanies);
}
// 시스템 관리자만 회사 번호를 실음 — 나머지는 서버가 세션 회사를 씀
const scoped = system ? state.adminCompanyId : undefined;
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;
});
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;
load(fetchAllUsers(null), [], (v) => (state.systemUsers = v));
load(fetchSupportRequests(), [], (v) => (state.supportRequests = v));
}
await Promise.all(jobs);
}
/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */
@@ -197,118 +239,72 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
return wrap;
}
/** 회사 알약(시스템 관리자만) — 컨테이너 제목 바로 옆 · 누르면 조회 후 고르기 · 고르면 저장 뒤 다시 읽음 */
function withCompanyPill(card: HTMLElement, state: DashboardState): HTMLElement {
/** 회사 알약(시스템 관리자만) — 탭 줄 오른쪽 하나 · 누르면 조회 후 고르기 · 고르면 저장 뒤 같은 탭을 다시 읽음 */
function buildCompanyPill(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", (event) => {
// 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음
event.stopPropagation();
pill.addEventListener("click", () => {
openCompanyPicker(state.allCompanies, state.adminCompanyId, (company) => {
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id));
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
});
card.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill);
return card;
return pill;
}
/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */
/** 시스템 설정 컨테이너 — 모든 역할 · 모든 탭 위 한 줄 통째 · 제목 눌러 통째로 접힘 */
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));
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()),
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,
);
// 시스템 로그는 맨 아래 (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",
+84
View File
@@ -0,0 +1,84 @@
/* =============================================================================
* 쪽 넘김 표(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();
},
};
}
+32 -1
View File
@@ -1,5 +1,6 @@
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,
@@ -8,7 +9,7 @@ import {
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common";
import { buildUserFields, formatDate, formatTime, L, runRequest } from "./B01_Dashboard_UI_Common";
const PASSWORD_MIN_LENGTH = 8;
@@ -108,3 +109,33 @@ 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()),
];
}
@@ -0,0 +1,82 @@
/* =============================================================================
* 프로젝트 목록 계산(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
);
}
+69 -44
View File
@@ -1,56 +1,81 @@
import { createButton } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { 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 { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common";
import { formatDate, L } from "./B01_Dashboard_UI_Common";
import { createPagedTable } from "./B01_Dashboard_UI_Paging";
/** 프로젝트 표 머리 — 연도 · 도로 종류 · 연장 · 발주처 · 작성자 · 만든 날까지(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 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,
);
return createPagedTable(
projectHeaders(),
(p: ProjectItem) => projectRow(p, currentUser),
projects,
).root;
}
export function workflow(project: ProjectItem): HTMLElement {
@@ -0,0 +1,136 @@
/* =============================================================================
* 「프로젝트」 탭(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(),
];
}
+99 -47
View File
@@ -1,6 +1,6 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createLineChart } from "@ui/ui_template_elements";
import type { ResourceData } from "./B01_Dashboard_Api_Fetch";
import type { ResourceData, ResourceHistoryPoint } from "./B01_Dashboard_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -10,13 +10,34 @@ function percent(value?: number | null): string {
return value == null ? "-" : `${Math.round(value)}%`;
}
export function buildResourcePanel(resources: ResourceData | null): HTMLElement {
export function buildResourcePanel(resources: ResourceData | null, days = 7): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__resource-panel";
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources));
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources, days));
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");
@@ -43,13 +64,50 @@ function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
return grid;
}
function buildResourceChart(resources: ResourceData | null): HTMLElement {
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 {
const box = document.createElement("div");
box.className = "b01-dashboard__chart";
const caption = document.createElement("p");
caption.className = "b01-dashboard__chart-caption";
caption.textContent = L("B01_Dashboard_Resources_ChartCaption");
caption.textContent = options.caption;
box.append(caption);
const chartWrap = document.createElement("div");
@@ -57,8 +115,8 @@ function buildResourceChart(resources: ResourceData | null): HTMLElement {
box.append(chartWrap);
const history = resources?.history ?? [];
const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] =
[];
const bucketHours = trendBucketHours(days);
const bucketMs = bucketHours * 60 * 60 * 1000;
const now = new Date();
const baseDate = new Date(
now.getFullYear(),
@@ -70,41 +128,45 @@ function buildResourceChart(resources: ResourceData | null): HTMLElement {
0,
);
// 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 });
// 기간을 시간 칸으로 나눔. 칸이 넓으면 짧은 가동 구간이 한 칸에 뭉쳐 점 하나(선 없음)가
// 되므로 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);
}
history.forEach((h) => {
if (!h.timestamp) return;
const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z";
const hTime = new Date(tsStr);
const hTime = new Date(tsStr).getTime();
let closestIdx = -1;
let minDiff = Infinity;
points.forEach((p, idx) => {
const diff = Math.abs(p.timestamp.getTime() - hTime.getTime());
if (diff < minDiff && diff <= 30 * 60 * 1000) {
times.forEach((time, idx) => {
const diff = Math.abs(time.getTime() - hTime);
if (diff < minDiff && diff <= Math.max(30 * 60 * 1000, bucketMs / 2)) {
minDiff = diff;
closestIdx = idx;
}
});
if (closestIdx !== -1) {
const p = points[closestIdx];
p.cpu = h.cpu_usage_percent;
p.mem = h.memory_usage_percent;
p.disk = h.disk_usage_percent;
}
if (closestIdx === -1) return;
options.series.forEach((item, s) => {
values[s][closestIdx] = item.pick(h);
});
});
const xLabels = points.map((p) => {
const hours = p.timestamp.getHours();
return hours === 0 ? `${p.timestamp.getMonth() + 1}/${p.timestamp.getDate()}` : "";
});
// 날짜 이름표는 하루 간격(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;
let resizeTimeout: number | null = null;
const observer = new ResizeObserver((entries) => {
@@ -119,27 +181,17 @@ function buildResourceChart(resources: ResourceData | null): HTMLElement {
resizeTimeout = window.requestAnimationFrame(() => {
chartWrap.innerHTML = "";
const chart = createLineChart({
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
ariaLabel: options.ariaLabel,
xLabels,
width: width,
height: 140,
series: [
{
name: L("B01_Dashboard_Metric_Cpu"),
colorIndex: 0,
values: points.map((p) => p.cpu),
},
{
name: L("B01_Dashboard_Metric_Memory"),
colorIndex: 1,
values: points.map((p) => p.mem),
},
{
name: L("B01_Dashboard_Metric_Disk"),
colorIndex: 2,
values: points.map((p) => p.disk),
},
],
yMax,
yUnit: options.count ? "" : "%",
series: options.series.map((item, s) => ({
name: item.name,
colorIndex: item.colorIndex,
values: values[s],
})),
});
chartWrap.append(chart);
});
@@ -0,0 +1,156 @@
/* 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;
}
+134
View File
@@ -0,0 +1,134 @@
/* =============================================================================
* 「시스템」 탭(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),
];
}
+62
View File
@@ -0,0 +1,62 @@
/* =============================================================================
* 대시보드 탭 줄(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));
go.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" }));
return el("div", {
className: "m02-layer-need",
children: [el("p", { text: L("M02_NeedCompany") }), go],
+200
View File
@@ -0,0 +1,200 @@
"""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
+15 -21
View File
@@ -99,14 +99,9 @@ def test_회사_관리자_개인_카드_하나():
def test_모든_역할_한_줄_통째로_접힘():
src = _text(PAGE)
build = _fn(src, "buildPage")
# 세 갈래 모두 한 번씩 · 시스템 관리자는 리소스 현황 아래(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")')
)
# 한 번만 · 모든 탭 위(탭 줄보다 앞) — 탭마다 다시 만들지 않음(92-1)
assert build.count("buildSystemSettingsSection(state)") == 1
assert build.index("buildSystemSettingsSection(state)") < build.index("buildTabBar(")
assert "StructureManage" not in src and "buildStructurePanel" not in src
assert "top-row" not in src
sect = _fn(src, "buildSystemSettingsSection")
@@ -120,25 +115,24 @@ def test_모든_역할_한_줄_통째로_접힘():
def test_회사_알약_시스템_관리자만():
src = _text(PAGE)
sect = _fn(src, "buildSystemSettingsSection")
assert 'if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);' in sect
pill = _fn(src, "withCompanyPill")
assert "company-pill" not in sect # 설정 카드 안 알약은 탭 줄 오른쪽으로 옮김(92-1)
pill = _fn(src, "buildCompanyPill")
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 loadRoleData")[1].split("\n}\n")[0]
for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)",
"fetchCompanyProjects(companyId)"): # fmt: skip
load = src.split("async function loadTabData")[1].split("\n}\n")[0]
for call in ("fetchAllUsers(scoped)", "fetchCompanyJoinRequests(scoped)",
"fetchCompanyProjects(scoped)"): # fmt: skip
assert call in load, call
assert "const scoped = system ? state.adminCompanyId : undefined;" in load
build = _fn(src, "buildPage")
# 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약
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
# 탭 줄 오른쪽 하나 — 시스템 관리자 · 회사 따라 바뀌는 탭에서만
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
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
+2
View File
@@ -22,6 +22,7 @@ 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";
@@ -58,6 +59,7 @@ 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,
+94
View File
@@ -0,0 +1,94 @@
/* =============================================================================
* 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;