/* ============================================================================= * B07_wf4_DesignDetail_UI_Page.ts * 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드 * * B07_wf4_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여 * /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로 * JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다. * * 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면. * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. * ========================================================================== */ import "./B07_wf4_DesignDetail_UI_Style.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { confirmDesignDrawing, fetchDesignDrawing, fetchDesignDrawingList, invalidateDesignDrawing, type CadDrawing, type DesignDrawingItem, } from "./B07_wf4_DesignDetail_Api_Fetch"; /** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */ const B07_CAD_APP_URL = "/b07-cad/index.html"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } const CAD_LOAD_MESSAGE = "aislo:b07:load-drawing"; const CAD_READY_MESSAGE = "aislo:b07:drawing-ready"; const CAD_LOADED_MESSAGE = "aislo:b07:drawing-loaded"; const CAD_ERROR_MESSAGE = "aislo:b07:drawing-error"; const CAD_CHANGED_MESSAGE = "aislo:b07:drawing-changed"; const CAD_SAVE_REQUEST_MESSAGE = "aislo:b07:save-request"; const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b07:save-response"; /** B06 확정 산출물 기반 도면 목록 패널. */ function buildDrawingSidePanel( drawings: DesignDrawingItem[], onSelect: (drawing: DesignDrawingItem, button: HTMLButtonElement) => Promise, errorMessage?: string, ): HTMLDivElement { const panel = document.createElement("div"); panel.className = "b07-drawing-list"; const heading = document.createElement("div"); heading.className = "b07-drawing-list__heading"; const title = document.createElement("strong"); title.textContent = "설계 도면"; const count = document.createElement("span"); count.textContent = `${drawings.length}건`; heading.append(title, count); panel.append(heading); if (errorMessage || drawings.length === 0) { const empty = document.createElement("p"); empty.className = "b07-drawing-list__empty"; empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다."; panel.append(empty); return panel; } const groups: [string, DesignDrawingItem[]][] = [ ["종단도", drawings.filter((item) => item.kind === "longitudinal")], ["횡단도", drawings.filter((item) => item.kind === "cross")], ]; for (const [label, items] of groups) { if (!items.length) continue; const section = document.createElement("section"); section.className = "b07-drawing-group"; const sectionTitle = document.createElement("h3"); sectionTitle.textContent = `${label} ${items.length}`; section.append(sectionTitle); for (const drawing of items) { const button = document.createElement("button"); button.type = "button"; button.className = "b07-drawing-button"; button.dataset.drawingId = drawing.id; button.dataset.confirmed = String(drawing.confirmed); const name = document.createElement("span"); name.textContent = drawing.label; const kind = document.createElement("small"); kind.textContent = drawing.confirmed ? "확정" : drawing.kind === "longitudinal" ? "PROFILE" : "SECTION"; button.append(name, kind); button.addEventListener("click", () => void onSelect(drawing, button)); section.append(button); } panel.append(section); } return panel; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let workflowState: WorkflowState | undefined; let drawings: DesignDrawingItem[] = []; let drawingError: string | undefined; if (projectId) { const [workflowResult, drawingResult] = await Promise.allSettled([ fetchWorkflowState(projectId), fetchDesignDrawingList(projectId), ]); if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings; else drawingError = drawingResult.reason instanceof Error ? drawingResult.reason.message : "도면 목록을 불러오지 못했습니다."; } const cadHost = document.createElement("div"); cadHost.className = "b07-cad-host"; const frame = document.createElement("iframe"); frame.className = "b07-cad-frame"; frame.src = B07_CAD_APP_URL; frame.title = L("B07_Design_Title"); const license = document.createElement("a"); license.className = "b07-cad-license"; license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt"; license.target = "_blank"; license.rel = "noreferrer"; license.textContent = "Drawing engine based on OpenWebCAD · MIT License"; cadHost.append(frame, license); let cadReady = false; let pendingDrawing: CadDrawing | undefined; let currentDrawing: DesignDrawingItem | undefined; let currentButton: HTMLButtonElement | undefined; let currentConfirmed = false; let allDrawingsConfirmed = drawings.length > 0 && drawings.every((item) => item.confirmed); let resolveSave: ((drawing: CadDrawing) => void) | undefined; const confirmButton = createButton({ label: "현재 도면 확정", variant: "filled", onClick: () => void confirmCurrentDrawing(), }); confirmButton.disabled = true; const sendDrawing = (drawing: CadDrawing) => { pendingDrawing = drawing; if (!cadReady) return; frame.contentWindow?.postMessage({ type: CAD_LOAD_MESSAGE, drawing }, window.location.origin); pendingDrawing = undefined; }; const selectDrawing = async (drawing: DesignDrawingItem, button: HTMLButtonElement) => { if (!projectId) return; const buttons = button .closest(".b07-drawing-list") ?.querySelectorAll(".b07-drawing-button"); buttons?.forEach((item) => { item.disabled = true; item.dataset.active = String(item === button); }); button.dataset.loading = "true"; cadHost.dataset.loading = "true"; try { const response = await fetchDesignDrawing(projectId, drawing.id); currentDrawing = drawing; currentButton = button; currentConfirmed = response.confirmed; confirmButton.disabled = response.confirmed; sendDrawing(response.drawing); } catch (error) { cadHost.dataset.loading = "false"; cadHost.dataset.error = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; } finally { button.dataset.loading = "false"; buttons?.forEach((item) => { item.disabled = false; }); } }; const requestCadDrawing = (): Promise => new Promise((resolve, reject) => { resolveSave = resolve; frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin); window.setTimeout(() => { if (!resolveSave) return; resolveSave = undefined; reject(new Error("CAD 저장 응답 시간이 초과되었습니다.")); }, 5000); }); async function confirmCurrentDrawing(): Promise { if (!projectId || !currentDrawing) return; showLoadingOverlay(); try { const drawing = await requestCadDrawing(); const result = await confirmDesignDrawing(projectId, currentDrawing.id, drawing); currentConfirmed = true; currentDrawing.confirmed = true; confirmButton.disabled = true; if (currentButton) { currentButton.dataset.confirmed = "true"; const status = currentButton.querySelector("small"); if (status) status.textContent = "확정"; } showToast("현재 도면을 확정하고 저장했습니다.", "success"); if (result.all_confirmed) { allDrawingsConfirmed = true; goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]); } } catch (error) { showToast( error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.", "error", ); } finally { hideLoadingOverlay(); } } const invalidateCurrentDrawing = async () => { if (!projectId || !currentDrawing) return; const wasConfirmed = currentConfirmed; currentConfirmed = false; allDrawingsConfirmed = false; currentDrawing.confirmed = false; confirmButton.disabled = false; if (currentButton) { currentButton.dataset.confirmed = "false"; const status = currentButton.querySelector("small"); if (status) status.textContent = currentDrawing.kind === "longitudinal" ? "PROFILE" : "SECTION"; } if (wasConfirmed) { try { await invalidateDesignDrawing(projectId, currentDrawing.id); } catch (error) { showToast( error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.", "error", ); } } }; window.addEventListener("message", (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return; const message = event.data as { type?: string; detail?: string; drawing?: CadDrawing; }; if (message.type === CAD_READY_MESSAGE) { cadReady = true; if (pendingDrawing) sendDrawing(pendingDrawing); } else if (message.type === CAD_LOADED_MESSAGE) { cadHost.dataset.loading = "false"; } else if (message.type === CAD_ERROR_MESSAGE) { cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다."; } else if (message.type === CAD_CHANGED_MESSAGE) { void invalidateCurrentDrawing(); } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { const resolve = resolveSave; resolveSave = undefined; resolve(message.drawing); } }); const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError); const confirmActions = document.createElement("div"); confirmActions.className = "b07-drawing-actions"; confirmActions.append(confirmButton); drawingPanel.append(confirmActions); const layout = createWorkflowLayout({ title: L("B07_Design_Title"), steps: workflowSteps(), activeStep: 4, leftPanel: drawingPanel, mainContent: cadHost, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { if (!projectId) return; if (stepIndex > 4 && !allDrawingsConfirmed) { showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning"); return; } goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); layout.root.classList.add("b07-design-layout"); root.replaceChildren(layout.root); }