`phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 **그리는 화면이 없었음**
(2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림. 화면 글자 전수에서 「조달」·
「뒷길이」·「돌 종류」 0건). 그 자리를 표준도가 맡음 — PLAN 4-5b 「표준도는 입력 화면이기도 함」.
B06 은 「어디에·몇 m」(배치), 표준도는 「어떤 제원」.
**장 하나 = 제원 조합 하나**라 한 번 고치면 그 조합의 개소 전부에 걸림.
지킨 것
· **자동값을 저장에 박지 않음** — 빈 칸이 「정한 적 없음」의 뜻(확정 ⑨). 비우면 그 키를
지움. 판정된 기울기는 칸이 아니라 **회색 도움말**로만 비춤.
· **막지 않음** — 실무 도면에 1:0.7·1:0.8 이 실재(구조물도 53장). 품셈 범위(0.20~0.50)
밖이어도 값은 받고 안내만.
· **값을 여기서 셈하지 않음** — 정본에 적기만 하고 표·그림은 다음 조회에서 그 정본으로
다시 섬. 표준도가 두 번째 정본이 되면 안 됨.
⚠ 만들다 잡은 것 셋
① `face_slope_ratio` 가 **구조물 등록부에 없어** 저장이 통째로 거절됐음
(`ValueError: 돌쌓기(찰)에 정의되지 않은 옵션입니다`). 한 칸 때문에 전부 못 저장되는
것은 나쁨 — 등록부에 없는 칸은 **빼고 이름으로 알림**(조용히 버리면 저장된 줄 앎).
그 칸 신설은 랩탑 메인 몫.
② 저장 뒤 표·그림을 다시 받으면서 **폼이 새로 그려져 안내가 지워졌음.** 밖에서 들고
있다가 다시 넣음.
③ **장 제목에 제원이 들어 있는데 좌측 단추 글자가 안 따라왔음.** 목록을 통째로 다시
받지 않고 표준도 줄만 갈아 끼움.
실화면 확인 (프로젝트 936be972, 개발 우회로로 열어서)
「표준도 2장」에서 돌 종류 견치돌·뒷길이 55 로 저장 →
단추 글자가 「…뒷길이 35㎝ 야면석·호박돌」 → 「…뒷길이 55㎝ 견치돌」로 **즉시 바뀜**
기울기 0.7 저장 → 안내 셋: 「1개소에 반영했습니다」 · 「1:0.7 는 품셈 표준경사 표
범위(1:0.2~1:0.5) 밖입니다 — 값은 그대로 씁니다」 · 「「전면 기울기」 칸이 masonry_dry
등록부에 아직 없어 저장하지 못했습니다」
조달 「구입」 저장 → 그 장의 **2개소에만** 들어가고 다른 장은 그대로(정본 실측)
확인 뒤 **지정받은 제원으로 되돌리고 우회로도 닫음**(목록 409 복귀).
700줄 제한으로 표준도 라우터를 `B07_DesignDetail_Router_Standard.py` 로 뗌(579 + 153).
자체검증 — 새 시험 7건 + 회귀 580 통과 · 0 실패, `tsc --noEmit` 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
699 lines
30 KiB
TypeScript
699 lines
30 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 { 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";
|
|
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
|
import {
|
|
buildDesignInfoPanel,
|
|
buildDrawingSidePanel,
|
|
isCrossSheet,
|
|
} from "./B07_DesignDetail_UI_Panels";
|
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
|
import {
|
|
fetchWorkflowState,
|
|
goToWorkflowStage,
|
|
WORKFLOW_STEP_ROUTES,
|
|
type WorkflowState,
|
|
} from "../A00_Common/b_workflow_nav";
|
|
import {
|
|
confirmDesignDrawing,
|
|
exportDrawing,
|
|
fetchDesignDrawing,
|
|
fetchDesignDrawingList,
|
|
fetchStandardSheets,
|
|
fetchStructureRevision,
|
|
invalidateDesignDrawing,
|
|
putStandardSheetSpec,
|
|
type CadDrawing,
|
|
type DesignDrawingItem,
|
|
type DesignDrawingResponse,
|
|
type QuantityTable,
|
|
type StandardSheetsResponse,
|
|
} from "./B07_DesignDetail_Api_Fetch";
|
|
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
|
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
|
import {
|
|
buildStandardSpecPanel,
|
|
type StandardSpecResult,
|
|
} from "./B07_DesignDetail_UI_StandardSpec";
|
|
|
|
/** 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";
|
|
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
|
|
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
|
|
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
|
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
|
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 화면 조립
|
|
* -------------------------------------------------------------------------- */
|
|
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
/** 표준도 장 목록 — 제원 폼이 쓰는 값. 진입 때 한 번 받고 저장 뒤 다시 받는다. */
|
|
let standardSheets: StandardSheetsResponse["sheets"] = [];
|
|
/** 저장 뒤 폼이 다시 그려질 때 이어서 보여 줄 안내. */
|
|
let specNotes: string[] = [];
|
|
let workflowState: WorkflowState | undefined;
|
|
let drawings: DesignDrawingItem[] = [];
|
|
let drawingError: string | undefined;
|
|
let devBypass = false;
|
|
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;
|
|
devBypass = drawingResult.value.dev_bypass === true;
|
|
if (drawings.some((item) => item.kind === "standard") && projectId) {
|
|
// 표준도 제원 폼이 쓸 장 목록 — 실패해도 도면은 열려야 하므로 조용히 넘어간다.
|
|
try {
|
|
standardSheets = (await fetchStandardSheets(projectId)).sheets;
|
|
} catch {
|
|
standardSheets = [];
|
|
}
|
|
}
|
|
} 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;
|
|
frameEdit: boolean;
|
|
frameFields: Record<string, string>;
|
|
}
|
|
| undefined;
|
|
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
|
|
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
|
|
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
|
|
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
|
|
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
|
|
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
|
|
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
|
|
const CAD_READY_TIMEOUT_MS = 20000;
|
|
const CAD_LOAD_TIMEOUT_MS = 15000;
|
|
let loadWatchdog: number | undefined;
|
|
|
|
const failCad = (detail: string): void => {
|
|
cadHost.dataset.loading = "false";
|
|
cadHost.dataset.error = detail;
|
|
showToast(detail, "error");
|
|
};
|
|
|
|
const stopLoadWatchdog = (): void => {
|
|
if (loadWatchdog === undefined) return;
|
|
window.clearTimeout(loadWatchdog);
|
|
loadWatchdog = undefined;
|
|
};
|
|
|
|
const startLoadWatchdog = (): void => {
|
|
stopLoadWatchdog();
|
|
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
|
|
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
|
|
loadWatchdog = window.setTimeout(() => {
|
|
loadWatchdog = undefined;
|
|
if (cadHost.dataset.loading !== "true") return;
|
|
failCad(
|
|
cadReady
|
|
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
|
|
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
|
|
);
|
|
}, wait);
|
|
};
|
|
|
|
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
|
|
frame.addEventListener("error", () => {
|
|
stopLoadWatchdog();
|
|
failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
|
|
});
|
|
|
|
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 showStandardSpec = (drawing: DesignDrawingItem): void => {
|
|
const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1;
|
|
const sheet = standardSheets[index];
|
|
if (!projectId || !sheet) {
|
|
infoPanelHost.replaceChildren();
|
|
return;
|
|
}
|
|
// 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다.
|
|
const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null;
|
|
infoPanelHost.replaceChildren(
|
|
buildStandardSpecPanel(
|
|
sheet,
|
|
judged,
|
|
async (result: StandardSpecResult) => {
|
|
const { revision } = await fetchStructureRevision(projectId);
|
|
const saved = await putStandardSheetSpec(projectId, {
|
|
...result,
|
|
base_revision: revision,
|
|
});
|
|
// 정본이 바뀌었으니 표·그림을 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 된다.
|
|
drawingCache.delete(drawing.id);
|
|
standardSheets = (await fetchStandardSheets(projectId)).sheets;
|
|
// ⚠ 장 제목에 제원이 들어 있다 — 고쳤으면 **좌측 단추 글자도 따라가야** 한다.
|
|
// 목록을 통째로 다시 받지 않고 표준도 줄만 갈아 끼운다(2026-09-09 실화면에서 잡음).
|
|
for (const [order, item] of drawings.filter((d) => d.kind === "standard").entries()) {
|
|
const fresh = standardSheets[order];
|
|
if (!fresh) continue;
|
|
item.label = `표준도 ${order + 1}장 (${fresh.title})`;
|
|
const name = findButton(item.id)?.querySelector(".b07-drawing-button__name");
|
|
if (name) name.textContent = item.label;
|
|
}
|
|
specNotes = [`${saved.changed}개소에 반영했습니다.`, ...(saved.notes ?? [])];
|
|
await loadDrawing(drawing, currentIndex);
|
|
return specNotes;
|
|
},
|
|
specNotes,
|
|
),
|
|
);
|
|
specNotes = [];
|
|
};
|
|
|
|
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
|
if (drawing.kind === "standard") {
|
|
showStandardSpec(drawing);
|
|
return;
|
|
}
|
|
if (drawing.kind !== "cross") {
|
|
infoPanelHost.replaceChildren();
|
|
return;
|
|
}
|
|
// 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은
|
|
// 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다.
|
|
const scope = isCrossSheet(drawing) ? "sheet" : "station";
|
|
infoPanelHost.replaceChildren(
|
|
buildDesignInfoPanel(drawing.label, response.design ?? null, scope),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
|
|
// frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
|
|
// (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김).
|
|
const sendLoad = (
|
|
drawing: CadDrawing,
|
|
meta: DesignMeta | null,
|
|
frameEdit = false,
|
|
frameFields: Record<string, string> = {},
|
|
) => {
|
|
pendingLoad = { drawing, meta, frameEdit, frameFields };
|
|
if (!cadReady) return;
|
|
frame.contentWindow?.postMessage(
|
|
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields },
|
|
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;
|
|
};
|
|
|
|
/** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다.
|
|
*
|
|
* ⚠ **사용자가 누른 도면에 자리를 내준다** (2026-09-09 — 무한 로딩으로 보이던 자리).
|
|
* 도면이 31 장이고 한 장에 0.3~1.0초라, 미리 받기가 도는 동안 클릭하면 그 요청이
|
|
* **앞의 여러 장 뒤에 줄을 서서** 30초 넘게 안 열렸다. 화면은 「불러오는 중」 그대로였다.
|
|
* 한 장 받을 때마다 **클릭이 끼어들 틈**을 주고, 클릭이 도는 동안은 쉬어 준다.
|
|
*/
|
|
const prefetchAllDrawings = async (): Promise<void> => {
|
|
if (!projectId) return;
|
|
for (const item of drawings) {
|
|
// 클릭이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다.
|
|
while (loadInFlight) await new Promise((done) => window.setTimeout(done, 120));
|
|
if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다
|
|
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 = ""; // 앞선 실패 표시를 지운다
|
|
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
|
|
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) {
|
|
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
|
|
failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
|
|
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,
|
|
isCrossSheet(currentDrawing) ? "sheet" : "station",
|
|
),
|
|
);
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시).
|
|
* 파일 만들기는 서버가 한다 — 캐드는 도면만 넘긴다.
|
|
*/
|
|
async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise<void> {
|
|
showLoadingOverlay();
|
|
try {
|
|
const { drawing } = await requestCadDrawing();
|
|
const name = currentDrawing?.label ?? "도면";
|
|
const result = await exportDrawing(projectId as string, drawing, fileFormat, name);
|
|
const link = document.createElement("a");
|
|
link.href = URL.createObjectURL(result.blob);
|
|
link.download = `${name}.${fileFormat}`;
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
showToast(
|
|
result.skipped > 0
|
|
? `${fileFormat.toUpperCase()} 로 내보냈습니다. 그림 ${result.skipped}개는 담기지 않았습니다.`
|
|
: `${fileFormat.toUpperCase()} 로 내보냈습니다.`,
|
|
"success",
|
|
);
|
|
} 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(),
|
|
currentDrawingInfo: () =>
|
|
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
|
|
});
|
|
|
|
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";
|
|
fileFormat?: "dxf" | "dwg";
|
|
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,
|
|
pendingLoad.frameEdit,
|
|
pendingLoad.frameFields,
|
|
);
|
|
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
|
|
if (cadHost.dataset.loading === "true") startLoadWatchdog();
|
|
}
|
|
} else if (message.type === CAD_LOADED_MESSAGE) {
|
|
stopLoadWatchdog();
|
|
cadHost.dataset.loading = "false";
|
|
} else if (message.type === CAD_ERROR_MESSAGE) {
|
|
stopLoadWatchdog();
|
|
failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
|
|
} 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_EXPORT_MESSAGE && message.fileFormat) {
|
|
void exportDrawingFile(message.fileFormat);
|
|
} 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, devBypass);
|
|
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;
|
|
// ⚠ 개발에서는 막지 않는다 (2026-09-09 사용자 지시) — B08·B09 를 확정 전에 봐야
|
|
// 화면 검증이 되고, 그 우회 단추가 **B08 안에** 있어 여기서 막히면 닿을 길이 없다.
|
|
// 운영에서는 그대로 막는다.
|
|
if (stepIndex > 5 && !allDrawingsConfirmed && !import.meta.env.DEV) {
|
|
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();
|
|
});
|
|
}
|
|
}
|