/* ============================================================================= * B07_DesignDetail_UI_Page.ts * 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드 * * B07_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여 * /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로 * JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다. * * 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면. * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. * ========================================================================== */ import "./B07_DesignDetail_UI_Style.css"; import { attachCollapsible } from "@ui/ui_template_collapsible"; 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 CrossDesignInfo, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, } from "./B07_DesignDetail_Api_Fetch"; import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { kind: DesignDrawingItem["kind"]; /** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */ drawingId: string; title: string; info: string; /** 확정본은 CAD에서 읽기 전용이 된다. 푸는 길은 [수정] 버튼 하나뿐. */ confirmed: boolean; quantityTable: QuantityTable | null; hasPrev: boolean; hasNext: boolean; } /** CAD 저장 응답 (도면 + 편집된 수량표). */ interface SaveResult { drawing: CadDrawing; quantityTable: QuantityTable | null; } /** 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:b08:load-drawing"; const CAD_READY_MESSAGE = "aislo:b08:drawing-ready"; const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded"; const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error"; const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed"; const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request"; const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response"; const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; /** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시). * CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */ const CAD_TOAST_MESSAGE = "aislo:b08:toast"; const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; /** * 도면 구성 12분류 (2026-08-29 사용자 확정 순서). * * 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다 * (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다. */ const DRAWING_GROUPS: readonly { label: string; kind?: DesignDrawingItem["kind"]; blankId?: string; }[] = [ { label: "표지", kind: "cover" }, { label: "계획평면도(지형)", blankId: "blank_plan_terrain" }, { label: "계획평면도(노선배치도)", blankId: "blank_plan_route" }, { label: "계획평면도(배치도)", blankId: "blank_plan_layout" }, { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, { label: "종단면도", kind: "longitudinal" }, { label: "표준 횡단면도", blankId: "blank_cross_standard" }, { label: "횡단면도", kind: "cross" }, { label: "토적도(유토곡선)", kind: "mass_haul" }, { label: "유역도(배수 유역도)", kind: "watershed" }, { label: "표준도", blankId: "blank_standard" }, { label: "용지도", blankId: "blank_landuse" }, ]; /** B06 확정 산출물 기반 도면 목록 패널. */ function buildDrawingSidePanel( drawings: DesignDrawingItem[], onSelect: (drawing: DesignDrawingItem) => void, 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 drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { 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.className = "b07-drawing-button__name"; name.textContent = label; button.append(name); button.addEventListener("click", () => onSelect(drawing)); return button; }; for (const group of DRAWING_GROUPS) { const items = group.kind ? drawings.filter((item) => item.kind === group.kind) : drawings.filter((item) => item.id === group.blankId); // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. if (items.length <= 1) { const [drawing] = items; // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. if (!drawing) { const placeholder = document.createElement("button"); placeholder.type = "button"; placeholder.className = "b07-drawing-button"; placeholder.disabled = true; placeholder.title = "준비 중"; placeholder.dataset.pending = "true"; const name = document.createElement("span"); name.className = "b07-drawing-button__name"; name.textContent = group.label; placeholder.append(name); panel.append(placeholder); continue; } const button = drawingButton(drawing, group.label); // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. button.dataset.pending = String(drawing.kind === "blank"); if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; panel.append(button); continue; } const section = document.createElement("section"); // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; if (group.kind) section.dataset.kind = group.kind; const sectionTitle = document.createElement("h3"); sectionTitle.className = "ui-collapsible__title"; sectionTitle.textContent = `${group.label} ${items.length}`; section.append(sectionTitle); for (const drawing of items) { // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. section.append(drawingButton(drawing, drawing.label)); } panel.append(section); } attachCollapsible(panel); return panel; } const GROUND_TYPE_LABEL: Record = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", blasting_rock: "B06_Design_Ground_Blasting", }; /** 단면유형에서 절토측 표기를 유도한다. */ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { if (mode === "left_cut") return L("B06_Design_Ditch_Left"); if (mode === "right_cut") return L("B06_Design_Ditch_Right"); if (mode === "both_cut") return L("B06_Design_Mode_BothCut"); return L("B06_Design_Mode_BothFill"); } /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음"; if (ditch.type === "l_type") return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; } function infoRow(label: string, value: string): HTMLElement { const row = document.createElement("div"); row.className = "b07-info__row"; const key = document.createElement("span"); key.className = "b07-info__key"; key.textContent = label; const val = document.createElement("span"); val.className = "b07-info__val"; val.textContent = value; row.append(key, val); return row; } /** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */ function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement { const panel = document.createElement("div"); panel.className = "b07-info"; const heading = document.createElement("div"); heading.className = "b07-info__heading"; const stationName = document.createElement("strong"); stationName.textContent = `${L("B07_Info_Station")} ${title}`; const confirmed = design?.status === "confirmed"; const badge = document.createElement("span"); badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`; badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional"); heading.append(stationName, badge); panel.append(heading); if (!design) { const empty = document.createElement("p"); empty.className = "b07-info__empty"; empty.textContent = L("B07_Info_NoDesign"); panel.append(empty); return panel; } const ground = document.createElement("section"); ground.className = "b07-info__block"; const groundTitle = document.createElement("h4"); groundTitle.textContent = L("B07_Info_Ground_Title"); ground.append( groundTitle, infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])), infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)), infoRow( L("B07_Info_DitchSide"), design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"), ), ); const plan = document.createElement("section"); plan.className = "b07-info__block"; const planTitle = document.createElement("h4"); planTitle.textContent = L("B07_Info_Plan_Title"); plan.append( planTitle, infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`), infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), infoRow(L("B07_Info_Ditch"), ditchLabel(design)), infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`), infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`), ); panel.append(ground, plan); 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 pendingLoad: { drawing: CadDrawing; meta: DesignMeta | null } | undefined; let currentDrawing: DesignDrawingItem | undefined; let currentIndex = -1; let currentConfirmed = false; // 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관). const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross"; let allDrawingsConfirmed = drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed); let resolveSave: ((payload: SaveResult) => void) | undefined; let drawingListEl: HTMLElement | undefined; const infoPanelHost = document.createElement("div"); infoPanelHost.className = "b07-info-host"; const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => { if (drawing.kind !== "cross") { infoPanelHost.replaceChildren(); return; } const title = drawing.label; infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null)); }; /** * CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나 * 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다 * (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다. */ let cadDirty = false; /** * 확정·미확정에 따라 버튼 한 자리를 바꾼다 (2026-09-01 사용자 확정). * 미확정이면 [현재 도면 확정], 확정이면 [수정] — 확정을 푸는 유일한 길이다. */ const confirmButton = createButton({ label: "현재 도면 확정", variant: "filled", onClick: () => { if (currentConfirmed) void reopenCurrentDrawing(); else void confirmCurrentDrawing(); }, }); confirmButton.disabled = true; const applyConfirmButtonState = (): void => { confirmButton.textContent = currentConfirmed ? "수정" : "현재 도면 확정"; confirmButton.title = currentConfirmed ? "확정을 풀고 이 도면을 고칩니다." : "이 도면을 확정하고 저장합니다."; confirmButton.disabled = !currentDrawing || currentDrawing.kind === "blank"; }; const findButton = (drawingId: string) => drawingListEl?.querySelector( `.b07-drawing-button[data-drawing-id="${drawingId}"]`, ) ?? undefined; const highlightActive = (drawingId: string) => { drawingListEl?.querySelectorAll(".b07-drawing-button").forEach((item) => { item.dataset.active = String(item.dataset.drawingId === drawingId); }); }; const buildMeta = ( drawing: DesignDrawingItem, response: DesignDrawingResponse, index: number, ): DesignMeta => ({ kind: drawing.kind, drawingId: drawing.id, title: drawing.label, info: drawing.kind === "cross" ? drawing.label : "", confirmed: response.confirmed, quantityTable: response.quantity_table ?? null, hasPrev: index > 0, hasNext: index < drawings.length - 1, }); const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null) => { pendingLoad = { drawing, meta }; if (!cadReady) return; frame.contentWindow?.postMessage( { type: CAD_LOAD_MESSAGE, drawing, meta }, window.location.origin, ); pendingLoad = undefined; }; /** * 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시). * * 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초, * 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다. * sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라 * 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다. */ const drawingCache = new Map>(); /** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */ const requestDrawing = (drawing: DesignDrawingItem): Promise => { const cached = drawingCache.get(drawing.id); if (cached) return cached; const request = (async () => { const response = await fetchDesignDrawing(projectId as string, drawing.id); // 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어 // 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다. if (drawing.kind === "cross" && !response.confirmed) { await appendStructureEntities(projectId as string, response.route_id, response.drawing); } return response; })().catch((error) => { drawingCache.delete(drawing.id); // 실패는 캐시에 남기지 않는다 — 다음 클릭에 다시 받는다. throw error; }); drawingCache.set(drawing.id, request); return request; }; /** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. */ const prefetchAllDrawings = async (): Promise => { if (!projectId) return; for (const item of drawings) { try { await requestDrawing(item); } catch { /* 한 장을 못 받아도 나머지는 계속 받는다 — 클릭 때 다시 시도한다. */ } } }; /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ const mayDiscardEdits = (): boolean => { if (!cadDirty) return true; return window.confirm( "저장하지 않은 편집이 있습니다.\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + "남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.", ); }; /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ let loadInFlight = false; const loadDrawing = async (drawing: DesignDrawingItem, index: number) => { if (!projectId || loadInFlight) return; loadInFlight = true; highlightActive(drawing.id); const button = findButton(drawing.id); if (button) button.dataset.loading = "true"; cadHost.dataset.loading = "true"; cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다 try { const response = await requestDrawing(drawing); currentDrawing = drawing; currentIndex = index; currentConfirmed = response.confirmed; cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다 applyConfirmButtonState(); updateInfoPanel(drawing, response); sendLoad(response.drawing, buildMeta(drawing, response, index)); } catch (error) { cadHost.dataset.loading = "false"; cadHost.dataset.error = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; showToast(cadHost.dataset.error, "error"); if (currentDrawing) highlightActive(currentDrawing.id); } finally { loadInFlight = false; if (button) button.dataset.loading = "false"; } }; const selectDrawing = (drawing: DesignDrawingItem) => { if (drawing === currentDrawing || !mayDiscardEdits()) return; void loadDrawing(drawing, drawings.indexOf(drawing)); }; const navigateDrawing = (direction: "prev" | "next") => { if (currentIndex < 0) return; const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1; if (target < 0 || target >= drawings.length) return; if (!mayDiscardEdits()) return; void loadDrawing(drawings[target], target); }; 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 saved = await requestCadDrawing(); const result = await confirmDesignDrawing( projectId, currentDrawing.id, saved.drawing, currentDrawing.kind === "cross" ? (saved.quantityTable ?? null) : null, ); currentConfirmed = true; currentDrawing.confirmed = true; cadDirty = false; // 저장했다 applyConfirmButtonState(); drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다. const button = findButton(currentDrawing.id); if (button) button.dataset.confirmed = "true"; // 저장본을 다시 실어 CAD를 읽기 전용으로 돌린다 — 확정한 도면은 고칠 수 없다. // **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직 // 편집이 열린 어긋난 순간이 생긴다. await loadDrawing(currentDrawing, currentIndex); // 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다. if (currentDrawing.kind === "cross") { infoPanelHost.replaceChildren( buildDesignInfoPanel(currentDrawing.label, result.design ?? null), ); } 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(); } } /** * [수정] — 확정을 풀고 다시 고칠 수 있게 한다. **확정이 풀리는 유일한 길**이다 * (2026-09-01 사용자 확정). 예전에는 CAD의 편집 통지가 확정을 풀어서, 되돌리기나 * 색 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. */ async function reopenCurrentDrawing(): Promise { if (!projectId || !currentDrawing || !currentConfirmed) return; showLoadingOverlay(); try { await invalidateDesignDrawing(projectId, currentDrawing.id); drawingCache.delete(currentDrawing.id); currentConfirmed = false; allDrawingsConfirmed = false; currentDrawing.confirmed = false; const button = findButton(currentDrawing.id); if (button) button.dataset.confirmed = "false"; applyConfirmButtonState(); // 확정을 풀면 서버가 원본에서 다시 그린다 — 그 도면을 실어야 편집이 열린다. await loadDrawing(currentDrawing, currentIndex); showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info"); } catch (error) { showToast( error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.", "error", ); } finally { hideLoadingOverlay(); } } const frameEditor = createFrameTemplateEditor({ projectId: projectId as string, sendLoad, requestCadDrawing: async () => (await requestCadDrawing()).drawing, restoreDrawing: () => { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); }, onSaved: () => drawingCache.clear(), }); cadHost.prepend(frameEditor.banner); 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; quantityTable?: QuantityTable | null; direction?: "prev" | "next"; dirty?: boolean; kind?: string; text?: string; actionId?: string; durationMs?: number; }; if (message.type === CAD_TOAST_MESSAGE) { const kind = (["info", "success", "warning", "error"] as const).find( (item) => item === message.kind, ); // autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다. const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000); const actionId = message.actionId; showToast( message.text ?? "", kind ?? "info", duration, actionId ? () => frame.contentWindow?.postMessage( { type: CAD_TOAST_ACTION_MESSAGE, actionId }, window.location.origin, ) : undefined, ); } else if (message.type === CAD_READY_MESSAGE) { cadReady = true; if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta); } else if (message.type === CAD_LOADED_MESSAGE) { cadHost.dataset.loading = "false"; } else if (message.type === CAD_ERROR_MESSAGE) { cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다."; cadHost.dataset.loading = "false"; showToast(cadHost.dataset.error, "error"); } else if (message.type === CAD_CHANGED_MESSAGE) { // 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다 // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 // 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. if (!frameEditor.isEditing()) cadDirty = message.dirty !== false; } else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) { navigateDrawing(message.direction); } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { const resolve = resolveSave; resolveSave = undefined; resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null, }); } }); const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 // 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통). confirmActions.className = "b07-drawing-actions ui-sidebar-actions"; confirmActions.append(frameEditor.button, confirmButton); drawingPanel.append(infoPanelHost, 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 > 5 && !allDrawingsConfirmed) { showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning"); return; } goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); layout.root.classList.add("b07-design-layout"); root.replaceChildren(layout.root); // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다. if (drawings.length > 0) { void loadDrawing(drawings[0], 0).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. void prefetchAllDrawings(); }); } }