diff --git a/A00_Common/router.ts b/A00_Common/router.ts index f41d74244..7a6a19c2b 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -187,7 +187,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { return; } if (!user.company_id && user.role !== "SYSTEM_ADMIN") { - navigateTo(ROUTES.B01_ACCOUNT); + navigateTo(ROUTES.B01_ACCOUNT, { tab: "company" }); return; } } catch { diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index a4d41a743..29c88a7a7 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -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 { 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 { + 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 { return request(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 7aca50247..d24752026 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -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, ); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index 17b08a8bf..b5582f3bf 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -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( diff --git a/B01_Dashboard/B01_Dashboard_UI_CompanyTab.ts b/B01_Dashboard/B01_Dashboard_UI_CompanyTab.ts new file mode 100644 index 000000000..8b97e09d2 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_CompanyTab.ts @@ -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; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Overview.ts b/B01_Dashboard/B01_Dashboard_UI_Overview.ts new file mode 100644 index 000000000..358b05a95 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Overview.ts @@ -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 = {}, + 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; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 75d0905ad..768250791 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -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 { 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 { } } -async function loadRoleData(state: DashboardState): Promise { - 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 { + const { user, tab } = state; + const system = user.role === "SYSTEM_ADMIN"; + const manager = user.role !== "USER"; + const jobs: Promise[] = []; + const load = (job: Promise, 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", diff --git a/B01_Dashboard/B01_Dashboard_UI_Paging.ts b/B01_Dashboard/B01_Dashboard_UI_Paging.ts new file mode 100644 index 000000000..ef002a8a7 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Paging.ts @@ -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 { + root: HTMLElement; + /** 목록을 바꿔 첫 쪽부터 다시 그림(찾기 · 거르기) */ + setItems: (items: T[]) => void; +} + +/** 받아 둔 목록을 `pageSize` 줄씩 나눠 그리는 표 */ +export function createPagedTable( + headers: string[], + rowOf: (item: T) => HTMLElement[], + items: T[], + pageSize = PAGE_SIZE, +): PagedTable { + 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(); + }, + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index f7c39ec3b..faa4cc08b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -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()), + ]; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectStats.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectStats.ts new file mode 100644 index 000000000..8ba970b0e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectStats.ts @@ -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 + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts index a07fe8071..19fe9d41b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -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 { diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts new file mode 100644 index 000000000..3e7dd69a8 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts @@ -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 = { + 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(), + ]; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Resources.ts b/B01_Dashboard/B01_Dashboard_UI_Resources.ts index 1cdea5195..923573137 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Resources.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Resources.ts @@ -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); }); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css b/B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css new file mode 100644 index 000000000..dd752a34e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css @@ -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; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_System.ts b/B01_Dashboard/B01_Dashboard_UI_System.ts new file mode 100644 index 000000000..2feef9972 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_System.ts @@ -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 = { + 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 => { + 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), + ]; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Tabs.ts b/B01_Dashboard/B01_Dashboard_UI_Tabs.ts new file mode 100644 index 000000000..b2fe63867 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Tabs.ts @@ -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 = {}): 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; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 43a7b6b86..f15336ea8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -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], diff --git a/resources/tester/test_b01_dashboard_tabs.py b/resources/tester/test_b01_dashboard_tabs.py new file mode 100644 index 000000000..a04460d8a --- /dev/null +++ b/resources/tester/test_b01_dashboard_tabs.py @@ -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 diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index f5872516f..0cc2f3afc 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -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 가 없음") diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a285a8a60..7f9ff7c92 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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, diff --git a/ui_template/ui_template_locale_b1_tabs.ts b/ui_template/ui_template_locale_b1_tabs.ts new file mode 100644 index 000000000..8084b4cb4 --- /dev/null +++ b/ui_template/ui_template_locale_b1_tabs.ts @@ -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;