Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_Page.ts
T
eomsangdonandClaude Opus 5 44fbe480df feat(B07): 표지를 A1 전면 템플릿으로 만들고 도면 목록에 붙인다
표지는 DRAWING_GROUPS에 kind 없이 라벨만 있어 화면에서 열 수 없었다. 실무 설계도면
원본(울진 소광리, A3) 1쪽의 잉크 bbox를 pymupdf로 mm 실측해 A1로 2배 환산했다.
원본에 도각이 없다 — 좌우 테두리 없이 상·하 굵은 가로선 두 줄뿐이라, 사용자가 고른
"도각 없는 전면 디자인"이 실무 그대로였다.

- resources/template_2dDrawing/00_template_cover.json 신설. 엔티티 14개(재단 표식
  Point 4·띠 Hatch 3·글자 Text 7), bbox가 정확히 A1(840x594). 좌표계는 00_template_A1
  과 같아 도각 도면과 나란히 놓인다.
- 굵은 띠는 solid Hatch로 냈다. lineWidth는 캔버스 화면 픽셀이라(screenCanvas
  .drawController.ts:261) 확대해도 두꺼워지지 않아 실치수를 못 낸다.
- 글자 크기는 잉크 높이가 아니라 폭으로 잡았다. 원본은 장평이 좁은 CAD 글꼴이라
  높이를 그대로 옮기면 위치값이 종이 밖으로 44mm 넘치고 라벨과 겹친다.
- B07_DesignDetail_Engine_Cad_Cover.py 신설. frame_entities()를 쓰지 않는다 —
  _transform_entity()가 Hatch의 points 배열을 못 옮기고, 표지는 A1 실치수 고정이라
  애초에 변환이 필요 없다. 공용 함수는 건드리지 않았다.
- kind "cover" 배선: Schema Literal, Api_Fetch, UI_Page DRAWING_GROUPS,
  Router_Support 목록·빌드 분기·확정 캐시 매핑.

값(공사명·위치·사업량·시행청)은 아직 빈칸이다 — 메타 배선은 다음 판.

검증: tmp/tests/test_cover_template.py 6건 통과(A1 치수·Hatch 두께·잠금 레이어·
치환·lru_cache 오염 없음). 공용 브라우저에서 표지를 열어 엔티티 14개 전부
b08-frame, Ctrl+A 선택 0 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:49:47 +09:00

608 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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";
/** 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 } | 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) => {
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",
);
}
}
};
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) {
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(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();
});
}
}