diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 9fe1618e..07c20a22 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -43,7 +43,12 @@ export type QuantityTable = Record; /** 측구 형식별 규격 (B06 엔진 ditch_spec 신구조와 1:1). */ export type DitchSpec = | { type: "none" } - | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } + | { + type: "standard"; + top_width_m: number; + bottom_width_m: number; + depth_m: number; + } | { type: "l_type"; width_m: number; depth_m: number }; /** B06에서 지정한 설계(지반정보·계획정보). 횡단도에만 존재. status로 잠정/확정 구분. */ @@ -61,7 +66,10 @@ export interface CrossDesignInfo { cross_slope_pct?: number; paved?: boolean; ditch: DitchSpec; - road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>; + road_edges?: Record< + "left" | "right", + { offset_m: number; elevation_m: number } + >; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; @@ -90,7 +98,10 @@ export interface DesignDrawingConfirmResponse { design?: CrossDesignInfo | null; } -async function requestJson(path: string, init: RequestInit = {}): Promise { +async function requestJson( + path: string, + init: RequestInit = {}, +): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -101,14 +112,17 @@ async function requestJson(path: string, init: RequestInit = {}): Promise signal: controller.signal, }); const payload = (await response.json()) as T & { message?: string }; - if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + if (!response.ok) + throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } finally { window.clearTimeout(timeoutId); } } -export function fetchDesignDrawingList(projectId: string): Promise { +export function fetchDesignDrawingList( + projectId: string, +): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -116,7 +130,9 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); + return requestJson( + `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`, + ); } export function confirmDesignDrawing( @@ -127,13 +143,44 @@ export function confirmDesignDrawing( ): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/confirm`, - { method: "PUT", body: JSON.stringify({ drawing, quantity_table: quantityTable ?? null }) }, + { + method: "PUT", + body: JSON.stringify({ drawing, quantity_table: quantityTable ?? null }), + }, ); } -export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { +export function invalidateDesignDrawing( + projectId: string, + drawingId: string, +): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, { method: "POST" }, ); } + +/** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */ +export interface FrameTemplateResponse { + status: string; + project_id: string; + drawing: CadDrawing; + /** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */ + customized: boolean; +} + +export function fetchFrameTemplate( + projectId: string, +): Promise { + return requestJson(`/projects/${projectId}/frame-template`); +} + +export function saveFrameTemplate( + projectId: string, + drawing: CadDrawing, +): Promise { + return requestJson(`/projects/${projectId}/frame-template`, { + method: "PUT", + body: JSON.stringify({ drawing }), + }); +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 402d5b8a..63bb956e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -12,6 +12,7 @@ A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표 import json import logging import re +from contextvars import ContextVar from functools import lru_cache from pathlib import Path from typing import Any @@ -19,6 +20,7 @@ from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, + DRAWING_FORMAT, FRAME_LAYER_ID, ) @@ -34,13 +36,81 @@ _A1_INNER = (42.0, 47.0, 812.0, 567.0) # 비워 횡단 장이 한 장 더 늘었다 — 2%로 줄여 작도 영역을 쓴다(2026-08-30 사용자). _CONTENT_MARGIN = 0.02 +# 회사가 자기 도각을 두는 자리 — `storage/{회사}/templates/`. 프로그램 기본 도각 +# (`resources/template_2dDrawing/`)은 **읽기 전용**이고, 고객이 고친 도각은 여기 쌓인다 +# (2026-09-01 사용자 확정: "정본은 그냥 두고 수정하는 기능"). +COMPANY_TEMPLATE_SUBDIR = "templates" + +# 이 요청이 읽을 회사 도각 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장· +# 토적도·유역도·표지·공용)의 서명을 줄줄이 고치지 않으려고 문맥 변수를 쓴다. +# `asyncio.to_thread`가 문맥을 복사하므로 스레드로 넘어간 작도에도 그대로 따라간다. +_company_dir: ContextVar[Path | None] = ContextVar("b07_company_template_dir", default=None) + + +def use_company_templates(company_dir: Path | None) -> None: + """이 요청이 읽을 회사 도각 폴더를 정한다. None이면 프로그램 기본 도각.""" + _company_dir.set(company_dir) + + +def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path: + """회사 도각 파일 경로(없을 수도 있다).""" + return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json" + + +@lru_cache(maxsize=16) +def _read_template(path_str: str, mtime_ns: int) -> dict[str, Any]: + """파일 하나를 읽어 캐시한다. 수정 시각이 캐시 키라 저장 즉시 새 도각이 나간다.""" + return json.loads(Path(path_str).read_text(encoding="utf-8")) + -@lru_cache(maxsize=8) def _load_template(name: str) -> dict[str, Any] | None: + """회사 도각이 있으면 그것, 없으면 프로그램 기본 도각.""" + company_dir = _company_dir.get() + if company_dir is not None: + override = company_template_path(company_dir, name) + if override.is_file(): + return _read_template(str(override), override.stat().st_mtime_ns) path = _TEMPLATE_DIR / f"{name}.json" - if not path.exists(): + if not path.is_file(): return None - return json.loads(path.read_text(encoding="utf-8")) + return _read_template(str(path), path.stat().st_mtime_ns) + + +def template_entities(name: str = A1_TEMPLATE) -> list[dict[str, Any]]: + """도각 원본 엔티티(실치수 1:1). 편집 화면이 그대로 싣고, 저장도 이 좌표계로 받는다.""" + template = _load_template(name) + return list(template.get("entities", [])) if template else [] + + +def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]: + """도각 편집 화면이 그대로 싣는 도면 — 실치수 1:1, 잠금 없는 도각 층 하나. + + 1:1이라 편집 캔버스 좌표가 곧 템플릿 좌표다. 저장할 때 되돌릴 변환이 없다. + """ + return { + "format": DRAWING_FORMAT, + "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in template_entities(name)], + "layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}], + } + + +def save_company_template( + company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE +) -> Path: + """편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다.""" + path = company_template_path(company_dir, name) + path.parent.mkdir(parents=True, exist_ok=True) + document = { + "format": DRAWING_FORMAT, + "source": "B07 도각 편집 화면", + # 도각은 도면마다 잠금 층 하나로 붙으므로 편집 중 새로 만든 층은 도각으로 모은다. + "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities], + "layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}], + } + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(path) + return path def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None: diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 3a01ed65..1012abeb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -24,6 +24,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( extract_quantity_table, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + company_template_path, + frame_template_document, + save_company_template, + use_company_templates, +) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( MASS_HAUL_ID, WATERSHED_ID, @@ -41,6 +47,9 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingInvalidateResponse, DesignDrawingListResponse, DesignDrawingResponse, + FrameTemplateResponse, + FrameTemplateSaveRequest, + FrameTemplateSaveResponse, ) from common_util.common_util_drainage_context import load_drainage_context from common_util.common_util_storage import resolve_stored_project_path @@ -74,6 +83,18 @@ async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]: return route_id, root, longitudinal_path +async def _company_dir(project_id: UUID) -> Path: + """프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`. + + 회사 도각은 회사 폴더에 산다(로고·서명과 같은 결). DB를 한 번 더 뒤지지 않는다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + root = Path(resolve_stored_project_path(stored_path)).resolve() + return root.parent.parent + + async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]: """노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다.""" pool = get_db_pool() @@ -117,6 +138,9 @@ async def get_design_drawing( """선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다.""" try: route_id, project_root, longitudinal_path = await _confirmed_source(project_id) + # 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각). + # 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다. + use_company_templates(project_root.parent.parent) # 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다. design: dict[str, Any] | None = None source_design: Any = None @@ -355,3 +379,50 @@ async def invalidate_design_drawing( status_code=500, content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."}, ) + + +@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse) +async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse: + """도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본.""" + try: + company_dir = await _company_dir(project_id) + use_company_templates(company_dir) + return FrameTemplateResponse( + project_id=str(project_id), + drawing=frame_template_document(), + customized=company_template_path(company_dir).is_file(), + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각을 읽지 못했습니다."}, + ) + + +@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) +async def put_frame_template( + project_id: UUID, request: FrameTemplateSaveRequest +) -> FrameTemplateSaveResponse | JSONResponse: + """편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다. + + 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 + 다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정). + """ + try: + entities = request.drawing.get("entities") + if not isinstance(entities, list): + raise ValueError("도각 엔티티가 없습니다.") + company_dir = await _company_dir(project_id) + await asyncio.to_thread(save_company_template, company_dir, entities) + return FrameTemplateSaveResponse(project_id=str(project_id)) + except (FileNotFoundError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각을 저장하지 못했습니다."}, + ) diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 12f54d6f..9c6b305d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -68,3 +68,27 @@ class DesignDrawingInvalidateResponse(BaseModel): project_id: str id: str confirmed: bool = False + + +class FrameTemplateResponse(BaseModel): + """도각 편집 화면이 실을 도각 한 장(실치수 1:1, 잠금 없는 도면층).""" + + status: str = "success" + project_id: str + drawing: dict[str, Any] + # 회사가 고친 도각을 쓰고 있으면 True, 프로그램 기본 도각이면 False. + customized: bool = False + + +class FrameTemplateSaveRequest(BaseModel): + """도각 편집 화면이 [완료]에서 넘기는 도각.""" + + drawing: dict[str, Any] + + +class FrameTemplateSaveResponse(BaseModel): + """회사 도각 저장 결과.""" + + status: str = "success" + project_id: str + customized: bool = True diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts new file mode 100644 index 00000000..1f51c304 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -0,0 +1,117 @@ +/** + * B07 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다. + * + * 정본(`resources/template_2dDrawing/00_template_A1.json`)은 프로그램 기본 도각이라 + * 건드리지 않는다. 고친 도각은 **회사 도각**(`storage/{회사}/templates/`)으로 저장되고, + * 이후 그리는 도면이 그것을 쓴다 (2026-09-01 사용자 확정). + * + * 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 다시 그려진다. + */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { + type CadDrawing, + fetchFrameTemplate, + saveFrameTemplate, +} from "./B07_DesignDetail_Api_Fetch"; + +export interface FrameTemplateEditor { + /** 도면 목록 아래에 놓는 「도각 편집」 버튼. */ + button: HTMLButtonElement; + /** 편집 중임을 알리는 CAD 화면 상단 띠 (평소엔 숨김). */ + banner: HTMLElement; + /** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */ + isEditing: () => boolean; +} + +interface Options { + projectId: string; + /** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). */ + sendLoad: (drawing: CadDrawing, meta: null) => void; + /** CAD에서 현재 편집본을 받아온다. */ + requestCadDrawing: () => Promise; + /** 편집을 마친 뒤 보던 도면으로 돌아간다. */ + restoreDrawing: () => void; + /** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */ + onSaved: () => void; +} + +export function createFrameTemplateEditor( + options: Options, +): FrameTemplateEditor { + let editing = false; + + const banner = document.createElement("div"); + banner.className = "b07-frame-edit"; + banner.hidden = true; + const label = document.createElement("span"); + label.className = "b07-frame-edit__label"; + banner.append(label); + + const finishButton = createButton({ + label: "완료", + variant: "filled", + onClick: () => void finish(), + }); + const cancelButton = createButton({ + label: "취소", + variant: "ghost", + onClick: () => leave(), + }); + banner.append(finishButton, cancelButton); + + const button = createButton({ + label: "도각 편집", + variant: "ghost", + onClick: () => void enter(), + }); + + const leave = (): void => { + editing = false; + banner.hidden = true; + button.disabled = false; + options.restoreDrawing(); + }; + + async function enter(): Promise { + try { + const response = await fetchFrameTemplate(options.projectId); + editing = true; + button.disabled = true; + banner.hidden = false; + label.textContent = response.customized + ? "도각 편집 중 — 회사 도각을 고치고 있습니다." + : "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다."; + options.sendLoad(response.drawing, null); + } catch (error) { + showToast( + error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", + "error", + ); + } + } + + async function finish(): Promise { + if (!editing) return; + finishButton.disabled = true; + try { + const drawing = await options.requestCadDrawing(); + await saveFrameTemplate(options.projectId, drawing); + options.onSaved(); + showToast( + "도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", + "success", + ); + leave(); + } catch (error) { + showToast( + error instanceof Error ? error.message : "도각을 저장하지 못했습니다.", + "error", + ); + } finally { + finishButton.disabled = false; + } + } + + return { button, banner, isEditing: () => editing }; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index dd332348..b78ae815 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -40,6 +40,7 @@ import { 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 { @@ -80,7 +81,10 @@ const CAD_TOAST_MESSAGE = "aislo:b08:toast"; const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; /** 도면 구성 12분류 (2026-08-29 사용자 확정 순서). kind가 없으면 아직 만들지 않는 도면. */ -const DRAWING_GROUPS: readonly { label: string; kind?: DesignDrawingItem["kind"] }[] = [ +const DRAWING_GROUPS: readonly { + label: string; + kind?: DesignDrawingItem["kind"]; +}[] = [ { label: "표지", kind: "cover" }, { label: "계획평면도(지형)" }, { label: "계획평면도(노선배치도)" }, @@ -120,7 +124,10 @@ function buildDrawingSidePanel( return panel; } - const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { + const drawingButton = ( + drawing: DesignDrawingItem, + label: string, + ): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; button.className = "b07-drawing-button"; @@ -135,7 +142,9 @@ function buildDrawingSidePanel( }; for (const group of DRAWING_GROUPS) { - const items = group.kind ? drawings.filter((item) => item.kind === group.kind) : []; + const items = group.kind + ? drawings.filter((item) => item.kind === group.kind) + : []; // 한 장짜리(와 아직 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. if (items.length <= 1) { const [drawing] = items; @@ -174,7 +183,10 @@ function buildDrawingSidePanel( return panel; } -const GROUND_TYPE_LABEL: Record = { +const GROUND_TYPE_LABEL: Record< + CrossDesignInfo["ground_type"], + keyof typeof ui_locales +> = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", blasting_rock: "B06_Design_Ground_Blasting", @@ -191,7 +203,8 @@ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { /** 측구 규격 표시 문자열 (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 || 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`; @@ -211,7 +224,10 @@ function infoRow(label: string, value: string): HTMLElement { } /** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */ -function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement { +function buildDesignInfoPanel( + title: string, + design: CrossDesignInfo | null, +): HTMLElement { const panel = document.createElement("div"); panel.className = "b07-info"; const heading = document.createElement("div"); @@ -221,7 +237,9 @@ function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HT 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"); + badge.textContent = confirmed + ? L("B07_Info_Confirmed") + : L("B07_Info_Provisional"); heading.append(stationName, badge); panel.append(heading); @@ -243,7 +261,9 @@ function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HT 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"), + design.ditch_side === "left" + ? L("B06_Design_Ditch_Left") + : L("B06_Design_Ditch_Right"), ), ); @@ -253,7 +273,10 @@ function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HT 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_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`), @@ -279,8 +302,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { fetchWorkflowState(projectId), fetchDesignDrawingList(projectId), ]); - if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; - if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings; + if (workflowResult.status === "fulfilled") + workflowState = workflowResult.value; + if (drawingResult.status === "fulfilled") + drawings = drawingResult.value.drawings; else drawingError = drawingResult.reason instanceof Error @@ -303,26 +328,32 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { cadHost.append(frame, license); let cadReady = false; - let pendingLoad: { drawing: CadDrawing; meta: DesignMeta } | undefined; + 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); + 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 => { + 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)); + infoPanelHost.replaceChildren( + buildDesignInfoPanel(title, response.design ?? null), + ); }; const confirmButton = createButton({ @@ -338,9 +369,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ) ?? undefined; const highlightActive = (drawingId: string) => { - drawingListEl?.querySelectorAll(".b07-drawing-button").forEach((item) => { - item.dataset.active = String(item.dataset.drawingId === drawingId); - }); + drawingListEl + ?.querySelectorAll(".b07-drawing-button") + .forEach((item) => { + item.dataset.active = String(item.dataset.drawingId === drawingId); + }); }; const buildMeta = ( @@ -357,7 +390,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { hasNext: index < drawings.length - 1, }); - const sendLoad = (drawing: CadDrawing, meta: DesignMeta) => { + const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null) => { pendingLoad = { drawing, meta }; if (!cadReady) return; frame.contentWindow?.postMessage( @@ -378,15 +411,24 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const drawingCache = new Map>(); /** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */ - const requestDrawing = (drawing: DesignDrawingItem): 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); + 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); + await appendStructureEntities( + projectId as string, + response.route_id, + response.drawing, + ); } return response; })().catch((error) => { @@ -426,7 +468,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } catch (error) { cadHost.dataset.loading = "false"; cadHost.dataset.error = - error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; + error instanceof Error + ? error.message + : "CAD 도면을 불러오지 못했습니다."; } finally { if (button) button.dataset.loading = "false"; } @@ -446,7 +490,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const requestCadDrawing = (): Promise => new Promise((resolve, reject) => { resolveSave = resolve; - frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin); + frame.contentWindow?.postMessage( + { type: CAD_SAVE_REQUEST_MESSAGE }, + window.location.origin, + ); window.setTimeout(() => { if (!resolveSave) return; resolveSave = undefined; @@ -485,7 +532,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } } catch (error) { showToast( - error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.", + error instanceof Error + ? error.message + : "현재 도면을 확정하지 못했습니다.", "error", ); } finally { @@ -508,15 +557,32 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { await invalidateDesignDrawing(projectId, currentDrawing.id); } catch (error) { showToast( - error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.", + error instanceof Error + ? error.message + : "도면 확정 상태를 되돌리지 못했습니다.", "error", ); } } }; + 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; + if ( + event.origin !== window.location.origin || + event.source !== frame.contentWindow + ) + return; const message = event.data as { type?: string; detail?: string; @@ -533,7 +599,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { (item) => item === message.kind, ); // autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다. - const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000); + const duration = + message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000); const actionId = message.actionId; showToast( message.text ?? "", @@ -553,25 +620,38 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } 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.error = + message.detail ?? "CAD 도면을 표시하지 못했습니다."; } else if (message.type === CAD_CHANGED_MESSAGE) { - void invalidateCurrentDrawing(); + // 도각을 고치는 중에 온 변경 알림은 도면 편집이 아니다 — 확정을 풀면 안 된다. + if (!frameEditor.isEditing()) void invalidateCurrentDrawing(); } else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) { navigateDrawing(message.direction); - } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { + } else if ( + message.type === CAD_SAVE_RESPONSE_MESSAGE && + message.drawing && + resolveSave + ) { const resolve = resolveSave; resolveSave = undefined; - resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null }); + resolve({ + drawing: message.drawing, + quantityTable: message.quantityTable ?? null, + }); } }); - const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError); + 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(confirmButton); + confirmActions.append(frameEditor.button, confirmButton); drawingPanel.append(infoPanelHost, confirmActions); @@ -587,7 +667,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { onStepClick: (stepIndex) => { if (!projectId) return; if (stepIndex > 5 && !allDrawingsConfirmed) { - showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning"); + showToast( + "모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", + "warning", + ); return; } goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 296e0bf3..cb6646f7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -111,7 +111,11 @@ /* 확정: 좌측 띠 + 측점 글자색을 함께 성공색으로 반영 */ .b07-drawing-button[data-confirmed="true"] { - border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border)); + border-color: color-mix( + in srgb, + var(--color-success) 35%, + var(--color-border) + ); border-left-color: var(--color-success); } @@ -231,3 +235,30 @@ font-size: 0.76rem; color: var(--color-text-muted); } + +/* 도각 편집 모드 띠 — CAD 위에 겹쳐 편집 중임을 알리고 [완료]·[취소]를 준다. */ +.b07-frame-edit { + position: absolute; + z-index: 3; + top: 8px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background-color: var(--color-surface); + box-shadow: 0 6px 18px rgb(0 0 0 / 25%); +} + +.b07-frame-edit__label { + font-size: 0.78rem; + color: var(--color-text); +} + +.b07-frame-edit > button { + padding: 4px 12px; + font-size: 0.78rem; +}