diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index d527086f..6cdad372 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -102,7 +102,6 @@ export async function renderPendingWorkflow( title: opts.title, steps: opts.steps, activeStep: opts.activeStep, - leftPanel: buildPendingBlock(), mainContent: buildPendingBlock(), stages: workflowState?.stages, currentStage: workflowState?.current_stage, diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 422a85d4..2ddb7be8 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -115,18 +115,6 @@ export interface ResourceData { stats: ResourceSnapshot; } -export interface AutomationItem { - id: number; - project_id: string; - name: string; - logic_type: string; - config_json: Record; - status: "DRAFT" | "ACTIVE" | "INACTIVE"; - last_executed_at?: string | null; - created_at?: string | null; - updated_at?: string | null; -} - export interface UpdateUserRequest { name: string; position?: string | null; @@ -148,13 +136,6 @@ export interface AdminUpdateUserRequest extends UpdateUserRequest { status?: string | null; } -export interface AutomationRequest { - name: string; - logic_type: string; - config_json: Record; - status: "DRAFT" | "ACTIVE" | "INACTIVE"; -} - export interface CreateCompanyRequest { name: string; business_registration_number: string; @@ -335,38 +316,6 @@ export async function fetchSystemResources(days = 30): Promise { return request(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`); } -export async function fetchProjectAutomations(projectId: string): Promise { - const data = await request<{ automations: AutomationItem[] }>( - `/dashboard/projects/${encodeURIComponent(projectId)}/automations`, - ); - return data.automations; -} - -export function createProjectAutomation( - projectId: string, - payload: AutomationRequest, -): Promise { - return request(`/dashboard/projects/${encodeURIComponent(projectId)}/automations`, { - method: "POST", - body: body(payload), - }); -} - -export function updateProjectAutomation( - automationId: number, - payload: AutomationRequest, -): Promise { - return request(`/dashboard/automations/${automationId}`, { method: "PUT", body: body(payload) }); -} - -export function deleteProjectAutomation(automationId: number): Promise { - return request(`/dashboard/automations/${automationId}`, { method: "DELETE" }); -} - -export function executeProjectAutomation(automationId: number): Promise { - return request(`/dashboard/automations/${automationId}/execute`, { method: "POST" }); -} - export function fetchProjectWorkflowState(projectId: string): Promise { return request(`/projects/${projectId}/workflow-state`, { method: "GET", diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 18db0ad3..1132aec2 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -678,114 +678,3 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]: (bucket_seconds, bucket_seconds, since, bucket_seconds), ) return {"current": current, "history": list(await cursor.fetchall()), "stats": current} - - -async def list_project_automations(project_id: str) -> list[dict[str, Any]]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT id, project_id, name, logic_type, config_json, status, - last_executed_at, created_at, updated_at - FROM project_automations - WHERE project_id = %s AND deleted_at IS NULL - ORDER BY updated_at DESC, created_at DESC""", - (project_id,), - ) - return list(await cursor.fetchall()) - - -async def create_project_automation( - project_id: str, actor_id: int, data: dict[str, Any] -) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await connection.begin() - await cursor.execute( - """INSERT INTO project_automations - (project_id, name, logic_type, config_json, status, created_by, updated_by) - VALUES (%s, %s, %s, %s, %s, %s, %s)""", - ( - project_id, - data["name"], - data["logic_type"], - json.dumps(data["config_json"], ensure_ascii=False), - data["status"], - actor_id, - actor_id, - ), - ) - automation_id = cursor.lastrowid - await connection.commit() - await cursor.execute("SELECT * FROM project_automations WHERE id = %s", (automation_id,)) - return await cursor.fetchone() - - -async def get_project_automation(automation_id: int) -> dict[str, Any] | None: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT a.*, p.company_id - FROM project_automations a - JOIN projects p ON p.id = a.project_id - WHERE a.id = %s AND a.deleted_at IS NULL AND p.deleted_at IS NULL""", - (automation_id,), - ) - return await cursor.fetchone() - - -async def update_project_automation( - automation_id: int, actor_id: int, data: dict[str, Any] -) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE project_automations - SET name = %s, logic_type = %s, config_json = %s, status = %s, updated_by = %s - WHERE id = %s AND deleted_at IS NULL""", - ( - data["name"], - data["logic_type"], - json.dumps(data["config_json"], ensure_ascii=False), - data["status"], - actor_id, - automation_id, - ), - ) - changed = cursor.rowcount > 0 - await connection.commit() - return changed - - -async def delete_project_automation(automation_id: int, actor_id: int) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await connection.begin() - await cursor.execute( - """UPDATE project_automations - SET deleted_at = CURRENT_TIMESTAMP, updated_by = %s - WHERE id = %s AND deleted_at IS NULL""", - (actor_id, automation_id), - ) - changed = cursor.rowcount > 0 - if changed: - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'AUTOMATION_DELETE', 'automation', %s)""", - (actor_id, automation_id), - ) - await connection.commit() - return changed - - -async def mark_project_automation_executed(automation_id: int, actor_id: int) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE project_automations - SET last_executed_at = CURRENT_TIMESTAMP, updated_by = %s - WHERE id = %s AND deleted_at IS NULL""", - (actor_id, automation_id), - ) - changed = cursor.rowcount > 0 - await connection.commit() - return changed diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index d44021c6..3a2e1cb8 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -10,14 +10,9 @@ from .B01_Dashboard_Repository import ( add_company_member, assign_user_company, change_user_role, - count_company_admins, create_company, - create_project_automation, - create_system_company, - delete_project_automation, get_dashboard_me, get_project, - get_project_automation, get_system_resources, get_user_admin_target, get_user_company, @@ -29,23 +24,19 @@ from .B01_Dashboard_Repository import ( list_company_members, list_company_projects, list_join_requests, - list_project_automations, list_user_projects, - mark_project_automation_executed, process_join_request, remove_company_member, search_companies, soft_delete_project, update_admin_user, update_project, - update_project_automation, update_user_profile, ) from .B01_Dashboard_Schema import ( AddMemberRequest, AdminUpdateUserRequest, AssignCompanyRequest, - AutomationRequest, ChangeUserRoleRequest, CreateCompanyRequest, JoinCompanyRequest, @@ -84,12 +75,6 @@ def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: return False -def _can_manage_automation(session: dict[str, Any], project: dict[str, Any]) -> bool: - if session["role"] == "SYSTEM_ADMIN": - return True - return session["role"] == "ADMIN" and _same_company(session, project.get("company_id")) - - def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool: if session["role"] == "SYSTEM_ADMIN": return True @@ -361,81 +346,3 @@ async def system_resources( async def system_projects(session: dict[str, Any] = Depends(require_system_admin)): _ = session return {"status": "success", "projects": await list_all_projects()} - - -@router.get("/projects/{project_id}/automations") -async def dashboard_project_automations( - project_id: str, - session: dict[str, Any] = Depends(verify_session), -): - project = await get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - if not _can_edit_project(session, project): - raise HTTPException(status_code=403, detail="자동화 로직 조회 권한이 없습니다.") - return {"status": "success", "automations": await list_project_automations(project_id)} - - -@router.post("/projects/{project_id}/automations") -async def dashboard_create_automation( - project_id: str, - payload: AutomationRequest, - session: dict[str, Any] = Depends(verify_session), -): - project = await get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - if not _can_manage_automation(session, project): - raise HTTPException(status_code=403, detail="자동화 로직 생성 권한이 없습니다.") - automation = await create_project_automation( - project_id, int(session["user_id"]), payload.model_dump() - ) - return {"status": "success", "automation": automation} - - -@router.put("/automations/{automation_id}") -async def dashboard_update_automation( - automation_id: int, - payload: AutomationRequest, - session: dict[str, Any] = Depends(verify_session), -): - automation = await get_project_automation(automation_id) - if not automation: - raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") - if not _can_manage_automation(session, automation): - raise HTTPException(status_code=403, detail="자동화 로직 수정 권한이 없습니다.") - if not await update_project_automation( - automation_id, int(session["user_id"]), payload.model_dump() - ): - raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") - return {"status": "success"} - - -@router.delete("/automations/{automation_id}") -async def dashboard_delete_automation( - automation_id: int, - session: dict[str, Any] = Depends(verify_session), -): - automation = await get_project_automation(automation_id) - if not automation: - raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") - if not _can_manage_automation(session, automation): - raise HTTPException(status_code=403, detail="자동화 로직 삭제 권한이 없습니다.") - if not await delete_project_automation(automation_id, int(session["user_id"])): - raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") - return {"status": "success"} - - -@router.post("/automations/{automation_id}/execute") -async def dashboard_execute_automation( - automation_id: int, - session: dict[str, Any] = Depends(verify_session), -): - automation = await get_project_automation(automation_id) - if not automation: - raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") - if not _can_manage_automation(session, automation): - raise HTTPException(status_code=403, detail="자동화 로직 실행 권한이 없습니다.") - if not await mark_project_automation_executed(automation_id, int(session["user_id"])): - raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.") - return {"status": "success"} diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index ea317ce2..de9e931c 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -52,10 +52,3 @@ class AdminUpdateUserRequest(UpdateUserRequest): status: str | None = Field( default=None, pattern="^(NO_COMPANY|PENDING|ACTIVE|INACTIVE|REJECTED)$" ) - - -class AutomationRequest(BaseModel): - name: str = Field(min_length=1, max_length=100) - logic_type: str = Field(min_length=1, max_length=50) - config_json: dict = Field(default_factory=dict) - status: str = Field(default="DRAFT", pattern="^(DRAFT|ACTIVE|INACTIVE)$") diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index d350dfdb..3689072b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -2,7 +2,8 @@ import { createButton } from "@ui/ui_template_elements"; import type { AuditLog, DashboardUser } from "./B01_Dashboard_Api_Fetch"; import { canChangeRole } from "./B01_Dashboard_UI_Helper"; import { openChangeRoleModal, openEditUserModal } from "./B01_Dashboard_UI_Modals"; -import { formatDate, L, table, text } from "./B01_Dashboard_UI_Common"; +import { table, text } from "@ui/ui_template_general_blocks"; +import { formatDate, L } from "./B01_Dashboard_UI_Common"; export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { return table( diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 49f8114e..29829381 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -1,110 +1,17 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { - createButton, - createCard, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; export function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -export function buildSectionHeader( - titleText: string, - actionButtons: HTMLElement[] = [], -): HTMLElement { - const header = document.createElement("div"); - header.className = "b01-dashboard__section-header"; - header.style.display = "flex"; - header.style.justifyContent = "space-between"; - header.style.alignItems = "center"; - header.style.marginBottom = "var(--spacing-16)"; - - const title = document.createElement("h3"); - title.className = "ui-card__title"; - title.style.margin = "0"; - title.textContent = titleText; - - const actions = document.createElement("div"); - actions.className = "b01-dashboard__actions"; - actions.append(...actionButtons); - - header.append(title, actions); - return header; -} - -export function section( - title: string, - body: HTMLElement, - wide = false, - actions: HTMLElement[] = [], -): HTMLElement { - const header = buildSectionHeader(title, actions); - const card = createCard({ body: [header, body], raised: true }); - card.classList.add("b01-dashboard__section"); - if (wide) card.classList.add("b01-dashboard__section--wide"); - return card; -} - export function roleLabel(role: DashboardUser["role"]): string { if (role === "SYSTEM_ADMIN") return L("B01_Dashboard_Role_SystemAdmin"); if (role === "ADMIN") return L("B01_Dashboard_Role_Admin"); return L("B01_Dashboard_Role_User"); } -export function table(headers: string[], rows: HTMLElement[][]): HTMLElement { - if (!rows.length) { - const empty = document.createElement("p"); - empty.className = "b01-dashboard__empty"; - empty.textContent = L("Common_Status_Empty"); - return empty; - } - const wrap = document.createElement("div"); - wrap.className = "b01-dashboard__table-wrap"; - const tableEl = document.createElement("table"); - tableEl.className = "b01-dashboard__table"; - const thead = document.createElement("thead"); - const headRow = document.createElement("tr"); - for (const header of headers) { - const th = document.createElement("th"); - th.textContent = header; - headRow.append(th); - } - thead.append(headRow); - const tbody = document.createElement("tbody"); - for (const row of rows) { - const tr = document.createElement("tr"); - for (const cell of row) { - const td = document.createElement("td"); - td.append(cell); - tr.append(td); - } - tbody.append(tr); - } - tableEl.append(thead, tbody); - wrap.append(tableEl); - return wrap; -} - -export function text(value: unknown): HTMLElement { - const span = document.createElement("span"); - span.textContent = value == null || value === "" ? "-" : String(value); - return span; -} - -export function actionPair(approve: () => void, reject: () => void): HTMLElement { - const row = document.createElement("div"); - row.className = "b01-dashboard__actions"; - row.append( - createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }), - createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }), - ); - return row; -} - export async function runRequest(action: () => Promise): Promise { showLoadingOverlay(); try { diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index e42a9338..86847eb0 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -15,7 +15,8 @@ import { openFindCompanyModal, } from "./B01_Dashboard_UI_Modals"; import type { DashboardState } from "./B01_Dashboard_UI_Page"; -import { actionPair, formatDate, L, runRequest, table, text } from "./B01_Dashboard_UI_Common"; +import { actionPair, table, text } from "@ui/ui_template_general_blocks"; +import { formatDate, L, runRequest } from "./B01_Dashboard_UI_Common"; export function buildCompanyPanel(state: DashboardState): HTMLElement { const wrap = document.createElement("div"); diff --git a/B01_Dashboard/B01_Dashboard_UI_Helper.ts b/B01_Dashboard/B01_Dashboard_UI_Helper.ts index c87dc310..261f491b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Helper.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Helper.ts @@ -26,10 +26,3 @@ export function canDeleteUser(user: DashboardUser, _targetUser: Member | Dashboa // ADMIN은 본인 회사의 멤버만 삭제 가능 return user.role === "ADMIN" && user.company_id !== null; } - -export function canManageAutomation( - user: DashboardUser, - _projectOrAutomation: ProjectItem | { company_id?: number | null }, -): boolean { - return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 3865a386..29bfdc37 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -13,11 +13,6 @@ import { changeUserRole, updateDashboardUser, removeCompanyMember, - fetchProjectAutomations, - createProjectAutomation, - updateProjectAutomation, - deleteProjectAutomation, - executeProjectAutomation, createCompany, joinCompany, searchCompanies, @@ -25,7 +20,6 @@ import { type DashboardUser, type ProjectItem, type Member, - type AutomationItem, } from "./B01_Dashboard_Api_Fetch"; function L(key: keyof typeof ui_locales): string { @@ -282,136 +276,3 @@ export function openAddMemberModal(): void { showToast(L("B01_Dashboard_Saved"), "success"); }); } - -export async function openAutomationModal(project: ProjectItem): Promise { - const modal = document.createElement("div"); - modal.className = "b01-dashboard__modal"; - const panel = document.createElement("div"); - panel.className = "b01-dashboard__modal-panel b01-dashboard__modal-panel--wide"; - - const heading = document.createElement("h3"); - heading.className = "b01-dashboard__modal-title"; - heading.textContent = `${project.name} - ${L("B01_Dashboard_AutomationLogic")}`; - - const listWrap = document.createElement("div"); - listWrap.className = "b01-dashboard__automation-list"; - - const refreshList = async () => { - listWrap.innerHTML = L("Common_Status_Loading"); - try { - const data = await fetchProjectAutomations(project.id); - listWrap.innerHTML = ""; - if (data.length === 0) { - listWrap.textContent = L("Common_Status_Empty"); - return; - } - data.forEach((item) => { - const itemEl = document.createElement("div"); - itemEl.className = "b01-dashboard__automation-item"; - - const info = document.createElement("div"); - info.innerHTML = `${item.name} (${item.logic_type}) - status: ${item.status}`; - - const actions = document.createElement("div"); - actions.className = "b01-dashboard__actions"; - - const runBtn = createButton({ - label: L("B01_Dashboard_ExecuteAutomation"), - variant: "ghost", - onClick: async () => { - await executeProjectAutomation(item.id); - showToast("실행 성공", "success"); - refreshList(); - }, - }); - - const editBtn = createButton({ - label: L("Common_Btn_Edit"), - variant: "ghost", - onClick: () => { - modal.remove(); - openEditAutomationModal(project, item); - }, - }); - - const delBtn = createButton({ - label: L("Common_Btn_Delete"), - variant: "danger", - onClick: () => { - modal.remove(); - openDeleteAutomationModal(project, item); - }, - }); - - actions.append(runBtn, editBtn, delBtn); - itemEl.append(info, actions); - listWrap.append(itemEl); - }); - } catch (e) { - listWrap.textContent = "로딩 에러"; - } - }; - - const createBtn = createButton({ - label: L("B01_Dashboard_CreateAutomation"), - onClick: () => { - modal.remove(); - openCreateAutomationModal(project); - }, - }); - - const closeBtn = createButton({ - label: L("Common_Btn_Close"), - variant: "ghost", - onClick: () => modal.remove(), - }); - - panel.append(heading, createBtn, listWrap, closeBtn); - modal.append(panel); - document.body.append(modal); - - await refreshList(); -} - -function openCreateAutomationModal(project: ProjectItem): void { - const name = createInputField({ label: "자동화 로직명", required: true }); - const type = createInputField({ label: "로직 타입", required: true }); - - openModal(L("B01_Dashboard_CreateAutomation"), [name.root, type.root], async () => { - await createProjectAutomation(project.id, { - name: name.input.value.trim(), - logic_type: type.input.value.trim(), - config_json: {}, - status: "DRAFT", - }); - showToast(L("B01_Dashboard_Saved"), "success"); - setTimeout(() => openAutomationModal(project), 300); - }); -} - -function openEditAutomationModal(project: ProjectItem, item: AutomationItem): void { - const name = createInputField({ label: "자동화 로직명", value: item.name, required: true }); - const type = createInputField({ label: "로직 타입", value: item.logic_type, required: true }); - - openModal(L("B01_Dashboard_EditAutomation"), [name.root, type.root], async () => { - await updateProjectAutomation(item.id, { - name: name.input.value.trim(), - logic_type: type.input.value.trim(), - config_json: item.config_json, - status: item.status, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - setTimeout(() => openAutomationModal(project), 300); - }); -} - -function openDeleteAutomationModal(project: ProjectItem, item: AutomationItem): void { - const warning = document.createElement("p"); - warning.textContent = L("B01_Dashboard_DeleteAutomation") + "?"; - - openModal(L("B01_Dashboard_DeleteAutomation"), [warning], async () => { - await deleteProjectAutomation(item.id); - showToast(L("B01_Dashboard_Saved"), "success"); - setTimeout(() => openAutomationModal(project), 300); - }); -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 3ab0b486..5b7c663f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -6,6 +6,7 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { section } from "@ui/ui_template_general_blocks"; import { navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, @@ -29,7 +30,7 @@ import { type ResourceData, } from "./B01_Dashboard_Api_Fetch"; import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin"; -import { L, roleLabel, section } from "./B01_Dashboard_UI_Common"; +import { L, roleLabel } from "./B01_Dashboard_UI_Common"; import { buildCompanyPanel, companyTable, diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts index 005c61bd..871531b7 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -1,15 +1,12 @@ import { createButton } from "@ui/ui_template_elements"; +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, canManageAutomation } from "./B01_Dashboard_UI_Helper"; -import { - openAutomationModal, - openDeleteProjectModal, - openEditProjectModal, -} from "./B01_Dashboard_UI_Modals"; -import { formatDate, L, table, text } from "./B01_Dashboard_UI_Common"; +import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper"; +import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals"; +import { formatDate, L } from "./B01_Dashboard_UI_Common"; export function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement { return table( @@ -43,15 +40,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser }), ); } - if (canManageAutomation(currentUser, project)) { - actCell.append( - createButton({ - label: L("B01_Dashboard_AutomationLogic"), - variant: "ghost", - onClick: () => openAutomationModal(project), - }), - ); - } return [ text(project.name), diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 9dc706f5..2ee48e66 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -9,7 +9,9 @@ import { } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createTag, showToast } from "@ui/ui_template_elements"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { createWorkflowOverlays } from "@ui/ui_template_overlay"; +import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout"; import { checkWF1AnalysisStatus, createUploadSession, @@ -29,151 +31,24 @@ import { saveB03UploadedFile, updateB03AnalysisState, } from "./B03_FileInput_State"; +import { + createFileCardTemplate, + formatBytes, + formatEta, + getExtension, + initializeSlots, + makeSessionKey, + type FileSlot, + type FileSlotState, + type StoredUploadSession, + type UploadStatus, +} from "./B03_FileInput_UI_Support"; import "./B03_FileInput_UI_Style.css"; -type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf"; -type UploadStatus = "pending" | "uploading" | "completed" | "failed"; - -interface SlotConfig { - slot: FileSlot; - labelKey: keyof typeof ui_locales; - icon: string; - extensions: readonly string[]; - isRequired: boolean; -} - -interface FileSlotState extends SlotConfig { - file?: File; - uploadSessionId?: string; - uploadStatus: UploadStatus; - progressBytes: number; - speedMbs: number; - etaSeconds: number | null; - error?: string; -} - -interface StoredUploadSession { - key: string; - projectId: string; - slot: FileSlot; - fileName: string; - fileSize: number; - uploadSessionId: string; - chunkSizeBytes: number; - totalChunks: number; - completedChunks: number; - updatedAt: number; -} - -const SLOT_CONFIGS: readonly SlotConfig[] = [ - { - slot: "las_laz", - labelKey: "B03_File_Slot_PointCloud", - icon: "●", - extensions: [".las", ".laz"], - isRequired: true, - }, - { - slot: "prj", - labelKey: "B03_File_Slot_Projection", - icon: "◇", - extensions: [".prj"], - isRequired: true, - }, - { - slot: "tfw", - labelKey: "B03_File_Slot_RasterCoord", - icon: "□", - extensions: [".tfw"], - isRequired: true, - }, - { - slot: "tif", - labelKey: "B03_File_Slot_TerrainDem", - icon: "▧", - extensions: [".tif"], - isRequired: false, - }, -]; - function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -function getExtension(fileName: string): string { - const index = fileName.lastIndexOf("."); - return index >= 0 ? fileName.slice(index).toLowerCase() : ""; -} - -function formatBytes(bytes: number): string { - const gb = bytes / 1024 / 1024 / 1024; - if (gb >= 1) return `${gb.toFixed(2)} GB`; - return `${(bytes / 1024 / 1024).toFixed(2)} MB`; -} - -function formatEta(seconds: number | null): string { - if (seconds === null || !Number.isFinite(seconds)) return "-"; - if (seconds < 60) return `${Math.ceil(seconds)}s`; - const minutes = Math.ceil(seconds / 60); - return `${minutes}m`; -} - -function makeSessionKey(projectId: string, file: File): string { - return `b03_upload_${projectId}_${file.name}_${file.size}`; -} - -function initializeSlots(): Map { - const map = new Map(); - for (const config of SLOT_CONFIGS) { - map.set(config.slot, { - ...config, - uploadStatus: "pending", - progressBytes: 0, - speedMbs: 0, - etaSeconds: null, - }); - } - return map; -} - -function createFileCardTemplate(): HTMLTemplateElement { - const template = document.createElement("template"); - template.id = "file-card-template"; - template.innerHTML = ` -
-
- -
- - -
-
- -
-
- - -
- - -
-
-
-
-
-
- - - -
-
- -
-
- `; - return template; -} - export async function renderB03FileInput(root: HTMLElement): Promise { const slots = initializeSlots(); const cardMap = new Map(); @@ -660,29 +535,36 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달 const cardsContainer = document.createElement("div"); - cardsContainer.className = - "b03-file__main-layout b03-file__control-panel b03-file__cards-container-panel"; // 동일한 양식으로 감싸기 + cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; cardsContainer.append(filesGroup); const workflowState = activeProjectId ? await fetchWorkflowState(activeProjectId).catch(() => undefined) : undefined; - const layout = createWorkflowLayout({ - title: L("B03_File_Title"), - steps: workflowSteps(), - activeStep: 0, - leftPanel: uploadControlPanel, - mainContent: cardsContainer, + const steps = workflowSteps(); + const progressContent = createStepBar(steps, 0, { stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, + orientation: "vertical", + icons: WORKFLOW_STEP_ICONS, onStepClick: (stepIndex) => { if (activeProjectId) { goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]); } }, }); - layout.root.classList.add("b03-file"); + const layout = createGeneralLayout({ + pageClass: "b03-file", + content: [uploadControlPanel, cardsContainer], + }); + layout.content.classList.add("b03-file__main-layout"); + const overlays = createWorkflowOverlays({ + title: L("B03_File_Title"), + progressContent, + showTitlePanel: false, + }); + layout.root.append(overlays.root); root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts new file mode 100644 index 00000000..40daa173 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -0,0 +1,139 @@ +import { ui_locales } from "@ui/ui_template_locale"; + +export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf"; +export type UploadStatus = "pending" | "uploading" | "completed" | "failed"; + +export interface SlotConfig { + slot: FileSlot; + labelKey: keyof typeof ui_locales; + icon: string; + extensions: readonly string[]; + isRequired: boolean; +} + +export interface FileSlotState extends SlotConfig { + file?: File; + uploadSessionId?: string; + uploadStatus: UploadStatus; + progressBytes: number; + speedMbs: number; + etaSeconds: number | null; + error?: string; +} + +export interface StoredUploadSession { + key: string; + projectId: string; + slot: FileSlot; + fileName: string; + fileSize: number; + uploadSessionId: string; + chunkSizeBytes: number; + totalChunks: number; + completedChunks: number; + updatedAt: number; +} + +const SLOT_CONFIGS: readonly SlotConfig[] = [ + { + slot: "las_laz", + labelKey: "B03_File_Slot_PointCloud", + icon: "●", + extensions: [".las", ".laz"], + isRequired: true, + }, + { + slot: "prj", + labelKey: "B03_File_Slot_Projection", + icon: "◇", + extensions: [".prj"], + isRequired: true, + }, + { + slot: "tfw", + labelKey: "B03_File_Slot_RasterCoord", + icon: "□", + extensions: [".tfw"], + isRequired: true, + }, + { + slot: "tif", + labelKey: "B03_File_Slot_TerrainDem", + icon: "▧", + extensions: [".tif"], + isRequired: false, + }, +]; + +export function getExtension(fileName: string): string { + const index = fileName.lastIndexOf("."); + return index >= 0 ? fileName.slice(index).toLowerCase() : ""; +} + +export function formatBytes(bytes: number): string { + const gb = bytes / 1024 / 1024 / 1024; + if (gb >= 1) return `${gb.toFixed(2)} GB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +export function formatEta(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return "-"; + if (seconds < 60) return `${Math.ceil(seconds)}s`; + return `${Math.ceil(seconds / 60)}m`; +} + +export function makeSessionKey(projectId: string, file: File): string { + return `b03_upload_${projectId}_${file.name}_${file.size}`; +} + +export function initializeSlots(): Map { + const map = new Map(); + for (const config of SLOT_CONFIGS) { + map.set(config.slot, { + ...config, + uploadStatus: "pending", + progressBytes: 0, + speedMbs: 0, + etaSeconds: null, + }); + } + return map; +} + +export function createFileCardTemplate(): HTMLTemplateElement { + const template = document.createElement("template"); + template.id = "file-card-template"; + template.innerHTML = ` +
+
+ +
+ + +
+
+ +
+
+ + +
+ + +
+
+
+
+
+
+ + + +
+
+ +
+
+ `; + return template; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 3fcf25a8..9eb53673 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -30,6 +30,13 @@ export interface SurfaceAnalyzeResponse { surface_model_ids: number[]; } +export interface SurfaceConfirmResponse { + status: string; + project_id: string; + model_id: number; + confirmed: boolean; +} + /** 저장된 지표면 모델 요약 (SurfaceModelSummary) */ export interface SurfaceModelSummary { id: number; @@ -37,6 +44,7 @@ export interface SurfaceModelSummary { status: string; resolution_m: number | null; model_file_path: string | null; + generation_params: Record | null; created_at: string | null; } @@ -131,6 +139,17 @@ export async function listSurfaceModels(projectId: string): Promise { + return requestJson(`/projects/${projectId}/surface/confirm`, { + method: "POST", + body: JSON.stringify({ model_id: modelId }), + }); +} + export async function listSurfaceInputFiles( projectId: string, ): Promise { diff --git a/B04_wf1_Surface/B04_wf1_Surface_Repository.py b/B04_wf1_Surface/B04_wf1_Surface_Repository.py index ed79e49b..ad97e9ba 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Repository.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Repository.py @@ -237,7 +237,8 @@ async def list_surface_models( async with connection.cursor() as cursor: await cursor.execute( """ - SELECT id, model_type, status, resolution_m, model_file_path, created_at + SELECT id, model_type, status, resolution_m, model_file_path, + generation_params, created_at FROM surface_models WHERE project_id = %s ORDER BY created_at DESC @@ -246,14 +247,58 @@ async def list_surface_models( ) rows = await cursor.fetchall() - return [ - { - "id": int(row[0]), - "model_type": row[1], - "status": row[2], - "resolution_m": row[3], - "model_file_path": row[4], - "created_at": row[5].isoformat() if row[5] else None, - } - for row in rows - ] + models: list[dict[str, Any]] = [] + for row in rows: + generation_params = row[5] + if isinstance(generation_params, str): + generation_params = json.loads(generation_params) + models.append( + { + "id": int(row[0]), + "model_type": row[1], + "status": row[2], + "resolution_m": row[3], + "model_file_path": row[4], + "generation_params": generation_params, + "created_at": row[6].isoformat() if row[6] else None, + } + ) + return models + + +async def clear_confirmed_surface_models(connection: aiomysql.Connection, project_id: UUID) -> None: + """재분석 시 기존 확정을 해제하여 새 분석 결과의 재확정을 요구한다.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE surface_models + SET status = 'COMPLETE' + WHERE project_id = %s AND status = 'CONFIRMED' + """, + (str(project_id),), + ) + + +async def confirm_surface_model( + connection: aiomysql.Connection, project_id: UUID, model_id: int +) -> None: + """프로젝트 내 단일 모델만 CONFIRMED가 되도록 갱신한다.""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE surface_models + SET status = 'COMPLETE' + WHERE project_id = %s AND status = 'CONFIRMED' + """, + (str(project_id),), + ) + await cursor.execute( + """ + UPDATE surface_models + SET status = 'CONFIRMED' + WHERE id = %s AND project_id = %s AND status = 'COMPLETE' + """, + (model_id, str(project_id)), + ) + if cursor.rowcount != 1: + raise LookupError("확정할 지표면 모델을 찾을 수 없습니다.") diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 55acf7cd..5c11c7b7 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -15,6 +15,8 @@ from fastapi.responses import FileResponse, JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis from B04_wf1_Surface.B04_wf1_Surface_Repository import ( + clear_confirmed_surface_models, + confirm_surface_model, create_processed_point_cloud, create_surface_model, create_terrain_layer, @@ -25,6 +27,8 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import ( from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, + SurfaceConfirmRequest, + SurfaceConfirmResponse, SurfaceGroundStatsResponse, SurfaceInputFileListResponse, SurfaceInputFileSummary, @@ -34,7 +38,12 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( ) from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage +from common_util.common_util_workflow_state import ( + complete_stage, + fail_stage, + start_stage, + update_stage_progress, +) from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -166,6 +175,7 @@ async def analyze_surface( async with pool.acquire() as connection: async with connection.cursor() as cursor: await start_stage(cursor, str(project_id), 1, params) + await clear_confirmed_surface_models(connection, project_id) await connection.commit() stored_path = await get_project_storage_relative_path(connection, project_id) @@ -206,13 +216,18 @@ async def analyze_surface( source_filters=source_filters, ) async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 1) + await update_stage_progress(cursor, str(project_id), 1, 100) await connection.commit() except Exception: await connection.rollback() raise - write_surface_progress(project_root, 100, "completed", "WF1 분석이 완료되었습니다.") + write_surface_progress( + project_root, + 100, + "awaiting_confirmation", + "WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.", + ) return SurfaceAnalyzeResponse( project_id=str(project_id), @@ -257,6 +272,34 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO ) +@router.post("/{project_id}/surface/confirm", response_model=SurfaceConfirmResponse) +async def confirm_surface( + project_id: UUID, request: SurfaceConfirmRequest +) -> SurfaceConfirmResponse | JSONResponse: + """사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await connection.begin() + try: + await confirm_surface_model(connection, project_id, request.model_id) + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 1) + await connection.commit() + except Exception: + await connection.rollback() + raise + return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B04 지표면 모델 확정 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "지표면 모델 확정 중 오류가 발생했습니다."}, + ) + + @router.get("/{project_id}/surface/input-files", response_model=SurfaceInputFileListResponse) async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListResponse | JSONResponse: """프로젝트의 WF1 분석 대상 LAS/LAZ 입력 파일 목록을 조회한다.""" diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index 690ff117..b2f32020 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -42,6 +42,23 @@ class SurfaceAnalyzeRequest(BaseModel): return methods +class SurfaceConfirmRequest(BaseModel): + """사용자가 선택한 지표면 모델 확정 요청.""" + + model_config = ConfigDict(extra="forbid") + + model_id: int = Field(gt=0, description="확정할 surface_models.id") + + +class SurfaceConfirmResponse(BaseModel): + """지표면 모델 확정 결과.""" + + status: str = "success" + project_id: str + model_id: int + confirmed: bool = True + + class SurfaceModelSummary(BaseModel): """저장된 지표면 모델 요약.""" @@ -50,6 +67,7 @@ class SurfaceModelSummary(BaseModel): status: str resolution_m: float | None = None model_file_path: str | None = None + generation_params: dict[str, Any] | None = None created_at: str | None = None diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts new file mode 100644 index 00000000..6582b4c8 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -0,0 +1,314 @@ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { + fetchGisGeoJson, + fetchVWorldMeta, + getVWorldMapUrl, + type VWorldMeta, +} from "./B04_wf1_Surface_Api_Fetch"; + +export interface SurfaceMapViewer { + root: HTMLElement; + render: (projectId: string) => void; + dispose: () => void; +} + +type GeoJsonGeometry = { + type: string; + coordinates: unknown; +}; + +type GeoJsonFeature = { + geometry?: GeoJsonGeometry | null; +}; + +type GeoJsonCollection = { + features?: GeoJsonFeature[]; +}; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +function makeOption(value: string, label: string): HTMLOptionElement { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + return option; +} + +function prettyScaleDistance(roughMeters: number): number { + const candidates = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; + return candidates.find((value) => value >= roughMeters) ?? candidates[candidates.length - 1]; +} + +export function createSurfaceMapViewer(): SurfaceMapViewer { + const root = document.createElement("section"); + root.className = "b04-map"; + + const header = document.createElement("div"); + header.className = "b04-map__header"; + const title = document.createElement("h3"); + title.textContent = L("B04_Surface_Map_Title"); + + const controls = document.createElement("div"); + controls.className = "b04-map__controls"; + const backgroundLabel = document.createElement("label"); + backgroundLabel.textContent = L("B04_Surface_Map_Background"); + const backgroundSelect = document.createElement("select"); + backgroundSelect.append( + makeOption("none", L("B04_Surface_Map_None")), + makeOption("satellite", L("B04_Surface_Map_Satellite")), + makeOption("hybrid", L("B04_Surface_Map_Hybrid")), + makeOption("white", L("B04_Surface_Map_White")), + ); + backgroundLabel.append(backgroundSelect); + + const gisLabel = document.createElement("label"); + gisLabel.textContent = L("B04_Surface_Map_GisLayer"); + const gisSelect = document.createElement("select"); + gisSelect.append( + makeOption("none", L("B04_Surface_Map_None")), + makeOption("지적도", L("B04_Surface_Map_Cadastral")), + makeOption("수계망", L("B04_Surface_Map_Water")), + makeOption("산사태", L("B04_Surface_Map_Landslide")), + makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")), + makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")), + ); + gisLabel.append(gisSelect); + + const resetButton = document.createElement("button"); + resetButton.type = "button"; + resetButton.textContent = L("B04_Surface_Map_Reset"); + controls.append(backgroundLabel, gisLabel, resetButton); + header.append(title, controls); + + const viewport = document.createElement("div"); + viewport.className = "b04-map__viewport"; + const image = document.createElement("img"); + image.className = "b04-map__image"; + image.alt = L("B04_Surface_Map_ImageAlt"); + image.draggable = false; + const canvas = document.createElement("canvas"); + canvas.className = "b04-map__canvas"; + const empty = document.createElement("p"); + empty.className = "b04-map__empty"; + empty.textContent = L("B04_Surface_Map_Empty"); + const status = document.createElement("span"); + status.className = "b04-map__status"; + const scaleBar = document.createElement("div"); + scaleBar.className = "b04-map__scale"; + const scaleText = document.createElement("span"); + scaleBar.append(scaleText); + viewport.append(image, canvas, empty, status, scaleBar); + root.append(header, viewport); + + let currentProjectId: string | null = null; + let meta: VWorldMeta | null = null; + let geoJson: GeoJsonCollection | null = null; + let scale = 1; + let offsetX = 0; + let offsetY = 0; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + let loadSequence = 0; + + function updateImageTransform(): void { + image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + } + + function resetView(): void { + scale = 1; + offsetX = 0; + offsetY = 0; + updateImageTransform(); + drawVectorLayer(); + } + + function toCanvasPoint( + lon: number, + lat: number, + width: number, + height: number, + ): [number, number] { + if (!meta) return [0, 0]; + const lonRange = meta.lon_max - meta.lon_min || 1; + const latRange = meta.lat_max - meta.lat_min || 1; + const baseX = ((lon - meta.lon_min) / lonRange) * width; + const baseY = height - ((lat - meta.lat_min) / latRange) * height; + const centerX = width / 2; + const centerY = height / 2; + return [ + centerX + (baseX - centerX) * scale + offsetX, + centerY + (baseY - centerY) * scale + offsetY, + ]; + } + + function drawRing( + context: CanvasRenderingContext2D, + ring: unknown, + width: number, + height: number, + fill: boolean, + ): void { + if (!Array.isArray(ring) || ring.length === 0) return; + const points = ring.filter( + (point): point is [number, number] => + Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number", + ); + if (points.length === 0) return; + context.beginPath(); + points.forEach(([lon, lat], index) => { + const [x, y] = toCanvasPoint(lon, lat, width, height); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + if (fill) { + context.closePath(); + context.save(); + context.globalAlpha = 0.16; + context.fill(); + context.restore(); + } + context.stroke(); + } + + function drawGeometry( + context: CanvasRenderingContext2D, + geometry: GeoJsonGeometry, + width: number, + height: number, + ): void { + const coordinates = geometry.coordinates; + if (!Array.isArray(coordinates)) return; + if (geometry.type === "LineString") { + drawRing(context, coordinates, width, height, false); + } else if (geometry.type === "MultiLineString") { + coordinates.forEach((line) => drawRing(context, line, width, height, false)); + } else if (geometry.type === "Polygon") { + coordinates.forEach((ring) => drawRing(context, ring, width, height, true)); + } else if (geometry.type === "MultiPolygon") { + coordinates.forEach((polygon) => { + if (Array.isArray(polygon)) { + polygon.forEach((ring) => drawRing(context, ring, width, height, true)); + } + }); + } + } + + function drawScaleBar(width: number): void { + if (!meta || width <= 0) { + scaleBar.hidden = true; + return; + } + const metersPerPixel = meta.width_meters / width / scale; + const meters = prettyScaleDistance(100 * metersPerPixel); + const pixels = meters / metersPerPixel; + scaleBar.hidden = false; + scaleBar.style.width = `${pixels}px`; + scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; + } + + function drawVectorLayer(): void { + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + const context = canvas.getContext("2d"); + if (!context) return; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); + const styles = getComputedStyle(root); + context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim(); + context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim(); + context.lineWidth = 1.5; + geoJson?.features?.forEach((feature) => { + if (feature.geometry) drawGeometry(context, feature.geometry, width, height); + }); + updateImageTransform(); + drawScaleBar(width); + } + + async function loadLayers(): Promise { + if (!currentProjectId) return; + const sequence = ++loadSequence; + const background = backgroundSelect.value; + const gisLayer = gisSelect.value; + empty.hidden = background !== "none" || gisLayer !== "none"; + image.hidden = background === "none"; + image.removeAttribute("src"); + meta = null; + geoJson = null; + resetView(); + if (background === "none" && gisLayer === "none") { + status.textContent = ""; + return; + } + status.textContent = L("B04_Surface_Map_Loading"); + const mapLayer = background === "none" ? "white" : background; + try { + const [nextMeta, nextGeoJson] = await Promise.all([ + fetchVWorldMeta(currentProjectId, mapLayer), + gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer), + ]); + if (sequence !== loadSequence) return; + meta = nextMeta; + geoJson = nextGeoJson as GeoJsonCollection | null; + if (background !== "none") { + image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`; + } + status.textContent = geoJson?.features + ? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString()) + : ""; + drawVectorLayer(); + } catch (error) { + if (sequence !== loadSequence) return; + status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); + } + } + + backgroundSelect.addEventListener("change", () => void loadLayers()); + gisSelect.addEventListener("change", () => void loadLayers()); + resetButton.addEventListener("click", resetView); + viewport.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); + drawVectorLayer(); + }, + { passive: false }, + ); + viewport.addEventListener("pointerdown", (event) => { + dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; + viewport.setPointerCapture(event.pointerId); + }); + viewport.addEventListener("pointermove", (event) => { + if (!dragStart) return; + offsetX = dragStart.offsetX + event.clientX - dragStart.x; + offsetY = dragStart.offsetY + event.clientY - dragStart.y; + drawVectorLayer(); + }); + const stopDragging = (): void => { + dragStart = null; + }; + viewport.addEventListener("pointerup", stopDragging); + viewport.addEventListener("pointercancel", stopDragging); + + const resizeObserver = new ResizeObserver(drawVectorLayer); + resizeObserver.observe(viewport); + + return { + root, + render(projectId) { + currentProjectId = projectId; + void loadLayers(); + }, + dispose() { + loadSequence += 1; + resizeObserver.disconnect(); + }, + }; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 76dd4617..d8ce006b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -29,6 +29,7 @@ import { } from "../A00_Common/b_workflow_nav"; import { analyzeSurface, + confirmSurfaceModel, fetchSurfaceGroundStats, fetchSurfacePointCloud, fetchSurfaceStatus, @@ -40,6 +41,7 @@ import { } from "./B04_wf1_Surface_Api_Fetch"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; +import { createSurfaceMapViewer } from "./B04_wf1_Surface_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer"; import "./B04_wf1_Surface_UI_Style.css"; @@ -113,6 +115,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { statsList.className = "b04-surface__stats"; const viewer = createSurfacePointCloudViewer(); const terrainViewer = createSurfaceTerrainViewer(); + const mapViewer = createSurfaceMapViewer(); const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [ "grid_min_z", @@ -194,7 +197,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { modelsSection.append(modelsTitle, modelList); bottom.append(statsSection, modelsSection); - workspace.append(topbar, viewer.root, terrainViewer.root, bottom); + workspace.append(topbar, viewer.root, terrainViewer.root, mapViewer.root, bottom); let workflowState: WorkflowState | undefined; const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); @@ -228,6 +231,22 @@ export async function renderB04Surface(root: HTMLElement): Promise { return projectId; } + function enableRouteStep(projectId: string): void { + const routeButton = layout.root.querySelectorAll( + ".ui-workflow-layout__step", + )[2]; + if (!routeButton) return; + routeButton.disabled = false; + routeButton.classList.add("is-enabled"); + routeButton.classList.remove("state-not_started", "state-stale"); + routeButton.classList.add("state-in_progress"); + if (routeButton.dataset.b04RouteEnabled === "true") return; + routeButton.dataset.b04RouteEnabled = "true"; + routeButton.addEventListener("click", () => { + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[2]); + }); + } + function renderStatus(status: SurfaceStatusResponse | null): void { statusBox.replaceChildren(); if (!status) { @@ -299,14 +318,34 @@ export async function renderB04Surface(root: HTMLElement): Promise { type.textContent = model.model_type; const variant = model.status === "CONFIRMED" || model.status === "COMPLETE" ? "success" : "neutral"; - head.append(type, createTag(model.status, variant)); + head.append( + type, + createTag( + model.status === "CONFIRMED" ? L("B04_Surface_Model_Confirmed") : model.status, + variant, + ), + ); const meta = document.createElement("div"); meta.className = "b04-surface__model-meta"; meta.append( + buildInfoLine(L("B04_Surface_Model_Filter"), model.generation_params?.source_filter), + buildInfoLine( + L("B04_Surface_Model_Representation"), + model.generation_params?.representation, + ), buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m), buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path), ); - card.append(head, meta); + const confirmButton = createButton({ + label: + model.status === "CONFIRMED" + ? L("B04_Surface_Model_Confirmed") + : L("B04_Surface_Btn_Confirm"), + variant: model.status === "CONFIRMED" ? "ghost" : "filled", + onClick: () => void onB04_Surface_Confirm_Click(model), + }); + confirmButton.disabled = model.status === "CONFIRMED"; + card.append(head, meta, confirmButton); modelList.append(card); } } @@ -343,6 +382,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { renderStatus(status); renderModels(models.models); renderGroundStats(stats.filters); + mapViewer.render(projectId); try { viewer.render(await fetchSurfacePointCloud(projectId)); } catch { @@ -350,6 +390,27 @@ export async function renderB04Surface(root: HTMLElement): Promise { } } + async function onB04_Surface_Confirm_Click(model: SurfaceModelSummary): Promise { + const projectId = getProjectId(); + if (!projectId) return; + showLoadingOverlay(); + try { + await confirmSurfaceModel(projectId, model.id); + const summary = L("B04_Surface_Confirm_Success") + .replace("{filter}", String(model.generation_params?.source_filter ?? "-")) + .replace("{method}", model.model_type) + .replace("{smoothing}", String(model.generation_params?.representation ?? "-")); + showToast(summary, "success"); + await loadProjectData(projectId); + enableRouteStep(projectId); + } catch (error) { + const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed"); + showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error"); + } finally { + hideLoadingOverlay(); + } + } + async function onB04_Surface_Analyze_Click(): Promise { const projectId = getProjectId(); if (!projectId || !selectedInputFile) return; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 47d23c24..428a2d88 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -86,9 +86,9 @@ /* --- 우측 결과 영역 --- */ .b04-surface__workspace { - display: grid; - grid-template-rows: auto minmax(360px, 1fr) auto; - min-height: calc(100vh - var(--wf-header-height)); + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--spacing-64)); } .b04-surface__topbar { @@ -329,3 +329,142 @@ height: 560px; background: var(--color-canvas); } + +/* --- 하단 2D 지도 --- */ +.b04-map { + --b04-map-vector: var(--color-accent); + display: flex; + flex-direction: column; + gap: var(--spacing-12); + margin: var(--spacing-24); + padding: var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.b04-map__header, +.b04-map__controls, +.b04-map__controls label { + display: flex; + align-items: center; +} + +.b04-map__header { + justify-content: space-between; + gap: var(--spacing-16); +} + +.b04-map__header h3 { + font-size: var(--text-body); + color: var(--color-text); +} + +.b04-map__controls { + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.b04-map__controls label { + gap: var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b04-map__controls select, +.b04-map__controls button { + min-height: 34px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-canvas); + color: var(--color-text-body); +} + +.b04-map__controls button { + cursor: pointer; +} + +.b04-map__viewport { + position: relative; + width: 100%; + height: 560px; + overflow: hidden; + touch-action: none; + cursor: grab; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-canvas); +} + +.b04-map__viewport:active { + cursor: grabbing; +} + +.b04-map__image, +.b04-map__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b04-map__image { + object-fit: fill; + transform-origin: center; + user-select: none; +} + +.b04-map__canvas { + pointer-events: none; +} + +.b04-map__empty, +.b04-map__status { + position: absolute; + z-index: 2; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b04-map__empty { + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); +} + +.b04-map__status { + top: var(--spacing-12); + left: var(--spacing-12); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-inputs); + background: var(--color-surface-raised); +} + +.b04-map__scale { + position: absolute; + bottom: var(--spacing-16); + left: var(--spacing-16); + z-index: 2; + height: var(--spacing-8); + border: 2px solid var(--color-text); + border-top: 0; + color: var(--color-text); + pointer-events: none; +} + +.b04-map__scale span { + position: absolute; + bottom: var(--spacing-8); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + font-size: var(--text-caption); + font-weight: var(--font-weight-semibold); +} + +@media (max-width: 760px) { + .b04-map__header { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index ef4595d7..62b73147 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -2,8 +2,8 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; +import { API_BASE_URL } from "@config/config_frontend"; import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; -import { fetchVWorldMeta, getVWorldMapUrl, fetchGisGeoJson } from "./B04_wf1_Surface_Api_Fetch"; export interface SurfaceTerrainViewer { root: HTMLElement; @@ -596,15 +596,21 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Animation render loop let animationFrameId = 0; + let hasConnected = false; function animate() { if (!root.isConnected) { - cancelAnimationFrame(animationFrameId); - clearMesh(); - clearContours(); - controls.dispose(); - renderer.dispose(); + if (!hasConnected) { + animationFrameId = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animationFrameId); + clearMesh(); + clearContours(); + controls.dispose(); + renderer.dispose(); + } return; } + hasConnected = true; controls.update(); // Render scale bar dynamically @@ -710,10 +716,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { currentModelsList = models; updateSelectedModel(); }, - updateBgMap(projectId, bgLayer) { + updateBgMap(_projectId, _bgLayer) { // 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의 }, - updateGisLayer(projectId, gisLayer) { + updateGisLayer(_projectId, _gisLayer) { // 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의 }, dispose() { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index f42d381f..9afb9b27 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { RENDER_OPTIONS } from "@config/config_frontend"; import { fetchVWorldMeta, @@ -208,6 +209,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { let gisObjects: THREE.Object3D[] = []; let animationFrame = 0; let currentData: SurfacePointCloudSampleResponse | null = null; + let hasConnected = false; function resize(): void { const rect = viewerArea.getBoundingClientRect(); @@ -233,12 +235,17 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { function animate(): void { if (!root.isConnected) { - cancelAnimationFrame(animationFrame); - clearPoints(); - controls.dispose(); - renderer.dispose(); + if (!hasConnected) { + animationFrame = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(animationFrame); + clearPoints(); + controls.dispose(); + renderer.dispose(); + } return; } + hasConnected = true; resize(); controls.update(); renderer.render(scene, camera); @@ -558,7 +565,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { } const getProjectIdLocal = () => { - return localStorage.getItem("current_project_id"); + return localStorage.getItem(CURRENT_PROJECT_ID_KEY); }; bgSelect.addEventListener("change", () => { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 25420441..5ac72a80 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -36,6 +36,10 @@ import { type RoutePoint, type RouteSolveResponse, } from "./B05_wf2_Route_Api_Fetch"; +import { + listSurfaceModels, + type SurfaceModelSummary, +} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import "./B05_wf2_Route_UI_Style.css"; /** locale 헬퍼 */ @@ -77,6 +81,7 @@ function parseNumber(value: string): number | null { } export async function renderB05Route(root: HTMLElement): Promise { + let confirmedSurface: SurfaceModelSummary | null = null; /* ---- 좌측: 경로 제어점 ---- */ const pointsGroup = document.createElement("fieldset"); pointsGroup.className = "b05-route__group"; @@ -124,12 +129,12 @@ export async function renderB05Route(root: HTMLElement): Promise { type: "text", value: "dtm", }); - const surfaceIdField = createInputField({ - label: L("B05_Route_Field_SurfaceId"), - type: "number", - min: 1, - }); - surfaceGroup.append(surfaceLegend, filterField.root, methodField.root, surfaceIdField.root); + filterField.input.readOnly = true; + methodField.input.readOnly = true; + const confirmedModelInfo = document.createElement("p"); + confirmedModelInfo.className = "b05-route__surface-info"; + confirmedModelInfo.textContent = L("B05_Route_Surface_NotConfirmed"); + surfaceGroup.append(surfaceLegend, confirmedModelInfo, filterField.root, methodField.root); /* ---- 좌측: 설계 제약 ---- */ const constraintGroup = document.createElement("fieldset"); @@ -281,17 +286,20 @@ export async function renderB05Route(root: HTMLElement): Promise { async function onB05_Route_Solve_Click(): Promise { const projectId = getProjectId(); if (!projectId) return; + if (!confirmedSurface) { + showToast(L("B05_Route_Surface_NotConfirmed"), "error"); + return; + } const base = collectRequest(); if (!base) return; - const surfaceId = parseNumber(surfaceIdField.input.value); showLoadingOverlay(); try { const result = await solveRoute(projectId, { ...base, method: methodField.input.value.trim() || "dtm", smooth: smoothBox.checked, - surface_model_id: surfaceId, + surface_model_id: confirmedSurface.id, algorithm: algorithmSelect.select.value, grade_class: gradeSelect.select.value, max_uphill_grade: parseNumber(maxUphillField.input.value), @@ -329,9 +337,25 @@ export async function renderB05Route(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (projectId) { try { - workflowState = await fetchWorkflowState(projectId); + const [nextWorkflowState, models] = await Promise.all([ + fetchWorkflowState(projectId), + listSurfaceModels(projectId), + ]); + workflowState = nextWorkflowState; + confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; + if (confirmedSurface) { + const sourceFilter = confirmedSurface.generation_params?.source_filter; + filterField.input.value = typeof sourceFilter === "string" ? sourceFilter : ""; + methodField.input.value = confirmedSurface.model_type; + confirmedModelInfo.textContent = L("B05_Route_Surface_Confirmed") + .replace("{id}", String(confirmedSurface.id)) + .replace("{method}", confirmedSurface.model_type); + } else { + solveButton.disabled = true; + } } catch { - /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ + solveButton.disabled = true; + confirmedModelInfo.textContent = L("B05_Route_Surface_LoadFailed"); } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 47a7504a..150614cd 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -63,6 +63,13 @@ cursor: pointer; } +.b05-route__surface-info { + margin: 0; + color: var(--color-text-body); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-medium); +} + /* --- 체크박스 --- */ .b05-route__check { display: flex; diff --git a/B10_Payment/B10_Payment_UI_Page.ts b/B10_Payment/B10_Payment_UI_Page.ts index fdb51b8e..3a6ddb7e 100644 --- a/B10_Payment/B10_Payment_UI_Page.ts +++ b/B10_Payment/B10_Payment_UI_Page.ts @@ -1,26 +1,45 @@ -/* ============================================================================= - * B10_Payment_UI_Page.ts - * 로그인 후 10: 결재 페이지 (견적 확인 + 결재 진행) - * - * ⚠️ 본문(견적 요약/결재 수단) 준비 중 — 헤더/안내만 구성. 추후 구체화. - * 제약 준수 (frontend.md): 문구는 locale 참조(§3), 공통 스캐폴드 사용(§2). - * ========================================================================== */ +import { createButton, createTag } from "@ui/ui_template_elements"; +import { section, table, text } from "@ui/ui_template_general_blocks"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import "./B10_Payment_UI_Style.css"; -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { renderPendingContent } from "../A00_Common/b_page_scaffold"; - -/** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ export function renderB10Payment(root: HTMLElement): void { - renderPendingContent(root, { + const invoiceBody = document.createElement("div"); + invoiceBody.className = "b10-payment__summary"; + const invoiceDescription = document.createElement("p"); + invoiceDescription.textContent = L("B10_Payment_Invoice_Description"); + invoiceBody.append(invoiceDescription, createTag(L("B10_Payment_Invoice_Status"), "neutral")); + + const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [ + createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }), + ]); + + const depositNote = document.createElement("p"); + depositNote.className = "b10-payment__note"; + depositNote.textContent = L("B10_Payment_Deposit_Note"); + const depositBody = document.createElement("div"); + depositBody.className = "b10-payment__summary"; + depositBody.append( + table( + [], + [ + [text(L("B10_Payment_Deposit_Account")), text(L("B10_Payment_Deposit_Pending"))], + [text(L("B10_Payment_Deposit_Amount")), text(L("B10_Payment_Deposit_Pending"))], + ], + ), + depositNote, + ); + + const layout = createGeneralLayout({ pageClass: "b10-payment", title: L("B10_Payment_Title"), subtitle: L("B10_Payment_Subtitle"), + content: [invoiceSection, section(L("B10_Payment_Deposit_Title"), depositBody, true)], }); + root.replaceChildren(layout.root); } diff --git a/B10_Payment/B10_Payment_UI_Style.css b/B10_Payment/B10_Payment_UI_Style.css new file mode 100644 index 00000000..76d5a437 --- /dev/null +++ b/B10_Payment/B10_Payment_UI_Style.css @@ -0,0 +1,14 @@ +.b10-payment__summary { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + color: var(--color-text-body); +} + +.b10-payment__note { + padding: var(--spacing-16); + border-radius: var(--radius-cards); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} diff --git a/B11_Status/B11_Status_UI_Page.ts b/B11_Status/B11_Status_UI_Page.ts index d74db681..b4f079f2 100644 --- a/B11_Status/B11_Status_UI_Page.ts +++ b/B11_Status/B11_Status_UI_Page.ts @@ -1,26 +1,51 @@ -/* ============================================================================= - * B11_Status_UI_Page.ts - * 로그인 후 11: 상태 출력 페이지 (결재완료/문서생성/다운로드) - * - * ⚠️ 본문(진행 상태/결과물 다운로드) 준비 중 — 헤더/안내만 구성. 추후 구체화. - * 제약 준수 (frontend.md): 문구는 locale 참조(§3), 공통 스캐폴드 사용(§2). - * ========================================================================== */ +import { createButton, createTag } from "@ui/ui_template_elements"; +import { section } from "@ui/ui_template_general_blocks"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import "./B11_Status_UI_Style.css"; -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { renderPendingContent } from "../A00_Common/b_page_scaffold"; - -/** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ +function buildStatusFlow(): HTMLElement { + const flow = document.createElement("ol"); + flow.className = "b11-status__flow"; + const steps = [ + L("B11_Status_Step_Request"), + L("B11_Status_Step_Issue"), + L("B11_Status_Step_Deposit"), + L("B11_Status_Step_Complete"), + ]; + for (const label of steps) { + const item = document.createElement("li"); + item.className = "b11-status__step"; + const title = document.createElement("strong"); + title.textContent = label; + item.append(title, createTag(L("B11_Status_NotStarted"), "neutral")); + flow.append(item); + } + return flow; +} + export function renderB11Status(root: HTMLElement): void { - renderPendingContent(root, { + const downloadBody = document.createElement("div"); + downloadBody.className = "b11-status__download"; + const description = document.createElement("p"); + description.textContent = L("B11_Status_Download_Description"); + downloadBody.append( + description, + createButton({ label: L("Common_Btn_Download"), variant: "ghost", disabled: true }), + ); + + const layout = createGeneralLayout({ pageClass: "b11-status", title: L("B11_Status_Title"), subtitle: L("B11_Status_Subtitle"), + content: [ + section(L("B11_Status_Flow_Title"), buildStatusFlow(), true), + section(L("B11_Status_Download_Title"), downloadBody, true), + ], }); + root.replaceChildren(layout.root); } diff --git a/B11_Status/B11_Status_UI_Style.css b/B11_Status/B11_Status_UI_Style.css new file mode 100644 index 00000000..e0862773 --- /dev/null +++ b/B11_Status/B11_Status_UI_Style.css @@ -0,0 +1,38 @@ +.b11-status__flow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--spacing-16); + margin: 0; + padding: 0; + list-style: none; +} + +.b11-status__step { + display: flex; + flex-direction: column; + gap: var(--spacing-12, var(--spacing-8)); + padding: var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + color: var(--color-text-body); +} + +.b11-status__download { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-16); + color: var(--color-text-body); +} + +@media (max-width: 860px) { + .b11-status__flow { + grid-template-columns: 1fr 1fr; + } + + .b11-status__download { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/db_management/001_create_schema.sql b/db_management/001_create_schema.sql index 489e1338..c9a08ad0 100644 --- a/db_management/001_create_schema.sql +++ b/db_management/001_create_schema.sql @@ -255,7 +255,7 @@ CREATE TABLE IF NOT EXISTS surface_models ( model_type VARCHAR(50), -- dem_grid, tin, mesh_triangulated, contour_lines source_file_id INT, -- FK는 later processed_cloud_id INT, -- FK는 later - status VARCHAR(50) DEFAULT 'PROCESSING', -- PROCESSING, COMPLETE, FAILED + status VARCHAR(50) DEFAULT 'PROCESSING', -- PROCESSING, COMPLETE, CONFIRMED, FAILED crs_epsg INT, resolution_m FLOAT, model_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/models/... diff --git a/db_management/004_dashboard.sql b/db_management/004_dashboard.sql index c003113b..dbf2df16 100644 --- a/db_management/004_dashboard.sql +++ b/db_management/004_dashboard.sql @@ -50,29 +50,6 @@ CREATE TABLE IF NOT EXISTS system_resources ( INDEX idx_system_resources_timestamp (timestamp) ); -CREATE TABLE IF NOT EXISTS project_automations ( - id INT PRIMARY KEY AUTO_INCREMENT, - project_id CHAR(36) NOT NULL, - name VARCHAR(100) NOT NULL, - logic_type VARCHAR(50) NOT NULL, - config_json JSON NOT NULL, - status ENUM('DRAFT', 'ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'DRAFT', - created_by INT NOT NULL, - updated_by INT NULL, - last_executed_at DATETIME NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL, - INDEX idx_project_automations_project_id (project_id), - INDEX idx_project_automations_status (status), - CONSTRAINT fk_project_automations_project_id FOREIGN KEY (project_id) REFERENCES projects(id) - ON DELETE CASCADE, - CONSTRAINT fk_project_automations_created_by FOREIGN KEY (created_by) REFERENCES users(id) - ON DELETE RESTRICT, - CONSTRAINT fk_project_automations_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) - ON DELETE SET NULL -); - ALTER TABLE join_requests ADD COLUMN IF NOT EXISTS review_comment TEXT NULL AFTER reviewed_at; diff --git a/db_management/008_drop_project_automations.sql b/db_management/008_drop_project_automations.sql new file mode 100644 index 00000000..d7eb8396 --- /dev/null +++ b/db_management/008_drop_project_automations.sql @@ -0,0 +1,4 @@ +-- "자동화 설계 로직" 기능 전면 제거 (2026-07-17) +-- 앱 전체가 자동화 워크플로우(wf1~wf6)이므로 프로젝트별 자동화 로직 CRUD는 불필요. +-- 004_dashboard.sql의 project_automations 정의도 함께 제거됨 (신규 설치는 애초에 생성 안 함). +DROP TABLE IF EXISTS project_automations; diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index a919fdef..eb19f7e1 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -35,8 +35,8 @@ "semantic_hash": "" }, "docs/wiki/concepts/db_schema/files_surface.md": { - "mtime": 1784259351.9277234, - "ast_hash": "7eb0461ec86ee4eae0ff72406e41f1cd", + "mtime": 1784267731.9194815, + "ast_hash": "7025d41c372c169b7929ed6baca3b07f", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/logs_monitoring.md": { @@ -90,23 +90,23 @@ "semantic_hash": "" }, "docs/wiki/concepts/ui_templates.md": { - "mtime": 1784260037.317444, - "ast_hash": "26b52705999f61d424a9060fd8509dcc", + "mtime": 1784264934.0988722, + "ast_hash": "6cb21cb237d90125d149b1db904a2d74", "semantic_hash": "" }, "docs/wiki/concepts/workflow_state.md": { - "mtime": 1784259062.3032951, - "ast_hash": "b5be92081d3d51e7aba4181ff3f7b677", + "mtime": 1784267747.180053, + "ast_hash": "c1fa859f4d19cea7059daf828b29a41e", "semantic_hash": "" }, "docs/wiki/index.md": { - "mtime": 1784260033.8125088, - "ast_hash": "9a8f4621e4acf485cd1d9c4909e077de", + "mtime": 1784267752.6659317, + "ast_hash": "bc91a2dfed2319cb07c08e3d148d24ab", "semantic_hash": "" }, "docs/wiki/log.md": { - "mtime": 1784259880.8909566, - "ast_hash": "157d6ebdee9f0c5ccd727e6ebd506260", + "mtime": 1784267757.9505198, + "ast_hash": "5db722c00a166bd1bd486e71a0613148", "semantic_hash": "" }, "docs/wiki/pages/A01_Home/A01_components.md": { @@ -205,8 +205,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B01_Dashboard/B01_frontend.md": { - "mtime": 1784259258.671021, - "ast_hash": "817c78981729333cd1542b944f7758de", + "mtime": 1784264944.284991, + "ast_hash": "720d69c49dce55121af9846763aa3258", "semantic_hash": "" }, "docs/wiki/pages/B02_ProjRegister/B02_backend.md": { @@ -245,23 +245,23 @@ "semantic_hash": "" }, "docs/wiki/pages/B03_FileInput/B03_frontend.md": { - "mtime": 1784259161.700674, - "ast_hash": "9d93af1df4e3350bb8775731e32ecc50", + "mtime": 1784264939.1710696, + "ast_hash": "ff5b954b7136ca7f73c842965240400c", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { - "mtime": 1783849775.0, - "ast_hash": "98dc9571c054a9cc67f41e34c924afc4", + "mtime": 1784267047.0904605, + "ast_hash": "5558d1f973ce53a60c263b62f71b9b8d", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { - "mtime": 1783849775.0, - "ast_hash": "0c5544f6648212b90660aa84f35982ba", + "mtime": 1784267712.4147556, + "ast_hash": "0de152bccf2967c5989e2ed9f0253781", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { - "mtime": 1783849775.0, - "ast_hash": "7cb52c0844a907b14cfbd5230988c56f", + "mtime": 1784267041.957495, + "ast_hash": "50541e6a765c96c40cb34856acc29900", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_dependencies.md": { @@ -270,8 +270,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "5735fc2ab57c3965dcf50149b078fc93", + "mtime": 1784267717.920956, + "ast_hash": "4891c5490bbe949e6c7a7f709270dac8", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_api.md": { @@ -295,8 +295,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "3a5bba842808d5a7a935882514495311", + "mtime": 1784267722.8724308, + "ast_hash": "f72755ed7acf8a7450feac8323fad91c", "semantic_hash": "" }, "docs/wiki/pages/B06_wf3_ProfileCross/B06_api.md": { @@ -320,8 +320,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B06_wf3_ProfileCross/B06_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "98cdce5d523d5750350c1c8eeb41ae8e", + "mtime": 1784264958.5574062, + "ast_hash": "edc082e6e035ebea9f7427393c8c863f", "semantic_hash": "" }, "docs/wiki/pages/B07_wf4_DesignDetail/B07_frontend.md": { @@ -348,5 +348,15 @@ "mtime": 1784260030.4069157, "ast_hash": "85aec17d904c548e299a4e3810669cc0", "semantic_hash": "" + }, + "docs/wiki/pages/B10_Payment/B10_frontend.md": { + "mtime": 1784264923.3316023, + "ast_hash": "922e565933ffcf90a3fa96307ae4dee3", + "semantic_hash": "" + }, + "docs/wiki/pages/B11_Status/B11_frontend.md": { + "mtime": 1784264926.0308616, + "ast_hash": "2cf11aa66d6346818c14c62fa8e2b12a", + "semantic_hash": "" } } \ No newline at end of file diff --git a/ui_template/ui_template_general_blocks.ts b/ui_template/ui_template_general_blocks.ts new file mode 100644 index 00000000..f30770b4 --- /dev/null +++ b/ui_template/ui_template_general_blocks.ts @@ -0,0 +1,90 @@ +import "./ui_template_general_layout.css"; +import { createButton, createCard } from "./ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "./ui_template_locale"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export function buildSectionHeader( + titleText: string, + actionButtons: HTMLElement[] = [], +): HTMLElement { + const header = document.createElement("div"); + header.className = "ui-general-block__section-header b01-dashboard__section-header"; + + const title = document.createElement("h3"); + title.className = "ui-card__title"; + title.style.margin = "0"; + title.textContent = titleText; + + const actions = document.createElement("div"); + actions.className = "ui-general-block__actions b01-dashboard__actions"; + actions.append(...actionButtons); + + header.append(title, actions); + return header; +} + +export function section( + title: string, + body: HTMLElement, + wide = false, + actions: HTMLElement[] = [], +): HTMLElement { + const header = buildSectionHeader(title, actions); + const card = createCard({ body: [header, body], raised: true }); + card.classList.add("ui-general-block__section", "b01-dashboard__section"); + if (wide) card.classList.add("ui-general-block__section--wide", "b01-dashboard__section--wide"); + return card; +} + +export function table(headers: string[], rows: HTMLElement[][]): HTMLElement { + if (!rows.length) { + const empty = document.createElement("p"); + empty.className = "ui-general-block__empty b01-dashboard__empty"; + empty.textContent = L("Common_Status_Empty"); + return empty; + } + const wrap = document.createElement("div"); + wrap.className = "ui-general-block__table-wrap b01-dashboard__table-wrap"; + const tableEl = document.createElement("table"); + tableEl.className = "ui-general-block__table b01-dashboard__table"; + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const header of headers) { + const th = document.createElement("th"); + th.textContent = header; + headRow.append(th); + } + thead.append(headRow); + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + for (const cell of row) { + const td = document.createElement("td"); + td.append(cell); + tr.append(td); + } + tbody.append(tr); + } + tableEl.append(thead, tbody); + wrap.append(tableEl); + return wrap; +} + +export function text(value: unknown): HTMLElement { + const span = document.createElement("span"); + span.textContent = value == null || value === "" ? "-" : String(value); + return span; +} + +export function actionPair(approve: () => void, reject: () => void): HTMLElement { + const row = document.createElement("div"); + row.className = "ui-general-block__actions b01-dashboard__actions"; + row.append( + createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }), + createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }), + ); + return row; +} diff --git a/ui_template/ui_template_general_layout.css b/ui_template/ui_template_general_layout.css new file mode 100644 index 00000000..843670bd --- /dev/null +++ b/ui_template/ui_template_general_layout.css @@ -0,0 +1,76 @@ +.ui-general-layout { + min-height: calc(100vh - var(--spacing-64)); + padding: var(--spacing-40) var(--spacing-24) var(--spacing-48); + background: var(--color-bg); +} + +.ui-general-layout__inner { + width: 100%; + max-width: var(--page-max-width); + margin: 0 auto; +} + +.ui-general-layout__header { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin-bottom: var(--spacing-24); +} + +.ui-general-layout__title { + color: var(--color-text); + font-family: var(--font-display); + font-size: var(--text-heading-sm); +} + +.ui-general-layout__subtitle { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.ui-general-layout__content { + display: flex; + flex-direction: column; + gap: var(--spacing-24); +} + +.ui-general-block__section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-16); + margin-bottom: var(--spacing-16); +} + +.ui-general-block__actions { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.ui-general-block__table-wrap { + overflow-x: auto; +} + +.ui-general-block__table { + width: 100%; + border-collapse: collapse; +} + +.ui-general-block__table th, +.ui-general-block__table td { + padding: var(--spacing-12, var(--spacing-8)) var(--spacing-16); + border-bottom: 1px solid var(--color-border); + text-align: left; +} + +.ui-general-block__empty { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +@media (max-width: 720px) { + .ui-general-layout { + padding: var(--spacing-24) var(--spacing-16) var(--spacing-40); + } +} diff --git a/ui_template/ui_template_general_layout.ts b/ui_template/ui_template_general_layout.ts new file mode 100644 index 00000000..01bd2e75 --- /dev/null +++ b/ui_template/ui_template_general_layout.ts @@ -0,0 +1,45 @@ +import "./ui_template_general_layout.css"; + +export interface GeneralLayoutOptions { + pageClass?: string; + title?: string; + subtitle?: string; + content: HTMLElement | readonly HTMLElement[]; +} + +export interface GeneralLayoutHandle { + root: HTMLElement; + content: HTMLElement; +} + +export function createGeneralLayout(options: GeneralLayoutOptions): GeneralLayoutHandle { + const root = document.createElement("div"); + root.className = "ui-general-layout"; + if (options.pageClass) root.classList.add(options.pageClass); + + const inner = document.createElement("div"); + inner.className = "ui-general-layout__inner"; + + if (options.title) { + const header = document.createElement("header"); + header.className = "ui-general-layout__header"; + const title = document.createElement("h1"); + title.className = "ui-general-layout__title"; + title.textContent = options.title; + header.append(title); + if (options.subtitle) { + const subtitle = document.createElement("p"); + subtitle.className = "ui-general-layout__subtitle"; + subtitle.textContent = options.subtitle; + header.append(subtitle); + } + inner.append(header); + } + + const content = document.createElement("main"); + content.className = "ui-general-layout__content"; + content.append(...(Array.isArray(options.content) ? options.content : [options.content])); + inner.append(content); + root.append(inner); + return { root, content }; +} diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 11dcacfb..d2de37e4 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -71,6 +71,9 @@ export const ui_locales = { Common_Msg_UnsavedChanges: ["저장되지 않은 변경사항이 있습니다", "You have unsaved changes"], Common_Msg_RequiredField: ["필수 입력 항목입니다", "This field is required"], Common_Msg_InvalidValue: ["올바르지 않은 값입니다", "Invalid value"], + Workflow_Progress_Title: ["진행단계", "Progress"], + Workflow_Overlay_Collapse: ["패널 접기", "Collapse panel"], + Workflow_Overlay_Expand: ["패널 펼치기", "Expand panel"], /* --------------------------------------------------------------------------- * 공통 — 폼 / 검증 @@ -632,6 +635,36 @@ export const ui_locales = { ], B04_Surface_Model_Resolution: ["해상도(m)", "Resolution (m)"], B04_Surface_Model_Path: ["파일 경로", "File path"], + B04_Surface_Model_Filter: ["지면 필터", "Ground filter"], + B04_Surface_Model_Representation: ["표현 방식", "Representation"], + B04_Surface_Model_Confirmed: ["확정됨", "Confirmed"], + B04_Surface_Btn_Confirm: ["이 모델 확정", "Confirm this model"], + B04_Surface_Confirm_Success: [ + "모델을 확정했습니다. 필터: {filter}, 기법: {method}, 스무딩/표현: {smoothing}", + "Model confirmed. Filter: {filter}, method: {method}, smoothing/representation: {smoothing}", + ], + B04_Surface_Confirm_Failed: ["모델 확정에 실패했습니다.", "Failed to confirm model."], + B04_Surface_Map_Title: ["2D 배경 지도 및 GIS 레이어", "2D Basemap and GIS Layers"], + B04_Surface_Map_Background: ["배경 지도", "Basemap"], + B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"], + B04_Surface_Map_None: ["없음", "None"], + B04_Surface_Map_Satellite: ["위성", "Satellite"], + B04_Surface_Map_Hybrid: ["하이브리드", "Hybrid"], + B04_Surface_Map_White: ["백지도", "White map"], + B04_Surface_Map_Cadastral: ["연속지적도", "Cadastral map"], + B04_Surface_Map_Water: ["수계망", "Water network"], + B04_Surface_Map_Landslide: ["산사태위험등급", "Landslide risk"], + B04_Surface_Map_Sigungu: ["시군구", "District boundary"], + B04_Surface_Map_Eupmyeondong: ["읍면동", "Town boundary"], + B04_Surface_Map_Reset: ["보기 초기화", "Reset view"], + B04_Surface_Map_ImageAlt: ["VWorld 배경 지도", "VWorld basemap"], + B04_Surface_Map_Empty: [ + "배경 지도 또는 GIS 레이어를 선택하세요.", + "Select a basemap or GIS layer.", + ], + B04_Surface_Map_Loading: ["지도 레이어를 불러오는 중입니다.", "Loading map layers."], + B04_Surface_Map_Features: ["{count}개 객체 표시", "Showing {count} features"], + B04_Surface_Map_LoadFailed: ["지도 레이어를 불러오지 못했습니다.", "Failed to load map."], B04_Surface_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], B04_Surface_Error_InputId: ["유효한 입력 파일 ID를 입력하세요.", "Enter a valid input file ID."], B04_Surface_Error_Selection: [ @@ -656,6 +689,15 @@ export const ui_locales = { B05_Route_Field_Filter: ["지면 필터", "Ground filter"], B05_Route_Field_Method: ["지표면 표현", "Surface method"], B05_Route_Field_SurfaceId: ["지표면 모델 ID", "Surface model ID"], + B05_Route_Surface_Confirmed: ["확정 모델 #{id} · {method}", "Confirmed model #{id} · {method}"], + B05_Route_Surface_NotConfirmed: [ + "WF1에서 지표면 모델을 확정하세요.", + "Confirm a surface model in WF1.", + ], + B05_Route_Surface_LoadFailed: [ + "확정 지표면 모델을 불러오지 못했습니다.", + "Failed to load the confirmed surface model.", + ], B05_Route_Group_Constraints: ["설계 제약", "Design Constraints"], B05_Route_Field_GradeClass: ["임도 등급", "Road grade class"], B05_Route_Field_Algorithm: ["경로 알고리즘", "Route algorithm"], @@ -724,8 +766,23 @@ export const ui_locales = { /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"], B10_Payment_Subtitle: [ - "산출된 견적을 확인하고 결재를 진행하세요.", - "Review the estimate and proceed with payment.", + "세금계산서 발행과 계좌 입금 절차를 확인하세요.", + "Review the tax invoice and bank transfer process.", + ], + B10_Payment_Invoice_Title: ["세금계산서 발행 요청", "Tax Invoice Request"], + B10_Payment_Invoice_Description: [ + "사업자 정보와 발행 금액은 견적 확정 후 연결됩니다.", + "Business details and the invoice amount will be linked after the estimate is finalized.", + ], + B10_Payment_Invoice_Request: ["발행 요청", "Request Invoice"], + B10_Payment_Invoice_Status: ["요청 전", "Not Requested"], + B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"], + B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"], + B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"], + B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"], + B10_Payment_Deposit_Note: [ + "입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.", + "Design documents and DWG downloads are enabled after the deposit is confirmed.", ], /* --- B11_Status 상태 출력 --- */ @@ -734,6 +791,17 @@ export const ui_locales = { "결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.", "Check payment and document status, and download results.", ], + B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"], + B11_Status_Step_Request: ["발행 요청", "Invoice Requested"], + B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"], + B11_Status_Step_Deposit: ["입금 확인", "Deposit Confirmed"], + B11_Status_Step_Complete: ["완료", "Complete"], + B11_Status_NotStarted: ["시작 전", "Not Started"], + B11_Status_Download_Title: ["결과물 다운로드", "Result Downloads"], + B11_Status_Download_Description: [ + "입금 확인이 완료되면 설계문서와 DWG 다운로드가 활성화됩니다.", + "Design document and DWG downloads are enabled after deposit confirmation.", + ], // 프로젝트 관리 B01_Dashboard_EditProject: ["프로젝트 수정", "Edit Project"], @@ -746,13 +814,6 @@ export const ui_locales = { B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"], B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"], - // 자동화 로직 - B01_Dashboard_AutomationLogic: ["자동화 설계 로직", "Automation Logic"], - B01_Dashboard_CreateAutomation: ["자동화 생성", "Create Automation"], - B01_Dashboard_EditAutomation: ["자동화 수정", "Edit Automation"], - B01_Dashboard_DeleteAutomation: ["자동화 삭제", "Delete Automation"], - B01_Dashboard_ExecuteAutomation: ["자동화 실행", "Execute Automation"], - // 확인 메시지 B01_Dashboard_Confirm_DeleteProject: [ "프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.", diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css new file mode 100644 index 00000000..fcc8da8d --- /dev/null +++ b/ui_template/ui_template_overlay.css @@ -0,0 +1,122 @@ +.ui-workflow-overlay { + pointer-events: none; +} + +.ui-workflow-overlay__panel { + position: fixed; + top: calc(var(--spacing-64) + var(--spacing-16)); + z-index: var(--z-dropdown); + display: flex; + flex-direction: column; + max-height: calc(100vh - var(--spacing-64) - var(--spacing-32)); + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); + pointer-events: auto; +} + +/* title 패널: 헤더 아래 전체 높이 도킹 사이드바 (열림: 본문 밀기 / 닫힘: 좌측으로 슬라이드 아웃) */ +.ui-workflow-overlay__panel--title { + top: var(--spacing-64); + bottom: 0; + left: 0; + width: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48))); + max-height: none; + overflow: visible; + border-top: 0; + border-bottom: 0; + border-left: 0; + border-radius: 0; + transition: transform var(--transition-base); +} + +.ui-workflow-overlay__panel--title.is-collapsed { + transform: translateX(-100%); + box-shadow: none; +} + +.ui-workflow-overlay__panel--title .ui-workflow-overlay__body { + flex: 1; +} + +/* 닫힘 전환 중에도 내용이 함께 슬라이드되도록 body를 숨기지 않음 */ +.ui-workflow-overlay__panel--title.is-collapsed .ui-workflow-overlay__body { + display: block; +} + +.ui-workflow-overlay__panel--progress { + right: var(--spacing-24); + width: calc(var(--wf-left-panel-width) / 2); +} + +.ui-workflow-overlay__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-12, var(--spacing-8)); + min-height: var(--spacing-48); + padding: var(--spacing-8) var(--spacing-16); +} + +.ui-workflow-overlay__title { + min-width: 0; + overflow: hidden; + color: var(--color-text); + font-size: var(--text-body); + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.ui-workflow-overlay__toggle { + flex: 0 0 auto; + width: var(--spacing-32); + height: var(--spacing-32); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-canvas); + color: var(--color-text); + cursor: pointer; +} + +/* title 패널 우측 가장자리 세로 중앙 핸들 — 닫히면 이 버튼만 화면 왼쪽에 남음 */ +.ui-workflow-overlay__handle { + position: absolute; + top: 50%; + right: calc(-1 * var(--spacing-24)); + width: var(--spacing-24); + height: var(--spacing-64); + transform: translateY(-50%); + border: 1px solid var(--color-border); + border-left: 0; + border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0; + background: var(--color-surface-raised); + color: var(--color-text); + box-shadow: var(--shadow-lg); + cursor: pointer; + pointer-events: auto; +} + +.ui-workflow-overlay__body { + overflow-y: auto; + padding: 0 var(--spacing-16) var(--spacing-16); + border-top: 1px solid var(--color-border); +} + +.ui-workflow-overlay__panel.is-collapsed .ui-workflow-overlay__body { + display: none; +} + +.ui-workflow-overlay__panel.is-collapsed .ui-workflow-overlay__header { + padding: var(--spacing-8) var(--spacing-16); +} + +@media (max-width: 860px) { + .ui-workflow-overlay__panel--progress { + top: calc(var(--spacing-64) + var(--spacing-8)); + right: var(--spacing-8); + max-height: calc(100vh - var(--spacing-64) - var(--spacing-16)); + } +} diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts new file mode 100644 index 00000000..7a9c76fd --- /dev/null +++ b/ui_template/ui_template_overlay.ts @@ -0,0 +1,121 @@ +import "./ui_template_overlay.css"; +import { t } from "./ui_template_locale"; + +const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; +const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; + +export interface WorkflowOverlayOptions { + title: string; + progressContent: HTMLElement; + optionsContent?: HTMLElement; + showTitlePanel?: boolean; + onOptionsOpenChange?: (isOpen: boolean) => void; +} + +export interface WorkflowOverlayHandle { + root: HTMLElement; + setTitleOpen: (isOpen: boolean) => void; + setProgressOpen: (isOpen: boolean) => void; +} + +function readOpenState(key: string): boolean { + return sessionStorage.getItem(key) !== "false"; +} + +function createPanel( + variant: "title" | "progress", + titleText: string, + body: HTMLElement, + storageKey: string, + onOpenChange?: (isOpen: boolean) => void, +): { root: HTMLElement; setOpen: (isOpen: boolean) => void } { + const root = document.createElement("aside"); + root.className = `ui-workflow-overlay__panel ui-workflow-overlay__panel--${variant}`; + + // title 패널은 사이드바 도킹형 — 토글이 패널 우측 가장자리 세로 중앙 핸들로 나감 + const isSidebar = variant === "title"; + + const header = document.createElement("div"); + header.className = "ui-workflow-overlay__header"; + const title = document.createElement("h2"); + title.className = "ui-workflow-overlay__title"; + title.textContent = titleText; + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = isSidebar ? "ui-workflow-overlay__handle" : "ui-workflow-overlay__toggle"; + + if (isSidebar) { + header.append(title); + root.append(header, toggle); + } else { + header.append(title, toggle); + root.append(header); + } + if (body.childElementCount > 0) { + body.classList.add("ui-workflow-overlay__body"); + root.append(body); + } + + function setOpen(isOpen: boolean): void { + root.classList.toggle("is-collapsed", !isOpen); + toggle.textContent = isSidebar ? (isOpen ? "◀" : "▶") : isOpen ? "−" : "+"; + toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); + toggle.setAttribute("aria-label", toggle.title); + toggle.setAttribute("aria-expanded", String(isOpen)); + sessionStorage.setItem(storageKey, String(isOpen)); + onOpenChange?.(isOpen); + } + + function toggleOpen(): void { + setOpen(root.classList.contains("is-collapsed")); + } + + title.tabIndex = 0; + title.setAttribute("role", "button"); + title.addEventListener("click", toggleOpen); + title.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleOpen(); + } + }); + toggle.addEventListener("click", toggleOpen); + setOpen(readOpenState(storageKey)); + return { root, setOpen }; +} + +export function createWorkflowOverlays(options: WorkflowOverlayOptions): WorkflowOverlayHandle { + const root = document.createElement("div"); + root.className = "ui-workflow-overlay"; + + const progressBody = document.createElement("div"); + progressBody.append(options.progressContent); + + const progressPanel = createPanel( + "progress", + t("Workflow_Progress_Title"), + progressBody, + PROGRESS_OVERLAY_STATE_KEY, + ); + let setTitleOpen = (_isOpen: boolean): void => {}; + if (options.showTitlePanel !== false) { + const titleBody = document.createElement("div"); + if (options.optionsContent) titleBody.append(options.optionsContent); + const titlePanel = createPanel( + "title", + options.title, + titleBody, + TITLE_OVERLAY_STATE_KEY, + options.onOptionsOpenChange, + ); + root.append(titlePanel.root); + setTitleOpen = titlePanel.setOpen; + } + root.append(progressPanel.root); + + return { + root, + setTitleOpen, + setProgressOpen: progressPanel.setOpen, + }; +} diff --git a/ui_template/ui_template_workflow_layout.css b/ui_template/ui_template_workflow_layout.css index 4734a3a6..73d104ae 100644 --- a/ui_template/ui_template_workflow_layout.css +++ b/ui_template/ui_template_workflow_layout.css @@ -1,92 +1,9 @@ .ui-workflow-layout { - display: flex; - flex-direction: column; - min-height: calc(100vh - var(--wf-header-height)); + position: relative; + min-height: calc(100vh - var(--spacing-64)); background: var(--color-bg); } -.ui-workflow-layout__header { - display: flex; - align-items: center; - gap: var(--spacing-16); - min-height: var(--wf-header-height); - padding: var(--spacing-8) var(--spacing-24); - border-bottom: 1px solid var(--color-border); - background: var(--color-surface-raised); -} - -.ui-workflow-layout__menu-button { - position: absolute; - top: 60px; - left: var(--spacing-24); - z-index: var(--z-dropdown); - width: auto; - min-width: 40px; - height: 40px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-buttons); - background: var(--color-canvas); - color: var(--color-text); - cursor: pointer; - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.ui-workflow-layout__header-hide { - width: 40px; - height: 40px; - border: 1px solid var(--color-border); - border-radius: var(--radius-buttons); - background: var(--color-canvas); - color: var(--color-text); - cursor: pointer; - flex: 0 0 auto; -} - -/* 헤더 슬라이드업 숨김 */ -.ui-workflow-layout__header { - transition: margin-top var(--transition-base); -} - -.ui-workflow-layout.is-header-hidden .ui-workflow-layout__header { - margin-top: calc(-1 * var(--wf-header-height)); - pointer-events: none; -} - -/* 헤더 복원 화살표: 숨김 상태에서만 표시 */ -.ui-workflow-layout__header-reveal { - display: none; - align-self: center; - width: 48px; - height: 24px; - margin: var(--spacing-4) auto; - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - background: var(--color-surface-raised); - color: var(--color-text-secondary); - cursor: pointer; -} - -.ui-workflow-layout.is-header-hidden .ui-workflow-layout__header-reveal { - display: block; -} - -.ui-workflow-layout__title-wrap { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-24); - flex: 1; - min-width: 0; -} - -.ui-workflow-layout__title { - font-size: var(--text-subheading); - white-space: nowrap; -} - .ui-workflow-layout__steps { display: flex; gap: var(--spacing-8); @@ -99,14 +16,19 @@ overflow: visible; } +.ui-workflow-layout__steps.is-vertical { + flex-direction: column; + overflow: visible; +} + .ui-workflow-layout__step { flex: 0 0 auto; padding: var(--spacing-4) var(--spacing-16); + border: 1px solid transparent; border-radius: var(--radius-pills); background: var(--color-paper); color: var(--color-text-secondary); font-size: var(--text-caption); - border: 1px solid transparent; cursor: default; transition: background-color var(--transition-base), @@ -114,17 +36,39 @@ cursor var(--transition-base); } +.ui-workflow-layout__steps.is-vertical .ui-workflow-layout__step { + display: flex; + align-items: center; + gap: var(--spacing-8); + width: 100%; + min-height: var(--spacing-40); + border-radius: var(--radius-buttons); + text-align: left; + white-space: normal; +} + +.ui-workflow-layout__step-icon { + flex: 0 0 var(--spacing-24); + font-size: var(--text-body); + line-height: 1; + text-align: center; +} + +.ui-workflow-layout__step-label { + min-width: 0; +} + .ui-workflow-layout__step.is-enabled { cursor: pointer; } .ui-workflow-layout__step.is-enabled:hover { - background: var(--color-paper-hover, var(--color-paper)); + background: var(--color-surface); } .ui-workflow-layout__steps.is-compact .ui-workflow-layout__step { height: 28px; - padding: 0 var(--spacing-12); + padding: 0 var(--spacing-12, var(--spacing-8)); white-space: nowrap; } @@ -156,58 +100,25 @@ } .ui-workflow-layout__body { - position: relative; - display: block; - flex: 1; - min-height: 0; + min-height: calc(100vh - var(--spacing-64)); + transition: padding-left var(--transition-base); } -.ui-workflow-layout__dropdown-panel { - position: absolute; - top: 108px; - left: var(--spacing-24); - width: 320px; - max-height: calc(100vh - var(--wf-header-height) - 80px); - z-index: var(--z-dropdown); - overflow-y: auto; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - box-shadow: var(--shadow-lg); - padding: var(--spacing-16); - opacity: 0; - transform: translateY(-10px); - pointer-events: none; - transition: - opacity var(--transition-base), - transform var(--transition-base); +/* 좌측 옵션 사이드바 열림 시 상세화면을 오른쪽으로 밀어냄 */ +.ui-workflow-layout.is-options-open .ui-workflow-layout__body { + padding-left: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48))); } -.ui-workflow-layout.is-menu-open .ui-workflow-layout__dropdown-panel { - opacity: 1; - transform: translateY(0); - pointer-events: all; +@media (max-width: 860px) { + .ui-workflow-layout.is-options-open .ui-workflow-layout__body { + padding-left: 0; + } } .ui-workflow-layout__main { width: 100%; - height: 100%; min-width: 0; - min-height: 0; + min-height: calc(100vh - var(--spacing-64)); overflow: auto; background: var(--color-canvas); } - -@media (max-width: 860px) { - .ui-workflow-layout__title-wrap { - align-items: flex-start; - flex-direction: column; - gap: var(--spacing-8); - } - - .ui-workflow-layout__dropdown-panel { - left: var(--spacing-8); - right: var(--spacing-8); - width: auto; - } -} diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index c3106cf1..fa2ce562 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -1,5 +1,6 @@ import "./ui_template_workflow_layout.css"; import { t } from "./ui_template_locale"; +import { createWorkflowOverlays } from "./ui_template_overlay"; export interface WorkflowStage { stage_no: number; @@ -11,20 +12,18 @@ export interface WorkflowLayoutOptions { title: string; steps: string[]; activeStep: number; - leftPanel: HTMLElement; + leftPanel?: HTMLElement; mainContent: HTMLElement; stages?: WorkflowStage[]; currentStage?: number; routes?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; - onMenuToggle?: (isOpen: boolean) => void; - onHeaderToggle?: (isVisible: boolean) => void; } export interface WorkflowLayoutHandle { root: HTMLElement; - setMenuOpen: (isOpen: boolean) => void; - setHeaderVisible: (isVisible: boolean) => void; + setOptionsOpen: (isOpen: boolean) => void; + setProgressOpen: (isOpen: boolean) => void; } export interface StepBarOptions { @@ -32,9 +31,13 @@ export interface StepBarOptions { currentStage?: number; routes?: readonly string[]; compact?: boolean; + orientation?: "horizontal" | "vertical"; + icons?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; } +export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "🏗️", "∑", "📄"] as const; + export function createStepBar( steps: readonly string[], activeStep: number, @@ -43,18 +46,31 @@ export function createStepBar( const bar = document.createElement("div"); bar.className = "ui-workflow-layout__steps"; if (options?.compact) bar.classList.add("is-compact"); + if (options?.orientation === "vertical") bar.classList.add("is-vertical"); steps.forEach((step, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "ui-workflow-layout__step"; if (index === activeStep) button.classList.add("is-active"); - button.textContent = step; + const iconText = options?.icons?.[index]; + if (iconText) { + const icon = document.createElement("span"); + icon.className = "ui-workflow-layout__step-icon"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = iconText; + const label = document.createElement("span"); + label.className = "ui-workflow-layout__step-label"; + label.textContent = step; + button.append(icon, label); + } else { + button.textContent = step; + } const stage = options?.stages?.find((item) => item.stage_no === index) ?? options?.stages?.[index]; const hasStages = Boolean(options?.stages?.length); - const isEnabled = - !hasStages || stage?.state !== "NOT_STARTED" || stage?.stage_no === options?.currentStage; + const isBlockedState = stage?.state === "NOT_STARTED" || stage?.state === "STALE"; + const isEnabled = !hasStages || !isBlockedState || stage?.stage_no === options?.currentStage; button.classList.toggle("is-enabled", isEnabled); button.disabled = !isEnabled; @@ -90,95 +106,33 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa const root = document.createElement("div"); root.className = "ui-workflow-layout"; - const header = document.createElement("header"); - header.className = "ui-workflow-layout__header"; - - const menuButton = document.createElement("button"); - menuButton.type = "button"; - menuButton.className = "ui-workflow-layout__menu-button"; - menuButton.setAttribute("aria-label", "Toggle settings panel"); - menuButton.innerHTML = "🛠️ Option ▾"; - - const titleWrap = document.createElement("div"); - titleWrap.className = "ui-workflow-layout__title-wrap"; - const title = document.createElement("h2"); - title.className = "ui-workflow-layout__title"; - title.textContent = options.title; - titleWrap.append( - title, - createStepBar(options.steps, options.activeStep, { - stages: options.stages, - currentStage: options.currentStage, - routes: options.routes, - onStepClick: options.onStepClick, - }), - ); - - const headerHideButton = document.createElement("button"); - headerHideButton.type = "button"; - headerHideButton.className = "ui-workflow-layout__header-hide"; - headerHideButton.setAttribute("aria-label", "Hide header"); - headerHideButton.textContent = "▲"; - - header.append(titleWrap, headerHideButton); - - // 헤더 숨김 시 표시되는 복원 화살표 - const headerRevealButton = document.createElement("button"); - headerRevealButton.type = "button"; - headerRevealButton.className = "ui-workflow-layout__header-reveal"; - headerRevealButton.setAttribute("aria-label", "Show header"); - headerRevealButton.textContent = "▼"; - const body = document.createElement("div"); body.className = "ui-workflow-layout__body"; - const dropdownPanel = document.createElement("div"); - dropdownPanel.className = "ui-workflow-layout__dropdown-panel"; - dropdownPanel.append(options.leftPanel); - const main = document.createElement("main"); main.className = "ui-workflow-layout__main"; - main.style.position = "relative"; - main.append(menuButton, options.mainContent); + main.append(options.mainContent); + body.append(main); - body.append(dropdownPanel, main); - root.append(header, headerRevealButton, body); - - function setMenuOpen(isOpen: boolean): void { - root.classList.toggle("is-menu-open", isOpen); - menuButton.setAttribute("aria-expanded", String(isOpen)); - const icon = menuButton.querySelector("span"); - if (icon) { - icon.textContent = isOpen ? "Option ▴" : "Option ▾"; - } - options.onMenuToggle?.(isOpen); - } - - function setHeaderVisible(isVisible: boolean): void { - root.classList.toggle("is-header-hidden", !isVisible); - headerHideButton.setAttribute("aria-expanded", String(isVisible)); - options.onHeaderToggle?.(isVisible); - } - - menuButton.addEventListener("click", (e) => { - e.stopPropagation(); - setMenuOpen(!root.classList.contains("is-menu-open")); + const progressContent = createStepBar(options.steps, options.activeStep, { + stages: options.stages, + currentStage: options.currentStage, + routes: options.routes, + orientation: "vertical", + icons: WORKFLOW_STEP_ICONS, + onStepClick: options.onStepClick, + }); + const overlays = createWorkflowOverlays({ + title: options.title, + optionsContent: options.leftPanel, + progressContent, + onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen), }); - document.addEventListener("click", (e) => { - if (root.classList.contains("is-menu-open")) { - const target = e.target as HTMLElement; - if (!dropdownPanel.contains(target) && !menuButton.contains(target)) { - setMenuOpen(false); - } - } - }); - - headerHideButton.addEventListener("click", () => setHeaderVisible(false)); - headerRevealButton.addEventListener("click", () => setHeaderVisible(true)); - - setMenuOpen(false); - setHeaderVisible(true); - - return { root, setMenuOpen, setHeaderVisible }; + root.append(body, overlays.root); + return { + root, + setOptionsOpen: overlays.setTitleOpen, + setProgressOpen: overlays.setProgressOpen, + }; }