Files
Aislo/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts
T
2026-07-19 18:26:00 +09:00

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);
}