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:
2026-09-01 14:51:46 +09:00
co-authored by Claude Opus 5
parent 64d944eb0f
commit 3c0e65d6cd
7 changed files with 490 additions and 47 deletions
+55 -8
View File
@@ -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 };
}
+118 -35
View File
@@ -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]);
+32 -1
View File
@@ -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;
}