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