From 7e34152de8dfba657502641c4dd32e97688f69d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 18:26:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=EB=B0=9B=EC=9D=80=20=EC=98=88?= =?UTF-8?q?=EC=A0=95=EB=85=B8=EC=84=A0=20=C2=B7=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B4=80=ED=95=A8=20=C2=B7=20=EC=82=AC=EB=9E=8C=20?= =?UTF-8?q?=C2=B7=20=ED=9A=8C=EC=82=AC=20=EC=A0=95=EB=B3=B4=20=C2=B7=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EC=84=A4=EA=B3=84=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EC=84=B8=EB=B6=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?(549-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 페이지 다섯(View_Assigned · Temp · People · Company · CompanyDefaults) · 등록부 다섯 줄 교체 - 표 줄 40 · 세로선 없음 · 줄 끝 ⋯ · 한글 배지 · 빈 상태 · 사람 탭 가입 요청 수 배지 · 보관함 남은 날 막대(7 일 안 빨강) - 옛 구역 정리(Legacy · Company · AssignedPlans · TempUpload 다시 씀) · 시험 test_549 · test_546 stub 일반화 Co-Authored-By: Claude Sonnet 5.5 --- .../B01_Dashboard_UI_AssignedPlans.ts | 64 +-- B01_Dashboard/B01_Dashboard_UI_Company.ts | 159 +------ B01_Dashboard/B01_Dashboard_UI_Legacy.ts | 85 +--- B01_Dashboard/B01_Dashboard_UI_PageKit.ts | 7 +- .../B01_Dashboard_UI_Style_Pages.css | 37 +- B01_Dashboard/B01_Dashboard_UI_Style_Temp.css | 127 +----- B01_Dashboard/B01_Dashboard_UI_TempModal.ts | 9 +- B01_Dashboard/B01_Dashboard_UI_TempUpload.ts | 391 +++++++++--------- .../B01_Dashboard_UI_View_Assigned.ts | 81 ++++ .../B01_Dashboard_UI_View_Company.ts | 107 +++++ .../B01_Dashboard_UI_View_CompanyDefaults.ts | 37 ++ B01_Dashboard/B01_Dashboard_UI_View_People.ts | 247 +++++++++++ B01_Dashboard/B01_Dashboard_UI_View_Temp.ts | 20 + B01_Dashboard/B01_Dashboard_UI_Views.ts | 15 +- resources/tester/test_546_b01_shell.py | 17 +- resources/tester/test_549_b01_pages.py | 114 +++++ 16 files changed, 885 insertions(+), 632 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_View_Company.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_View_CompanyDefaults.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_View_People.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_View_Temp.ts create mode 100644 resources/tester/test_549_b01_pages.py diff --git a/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts index a5915c0e6..1b0d07868 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts @@ -1,17 +1,10 @@ /* ============================================================================= * B01_Dashboard_UI_AssignedPlans.ts - * 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도. - * 열 = 예정노선 · 연도 · 확정(KST) · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결] - * [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 모달(`B01_Dashboard_UI_PlanLink`) + * 「받은 예정노선」 자료 읽기(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도. + * 화면 = `B01_Dashboard_UI_View_Assigned.ts` · [연결] 모달 = `B01_Dashboard_UI_PlanLink` * ========================================================================== */ -import { API_BASE_URL, ROUTES } from "@config/config_frontend"; -import { createButton, createTag } from "@ui/ui_template_elements"; -import { table, text } from "@ui/ui_template_general_blocks"; -import { goToWorkflowStage } from "../A00_Common/b_workflow_nav"; -import { kstText } from "../common_util/common_util_time_kst"; -import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common"; -import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink"; +import { API_BASE_URL } from "@config/config_frontend"; export interface AssignedPlan { assignee_id: number; @@ -32,54 +25,3 @@ export async function fetchAssignedPlans(): Promise { if (!response.ok) return []; return ((await response.json()) as { routes?: AssignedPlan[] }).routes ?? []; } - -function linkState(plan: AssignedPlan): HTMLElement { - const cell = document.createElement("div"); - cell.className = "b01-dashboard__actions"; - cell.append( - createTag( - L(plan.linked_project_id ? "DE_Assigned_Linked" : "DE_Assigned_Unlinked"), - plan.linked_project_id ? "success" : "neutral", - ), - ); - if (plan.update_available) cell.append(createTag(L("DE_Assigned_Update"), "warning")); - return cell; -} - -export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement { - return table( - [ - L("DE_New_Plan"), - L("DE_Assigned_Year"), - L("D04_Col_Confirmed"), - L("DE_Assigned_State"), - L("B01_Dashboard_Table_Action"), - ], - plans.map((plan) => { - const actions = document.createElement("div"); - actions.className = "b01-dashboard__actions"; - const link = createButton({ - label: L("DE_Assigned_Link"), - variant: "ghost", - onClick: () => - void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`), - }); - actions.append( - createButton({ - label: L("DE_Assigned_View"), - variant: "ghost", - onClick: () => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE), - }), - link, - ); - return [ - text(plan.project_name), - text(plan.year), - text(kstText(plan.confirmed_at)), - linkState(plan), - actions, - ]; - }), - DASHBOARD_VISIBLE_ROWS, - ); -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index 17b08a8bf..b22649937 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -1,163 +1,14 @@ -import { createButton, createTag } from "@ui/ui_template_elements"; +import { createButton } from "@ui/ui_template_elements"; import { - fetchCompanyAssets, processJoinRequest, setCompanyLogo, type CompanyInfo, type DashboardUser, - type JoinRequest, - type Member, } from "./B01_Dashboard_Api_Fetch"; -import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker"; -import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; -import { - openChangeRoleModal, - openEditCompanyModal, - openCreateCompanyModal, - openDeleteUserModal, - openEditUserModal, - 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"; - -export function buildCompanyPanel(state: DashboardState): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b01-dashboard__actions"; - if (!state.company) { - wrap.append( - createTag(L("B01_Dashboard_NoCompany"), "warning"), - createButton({ - label: L("B01_Dashboard_CreateCompany"), - onClick: () => openCreateCompanyModal(), - }), - createButton({ - label: L("B01_Dashboard_FindCompany"), - variant: "ghost", - onClick: () => openFindCompanyModal(), - }), - ); - return wrap; - } - wrap.append( - createTag(`${state.company.name} (${state.user.status})`, "success"), - text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), - text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), - // 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시). - text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`), - ); - // 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다. - if (state.user.role !== "USER" && state.company) { - const company = state.company; - wrap.append( - createButton({ - label: "회사 정보 수정", - variant: "ghost", - onClick: () => openEditCompanyModal(company), - }), - ); - } - // 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (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; -} - -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"), - L("B01_Dashboard_Table_Action"), - ], - members.map((member) => { - const actionsEl = document.createElement("div"); - actionsEl.className = "b01-dashboard__actions"; - - actionsEl.append( - createButton({ - label: L("Common_Btn_Edit"), - variant: "ghost", - onClick: () => openEditUserModal(currentUser, member), - }), - ); - - if (canChangeRole(currentUser, member)) { - actionsEl.append( - createButton({ - label: L("B01_Dashboard_ChangeRole"), - variant: "ghost", - onClick: () => openChangeRoleModal(member), - }), - ); - } - - if (canDeleteUser(currentUser, member)) { - actionsEl.append( - createButton({ - label: L("B01_Dashboard_RemoveMember"), - variant: "danger", - onClick: () => openDeleteUserModal(member), - }), - ); - } - - return [ - text(member.email), - text(member.name), - text(member.position), - text(member.department), - text(member.role), - actionsEl, - ]; - }), - DASHBOARD_VISIBLE_ROWS, - ); -} - -export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement { - return table( - [ - L("B01_Dashboard_Table_Email"), - ...(systemMode ? [L("B01_Dashboard_Table_Company")] : []), - L("B01_Dashboard_Table_Requested"), - L("B01_Dashboard_Table_Status"), - L("B01_Dashboard_Table_Action"), - ], - requests.map((request) => [ - text(request.user_email), - ...(systemMode ? [text(request.company_name)] : []), - text(formatDate(request.requested_at)), - text(request.status), - actionPair( - () => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode), - () => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode), - ), - ]), - DASHBOARD_VISIBLE_ROWS, - ); -} +import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker"; +import { openEditCompanyModal } from "./B01_Dashboard_UI_Modals"; +import { table, text } from "@ui/ui_template_general_blocks"; +import { DASHBOARD_VISIBLE_ROWS, L, runRequest } from "./B01_Dashboard_UI_Common"; export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement { // 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정). diff --git a/B01_Dashboard/B01_Dashboard_UI_Legacy.ts b/B01_Dashboard/B01_Dashboard_UI_Legacy.ts index d06f2bd88..2df94963d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Legacy.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Legacy.ts @@ -14,55 +14,36 @@ import { fetchAllUsers, fetchAuditLogs, fetchCompanyJoinRequests, - fetchCompanyMembers, fetchCompanyProjects, fetchSystemResources, - fetchUserCompany, fetchUserProjects, type AuditLog, type CompanyInfo, type DashboardUser, type JoinRequest, - type Member, type ProjectItem, type ResourceData, } from "./B01_Dashboard_Api_Fetch"; import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin"; -import { - assignedPlanTable, - fetchAssignedPlans, - type AssignedPlan, -} from "./B01_Dashboard_UI_AssignedPlans"; import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites"; import { L } from "./B01_Dashboard_UI_Common"; -import { - buildCompanyPanel, - companyTable, - joinRequestTable, - memberTable, -} from "./B01_Dashboard_UI_Company"; +import { companyTable } from "./B01_Dashboard_UI_Company"; import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals"; import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile"; import { projectTable } from "./B01_Dashboard_UI_Projects"; import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; -import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload"; export interface DashboardState { user: DashboardUser; userProjects: ProjectItem[]; companyProjects: ProjectItem[]; allProjects: ProjectItem[]; - company: CompanyInfo | null; - members: Member[]; - joinRequests: JoinRequest[]; allCompanies: CompanyInfo[]; allUsers: DashboardUser[]; allJoinRequests: JoinRequest[]; auditLogs: AuditLog[]; resources: ResourceData | null; - /** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */ - assignedPlans: AssignedPlan[]; /** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */ adminCompanyId: number | null; } @@ -103,23 +84,9 @@ async function loadRoleData(state: DashboardState): Promise { }); return; } - const [projects, company, assigned] = await Promise.all([ - fetchUserProjects().catch(() => []), - fetchUserCompany().catch(() => null), - fetchAssignedPlans().catch(() => []), - ]); - state.userProjects = projects; - state.company = company; - state.assignedPlans = assigned; + state.userProjects = await fetchUserProjects().catch(() => []); 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; + state.companyProjects = await fetchCompanyProjects().catch(() => []); } } @@ -173,48 +140,6 @@ const SECTIONS: Record HTMLElement[]> = { ), ]; }, - assigned: (s) => [section(L("DE_Assigned_Title"), assignedPlanTable(s.assignedPlans), true)], - temp: () => [buildTempUploadSection()], - people: (s) => - s.user.role === "SYSTEM_ADMIN" - ? [ - section( - L("B01_Dashboard_Users"), - userTable(s.allUsers, s.user), - true, - addButton(() => openAddMemberModal(undefined, s.adminCompanyId)), - ), - section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.allJoinRequests, true), true), - ] - : [ - section( - L("B01_Dashboard_Members"), - memberTable(s.members, s.user), - true, - addButton(() => openAddMemberModal()), - ), - section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.joinRequests, false), true), - ], - company: (s) => - s.user.role === "SYSTEM_ADMIN" - ? [ - section( - L("B01_Dashboard_Companies"), - companyTable( - s.allCompanies.filter((c) => c.id === s.adminCompanyId), - s.user, - ), - true, - ), - ] - : [section(L("B01_Dashboard_Company"), buildCompanyPanel(s), true)], - companyDefaults: (s) => [ - section( - L("B01_Dashboard_SystemSettings"), - buildSystemSettingsPanel(s.user, s.adminCompanyId), - true, - ), - ], sysOverview: (s) => [ section(L("B01_Dashboard_Resources"), buildResourcePanel(s.resources), true), ], @@ -261,15 +186,11 @@ export async function mountLegacy( userProjects: [], companyProjects: [], allProjects: [], - company: null, - members: [], - joinRequests: [], allCompanies: [], allUsers: [], allJoinRequests: [], auditLogs: [], resources: null, - assignedPlans: [], adminCompanyId: null, }; showLoadingOverlay(); diff --git a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts index daa47fb7e..b08c8da0c 100644 --- a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts +++ b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts @@ -14,6 +14,7 @@ import "./B01_Dashboard_UI_Style_Pages.css"; const KIT_PATHS = { dots: "M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0", plus: "M12 5l0 14|M5 12l14 0", + pencil: "M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4|M13.5 6.5l4 4", upload: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 9l5 -5l5 5|M12 4l0 12", mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6", inbox: @@ -91,6 +92,8 @@ export interface RowMenuItem { danger?: boolean; /** 확인창 글(danger 일 때) */ confirm?: string; + /** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */ + ownConfirm?: boolean; } /** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */ @@ -117,7 +120,7 @@ export function rowMenu(items: RowMenuItem[]): HTMLElement { b.setAttribute("role", "menuitem"); b.addEventListener("click", () => { setOpen(false); - if (!item.danger) return item.onClick(); + if (!item.danger || item.ownConfirm) return item.onClick(); void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then( (ok) => ok && item.onClick(), ); @@ -126,7 +129,7 @@ export function rowMenu(items: RowMenuItem[]): HTMLElement { } trigger.addEventListener("click", (e) => { e.stopPropagation(); - setOpen(pop.hidden); + setOpen(pop.hidden === true); }); const outside = (e: MouseEvent): void => { if (!wrap.isConnected) document.removeEventListener("click", outside); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css index 8d63f3c6f..42ea95f6e 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css @@ -83,7 +83,7 @@ padding: 0 8px; text-align: right; } -.b01-pg-table tr.is-sub td { +.b01-pg-table tr.is-sub > td { height: auto; padding: 0; background: var(--b01-bg); @@ -254,6 +254,9 @@ flex: none; max-width: 55%; } +.b01-pg-row__side .ui-field__label:empty { + display: none; +} .b01-pg-row__value { text-align: right; overflow-wrap: anywhere; @@ -340,10 +343,11 @@ background: none; color: var(--b01-muted); cursor: pointer; + transform: rotate(-90deg); transition: transform 0.12s; } .b01-pg-caret.is-open { - transform: rotate(90deg); + transform: none; } .b01-pg-files { width: 100%; @@ -422,3 +426,32 @@ font-size: 12.5px; color: var(--b01-muted); } + +/* 배지 색 — 셸 변수에서 (밝음 · 어두움 같이) */ +.b01-pg .ui-tag { + border: 1px solid transparent; +} +.b01-pg .ui-tag--success { + background: var(--b01-accent-soft); + color: var(--b01-accent); +} +.b01-pg .ui-tag--accent { + background: transparent; + border-color: var(--b01-accent); + color: var(--b01-accent); +} +.b01-pg .ui-tag--neutral { + background: var(--b01-hover); + color: var(--b01-muted); +} +.b01-pg .ui-tag--warning { + background: color-mix(in srgb, #d97706 16%, transparent); + color: #b45309; +} +.b01-pg .ui-tag--danger { + background: color-mix(in srgb, var(--b01-alert) 14%, transparent); + color: var(--b01-alert); +} +:root[data-theme="dark"] .b01-pg .ui-tag--warning { + color: #f5a524; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css b/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css index 8d89703ce..5eab1d5a9 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css @@ -1,133 +1,8 @@ /* ============================================================================= * B01_Dashboard_UI_Style_Temp.css - * 임시 보관함 섹션 — 그룹(임시 프로젝트명) + 하위 파일 표, 등록 모달 - * 표·모달 껍데기는 대시보드 공통 스타일을 그대로 쓰고, 여기서는 그룹 카드와 - * 진행률 막대만 정의한다. 색·간격은 theme.css 변수만 사용한다. + * 임시 보관함 — 등록 · 파일 추가 모달 조각(목록 표는 `B01_Dashboard_UI_Style_Pages.css`). * ========================================================================== */ -/* 제목 + 보관 기간 태그를 한 묶음으로 — 공용 섹션 헤더의 왼쪽 칸에 들어간다. */ -.b01-temp__section-title { - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.b01-temp__list { - display: flex; - flex-direction: column; - gap: var(--spacing-16); -} - -/* 그룹 = 임시 프로젝트명 1건. 아래에 파일 표가 붙는다. */ -.b01-temp__group { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-paper); -} - -/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 지금 쓸 수 있는 자료와 구분한다. */ -.b01-temp__group.is-linked { - opacity: 0.6; -} - -/* 제목 행 전체가 접기/펼치기 트리거다. */ -.b01-temp__group-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8); - flex-wrap: wrap; - cursor: pointer; -} - -.b01-temp__group-title { - display: flex; - align-items: center; - gap: var(--spacing-8); - flex-wrap: wrap; -} - -.b01-temp__caret { - width: 12px; - font-family: var(--font-body); - font-size: var(--text-caption); - line-height: 1; - color: var(--color-text-muted); -} - -.b01-temp__group-meta { - font-family: var(--font-body); - font-size: var(--text-caption); - color: var(--color-text-muted); -} - -/* 기본은 접힘 — 펼친 그룹만 파일 표를 보여 준다. */ -.b01-temp__group-body { - display: none; - margin-top: var(--spacing-8); -} - -.b01-temp__group.is-open .b01-temp__group-body { - display: block; -} - -/* 파일 표는 행이 많아 촘촘하게 — 글자 크기는 그대로 두고 상하 여백만 줄인다. */ -.b01-temp__group-body .ui-general-block__table th, -.b01-temp__group-body .ui-general-block__table td { - padding-top: 2px; - padding-bottom: 2px; -} - -.b01-temp__group-body .ui-general-block__table .ui-btn { - padding-top: 2px; - padding-bottom: 2px; - min-height: 0; -} - -.b01-temp__badge { - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - font-size: 11px; - white-space: nowrap; -} - -.b01-temp__group.is-completed .b01-temp__badge { - border-color: var(--color-success); - color: var(--color-success); -} - -.b01-temp__group.is-uploading .b01-temp__badge { - border-color: var(--color-warning); - color: var(--color-warning); -} - -/* 업로드 진행률은 모달이 아니라 해당 파일 행 안에서 보여 준다(2026-08-08 사용자 지시). */ -.b01-temp__progress { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 140px; - font-size: 11px; -} - -.b01-temp__progress-bar { - height: 6px; - border-radius: 3px; - background: var(--color-canvas); - overflow: hidden; -} - -.b01-temp__progress-bar > div { - height: 100%; - background: var(--color-primary); - transition: width 0.2s ease; -} - /* ── 등록/추가 모달 ───────────────────────────────────────────────────── */ .b01-temp__hidden-input { diff --git a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts index a4e7a3051..5f6b77de6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts +++ b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts @@ -12,6 +12,7 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { table, text } from "@ui/ui_template_general_blocks"; +import { bytesText } from "./logic/B01_Dashboard_Logic_Pages"; import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; /** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */ @@ -20,13 +21,7 @@ export function tempFileType(fileName: string): string { return index >= 0 ? fileName.slice(index + 1).toLowerCase() : ""; } -export function formatTempBytes(bytes: number): string { - const gb = bytes / 1024 / 1024 / 1024; - if (gb >= 1) return `${gb.toFixed(2)} GB`; - const mb = bytes / 1024 / 1024; - if (mb >= 1) return `${mb.toFixed(1)} MB`; - return `${Math.max(1, Math.round(bytes / 1024))} KB`; -} +export const formatTempBytes = bytesText; export interface TempModalOptions { title: string; diff --git a/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts index a28e01793..f0a655371 100644 --- a/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts +++ b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts @@ -1,18 +1,22 @@ /* ============================================================================= * B01_Dashboard_UI_TempUpload.ts - * 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳. - * - * 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시): - * - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환 - * - 그룹(임시 프로젝트명) 아래에 파일 리스트(표) - * - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음) - * - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다 - * 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다. + * 임시 보관함 본체(549-1) — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳. + * 목록 = 묶음 표(이름 · 상태 · 남은 날 막대 · 올린 날 · 줄 끝 ⋯) · 줄을 펼치면 그 안 파일 · 진행률 + * ⋯ = 파일 추가 · 삭제(확인) · 파일 줄 ⋯ = 삭제(확인) · 삭제하면 임시 저장소 실제 파일도 지움 + * 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있음 + * 화면 틀(머리 · 주 단추)은 `B01_Dashboard_UI_View_Temp.ts` * ========================================================================== */ import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; -import { createButton, createTag, showToast } from "@ui/ui_template_elements"; -import { limitVisibleRows, section, table, text } from "@ui/ui_template_general_blocks"; +import { showToast } from "@ui/ui_template_elements"; +import { icon } from "@ui/ui_template_icons"; +import { + bytesText, + daysLeft, + EXPIRY_WARN_DAYS, + isExpiringSoon, + leftPercent, +} from "./logic/B01_Dashboard_Logic_Pages"; import { createTempBatch, createTempUploadSession, @@ -25,8 +29,18 @@ import { uploadTempChunk, type TempBatchItem, } from "./B01_Dashboard_Api_Temp"; -import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common"; -import { formatTempBytes, openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal"; +import { L } from "./B01_Dashboard_UI_Common"; +import { + badge, + emptyState, + nameCell, + primaryButton, + rowMenu, + tableRow, + tableShell, + type Column, +} from "./B01_Dashboard_UI_PageKit"; +import { openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal"; import "./B01_Dashboard_UI_Style_Temp.css"; /** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */ @@ -65,40 +79,48 @@ function formatDate(value: string | null): string { return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString(); } -/** - * 보관함 섹션(제목 + [+] 버튼 + 그룹 목록)을 통째로 만든다. - * 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음). - */ -export function buildTempUploadSection(): HTMLElement { - const panel = document.createElement("div"); - panel.className = "b01-temp"; +const COLUMNS: Column[] = [ + { label: "보관 이름" }, + { label: "상태", width: "120px" }, + { label: "남은 기간", width: "210px" }, + { label: "올린 날", width: "110px" }, + { label: "", width: "44px" }, +]; - // 보관 기간은 제목 옆 태그로 알린다(서버가 준 값으로 목록 조회 때 갱신). - const retentionTag = createTag(`${30}${L("B01_Temp_Hint_Days")}`, "neutral"); +export interface TempBox { + root: HTMLElement; + /** 새 자료 등록 모달 — 머리 주 단추가 부름 */ + openCreate: () => void; +} +/** 보관함 본체 — 표 · 빈 상태 · 업로드를 한 덩이로 · 목록은 안에서 다시 그림(전체 페이지 재렌더 없음) */ +export function buildTempBox(onGuide: () => void): TempBox { + const root = document.createElement("div"); + root.className = "b01-pg__body"; + const note = document.createElement("p"); + note.className = "b01-pg-note"; const listHost = document.createElement("div"); - listHost.className = "b01-temp__list"; - panel.append(listHost); + root.append(note, listHost); /** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */ const tasks = new Map(); /** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */ const progressNodes = new Map(); - /** 펼쳐 둔 그룹 — 기본은 접힘, 사용자가 연 그룹만 목록을 다시 그려도 열린 상태를 유지한다. */ + /** 펼쳐 둔 묶음 — 목록을 다시 그려도 열린 상태를 유지한다. */ const openGroups = new Set(); - /** 4개 높이 제한을 다시 재는 함수 — 그룹을 펼치면 높이가 달라져 다시 재야 한다. */ - let remeasureGroups: (() => void) | null = null; + let retentionDays = 30; + let lastBatches: TempBatchItem[] = []; /* ── 행 구성 ─────────────────────────────────────────────────────────── */ function progressCell(key: string, percent: number, label: string): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b01-temp__progress"; + const wrap = document.createElement("span"); + wrap.className = "b01-pg-progress"; const caption = document.createElement("span"); caption.textContent = `${label} ${Math.round(percent)}%`; - const bar = document.createElement("div"); - bar.className = "b01-temp__progress-bar"; - const fill = document.createElement("div"); + const bar = document.createElement("span"); + bar.className = "b01-pg-progress__bar"; + const fill = document.createElement("i"); fill.style.width = `${percent}%`; bar.append(fill); wrap.append(caption, bar); @@ -113,181 +135,195 @@ export function buildTempUploadSection(): HTMLElement { node.caption.textContent = `${L("B01_Temp_File_Status_Uploading")} ${Math.round(task.percent)}%`; } - function fileRows(batch: TempBatchItem): HTMLElement[][] { - const rows: HTMLElement[][] = []; - - for (const file of batch.files) { - // 목록에는 아직 프로젝트에 안 쓴 묶음만 온다 — 항상 지울 수 있다. - const action = createButton({ - label: L("Common_Btn_Delete"), - variant: "danger", - onClick: () => void removeFile(batch, file.file_type, file.original_filename), - }); - rows.push([ - text(file.file_type.toUpperCase()), - text(file.original_filename), - text(formatTempBytes(file.file_size_bytes)), - text(L("B01_Temp_File_Status_Stored")), - action, - ]); + function fileLine(cells: (string | Node)[]): HTMLElement { + const tr = document.createElement("tr"); + for (const cell of cells) { + const td = document.createElement("td"); + td.append(cell); + tr.append(td); } + return tr; + } + /** 펼친 묶음 안 파일 표 — 올린 파일 · 올리는 중 · 이어올리기 대기 */ + function filesTable(batch: TempBatchItem): HTMLElement { + const table = document.createElement("table"); + table.className = "b01-pg-files"; + const body = document.createElement("tbody"); + for (const file of batch.files) { + body.append( + fileLine([ + file.file_type.toUpperCase(), + file.original_filename, + bytesText(file.file_size_bytes), + badge(L("B01_Temp_File_Status_Stored"), "success"), + rowMenu([ + { + label: L("Common_Btn_Delete"), + danger: true, + confirm: `${file.original_filename}\n${L("B01_Temp_File_Delete_Confirm")}`, + onClick: () => void removeFile(batch, file.file_type), + }, + ]), + ]), + ); + } const running = tasks.get(batch.batch_id) ?? []; for (const task of running) { const status = task.status === "failed" - ? text(L("B01_Temp_File_Status_Failed")) + ? badge(L("B01_Temp_File_Status_Failed"), "danger") : task.status === "waiting" - ? text(L("B01_Temp_File_Status_Waiting")) + ? badge(L("B01_Temp_File_Status_Waiting"), "neutral") : progressCell( `${batch.batch_id}::${task.key}`, task.percent, L("B01_Temp_File_Status_Uploading"), ); - rows.push([ - text(task.fileType.toUpperCase()), - text(task.fileName), - text(formatTempBytes(task.sizeBytes)), - status, - text("-"), - ]); + body.append( + fileLine([ + task.fileType.toUpperCase(), + task.fileName, + bytesText(task.sizeBytes), + status, + "", + ]), + ); } - // 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다. for (const pending of batch.pending_sessions) { - const known = running.some((task) => task.fileName === pending.original_filename); - if (known) continue; - rows.push([ - text(tempFileType(pending.original_filename).toUpperCase()), - text(pending.original_filename), - text(formatTempBytes(pending.file_size_bytes)), - progressCell( - `${batch.batch_id}::pending::${pending.upload_session_id}`, - pending.progress_percent, - L("B01_Temp_File_Status_Paused"), - ), - text("-"), - ]); + if (running.some((task) => task.fileName === pending.original_filename)) continue; + body.append( + fileLine([ + tempFileType(pending.original_filename).toUpperCase(), + pending.original_filename, + bytesText(pending.file_size_bytes), + progressCell( + `${batch.batch_id}::pending::${pending.upload_session_id}`, + pending.progress_percent, + L("B01_Temp_File_Status_Paused"), + ), + "", + ]), + ); } - return rows; + if (!body.children.length) + body.append(fileLine(["", "파일 없음 · 묶음 ⋯ 에서 파일 추가", "", "", ""])); + table.append(body); + return table; } - function renderGroup(batch: TempBatchItem): HTMLElement { - const group = document.createElement("div"); - group.className = `b01-temp__group is-${batch.status}`; - - const opened = openGroups.has(batch.batch_id); - group.classList.toggle("is-open", opened); - - const head = document.createElement("div"); - head.className = "b01-temp__group-head"; - - // 제목 행 = 접기/펼치기 트리거. 이름·상태·요약이 한 줄에 들어간다. - const titleRow = document.createElement("div"); - titleRow.className = "b01-temp__group-title"; - const caret = document.createElement("span"); - caret.className = "b01-temp__caret"; - caret.textContent = opened ? "▾" : "▸"; - const title = document.createElement("strong"); - title.textContent = batch.name; - const badge = document.createElement("span"); - badge.className = "b01-temp__badge"; - badge.textContent = batch.required_complete - ? L("B01_Temp_Status_Ready") - : L("B01_Temp_Status_Uploading"); - - const meta = document.createElement("span"); - meta.className = "b01-temp__group-meta"; - const parts = [ - `${batch.files.length}${L("B01_Temp_Meta_FileCount")}`, - formatTempBytes(batch.total_size_bytes), - ]; - if (batch.expires_at) { - parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`); + /** 남은 날 칸 — 막대 + 글 · 7 일 안 = 빨강 */ + function leftCell(batch: TempBatchItem): HTMLElement { + const left = daysLeft(batch.expires_at); + const cell = document.createElement("span"); + // 다 올리기 전에는 만료가 정해지지 않음 — 막대 없이 「-」 + if (left === null) { + cell.className = "b01-pg-meta"; + cell.textContent = "-"; + return cell; } - meta.textContent = parts.join(" · "); - titleRow.append(caret, title, badge, meta); + cell.className = `b01-pg-left${isExpiringSoon(left) ? " is-soon" : ""}`; + const bar = document.createElement("span"); + bar.className = "b01-pg-left__bar"; + const fill = document.createElement("i"); + fill.style.width = `${leftPercent(left, retentionDays)}%`; + bar.append(fill); + const label = document.createElement("span"); + label.textContent = left === 0 ? "오늘 만료" : `${left}일 남음`; + cell.append(bar, label); + return cell; + } - const actions = document.createElement("div"); - actions.className = "b01-dashboard__actions"; - // 버튼 클릭이 접기/펼치기까지 건드리지 않게 막는다. - actions.addEventListener("click", (event) => event.stopPropagation()); - actions.append( - createButton({ - label: L("B01_Temp_Btn_Add"), - variant: "ghost", - onClick: () => openAddModal(batch), - }), - createButton({ - label: L("Common_Btn_Delete"), - variant: "danger", - onClick: () => void removeBatch(batch), - }), - ); - head.append(titleRow, actions); - head.setAttribute("aria-expanded", String(opened)); - head.addEventListener("click", () => { - const nowOpen = !group.classList.contains("is-open"); - group.classList.toggle("is-open", nowOpen); - head.setAttribute("aria-expanded", String(nowOpen)); - caret.textContent = nowOpen ? "▾" : "▸"; - // 목록을 다시 그려도 열어 둔 그룹은 계속 열려 있어야 한다(업로드 중 진행률 확인). - if (nowOpen) openGroups.add(batch.batch_id); - else openGroups.delete(batch.batch_id); - // 펼치면 그 묶음이 커진다 — 4개까지 보이는 높이를 다시 잡는다. - remeasureGroups?.(); + function nameWithCaret(batch: TempBatchItem, opened: boolean): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b01-pg-person"; + const caret = document.createElement("button"); + caret.type = "button"; + caret.className = `b01-pg-caret${opened ? " is-open" : ""}`; + caret.title = caret.ariaLabel = "파일 펼치기"; + caret.setAttribute("aria-expanded", String(opened)); + caret.append(icon("chevronDown", 14)); + caret.addEventListener("click", () => { + if (!openGroups.delete(batch.batch_id)) openGroups.add(batch.batch_id); + draw(lastBatches); }); + const sub = `${batch.files.length}${L("B01_Temp_Meta_FileCount")} · ${bytesText(batch.total_size_bytes)}`; + wrap.append(caret, nameCell(batch.name, sub)); + return wrap; + } - const body = document.createElement("div"); - body.className = "b01-temp__group-body"; - body.append( - table( - [ - L("B01_Temp_Table_Type"), - L("B01_Temp_Table_Name"), - L("B01_Temp_Table_Size"), - L("B01_Temp_Table_Status"), - L("B01_Temp_Table_Action"), - ], - fileRows(batch), - ), + function renderBatch(batch: TempBatchItem, tbody: HTMLElement): void { + const opened = openGroups.has(batch.batch_id); + const status = batch.required_complete + ? badge(L("B01_Temp_Status_Ready"), "success") + : badge(L("B01_Temp_Status_Uploading"), "warning"); + tbody.append( + tableRow(COLUMNS, [ + nameWithCaret(batch, opened), + status, + leftCell(batch), + formatDate(batch.created_at), + rowMenu([ + { label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) }, + { + label: L("Common_Btn_Delete"), + danger: true, + confirm: `${batch.name}\n${L("B01_Temp_Delete_Confirm")}`, + onClick: () => void removeBatch(batch), + }, + ]), + ]), ); + if (!opened) return; + const sub = document.createElement("tr"); + sub.className = "is-sub"; + const cell = document.createElement("td"); + cell.colSpan = COLUMNS.length; + cell.append(filesTable(batch)); + sub.append(cell); + tbody.append(sub); + } - group.append(head, body); - return group; + function draw(batches: TempBatchItem[]): void { + lastBatches = batches; + progressNodes.clear(); + note.textContent = `보관 기간 ${retentionDays}일 · 만료 ${EXPIRY_WARN_DAYS}일 전부터 빨강`; + note.hidden = batches.length === 0; + if (batches.length === 0) { + listHost.replaceChildren( + emptyState({ + icon: "archive", + text: "임시 보관함이 비어 있음", + hint: "프로젝트를 만들기 전에 측량 파일을 먼저 올려 둘 수 있음", + action: primaryButton("자료 올리기", openCreateModal, "upload"), + onGuide, + }), + ); + return; + } + const shell = tableShell(COLUMNS); + for (const batch of batches) renderBatch(batch, shell.tbody); + listHost.replaceChildren(shell.root); } async function refresh(): Promise { try { const response = await fetchTempBatches(); - retentionTag.textContent = `${response.retention_days}${L("B01_Temp_Hint_Days")}`; - progressNodes.clear(); - listHost.replaceChildren(); - if (response.batches.length === 0) { - // 빈 상태 문구·서식은 프로젝트 컨테이너와 같게 공용 표 블록을 그대로 쓴다. - listHost.append(table([], [])); - return; - } - for (const batch of response.batches) listHost.append(renderGroup(batch)); - // 묶음이 쌓여도 컨테이너는 4개 높이까지만 — 나머지는 안쪽 스크롤. - // 표가 아니라 묶음이 한 행이다. 묶음 안 파일 표는 접혀 있어 대상이 아니다. - remeasureGroups = limitVisibleRows( - listHost, - () => [...listHost.querySelectorAll(":scope > .b01-temp__group")], - DASHBOARD_VISIBLE_ROWS, - ); + retentionDays = response.retention_days; + draw(response.batches); } catch (error) { showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error"); } } - /* ── 삭제 ────────────────────────────────────────────────────────────── */ + /* ── 삭제 — 확인은 ⋯ 메뉴가 먼저 받음 ─────────────────────────────────── */ async function removeBatch(batch: TempBatchItem): Promise { - if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return; try { await deleteTempBatch(batch.batch_id); tasks.delete(batch.batch_id); + openGroups.delete(batch.batch_id); showToast(L("B01_Temp_Delete_Success"), "success"); await refresh(); } catch (error) { @@ -295,12 +331,7 @@ export function buildTempUploadSection(): HTMLElement { } } - async function removeFile( - batch: TempBatchItem, - fileType: string, - fileName: string, - ): Promise { - if (!window.confirm(`${fileName}\n${L("B01_Temp_File_Delete_Confirm")}`)) return; + async function removeFile(batch: TempBatchItem, fileType: string): Promise { try { await deleteTempBatchFile(batch.batch_id, fileType); showToast(L("B01_Temp_File_Delete_Success"), "success"); @@ -453,17 +484,5 @@ export function buildTempUploadSection(): HTMLElement { } void refresh(); - - const root = section(L("B01_Temp_Section"), panel, true, [ - createButton({ label: "+", onClick: openCreateModal }), - ]); - // 공용 섹션 헤더는 [제목 | 액션] 2단이라, 보관 기간 태그를 제목과 한 묶음으로 감싼다. - const heading = root.querySelector("h3"); - if (heading) { - const titleWrap = document.createElement("div"); - titleWrap.className = "b01-temp__section-title"; - heading.replaceWith(titleWrap); - titleWrap.append(heading, retentionTag); - } - return root; + return { root, openCreate: openCreateModal }; } diff --git a/B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts b/B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts new file mode 100644 index 000000000..37f84ff68 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_Assigned.ts + * 세부 페이지 「받은 예정노선」(549-1) — 산림청이 우리 회사에 지정한 예정노선 연도 표. + * 0 건이어도 메뉴 · 페이지 보임 · 줄 끝 ⋯ = 보기(D02) · 연결(모달) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { goToWorkflowStage } from "../A00_Common/b_workflow_nav"; +import { kstText } from "../common_util/common_util_time_kst"; +import { fetchAssignedPlans, type AssignedPlan } from "./B01_Dashboard_UI_AssignedPlans"; +import { L } from "./B01_Dashboard_UI_Common"; +import { + badge, + dataTable, + emptyState, + nameCell, + pageFrame, + rowMenu, + type Cell, + type Column, +} from "./B01_Dashboard_UI_PageKit"; +import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink"; +import type { ViewCtx } from "./B01_Dashboard_UI_Views"; + +const COLUMNS: Column[] = [ + { label: L("DE_New_Plan") }, + { label: L("DE_Assigned_Year"), width: "90px" }, + { label: L("D04_Col_Confirmed"), width: "150px" }, + { label: L("DE_Assigned_State"), width: "220px" }, + { label: "", width: "44px" }, +]; + +function stateCell(plan: AssignedPlan): HTMLElement { + const cell = document.createElement("span"); + cell.className = "b01-pg-inline"; + cell.append( + plan.linked_project_id + ? badge(L("DE_Assigned_Linked"), "success") + : badge(L("DE_Assigned_Unlinked"), "neutral"), + ); + if (plan.update_available) cell.append(badge(L("DE_Assigned_Update"), "warning")); + return cell; +} + +function planRow(plan: AssignedPlan): Cell[] { + const view = (): void => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE); + return [ + nameCell(plan.project_name), + String(plan.year), + kstText(plan.confirmed_at), + stateCell(plan), + rowMenu([ + { label: L("DE_Assigned_View"), onClick: view }, + { + label: L("DE_Assigned_Link"), + onClick: () => + void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`), + }, + ]), + ]; +} + +export async function mountAssigned(host: HTMLElement, ctx: ViewCtx): Promise { + const page = pageFrame( + host, + L("DE_Assigned_Title"), + "산림청이 지정한 예정노선이 여기 모임 · 연결하면 임도 설계가 그 노선으로 시작됨", + ); + const plans = await fetchAssignedPlans().catch(() => []); + if (!host.isConnected) return; + page.body.append( + plans.length + ? dataTable(COLUMNS, plans.map(planRow)) + : emptyState({ + icon: "route", + text: "아직 받은 예정노선이 없음", + hint: "산림청이 우리 회사에 예정노선을 지정하면 여기에 나타남", + onGuide: () => ctx.go("guide"), + }), + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_Company.ts b/B01_Dashboard/B01_Dashboard_UI_View_Company.ts new file mode 100644 index 000000000..022d604dc --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_Company.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_Company.ts + * 세부 페이지 「회사 정보」(549-1) — 설정 줄 카드(이름 · 사업자번호 · 대표자 · 주소 · 상태 · 수 · 로고). + * 회사 관리자 · 시스템 관리자 = [회사 정보 수정] · 로고 고르기 / 일반 · 산림청 = 보기만 + * 시스템 관리자 = 머리에서 고른 회사 · 회사 없음 = [회사 생성] [회사 검색] + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { + fetchAllCompanies, + fetchCompanyAssets, + fetchUserCompany, + setCompanyLogo, + type CompanyInfo, +} from "./B01_Dashboard_Api_Fetch"; +import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; +import { L } from "./B01_Dashboard_UI_Common"; +import { + openCreateCompanyModal, + openEditCompanyModal, + openFindCompanyModal, +} from "./B01_Dashboard_UI_Modals"; +import { + badge, + emptyState, + pageFrame, + primaryButton, + settingCard, + settingRow, + valueText, +} from "./B01_Dashboard_UI_PageKit"; +import type { ViewCtx } from "./B01_Dashboard_UI_Views"; +import { statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages"; + +async function loadCompany(ctx: ViewCtx): Promise { + if (ctx.role === "SYSTEM_ADMIN") { + const all = await fetchAllCompanies().catch(() => []); + return all.find((c) => c.id === ctx.companyId) ?? null; + } + return fetchUserCompany().catch(() => null); +} + +/** 로고 줄 — 관리자는 고르기 · 그 밖은 등록 여부만 */ +function logoRow(company: CompanyInfo, ctx: ViewCtx, canEdit: boolean): HTMLElement { + const desc = "도면 표제란에 들어감 · 프로젝트가 따로 고르지 않으면 이 로고를 씀"; + if (!canEdit) { + return settingRow("회사 로고", desc, valueText(company.logo_asset_id ? "등록됨" : null)); + } + const slot = document.createElement("div"); + void fetchCompanyAssets(company.id).then((assets) => { + slot.append( + createAssetField("", "LOGO", assets, company.logo_asset_id ?? null, company.id, ctx.me, { + onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined), + }).root, + ); + }); + return settingRow("회사 로고", desc, slot); +} + +export async function mountCompany(host: HTMLElement, ctx: ViewCtx): Promise { + const page = pageFrame(host, "회사 정보", "회사 이름 · 사업자 정보 · 도면에 들어가는 로고"); + const company = await loadCompany(ctx); + if (!host.isConnected) return; + if (!company) { + const actions = document.createElement("div"); + actions.className = "b01-pg-inline"; + actions.append( + primaryButton(L("B01_Dashboard_CreateCompany"), () => openCreateCompanyModal()), + createButton({ + label: L("B01_Dashboard_FindCompany"), + variant: "ghost", + onClick: () => openFindCompanyModal(), + }), + ); + page.body.append( + emptyState({ + icon: "building", + text: "소속 회사가 없음", + hint: "회사를 만들거나 이미 있는 회사를 찾아 가입 요청을 보낼 수 있음", + action: actions, + onGuide: () => ctx.go("guide"), + }), + ); + return; + } + // 회사 정보 수정 · 로고 = 관리자만(범위는 백엔드가 다시 봄) + const canEdit = ctx.role === "ADMIN" || ctx.role === "SYSTEM_ADMIN"; + if (canEdit) + page.setAction(primaryButton("회사 정보 수정", () => openEditCompanyModal(company), "pencil")); + const status = company.business_status ? statusBadgeOf(company.business_status) : null; + page.body.append( + settingCard([ + settingRow("회사 이름", "", valueText(company.name)), + settingRow( + L("B01_Dashboard_Field_BusinessNumber"), + "", + valueText(company.business_registration_number), + ), + settingRow(L("B01_Dashboard_Field_Owner"), "", valueText(company.business_owner)), + settingRow(L("B01_Dashboard_Field_Address"), "", valueText(company.business_address)), + settingRow("사업자 상태", "", status ? badge(status.text, status.tone) : valueText(null)), + settingRow(L("B01_Dashboard_Metric_ActiveUsers"), "", valueText(company.user_count ?? 0)), + settingRow(L("B01_Dashboard_Projects"), "", valueText(company.project_count ?? 0)), + logoRow(company, ctx, canEdit), + ]), + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_CompanyDefaults.ts b/B01_Dashboard/B01_Dashboard_UI_View_CompanyDefaults.ts new file mode 100644 index 000000000..4d3af66f8 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_CompanyDefaults.ts @@ -0,0 +1,37 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_CompanyDefaults.ts + * 세부 페이지 「회사 설계 기본값」(549-1) — 구조물 조합 회사 층 등 M02 로 가는 설정 줄. + * 줄 규칙 = `settingLinks` 한 벌(역할로 카드 숨김 · 회사 관리자만 수정 · 그 밖은 읽기) + * ========================================================================== */ + +import { navigateTo } from "../A00_Common/router"; +import { settingLinks } from "../A00_Common/m_settings_nav"; +import { createButton } from "@ui/ui_template_elements"; +import { emptyState, pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit"; +import type { ViewCtx } from "./B01_Dashboard_UI_Views"; + +export function mountCompanyDefaults(host: HTMLElement, ctx: ViewCtx): void { + const page = pageFrame( + host, + "회사 설계 기본값", + "새 프로젝트가 처음 받는 구조물 조합 · 회사 층은 회사 관리자가 고침", + ); + const links = settingLinks(ctx.me, ctx.companyId); + page.body.append( + links.length + ? settingCard( + links.map((link) => + settingRow( + link.title, + link.desc, + createButton({ + label: ctx.role === "ADMIN" ? "열기 · 수정" : "열기", + variant: "ghost", + onClick: () => navigateTo(link.route, link.query), + }), + ), + ), + ) + : emptyState({ icon: "adjustments", text: "표시할 설정이 없음" }), + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_People.ts b/B01_Dashboard/B01_Dashboard_UI_View_People.ts new file mode 100644 index 000000000..e988ebad8 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_People.ts @@ -0,0 +1,247 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_People.ts + * 세부 페이지 「사람」(549-1) — 탭 셋: 팀원 · 가입 요청(수 배지) · 초대. + * 회사 관리자 = 자기 회사 · 시스템 관리자 = 머리에서 고른 회사 + * 팀원 줄 ⋯ = 수정 · 역할 변경 · 회사에서 빼기(빨강) — 확인은 그 모달이 맡음 + * 가입 요청 줄 = [승인] [거절] · 초대 = 이메일로 안내 메일 · 소속 없는 가입자는 [팀원 추가] + * ========================================================================== */ + +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { + fetchAllUsers, + fetchCompanyJoinRequests, + fetchCompanyMembers, + inviteMember, + type DashboardUser, + type JoinRequest, + type Member, +} from "./B01_Dashboard_Api_Fetch"; +import { formatDate, L } from "./B01_Dashboard_UI_Common"; +import { onB01_JoinRequest_Process_Click } from "./B01_Dashboard_UI_Company"; +import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; +import { + openAddMemberModal, + openChangeRoleModal, + openDeleteUserModal, + openEditUserModal, +} from "./B01_Dashboard_UI_Modals"; +import { + badge, + dataTable, + emptyState, + kitIcon, + pageFrame, + primaryButton, + rowMenu, + tabBar, + type Cell, + type Column, +} from "./B01_Dashboard_UI_PageKit"; +import type { ViewCtx } from "./B01_Dashboard_UI_Views"; +import { pendingCount, roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages"; + +type TabKey = "members" | "requests" | "invite"; +/** 승인 · 거절 뒤 페이지가 다시 그려져도 보던 탭을 유지 */ +let lastTab: TabKey = "members"; + +const MEMBER_COLUMNS: Column[] = [ + { label: "이름" }, + { label: "이메일" }, + { label: "직급 · 부서" }, + { label: "역할", width: "120px" }, + { label: "상태", width: "90px" }, + { label: "", width: "44px" }, +]; +const REQUEST_COLUMNS: Column[] = [ + { label: "요청자" }, + { label: "이메일" }, + { label: "요청일", width: "110px" }, + { label: "상태", width: "90px" }, + { label: "", width: "150px", align: "end" }, +]; + +function person(name: string): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b01-pg-person"; + const avatar = document.createElement("span"); + avatar.className = "b01-pg-avatar"; + avatar.textContent = (name.trim() || "?").slice(0, 1); + const text = document.createElement("span"); + text.className = "b01-pg-name__main"; + text.textContent = name || "-"; + wrap.append(avatar, text); + return wrap; +} + +function memberRow(m: Member, ctx: ViewCtx): Cell[] { + const me = ctx.me; + const status = statusBadgeOf(m.status); + const items = [{ label: L("Common_Btn_Edit"), onClick: () => openEditUserModal(me, m) }]; + const rows = [...items]; + if (canChangeRole(me, m)) { + rows.push({ label: L("B01_Dashboard_ChangeRole"), onClick: () => openChangeRoleModal(m) }); + } + const system = ctx.role === "SYSTEM_ADMIN"; + const removable = canDeleteUser(me, m) && (!system || m.id !== me.id); + return [ + person(m.name), + m.email, + [m.position, m.department].filter(Boolean).join(" · ") || "-", + badge(roleText(m.role), m.role === "ADMIN" || m.role === "SYSTEM_ADMIN" ? "accent" : "neutral"), + badge(status.text, status.tone), + rowMenu([ + ...rows, + ...(removable + ? [ + { + label: L(system ? "B01_Dashboard_DeleteUser" : "B01_Dashboard_RemoveMember"), + danger: true, + ownConfirm: true, + onClick: () => openDeleteUserModal(m), + }, + ] + : []), + ]), + ]; +} + +function requestRow(r: JoinRequest, system: boolean): Cell[] { + const status = statusBadgeOf(r.status); + const waiting = !r.status || r.status.toUpperCase() === "PENDING"; + const actions = document.createElement("span"); + actions.className = "b01-pg-inline"; + if (waiting) { + actions.append( + createButton({ + label: L("B01_Dashboard_Approve"), + size: "sm", + onClick: () => void onB01_JoinRequest_Process_Click(r.id, "APPROVE", system), + }), + createButton({ + label: L("B01_Dashboard_Reject"), + size: "sm", + variant: "danger", + onClick: () => void onB01_JoinRequest_Process_Click(r.id, "REJECT", system), + }), + ); + } + return [ + person(r.user_name ?? r.user_email), + r.user_email, + formatDate(r.requested_at), + badge(status.text, status.tone), + actions, + ]; +} + +/** 초대 탭 — 아직 가입 전인 사람에게 안내 메일 · 이미 가입한 사람은 [팀원 추가] */ +function invitePanel(companyId: number | null | undefined, onAdd: () => void): HTMLElement { + const box = document.createElement("div"); + box.className = "b01-pg-card b01-pg-invite"; + const email = createInputField({ + label: "초대할 이메일", + type: "email", + placeholder: "name@company.com", + }); + const send = createButton({ + label: "초대 메일 보내기", + iconStart: kitIcon("mail"), + onClick: async () => { + const value = email.input.value.trim(); + if (!value.includes("@")) { + email.setError("메일을 보낼 이메일 주소를 입력"); + return; + } + email.setError(); + try { + await inviteMember(value, null, companyId); + email.input.value = ""; + showToast("안내 메일을 보냈음", "success"); + } catch (error) { + showToast( + error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), + "error", + ); + } + }, + }); + const form = document.createElement("div"); + form.className = "b01-pg-invite__form"; + form.append(email.root, send); + const note = document.createElement("p"); + note.className = "b01-pg-note"; + note.textContent = "아직 가입하지 않은 사람에게 가입 안내 메일만 보냄 · 계정은 본인이 만듦"; + const already = document.createElement("p"); + already.className = "b01-pg-note"; + already.append("이미 가입했고 소속이 없는 사람은 "); + const link = document.createElement("button"); + link.type = "button"; + link.className = "b01-pg-link"; + link.textContent = "팀원 추가"; + link.addEventListener("click", onAdd); + already.append(link, " 에서 바로 등록"); + box.append(form, note, already); + return box; +} + +async function loadPeople(ctx: ViewCtx): Promise<{ members: Member[]; requests: JoinRequest[] }> { + const system = ctx.role === "SYSTEM_ADMIN"; + const [members, requests] = await Promise.all([ + (system ? fetchAllUsers(ctx.companyId) : fetchCompanyMembers()).catch(() => [] as Member[]), + fetchCompanyJoinRequests(system ? ctx.companyId : undefined).catch(() => []), + ]); + return { members: members as DashboardUser[], requests }; +} + +export async function mountPeople(host: HTMLElement, ctx: ViewCtx): Promise { + const system = ctx.role === "SYSTEM_ADMIN"; + const companyId = system ? ctx.companyId : undefined; + const page = pageFrame(host, "사람", "회사 팀원 · 가입 요청 · 초대를 한곳에서 관리"); + const addMember = (): void => openAddMemberModal(undefined, companyId); + page.setAction(primaryButton("팀원 추가", addMember)); + + const { members, requests } = await loadPeople(ctx); + if (!host.isConnected) return; + + const content = document.createElement("div"); + const draw = (key: TabKey): void => { + if (key === "members") { + content.replaceChildren( + members.length + ? dataTable( + MEMBER_COLUMNS, + members.map((m) => memberRow(m, ctx)), + ) + : emptyState({ + icon: "users", + text: "등록된 팀원이 없음", + action: primaryButton("팀원 추가", addMember), + onGuide: () => ctx.go("guide"), + }), + ); + } else if (key === "requests") { + content.replaceChildren( + requests.length + ? dataTable( + REQUEST_COLUMNS, + requests.map((r) => requestRow(r, system)), + ) + : emptyState({ icon: "users", text: "대기 중인 가입 요청이 없음" }), + ); + } else { + content.replaceChildren(invitePanel(companyId, addMember)); + } + }; + const tabs = tabBar( + [ + { key: "members", label: "팀원" }, + { key: "requests", label: "가입 요청" }, + { key: "invite", label: "초대" }, + ], + lastTab, + (key) => draw((lastTab = key as TabKey)), + ); + tabs.setBadge("requests", pendingCount(requests)); + page.body.append(tabs.root, content); + draw(lastTab); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_Temp.ts b/B01_Dashboard/B01_Dashboard_UI_View_Temp.ts new file mode 100644 index 000000000..c93bdafac --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_Temp.ts @@ -0,0 +1,20 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_Temp.ts + * 세부 페이지 「임시 보관함」(549-1) — 머리 + 오른쪽 위 [자료 올리기] + 보관함 본체(`UI_TempUpload`). + * ========================================================================== */ + +import { L } from "./B01_Dashboard_UI_Common"; +import { pageFrame, primaryButton } from "./B01_Dashboard_UI_PageKit"; +import { buildTempBox } from "./B01_Dashboard_UI_TempUpload"; +import type { ViewCtx } from "./B01_Dashboard_UI_Views"; + +export function mountTemp(host: HTMLElement, ctx: ViewCtx): void { + const page = pageFrame( + host, + L("B01_Temp_Section"), + "프로젝트를 만들기 전에 측량 파일을 먼저 올려 두는 곳 · 새 프로젝트를 만들 때 불러 씀", + ); + const box = buildTempBox(() => ctx.go("guide")); + page.setAction(primaryButton("자료 올리기", box.openCreate, "upload")); + page.body.append(box.root); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Views.ts b/B01_Dashboard/B01_Dashboard_UI_Views.ts index 3297a73a9..a728f3ec1 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Views.ts @@ -12,6 +12,11 @@ import type { IconName } from "@ui/ui_template_icons"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy"; import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav"; +import { mountAssigned } from "./B01_Dashboard_UI_View_Assigned"; +import { mountCompany } from "./B01_Dashboard_UI_View_Company"; +import { mountCompanyDefaults } from "./B01_Dashboard_UI_View_CompanyDefaults"; +import { mountPeople } from "./B01_Dashboard_UI_View_People"; +import { mountTemp } from "./B01_Dashboard_UI_View_Temp"; /** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */ export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN"; @@ -49,12 +54,12 @@ export const VIEWS: View[] = [ { key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") }, { key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") }, { key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") }, - { key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: legacy("assigned") }, - { key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: legacy("temp") }, + { key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: mountAssigned }, + { key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: mountTemp }, // 회사 관리 - { key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: legacy("people") }, - { key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: legacy("company") }, - { key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: legacy("companyDefaults") }, + { key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: mountPeople }, + { key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: mountCompany }, + { key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: mountCompanyDefaults }, // 시스템 관리 { key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: legacy("sysOverview") }, { key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: legacy("sysCompanies") }, diff --git a/resources/tester/test_546_b01_shell.py b/resources/tester/test_546_b01_shell.py index baf8cfe74..a11c620e0 100644 --- a/resources/tester/test_546_b01_shell.py +++ b/resources/tester/test_546_b01_shell.py @@ -24,13 +24,16 @@ def _text(path: Path) -> str: def _run_views(body: str) -> object: - src = re.sub(r"^import[\s\S]*?;\n", "", _text(B01 / "B01_Dashboard_UI_Views.ts"), flags=re.M) - script = ( - "const mountLegacy = () => {}; const mountGuide = () => {}; const mountChat = () => {};\n" - + src - + "\n" - + body - ) + raw = _text(B01 / "B01_Dashboard_UI_Views.ts") + src = re.sub(r"^import[\s\S]*?;\n", "", raw, flags=re.M) + # 값으로 가져오는 이름(mount 함수들)은 빈 함수로 — 타입 가져오기는 건너뜀 + names = [ + n.split(" as ")[-1].strip() + for m in re.finditer(r"^import\s+\{([^}]*)\}\s+from[^;]*;", raw, flags=re.M) + for n in m.group(1).split(",") + if n.strip() and not n.strip().startswith("type ") + ] + script = "".join(f"const {n} = () => {{}}; " for n in names) + "\n" + src + "\n" + body result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", script], diff --git a/resources/tester/test_549_b01_pages.py b/resources/tester/test_549_b01_pages.py new file mode 100644 index 000000000..943bc0d6e --- /dev/null +++ b/resources/tester/test_549_b01_pages.py @@ -0,0 +1,114 @@ +"""549-1 대시보드 세부 페이지(받은 예정노선 · 임시 보관함 · 사람 · 회사 정보 · 회사 설계 기본값) 시험. + +· 화면 계산(`logic/B01_Dashboard_Logic_Pages.ts`) — 역할 · 상태 한글 · 남은 날 · 막대 · 가입 요청 수 — Node 로 바로 돌림 +· 등록부 — 네 몫 다섯 열쇠가 옛 구역(legacy)이 아닌 새 페이지를 가리킴 · 역할 값 그대로 +· 화면 조작 · 눌러 보기는 ORCA +""" + +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" +LOGIC = B01 / "logic" / "B01_Dashboard_Logic_Pages.ts" +MINE = { + "assigned": "mountAssigned", + "temp": "mountTemp", + "people": "mountPeople", + "company": "mountCompany", + "company-defaults": "mountCompanyDefaults", +} + +needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _run_logic(body: str) -> object: + script = LOGIC.read_text(encoding="utf-8") + "\n" + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@needs_node +def test_역할_상태_한글_배지(): + out = _run_logic( + "console.log(JSON.stringify([" + "roleText('SYSTEM_ADMIN'), roleText('ADMIN'), roleText('USER'), roleText(null)," + "statusBadgeOf('ACTIVE'), statusBadgeOf('inactive'), statusBadgeOf('PENDING')," + "statusBadgeOf('REJECTED'), statusBadgeOf('뭔가'), statusBadgeOf(null)]));" + ) + assert out[:4] == ["시스템 관리자", "회사 관리자", "일반 사용자", "-"] + assert out[4] == {"text": "활성", "tone": "success"} + assert out[5] == {"text": "비활성", "tone": "neutral"} + assert out[6] == {"text": "대기", "tone": "warning"} + assert out[7] == {"text": "거절", "tone": "danger"} + assert out[8] == {"text": "뭔가", "tone": "neutral"} + assert out[9] == {"text": "-", "tone": "neutral"} + + +@needs_node +def test_보관함_남은_날_막대_강조(): + out = _run_logic( + "const now = Date.parse('2026-10-26T00:00:00Z');" + "const d = (s: string | null) => daysLeft(s, now);" + "console.log(JSON.stringify([" + "d('2026-11-25T00:00:00Z'), d('2026-11-02T00:00:00Z'), d('2026-11-01T12:00:00Z')," + "d('2026-10-20T00:00:00Z'), d(null), d('아무말')," + "leftPercent(30, 30), leftPercent(15, 30), leftPercent(0, 30), leftPercent(null, 30)," + "leftPercent(40, 30), leftPercent(5, 0)," + "isExpiringSoon(7), isExpiringSoon(8), isExpiringSoon(0), isExpiringSoon(null)]));" + ) + # 30 일 · 7 일 · 올림(6.5 → 7) · 지남 0 · 없음 · 잘못된 값 + assert out[:6] == [30, 7, 7, 0, None, None] + assert out[6:12] == [100, 50, 0, 0, 100, 0] + assert out[12:] == [True, False, True, False] + + +@needs_node +def test_가입_요청_수는_대기만(): + out = _run_logic( + "console.log(JSON.stringify([" + "pendingCount([]), pendingCount([{status:'PENDING'},{status:'pending'},{}])," + "pendingCount([{status:'APPROVED'},{status:'REJECTED'}])]));" + ) + assert out == [0, 3, 0] + + +@needs_node +def test_바이트_글(): + out = _run_logic( + "console.log(JSON.stringify([bytesText(100), bytesText(2048), bytesText(5*1024*1024)," + "bytesText(3*1024*1024*1024)]));" + ) + assert out == ["1 KB", "2 KB", "5.0 MB", "3.00 GB"] + + +def test_등록부_네_몫_다섯_열쇠는_새_페이지(): + text = (B01 / "B01_Dashboard_UI_Views.ts").read_text(encoding="utf-8") + for key, mount in MINE.items(): + line = next(ln for ln in text.splitlines() if f'key: "{key}"' in ln) + assert f"mount: {mount}" in line, key + assert "legacy(" not in line, key + assert f"import {{ {mount} }}" in text, key + # 역할 값은 546 그대로 + assert re.search(r'key: "assigned".*roles: \["USER", "ADMIN", "SYSTEM_ADMIN"\]', text) + assert re.search(r'key: "people".*roles: \["ADMIN", "SYSTEM_ADMIN"\]', text) + assert re.search(r'key: "company-defaults".*roles: \["USER", "ADMIN", "FOREST"\]', text) + + +def test_새_페이지_파일은_700줄_이하(): + files = [*B01.glob("B01_Dashboard_UI_View_*.ts"), B01 / "B01_Dashboard_UI_PageKit.ts", + B01 / "B01_Dashboard_UI_TempUpload.ts", LOGIC] # fmt: skip + for path in files: + assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name