feat(B07): 캐드에서 도각을 고치고 [완료] 한 번에 반영하는 편집 모드를 붙인다
프로그램 기본 도각(resources/template_2dDrawing)은 읽기 전용으로 두고, 고친 도각은
회사 도각(storage/{회사}/templates)으로 저장한다. 이후 그리는 도면이 그것을 쓴다.
- Engine_Template: 회사 도각 우선 로더(ContextVar로 요청마다 회사 폴더 지정),
캐시 키에 mtime을 넣어 저장 즉시 반영(템플릿 수정에 백엔드 재시작이 필요 없어짐),
frame_template_document()/save_company_template() 신설.
- Router: GET/PUT /api/projects/{id}/frame-template. 도각은 실치수 1:1로 오가므로
좌표 역변환이 없다.
- UI_FrameEdit(신규): 「도각 편집」 버튼·배너·[완료]/[취소]. 완료 시 도면 캐시를 버리고
보던 도면을 다시 싣는다. 편집 중 변경 알림이 도면 확정을 풀지 않게 막았다.
확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지하고, 확정을 풀면 새 도각으로
다시 그려진다(2026-09-01 사용자 확정).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,12 @@ export type QuantityTable = Record<string, number | null>;
|
||||
/** 측구 형식별 규격 (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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
@@ -101,14 +112,17 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
|
||||
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<DesignDrawingListResponse> {
|
||||
export function fetchDesignDrawingList(
|
||||
projectId: string,
|
||||
): Promise<DesignDrawingListResponse> {
|
||||
return requestJson(`/projects/${projectId}/design-drawings`);
|
||||
}
|
||||
|
||||
@@ -116,7 +130,9 @@ export function fetchDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<DesignDrawingResponse> {
|
||||
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<DesignDrawingConfirmResponse> {
|
||||
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<void> {
|
||||
export function invalidateDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<void> {
|
||||
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<FrameTemplateResponse> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`);
|
||||
}
|
||||
|
||||
export function saveFrameTemplate(
|
||||
projectId: string,
|
||||
drawing: CadDrawing,
|
||||
): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ drawing }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "도각을 저장하지 못했습니다."},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CadDrawing>;
|
||||
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
) ?? undefined;
|
||||
|
||||
const highlightActive = (drawingId: string) => {
|
||||
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
|
||||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||||
});
|
||||
drawingListEl
|
||||
?.querySelectorAll<HTMLButtonElement>(".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<void> {
|
||||
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<void> {
|
||||
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
|
||||
|
||||
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
|
||||
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
|
||||
const requestDrawing = (
|
||||
drawing: DesignDrawingItem,
|
||||
): Promise<DesignDrawingResponse> => {
|
||||
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<void> {
|
||||
} 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<void> {
|
||||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||||
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<void> {
|
||||
}
|
||||
} 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<void> {
|
||||
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<unknown>) => {
|
||||
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<void> {
|
||||
(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<void> {
|
||||
} 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<void> {
|
||||
onStepClick: (stepIndex) => {
|
||||
if (!projectId) return;
|
||||
if (stepIndex > 5 && !allDrawingsConfirmed) {
|
||||
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
|
||||
showToast(
|
||||
"모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user