/* ============================================================================= * 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 { 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 { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { completeDesignStage, exportDrawing, fetchCrossLayout, fetchDesignDrawing, fetchDesignDrawingList, fetchFrameTemplate, importFrameTemplate, resetFrameTemplate, saveCrossLayout, saveFrameTemplate, type CadDrawing, type CrossLayout, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, } from "./B07_DesignDetail_Api_Fetch"; import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle"; import { hasRedrawable, redrawFromSource, 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; hasPrev: boolean; hasNext: boolean; } /** 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) => { if (frameEditor.isEditing()) return; cadDirty = dirty; if (dirty) tabs.markDirty(); }, onNavigate: (direction) => navigateDrawing(direction), 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, index: number, ): DesignMeta => ({ kind: drawing.kind, drawingId: drawing.id, title: drawing.label, info: drawing.kind === "cross" ? drawing.label : "", confirmed: false, // 잠금 없음(요구 281) quantityTable: response.quantity_table ?? null, hasPrev: index > 0, hasNext: index < drawings.length - 1, }); /** * 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(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; }; /** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. * * ⚠ **사용자가 누른 도면에 자리를 내준다** (2026-09-09 — 무한 로딩으로 보이던 자리). * 도면이 31 장이고 한 장에 0.3~1.0초라, 미리 받기가 도는 동안 클릭하면 그 요청이 * **앞의 여러 장 뒤에 줄을 서서** 30초 넘게 안 열렸다. 화면은 「불러오는 중」 그대로였다. * 한 장 받을 때마다 **클릭이 끼어들 틈**을 주고, 클릭이 도는 동안은 쉬어 준다. */ const prefetchAllDrawings = async (): Promise => { if (!projectId) return; for (const item of drawings) { // 클릭이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다. while (loadInFlight) await new Promise((done) => window.setTimeout(done, 120)); if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다 try { await requestDrawing(item); } catch { /* 한 장을 못 받아도 나머지는 계속 받는다 — 클릭 때 다시 시도한다. */ } } }; /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ const mayDiscardEdits = (): boolean => { if (!cadDirty && !tabs.anyDirty()) return true; return window.confirm( "저장하지 않은 편집이 있습니다.\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + "남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.", ); }; /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ let loadInFlight = false; /** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */ const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => { if (!projectId || loadInFlight) return; loadInFlight = true; highlightActive(drawing.id); const button = findButton(drawing.id); if (button) button.dataset.loading = "true"; cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다 try { const response = await requestDrawing(drawing); currentDrawing = drawing; currentIndex = index; cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만 updateInfoPanel(drawing, response); // 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만) const guide = response.guide ?? null; const meta = buildMeta(drawing, response, index); cad.load(stash ?? response.drawing, meta, false, {}, { guide }); 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) { cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); if (currentDrawing) highlightActive(currentDrawing.id); } finally { loadInFlight = false; if (button) button.dataset.loading = "false"; } }; /** * 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다. * 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음. */ const tabs = createCadTabs({ features: { close: true }, capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }), canLeave: () => { if (!frameEditor.isEditing()) return !loadInFlight; showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning"); return false; }, show: async (tab: CadTab) => { const index = drawings.findIndex((item) => item.id === tab.id); if (index >= 0) await 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; void openTab(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; void openTab(drawings[target]); }; 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: (id: string) => void drawingCache.delete(id), 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), setGuideArea: (area) => cad.setGuideArea(area), requestCadDrawing: async () => (await requestCadDrawing()).drawing, restoreDrawing: () => { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); }, onSaved: () => drawingCache.clear(), 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; 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 에 지움). const actionRow = document.createElement("div"); actionRow.className = "b07-drawing-actions__row"; actionRow.dataset.helpRow = ""; actionRow.append(frameEditor.button); actions.append(frameEditor.banner, 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 (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(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. void prefetchAllDrawings(); }); } }