314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B07_wf4_DesignDetail_UI_Page.ts
|
|
* 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드
|
|
*
|
|
* B07_wf4_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여
|
|
* /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로
|
|
* JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다.
|
|
*
|
|
* 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면.
|
|
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
|
|
* ========================================================================== */
|
|
|
|
import "./B07_wf4_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";
|
|
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 DesignDrawingItem,
|
|
} from "./B07_wf4_DesignDetail_Api_Fetch";
|
|
|
|
/** 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:b07:load-drawing";
|
|
const CAD_READY_MESSAGE = "aislo:b07:drawing-ready";
|
|
const CAD_LOADED_MESSAGE = "aislo:b07:drawing-loaded";
|
|
const CAD_ERROR_MESSAGE = "aislo:b07:drawing-error";
|
|
const CAD_CHANGED_MESSAGE = "aislo:b07:drawing-changed";
|
|
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b07:save-request";
|
|
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b07:save-response";
|
|
|
|
/** B06 확정 산출물 기반 도면 목록 패널. */
|
|
function buildDrawingSidePanel(
|
|
drawings: DesignDrawingItem[],
|
|
onSelect: (drawing: DesignDrawingItem, button: HTMLButtonElement) => Promise<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 groups: [string, DesignDrawingItem[]][] = [
|
|
["종단도", drawings.filter((item) => item.kind === "longitudinal")],
|
|
["횡단도", drawings.filter((item) => item.kind === "cross")],
|
|
];
|
|
for (const [label, items] of groups) {
|
|
if (!items.length) continue;
|
|
const section = document.createElement("section");
|
|
section.className = "b07-drawing-group";
|
|
const sectionTitle = document.createElement("h3");
|
|
sectionTitle.textContent = `${label} ${items.length}`;
|
|
section.append(sectionTitle);
|
|
for (const drawing of items) {
|
|
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.textContent = drawing.label;
|
|
const kind = document.createElement("small");
|
|
kind.textContent = drawing.confirmed
|
|
? "확정"
|
|
: drawing.kind === "longitudinal"
|
|
? "PROFILE"
|
|
: "SECTION";
|
|
button.append(name, kind);
|
|
button.addEventListener("click", () => void onSelect(drawing, button));
|
|
section.append(button);
|
|
}
|
|
panel.append(section);
|
|
}
|
|
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 pendingDrawing: CadDrawing | undefined;
|
|
let currentDrawing: DesignDrawingItem | undefined;
|
|
let currentButton: HTMLButtonElement | undefined;
|
|
let currentConfirmed = false;
|
|
let allDrawingsConfirmed = drawings.length > 0 && drawings.every((item) => item.confirmed);
|
|
let resolveSave: ((drawing: CadDrawing) => void) | undefined;
|
|
const confirmButton = createButton({
|
|
label: "현재 도면 확정",
|
|
variant: "filled",
|
|
onClick: () => void confirmCurrentDrawing(),
|
|
});
|
|
confirmButton.disabled = true;
|
|
const sendDrawing = (drawing: CadDrawing) => {
|
|
pendingDrawing = drawing;
|
|
if (!cadReady) return;
|
|
frame.contentWindow?.postMessage({ type: CAD_LOAD_MESSAGE, drawing }, window.location.origin);
|
|
pendingDrawing = undefined;
|
|
};
|
|
const selectDrawing = async (drawing: DesignDrawingItem, button: HTMLButtonElement) => {
|
|
if (!projectId) return;
|
|
const buttons = button
|
|
.closest(".b07-drawing-list")
|
|
?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button");
|
|
buttons?.forEach((item) => {
|
|
item.disabled = true;
|
|
item.dataset.active = String(item === button);
|
|
});
|
|
button.dataset.loading = "true";
|
|
cadHost.dataset.loading = "true";
|
|
try {
|
|
const response = await fetchDesignDrawing(projectId, drawing.id);
|
|
currentDrawing = drawing;
|
|
currentButton = button;
|
|
currentConfirmed = response.confirmed;
|
|
confirmButton.disabled = response.confirmed;
|
|
sendDrawing(response.drawing);
|
|
} catch (error) {
|
|
cadHost.dataset.loading = "false";
|
|
cadHost.dataset.error =
|
|
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
|
|
} finally {
|
|
button.dataset.loading = "false";
|
|
buttons?.forEach((item) => {
|
|
item.disabled = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
const requestCadDrawing = (): Promise<CadDrawing> =>
|
|
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 drawing = await requestCadDrawing();
|
|
const result = await confirmDesignDrawing(projectId, currentDrawing.id, drawing);
|
|
currentConfirmed = true;
|
|
currentDrawing.confirmed = true;
|
|
confirmButton.disabled = true;
|
|
if (currentButton) {
|
|
currentButton.dataset.confirmed = "true";
|
|
const status = currentButton.querySelector("small");
|
|
if (status) status.textContent = "확정";
|
|
}
|
|
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;
|
|
currentConfirmed = false;
|
|
allDrawingsConfirmed = false;
|
|
currentDrawing.confirmed = false;
|
|
confirmButton.disabled = false;
|
|
if (currentButton) {
|
|
currentButton.dataset.confirmed = "false";
|
|
const status = currentButton.querySelector("small");
|
|
if (status)
|
|
status.textContent = currentDrawing.kind === "longitudinal" ? "PROFILE" : "SECTION";
|
|
}
|
|
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;
|
|
};
|
|
if (message.type === CAD_READY_MESSAGE) {
|
|
cadReady = true;
|
|
if (pendingDrawing) sendDrawing(pendingDrawing);
|
|
} 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_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
|
const resolve = resolveSave;
|
|
resolveSave = undefined;
|
|
resolve(message.drawing);
|
|
}
|
|
});
|
|
|
|
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
|
|
const confirmActions = document.createElement("div");
|
|
confirmActions.className = "b07-drawing-actions";
|
|
confirmActions.append(confirmButton);
|
|
drawingPanel.append(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 > 4 && !allDrawingsConfirmed) {
|
|
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
|
|
return;
|
|
}
|
|
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
|
},
|
|
});
|
|
layout.root.classList.add("b07-design-layout");
|
|
root.replaceChildren(layout.root);
|
|
}
|