프로그램 기본 도각(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>
691 lines
25 KiB
TypeScript
691 lines
25 KiB
TypeScript
/* =============================================================================
|
||
* B07_DesignDetail_UI_Page.ts
|
||
* 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드
|
||
*
|
||
* B07_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여
|
||
* /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로
|
||
* JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다.
|
||
*
|
||
* 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면.
|
||
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
|
||
* ========================================================================== */
|
||
|
||
import "./B07_DesignDetail_UI_Style.css";
|
||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import {
|
||
createButton,
|
||
hideLoadingOverlay,
|
||
showLoadingOverlay,
|
||
showToast,
|
||
} from "@ui/ui_template_elements";
|
||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||
import {
|
||
fetchWorkflowState,
|
||
goToWorkflowStage,
|
||
WORKFLOW_STEP_ROUTES,
|
||
type WorkflowState,
|
||
} from "../A00_Common/b_workflow_nav";
|
||
import {
|
||
confirmDesignDrawing,
|
||
fetchDesignDrawing,
|
||
fetchDesignDrawingList,
|
||
invalidateDesignDrawing,
|
||
type CadDrawing,
|
||
type CrossDesignInfo,
|
||
type DesignDrawingItem,
|
||
type DesignDrawingResponse,
|
||
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 {
|
||
kind: "cover" | "cross" | "longitudinal" | "mass_haul" | "watershed";
|
||
title: string;
|
||
info: string;
|
||
confirmed: boolean;
|
||
quantityTable: QuantityTable | null;
|
||
hasPrev: boolean;
|
||
hasNext: boolean;
|
||
}
|
||
|
||
/** CAD 저장 응답 (도면 + 편집된 수량표). */
|
||
interface SaveResult {
|
||
drawing: CadDrawing;
|
||
quantityTable: QuantityTable | null;
|
||
}
|
||
|
||
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
||
const B07_CAD_APP_URL = "/b07-cad/index.html";
|
||
|
||
/** locale 헬퍼 */
|
||
function L(key: keyof typeof ui_locales): string {
|
||
return ui_locales[key][currentLanguageIndex];
|
||
}
|
||
|
||
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
|
||
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
|
||
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
|
||
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
|
||
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
|
||
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
|
||
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
|
||
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
|
||
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
||
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"];
|
||
}[] = [
|
||
{ label: "표지", kind: "cover" },
|
||
{ label: "계획평면도(지형)" },
|
||
{ label: "계획평면도(노선배치도)" },
|
||
{ label: "계획평면도(배치도)" },
|
||
{ label: "계획평면도(라이다)" },
|
||
{ label: "종단면도", kind: "longitudinal" },
|
||
{ label: "표준 횡단면도" },
|
||
{ label: "횡단면도", kind: "cross" },
|
||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||
{ label: "유역도(배수 유역도)", kind: "watershed" },
|
||
{ label: "표준도" },
|
||
{ label: "용지도" },
|
||
];
|
||
|
||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||
function buildDrawingSidePanel(
|
||
drawings: DesignDrawingItem[],
|
||
onSelect: (drawing: DesignDrawingItem) => void,
|
||
errorMessage?: string,
|
||
): HTMLDivElement {
|
||
const panel = document.createElement("div");
|
||
panel.className = "b07-drawing-list";
|
||
const heading = document.createElement("div");
|
||
heading.className = "b07-drawing-list__heading";
|
||
const title = document.createElement("strong");
|
||
title.textContent = "설계 도면";
|
||
const count = document.createElement("span");
|
||
count.textContent = `${drawings.length}건`;
|
||
heading.append(title, count);
|
||
panel.append(heading);
|
||
|
||
if (errorMessage || drawings.length === 0) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "b07-drawing-list__empty";
|
||
empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다.";
|
||
panel.append(empty);
|
||
return panel;
|
||
}
|
||
|
||
const drawingButton = (
|
||
drawing: DesignDrawingItem,
|
||
label: string,
|
||
): HTMLButtonElement => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "b07-drawing-button";
|
||
button.dataset.drawingId = drawing.id;
|
||
button.dataset.confirmed = String(drawing.confirmed);
|
||
const name = document.createElement("span");
|
||
name.className = "b07-drawing-button__name";
|
||
name.textContent = label;
|
||
button.append(name);
|
||
button.addEventListener("click", () => onSelect(drawing));
|
||
return button;
|
||
};
|
||
|
||
for (const group of DRAWING_GROUPS) {
|
||
const items = group.kind
|
||
? drawings.filter((item) => item.kind === group.kind)
|
||
: [];
|
||
// 한 장짜리(와 아직 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
||
if (items.length <= 1) {
|
||
const [drawing] = items;
|
||
const button = drawing
|
||
? drawingButton(drawing, group.label)
|
||
: document.createElement("button");
|
||
if (!drawing) {
|
||
button.type = "button";
|
||
button.className = "b07-drawing-button";
|
||
button.disabled = true;
|
||
button.title = "준비 중";
|
||
const name = document.createElement("span");
|
||
name.className = "b07-drawing-button__name";
|
||
name.textContent = group.label;
|
||
button.append(name);
|
||
}
|
||
button.dataset.pending = String(!drawing);
|
||
panel.append(button);
|
||
continue;
|
||
}
|
||
const section = document.createElement("section");
|
||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시).
|
||
section.className = "b07-drawing-group ui-collapsible ui-sidebar-section";
|
||
if (group.kind) section.dataset.kind = group.kind;
|
||
const sectionTitle = document.createElement("h3");
|
||
sectionTitle.className = "ui-collapsible__title";
|
||
sectionTitle.textContent = `${group.label} ${items.length}`;
|
||
section.append(sectionTitle);
|
||
for (const drawing of items) {
|
||
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
|
||
section.append(drawingButton(drawing, drawing.label));
|
||
}
|
||
panel.append(section);
|
||
}
|
||
attachCollapsible(panel);
|
||
return panel;
|
||
}
|
||
|
||
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",
|
||
};
|
||
|
||
/** 단면유형에서 절토측 표기를 유도한다. */
|
||
function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
|
||
if (mode === "left_cut") return L("B06_Design_Ditch_Left");
|
||
if (mode === "right_cut") return L("B06_Design_Ditch_Right");
|
||
if (mode === "both_cut") return L("B06_Design_Mode_BothCut");
|
||
return L("B06_Design_Mode_BothFill");
|
||
}
|
||
|
||
/** 측구 규격 표시 문자열 (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.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`;
|
||
}
|
||
|
||
function infoRow(label: string, value: string): HTMLElement {
|
||
const row = document.createElement("div");
|
||
row.className = "b07-info__row";
|
||
const key = document.createElement("span");
|
||
key.className = "b07-info__key";
|
||
key.textContent = label;
|
||
const val = document.createElement("span");
|
||
val.className = "b07-info__val";
|
||
val.textContent = value;
|
||
row.append(key, val);
|
||
return row;
|
||
}
|
||
|
||
/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
|
||
function buildDesignInfoPanel(
|
||
title: string,
|
||
design: CrossDesignInfo | null,
|
||
): HTMLElement {
|
||
const panel = document.createElement("div");
|
||
panel.className = "b07-info";
|
||
const heading = document.createElement("div");
|
||
heading.className = "b07-info__heading";
|
||
const stationName = document.createElement("strong");
|
||
stationName.textContent = `${L("B07_Info_Station")} ${title}`;
|
||
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");
|
||
heading.append(stationName, badge);
|
||
panel.append(heading);
|
||
|
||
if (!design) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "b07-info__empty";
|
||
empty.textContent = L("B07_Info_NoDesign");
|
||
panel.append(empty);
|
||
return panel;
|
||
}
|
||
|
||
const ground = document.createElement("section");
|
||
ground.className = "b07-info__block";
|
||
const groundTitle = document.createElement("h4");
|
||
groundTitle.textContent = L("B07_Info_Ground_Title");
|
||
ground.append(
|
||
groundTitle,
|
||
infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])),
|
||
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"),
|
||
),
|
||
);
|
||
|
||
const plan = document.createElement("section");
|
||
plan.className = "b07-info__block";
|
||
const planTitle = document.createElement("h4");
|
||
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_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`),
|
||
infoRow(L("B07_Info_Ditch"), ditchLabel(design)),
|
||
infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`),
|
||
infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`),
|
||
);
|
||
|
||
panel.append(ground, plan);
|
||
return panel;
|
||
}
|
||
|
||
/* -----------------------------------------------------------------------------
|
||
* 페이지 진입점
|
||
* -------------------------------------------------------------------------- */
|
||
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||
let workflowState: WorkflowState | undefined;
|
||
let drawings: DesignDrawingItem[] = [];
|
||
let drawingError: string | undefined;
|
||
if (projectId) {
|
||
const [workflowResult, drawingResult] = await Promise.allSettled([
|
||
fetchWorkflowState(projectId),
|
||
fetchDesignDrawingList(projectId),
|
||
]);
|
||
if (workflowResult.status === "fulfilled")
|
||
workflowState = workflowResult.value;
|
||
if (drawingResult.status === "fulfilled")
|
||
drawings = drawingResult.value.drawings;
|
||
else
|
||
drawingError =
|
||
drawingResult.reason instanceof Error
|
||
? drawingResult.reason.message
|
||
: "도면 목록을 불러오지 못했습니다.";
|
||
}
|
||
|
||
const cadHost = document.createElement("div");
|
||
cadHost.className = "b07-cad-host";
|
||
const frame = document.createElement("iframe");
|
||
frame.className = "b07-cad-frame";
|
||
frame.src = B07_CAD_APP_URL;
|
||
frame.title = L("B07_Design_Title");
|
||
const license = document.createElement("a");
|
||
license.className = "b07-cad-license";
|
||
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
|
||
license.target = "_blank";
|
||
license.rel = "noreferrer";
|
||
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
|
||
cadHost.append(frame, license);
|
||
|
||
let cadReady = false;
|
||
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);
|
||
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 => {
|
||
if (drawing.kind !== "cross") {
|
||
infoPanelHost.replaceChildren();
|
||
return;
|
||
}
|
||
const title = drawing.label;
|
||
infoPanelHost.replaceChildren(
|
||
buildDesignInfoPanel(title, response.design ?? null),
|
||
);
|
||
};
|
||
|
||
const confirmButton = createButton({
|
||
label: "현재 도면 확정",
|
||
variant: "filled",
|
||
onClick: () => void confirmCurrentDrawing(),
|
||
});
|
||
confirmButton.disabled = true;
|
||
|
||
const findButton = (drawingId: string) =>
|
||
drawingListEl?.querySelector<HTMLButtonElement>(
|
||
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
|
||
) ?? undefined;
|
||
|
||
const highlightActive = (drawingId: string) => {
|
||
drawingListEl
|
||
?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button")
|
||
.forEach((item) => {
|
||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||
});
|
||
};
|
||
|
||
const buildMeta = (
|
||
drawing: DesignDrawingItem,
|
||
response: DesignDrawingResponse,
|
||
index: number,
|
||
): DesignMeta => ({
|
||
kind: drawing.kind,
|
||
title: drawing.label,
|
||
info: drawing.kind === "cross" ? drawing.label : "",
|
||
confirmed: response.confirmed,
|
||
quantityTable: response.quantity_table ?? null,
|
||
hasPrev: index > 0,
|
||
hasNext: index < drawings.length - 1,
|
||
});
|
||
|
||
const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null) => {
|
||
pendingLoad = { drawing, meta };
|
||
if (!cadReady) return;
|
||
frame.contentWindow?.postMessage(
|
||
{ type: CAD_LOAD_MESSAGE, drawing, meta },
|
||
window.location.origin,
|
||
);
|
||
pendingLoad = undefined;
|
||
};
|
||
|
||
/**
|
||
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
|
||
*
|
||
* 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초,
|
||
* 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다.
|
||
* sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라
|
||
* 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다.
|
||
*/
|
||
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
|
||
|
||
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
|
||
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,
|
||
);
|
||
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
|
||
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
|
||
if (drawing.kind === "cross" && !response.confirmed) {
|
||
await appendStructureEntities(
|
||
projectId as string,
|
||
response.route_id,
|
||
response.drawing,
|
||
);
|
||
}
|
||
return response;
|
||
})().catch((error) => {
|
||
drawingCache.delete(drawing.id); // 실패는 캐시에 남기지 않는다 — 다음 클릭에 다시 받는다.
|
||
throw error;
|
||
});
|
||
drawingCache.set(drawing.id, request);
|
||
return request;
|
||
};
|
||
|
||
/** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. */
|
||
const prefetchAllDrawings = async (): Promise<void> => {
|
||
if (!projectId) return;
|
||
for (const item of drawings) {
|
||
try {
|
||
await requestDrawing(item);
|
||
} catch {
|
||
/* 한 장을 못 받아도 나머지는 계속 받는다 — 클릭 때 다시 시도한다. */
|
||
}
|
||
}
|
||
};
|
||
|
||
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
|
||
if (!projectId) return;
|
||
highlightActive(drawing.id);
|
||
const button = findButton(drawing.id);
|
||
if (button) button.dataset.loading = "true";
|
||
cadHost.dataset.loading = "true";
|
||
try {
|
||
const response = await requestDrawing(drawing);
|
||
currentDrawing = drawing;
|
||
currentIndex = index;
|
||
currentConfirmed = response.confirmed;
|
||
confirmButton.disabled = response.confirmed;
|
||
updateInfoPanel(drawing, response);
|
||
sendLoad(response.drawing, buildMeta(drawing, response, index));
|
||
} catch (error) {
|
||
cadHost.dataset.loading = "false";
|
||
cadHost.dataset.error =
|
||
error instanceof Error
|
||
? error.message
|
||
: "CAD 도면을 불러오지 못했습니다.";
|
||
} finally {
|
||
if (button) button.dataset.loading = "false";
|
||
}
|
||
};
|
||
|
||
const selectDrawing = (drawing: DesignDrawingItem) => {
|
||
void loadDrawing(drawing, drawings.indexOf(drawing));
|
||
};
|
||
|
||
const navigateDrawing = (direction: "prev" | "next") => {
|
||
if (currentIndex < 0) return;
|
||
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||
if (target < 0 || target >= drawings.length) return;
|
||
void loadDrawing(drawings[target], target);
|
||
};
|
||
|
||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||
new Promise((resolve, reject) => {
|
||
resolveSave = resolve;
|
||
frame.contentWindow?.postMessage(
|
||
{ type: CAD_SAVE_REQUEST_MESSAGE },
|
||
window.location.origin,
|
||
);
|
||
window.setTimeout(() => {
|
||
if (!resolveSave) return;
|
||
resolveSave = undefined;
|
||
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
|
||
}, 5000);
|
||
});
|
||
|
||
async function confirmCurrentDrawing(): Promise<void> {
|
||
if (!projectId || !currentDrawing) return;
|
||
showLoadingOverlay();
|
||
try {
|
||
const saved = await requestCadDrawing();
|
||
const result = await confirmDesignDrawing(
|
||
projectId,
|
||
currentDrawing.id,
|
||
saved.drawing,
|
||
currentDrawing.kind === "cross" ? (saved.quantityTable ?? null) : null,
|
||
);
|
||
currentConfirmed = true;
|
||
currentDrawing.confirmed = true;
|
||
confirmButton.disabled = true;
|
||
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
|
||
|
||
const button = findButton(currentDrawing.id);
|
||
if (button) button.dataset.confirmed = "true";
|
||
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
|
||
if (currentDrawing.kind === "cross") {
|
||
infoPanelHost.replaceChildren(
|
||
buildDesignInfoPanel(currentDrawing.label, result.design ?? null),
|
||
);
|
||
}
|
||
showToast("현재 도면을 확정하고 저장했습니다.", "success");
|
||
if (result.all_confirmed) {
|
||
allDrawingsConfirmed = true;
|
||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]);
|
||
}
|
||
} catch (error) {
|
||
showToast(
|
||
error instanceof Error
|
||
? error.message
|
||
: "현재 도면을 확정하지 못했습니다.",
|
||
"error",
|
||
);
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
const invalidateCurrentDrawing = async () => {
|
||
if (!projectId || !currentDrawing) return;
|
||
const wasConfirmed = currentConfirmed;
|
||
drawingCache.delete(currentDrawing.id);
|
||
currentConfirmed = false;
|
||
allDrawingsConfirmed = false;
|
||
currentDrawing.confirmed = false;
|
||
confirmButton.disabled = false;
|
||
const button = findButton(currentDrawing.id);
|
||
if (button) button.dataset.confirmed = "false";
|
||
if (wasConfirmed) {
|
||
try {
|
||
await invalidateDesignDrawing(projectId, currentDrawing.id);
|
||
} catch (error) {
|
||
showToast(
|
||
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;
|
||
const message = event.data as {
|
||
type?: string;
|
||
detail?: string;
|
||
drawing?: CadDrawing;
|
||
quantityTable?: QuantityTable | null;
|
||
direction?: "prev" | "next";
|
||
kind?: string;
|
||
text?: string;
|
||
actionId?: string;
|
||
durationMs?: number;
|
||
};
|
||
if (message.type === CAD_TOAST_MESSAGE) {
|
||
const kind = (["info", "success", "warning", "error"] as const).find(
|
||
(item) => item === message.kind,
|
||
);
|
||
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
||
const duration =
|
||
message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||
const actionId = message.actionId;
|
||
showToast(
|
||
message.text ?? "",
|
||
kind ?? "info",
|
||
duration,
|
||
actionId
|
||
? () =>
|
||
frame.contentWindow?.postMessage(
|
||
{ type: CAD_TOAST_ACTION_MESSAGE, actionId },
|
||
window.location.origin,
|
||
)
|
||
: undefined,
|
||
);
|
||
} else if (message.type === CAD_READY_MESSAGE) {
|
||
cadReady = true;
|
||
if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta);
|
||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||
cadHost.dataset.loading = "false";
|
||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||
cadHost.dataset.error =
|
||
message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||
// 도각을 고치는 중에 온 변경 알림은 도면 편집이 아니다 — 확정을 풀면 안 된다.
|
||
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
|
||
) {
|
||
const resolve = resolveSave;
|
||
resolveSave = undefined;
|
||
resolve({
|
||
drawing: message.drawing,
|
||
quantityTable: message.quantityTable ?? null,
|
||
});
|
||
}
|
||
});
|
||
|
||
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(frameEditor.button, confirmButton);
|
||
|
||
drawingPanel.append(infoPanelHost, confirmActions);
|
||
|
||
const layout = createWorkflowLayout({
|
||
title: L("B07_Design_Title"),
|
||
steps: workflowSteps(),
|
||
activeStep: 4,
|
||
leftPanel: drawingPanel,
|
||
mainContent: cadHost,
|
||
stages: workflowState?.stages,
|
||
currentStage: workflowState?.current_stage,
|
||
routes: WORKFLOW_STEP_ROUTES,
|
||
onStepClick: (stepIndex) => {
|
||
if (!projectId) return;
|
||
if (stepIndex > 5 && !allDrawingsConfirmed) {
|
||
showToast(
|
||
"모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.",
|
||
"warning",
|
||
);
|
||
return;
|
||
}
|
||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||
},
|
||
});
|
||
layout.root.classList.add("b07-design-layout");
|
||
root.replaceChildren(layout.root);
|
||
|
||
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
|
||
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
|
||
if (drawings.length > 0) {
|
||
void loadDrawing(drawings[0], 0).then(() => {
|
||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||
void prefetchAllDrawings();
|
||
});
|
||
}
|
||
}
|