Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_Page.ts
T
eomsangdonandClaude Opus 5 65d6fd261f fix(B07): 도각 편집 띠 사이드바 이관·깨진 그림에 렌더 루프 정지 해소
- 편집 띠를 CAD 위 절대배치에서 도면 목록 하단 액션 칸으로 옮김 —
  1행 편집 띠, 2행 [도각 편집]·[현재 도면 확정] (리본과 겹쳐 문구·버튼이 찌그러지던 문제)
- `drawImage` 가 'broken' 상태 그림에서 던지던 예외로 렌더 루프가 끊겨
  이후 모든 도면이 백지로 남던 결함 해소 — 못 읽은 그림은 건너뜀
- `ImageEntity` 가 자리표시자(`{{회사로고}}`)를 절대 URL 로 굳히던 저장 경로 수정 —
  원본 주소를 보관해 그대로 돌려주고, 자리표시자는 요청조차 하지 않음
- 버튼 줌(ZOOMIN·ZOOMOUT)이 배율만 바꿔 도면이 화면 밖으로 밀리던 것을
  화면 중심 고정으로 수정

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:12:28 +09:00

709 lines
29 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";
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
kind: DesignDrawingItem["kind"];
/** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */
drawingId: string;
title: string;
info: string;
/** 확정본은 CAD에서 읽기 전용이 된다. 푸는 길은 [수정] 버튼 하나뿐. */
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 사용자 확정 순서).
*
* 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다
* (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다.
*/
const DRAWING_GROUPS: readonly {
label: string;
kind?: DesignDrawingItem["kind"];
blankId?: string;
}[] = [
{ label: "표지", kind: "cover" },
{ label: "계획평면도(지형)", blankId: "blank_plan_terrain" },
{ label: "계획평면도(노선배치도)", blankId: "blank_plan_route" },
{ label: "계획평면도(배치도)", blankId: "blank_plan_layout" },
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" },
{ label: "종단면도", kind: "longitudinal" },
{ label: "표준 횡단면도", blankId: "blank_cross_standard" },
{ label: "횡단면도", kind: "cross" },
{ label: "토적도(유토곡선)", kind: "mass_haul" },
{ label: "유역도(배수 유역도)", kind: "watershed" },
{ label: "표준도", blankId: "blank_standard" },
{ label: "용지도", blankId: "blank_landuse" },
];
/** 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)
: drawings.filter((item) => item.id === group.blankId);
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
if (items.length <= 1) {
const [drawing] = items;
// 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다.
if (!drawing) {
const placeholder = document.createElement("button");
placeholder.type = "button";
placeholder.className = "b07-drawing-button";
placeholder.disabled = true;
placeholder.title = "준비 중";
placeholder.dataset.pending = "true";
const name = document.createElement("span");
name.className = "b07-drawing-button__name";
name.textContent = group.label;
placeholder.append(name);
panel.append(placeholder);
continue;
}
const button = drawingButton(drawing, group.label);
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
button.dataset.pending = String(drawing.kind === "blank");
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
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));
};
/**
* CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나
* 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다
* (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다.
*/
let cadDirty = false;
/**
* 확정·미확정에 따라 버튼 한 자리를 바꾼다 (2026-09-01 사용자 확정).
* 미확정이면 [현재 도면 확정], 확정이면 [수정] — 확정을 푸는 유일한 길이다.
*/
const confirmButton = createButton({
label: "현재 도면 확정",
variant: "filled",
onClick: () => {
if (currentConfirmed) void reopenCurrentDrawing();
else void confirmCurrentDrawing();
},
});
confirmButton.disabled = true;
const applyConfirmButtonState = (): void => {
confirmButton.textContent = currentConfirmed ? "수정" : "현재 도면 확정";
confirmButton.title = currentConfirmed
? "확정을 풀고 이 도면을 고칩니다."
: "이 도면을 확정하고 저장합니다.";
confirmButton.disabled = !currentDrawing || currentDrawing.kind === "blank";
};
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,
drawingId: drawing.id,
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 mayDiscardEdits = (): boolean => {
if (!cadDirty) return true;
return window.confirm(
"저장하지 않은 편집이 있습니다.\n" +
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
"남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.",
);
};
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
let loadInFlight = false;
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
if (!projectId || loadInFlight) return;
loadInFlight = true;
highlightActive(drawing.id);
const button = findButton(drawing.id);
if (button) button.dataset.loading = "true";
cadHost.dataset.loading = "true";
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
try {
const response = await requestDrawing(drawing);
currentDrawing = drawing;
currentIndex = index;
currentConfirmed = response.confirmed;
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
applyConfirmButtonState();
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 도면을 불러오지 못했습니다.";
showToast(cadHost.dataset.error, "error");
if (currentDrawing) highlightActive(currentDrawing.id);
} finally {
loadInFlight = false;
if (button) button.dataset.loading = "false";
}
};
const selectDrawing = (drawing: DesignDrawingItem) => {
if (drawing === currentDrawing || !mayDiscardEdits()) return;
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;
if (!mayDiscardEdits()) 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;
cadDirty = false; // 저장했다
applyConfirmButtonState();
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "true";
// 저장본을 다시 실어 CAD를 읽기 전용으로 돌린다 — 확정한 도면은 고칠 수 없다.
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex);
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
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();
}
}
/**
* [수정] — 확정을 풀고 다시 고칠 수 있게 한다. **확정이 풀리는 유일한 길**이다
* (2026-09-01 사용자 확정). 예전에는 CAD의 편집 통지가 확정을 풀어서, 되돌리기나
* 색 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
*/
async function reopenCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing || !currentConfirmed) return;
showLoadingOverlay();
try {
await invalidateDesignDrawing(projectId, currentDrawing.id);
drawingCache.delete(currentDrawing.id);
currentConfirmed = false;
allDrawingsConfirmed = false;
currentDrawing.confirmed = false;
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "false";
applyConfirmButtonState();
// 확정을 풀면 서버가 원본에서 다시 그린다 — 그 도면을 실어야 편집이 열린다.
await loadDrawing(currentDrawing, currentIndex);
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
} catch (error) {
showToast(
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
}
const frameEditor = createFrameTemplateEditor({
projectId: projectId as string,
sendLoad,
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
restoreDrawing: () => {
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
},
onSaved: () => drawingCache.clear(),
});
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";
dirty?: boolean;
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 도면을 표시하지 못했습니다.";
cadHost.dataset.loading = "false";
showToast(cadHost.dataset.error, "error");
} else if (message.type === CAD_CHANGED_MESSAGE) {
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
} 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";
// 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]·[현재 도면 확정]
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
const confirmButtonRow = document.createElement("div");
confirmButtonRow.className = "b07-drawing-actions__row";
confirmButtonRow.append(frameEditor.button, confirmButton);
confirmActions.append(frameEditor.banner, confirmButtonRow);
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();
});
}
}