/* ============================================================================= * 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 { 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"; // 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { buildDesignInfoPanel, buildDrawingSidePanel, foldedGroups, isCrossSheet, } from "./B07_DesignDetail_UI_Panels"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { clearWorkflowStateCache, fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { completeDesignStage, exportDrawing, fetchCrossLayout, fetchDesignDrawingList, fetchFrameTemplate, importFrameTemplate, resetFrameTemplate, saveCrossLayout, saveFrameTemplate, type CadDrawing, type CrossLayout, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, } from "./B07_DesignDetail_Api_Fetch"; import { fetchState, forgetAll, forgetDrawing, onFetchState, openDrawing, startPrefetch, syncDrawings, } from "./B07_DesignDetail_Prefetch"; import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle"; import { hasRedrawable, recoveryScopeOf, redrawFromSource, resetDrawings, saveDrawingsNow, saveEditedDrawings, } from "./B07_DesignDetail_UI_Save"; import { openDrawingSettings } from "./B07_DesignDetail_UI_Settings"; // CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3). import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host"; import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit"; import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { kind: DesignDrawingItem["kind"]; /** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */ drawingId: string; title: string; info: string; /** 참이면 CAD 읽기 전용 — 잠금 없음(요구 281)이라 늘 거짓 · 저장본도 그대로 고침. */ confirmed: boolean; quantityTable: QuantityTable | null; } /** CAD 저장 응답 (도면 + 편집된 수량표). */ type SaveResult = CadSaveResult; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /* ----------------------------------------------------------------------------- * 화면 조립 * -------------------------------------------------------------------------- */ /** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */ let reopenDrawingId: string | undefined; /** 횡단 장 배치를 바꿔 다시 세울 때 — 보던 장 번호(배치가 바뀌면 장 id 가 바뀜 · 140-3). */ let reopenCrossSheet: number | undefined; export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); // 311 다시 세우면 옛 목록 접힘 그대로 — 기다리기 전에 읽음(처음 들어오면 B07 목록이 없어 기본값) const folded = foldedGroups(root); let workflowState: WorkflowState | undefined; let drawings: DesignDrawingItem[] = []; let drawingError: string | undefined; let devBypass = false; 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; devBypass = drawingResult.value.dev_bypass === true; } else drawingError = drawingResult.reason instanceof Error ? drawingResult.reason.message : "도면 목록을 불러오지 못했습니다."; } const cad = createCadHost({ title: L("B07_Design_Title"), // 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281). onChanged: (dirty) => { // 받는 중(508-3b) = CAD 엔 앞 도면이 남아 있음 — 그 고침을 고른 탭에 묻히지 않음 if (frameEditor.isEditing() || pending) return; cadDirty = dirty; if (dirty) tabs.markDirty(); }, onExport: (fileFormat) => void exportDrawingFile(fileFormat), onGuideChange: (area) => frameEditor.guideChanged(area), }); // 180-3 안내선 보이기 / 숨기기 — 숨김은 화면을 다시 세워도 이어 줌(`…_UI_GuideToggle` 모듈 값) if (!guideShown()) cad.setGuideVisible(false); const guideToggle = buildGuideToggle((visible) => cad.setGuideVisible(visible)); let currentDrawing: DesignDrawingItem | undefined; let currentIndex = -1; let drawingListEl: HTMLElement | undefined; const infoPanelHost = document.createElement("div"); infoPanelHost.className = "b07-info-host"; // 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④). const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => { guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만 if (drawing.kind !== "cross") { infoPanelHost.replaceChildren(); return; } // 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은 // 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다. const scope = isCrossSheet(drawing) ? "sheet" : "station"; infoPanelHost.replaceChildren( buildDesignInfoPanel(drawing.label, response.design ?? null, scope), ); }; let crossLayoutRequest: Promise | undefined; const crossLayout = (): Promise => (crossLayoutRequest ??= fetchCrossLayout(projectId as string)).catch((error) => { crossLayoutRequest = undefined; // 실패는 남기지 않음 — 다음 ⚙ 에 다시 받음 throw error; }); /** 장 배치 저장(140-3 · ⚙ 횡단 모달) — 서버 쓰기 → 화면 다시 세움 · 같은 장 번호를 다시 엶. */ const saveCrossLayoutAndRebuild = async (next: CrossLayout, sheet: number) => { if (!projectId || !mayDiscardEdits()) return; showLoadingOverlay(); try { await saveCrossLayout(projectId, next); reopenCrossSheet = sheet || undefined; reopenDrawingId = sheet ? undefined : currentDrawing?.id; cad.destroy(); void renderB07DesignDetail(root); } catch (error) { showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error"); } finally { hideLoadingOverlay(); } }; /** * CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나 * 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다 * (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다. */ let cadDirty = false; 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): DesignMeta => ({ kind: drawing.kind, drawingId: drawing.id, title: drawing.label, info: drawing.kind === "cross" ? drawing.label : "", confirmed: false, // 잠금 없음(요구 281) quantityTable: response.quantity_table ?? null, }); /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ const mayDiscardEdits = (): boolean => { if (!cadDirty && !tabs.anyDirty()) return true; return window.confirm( "저장하지 않은 편집이 있습니다.\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + "남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.", ); }; /** * 고른 도면을 받는 중(508-3b) — 받는 동안 다른 도면 · 페이지로 옮길 수 있음 · 늦게 온 응답은 * 그 도면이 아직 고른 것일 때만 실음. 못 받으면 그대로 남김(CAD 엔 앞 도면 · 고침 안 묻힘). */ let pending: { id: string; stash?: CadDrawing } | undefined; /** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */ const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => { if (!projectId) return; const mine = { id: drawing.id, stash }; pending = mine; currentDrawing = undefined; // 저장 · 다시 그리기가 CAD 의 앞 도면을 이 장으로 안 씀 highlightActive(drawing.id); const button = findButton(drawing.id); if (button) button.dataset.loading = "true"; const ready = fetchState(drawing.id) === "ready"; cad.beginLoading(ready); // 받는 중엔 시계 없음 — 서버가 오래 걸려도 CAD 탓으로 안 끊음 if (!ready) showToast("도면을 받는 중입니다 — 다 받으면 바로 보여 줍니다.", "info"); try { const response = await openDrawing(drawing); if (pending !== mine) return; // 그 사이 다른 도면을 고름 pending = undefined; if (!ready) cad.beginLoading(); // 이제 CAD 가 여는 몫 — 시계를 검 currentDrawing = drawing; currentIndex = index; cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만 updateInfoPanel(drawing, response); // 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만) const guide = response.guide ?? null; const meta = buildMeta(drawing, response); const recoveryScope = recoveryScopeOf(projectId, drawing.id); cad.load(stash ?? response.drawing, meta, false, {}, { guide, recoveryScope }); if (guideShown() && guide?.marks?.length) { showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning"); } if (response.drawing.stale_layout) { showToast( "옛 배치로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.", "info", ); } else if (response.drawing.stale_scale) { showToast( "옛 표 배율로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 지금 배율로 다시 만듭니다.", "info", ); } else if (response.drawing.stale_layers) { showToast( "측점 도면층 앞에 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 측점마다 층(횡단 No.)으로 다시 만듭니다.", "info", ); } } catch (error) { if (pending !== mine) return; const reason = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; cad.fail(`${reason} — 목록의 「다시 받기」를 누르면 다시 받습니다.`); } finally { if (button) button.dataset.loading = "false"; } }; /** * 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다. * 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음. */ const tabs = createCadTabs({ features: { close: true }, // 받는 중이면 CAD 엔 앞 도면 — 고른 탭엔 제 편집본(없으면 빈 장 · 미저장 아님)을 둠 capture: async () => pending ? { drawing: pending.stash ?? { entities: [], layers: [] }, dirty: false } : { drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }, canLeave: () => { if (!frameEditor.isEditing()) return true; showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning"); return false; }, show: async (tab: CadTab) => { const index = drawings.findIndex((item) => item.id === tab.id); // 기다리지 않음 — 받는 동안 다른 탭을 고를 수 있게(탭 줄은 show 동안 잠김) if (index >= 0) void loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined); }, }); const openTab = (drawing: DesignDrawingItem): Promise => tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label }); const selectDrawing = (drawing: DesignDrawingItem) => { if (drawing === currentDrawing) return; // 지금 탭이 못 받은 도면 = 「다시 받기」(탭은 그대로라 탭 열기로는 안 다시 받음) if (pending?.id === drawing.id && fetchState(drawing.id) === "error") { void loadDrawing(drawing, drawings.indexOf(drawing), pending.stash); return; } void openTab(drawing); }; const requestCadDrawing = (): Promise => cad.requestSave(); /** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */ const store = { projectId: projectId as string, drawings, tabs, current: () => currentDrawing, cadDirty: () => cadDirty, setCadClean: () => void (cadDirty = false), requestCad: requestCadDrawing, isFrameEditing: () => frameEditor.isEditing(), forget: forgetDrawing, reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)), }; /** * 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시). * 파일 만들기는 서버가 한다 — 캐드는 도면만 넘긴다. */ async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise { showLoadingOverlay(); try { const { drawing } = await requestCadDrawing(); const name = currentDrawing?.label ?? "도면"; const result = await exportDrawing(projectId as string, drawing, fileFormat, name); const link = document.createElement("a"); link.href = URL.createObjectURL(result.blob); link.download = `${name}.${fileFormat}`; link.click(); URL.revokeObjectURL(link.href); showToast( result.skipped > 0 ? `${fileFormat.toUpperCase()} 로 내보냈습니다. 그림 ${result.skipped}개는 담기지 않았습니다.` : `${fileFormat.toUpperCase()} 로 내보냈습니다.`, "success", ); } catch (error) { showToast(error instanceof Error ? error.message : "도면을 내보내지 못했습니다.", "error"); } finally { hideLoadingOverlay(); } } const frameEditor = createFrameTemplateEditor({ api: { fetch: () => fetchFrameTemplate(projectId as string), save: (drawing) => saveFrameTemplate(projectId as string, drawing), importFile: (file) => importFrameTemplate(projectId as string, file), reset: () => resetFrameTemplate(projectId as string), }, sendLoad: (drawing, meta, frameEdit, frameFields, extra) => cad.load(drawing, meta, frameEdit, frameFields, { ...extra, recoveryScope: `b07-frame:${projectId}`, }), setGuideArea: (area) => cad.setGuideArea(area), requestCadDrawing: async () => (await requestCadDrawing()).drawing, restoreDrawing: () => { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); }, onSaved: forgetAll, currentDrawingInfo: () => currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); /** [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. */ const onPlanClosed = (changed: boolean, openId?: string): void => { if (!changed) { const target = drawings.find((item) => item.id === openId); if (target) selectDrawing(target); return; } if (!mayDiscardEdits()) return; reopenDrawingId = openId ?? currentDrawing?.id; cad.destroy(); void renderB07DesignDetail(root); }; // 묶음 ⚙(280-5) — 평면도 = 평면도 관리 · 횡단도 = 장 배치 · 안내선 · 머리 [원본에서 다시 그리기] const openSettings = (kind: DesignDrawingItem["kind"]): void => openDrawingSettings(kind, { drawings, current: () => currentDrawing, canRedraw: () => hasRedrawable(store), redraw: () => redrawFromSource(store), plan: projectId ? { projectId, onClosed: onPlanClosed } : undefined, cross: { layout: crossLayout, save: (next, sheet) => void saveCrossLayoutAndRebuild(next, sheet), guideToggle, }, }); const drawingPanel = buildDrawingSidePanel( drawings, selectDrawing, drawingError, devBypass, projectId ? openSettings : undefined, folded, ); drawingListEl = drawingPanel; // 미리받기 상태(508-3b) — 못 받은 장 = 「다시 받기」 표시(누르면 다시 받음) const markFetch = (id: string, state: string): void => { const button = findButton(id); if (button) button.dataset.fetch = state; }; drawings.forEach((item) => markFetch(item.id, fetchState(item.id))); onFetchState(markFetch); const actions = document.createElement("div"); // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 // 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통). actions.className = "b07-drawing-actions ui-sidebar-actions"; // 액션 칸은 세 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집] // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움), // 3행 [?] [<] [>] [저장] [초기화](298-2 · B05 · B06 과 같은 차례 · [?] [<] [>] 는 공용이 앞에 붙임). const frameRow = document.createElement("div"); frameRow.className = "b07-drawing-actions__row"; frameRow.append(frameEditor.button); const actionRow = document.createElement("div"); actionRow.className = "b07-drawing-actions__row"; actionRow.dataset.helpRow = ""; const refreshStages = (): void => { if (!projectId) return; clearWorkflowStateCache(); void fetchWorkflowState(projectId) .then((state) => layout.setStages(state.stages)) .catch(() => undefined); }; const saveButton = createButton({ label: L("Common_Btn_Save"), variant: "ghost", onClick: () => void saveDrawingsNow(store, refreshStages), }); const resetButton = createButton({ label: L("Common_Btn_Reset"), variant: "danger-ghost", onClick: () => void resetDrawings(store), }); saveButton.disabled = resetButton.disabled = !projectId; actionRow.append(saveButton, resetButton); actions.append(frameEditor.banner, frameRow, actionRow); drawingPanel.append(infoPanelHost, actions); const cadStack = document.createElement("div"); cadStack.className = "b07-cad-stack"; cadStack.append(tabs.element, cad.element); // 탭 줄 겹판(280-6) — CAD 가 리본 [출력] 오른쪽 빈 자리를 `.cad-host` 에 `--cad-tab-slot-left/right` // (px · CSS left · right 꼴)로 적으면 그 자리에 겹침 · 값이 없으면 CAD 위 지금 자리. const placeTabs = (): void => { const slotted = cad.element.style.getPropertyValue("--cad-tab-slot-left") !== ""; if (slotted === (tabs.element.parentElement === cad.element)) return; if (slotted) cad.element.append(tabs.element); else cadStack.prepend(tabs.element); }; new MutationObserver(placeTabs).observe(cad.element, { attributes: true, attributeFilter: ["style"], }); placeTabs(); /** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */ let moving = false; const moveToStage = async (stepIndex: number): Promise => { if (!projectId || moving) return; moving = true; try { if (!(await saveEditedDrawings(store))) return; if (stepIndex > 4 && drawings.length > 0) { try { await completeDesignStage(projectId); } catch (error) { showToast( error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.", "error", ); return; } } goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); } finally { moving = false; } }; const layout = createWorkflowLayout({ title: L("B07_Design_Title"), steps: workflowSteps(), activeStep: 4, leftPanel: drawingPanel, mainContent: cadStack, stages: workflowState?.stages, // B08 = 옮길 때 단계 4 를 완료하므로 운영에서 막힌 상태여도 켬(281) — 공용 판은 이 단계를 켬 currentStage: 5, routes: WORKFLOW_STEP_ROUTES, // ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음) onStepClick: (stepIndex) => void moveToStage(stepIndex), }); layout.root.classList.add("b07-design-layout"); root.replaceChildren(layout.root); // 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. // 목록 지문으로 받아 둔 도면을 맞춤 — 바뀐 도면만 버림(다른 페이지에서 돌던 받기는 이어 감) if (projectId) syncDrawings(projectId, drawings); if (drawings.length > 0) { const crossSheets = drawings.filter(isCrossSheet); const reopenId = reopenCrossSheet ? crossSheets[Math.min(reopenCrossSheet, crossSheets.length) - 1]?.id : reopenDrawingId; const reopen = drawings.findIndex((item) => item.id === reopenId); reopenDrawingId = undefined; reopenCrossSheet = undefined; // 311 기억한 장이 없으면 표지 const cover = drawings.findIndex((item) => item.kind === "cover"); const first = reopen >= 0 ? reopen : Math.max(cover, 0); // 첫 장을 고른 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다(고른 장을 받는 동안 쉼). void openTab(drawings[first]).then(startPrefetch); } }