검증_sub2: B06 측점 하나 [저장] 뒤 다시 받음 = 바뀐 6 장(횡단 그 장 · 측점 하나) · 평면도 지형 첫 열기 1.3~4.4 s → 저장본 0.2~0.4 s · 안 받은 유역도 = 받는 중 → 4.4 s 뒤 보임 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
546 lines
23 KiB
TypeScript
546 lines
23 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,
|
|
foldedGroups,
|
|
isCrossSheet,
|
|
} from "./B07_DesignDetail_UI_Panels";
|
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
|
import {
|
|
clearWorkflowStateCache,
|
|
fetchWorkflowState,
|
|
goToWorkflowStage,
|
|
WORKFLOW_STEP_ROUTES,
|
|
type WorkflowState,
|
|
} from "../A00_Common/b_workflow_nav";
|
|
import {
|
|
completeDesignStage,
|
|
exportDrawing,
|
|
fetchCrossLayout,
|
|
fetchDesignDrawingList,
|
|
fetchFrameTemplate,
|
|
importFrameTemplate,
|
|
resetFrameTemplate,
|
|
saveCrossLayout,
|
|
saveFrameTemplate,
|
|
type CadDrawing,
|
|
type CrossLayout,
|
|
type DesignDrawingItem,
|
|
type DesignDrawingResponse,
|
|
type QuantityTable,
|
|
} from "./B07_DesignDetail_Api_Fetch";
|
|
import {
|
|
fetchState,
|
|
forgetAll,
|
|
forgetDrawing,
|
|
onFetchState,
|
|
openDrawing,
|
|
startPrefetch,
|
|
syncDrawings,
|
|
} from "./B07_DesignDetail_Prefetch";
|
|
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
|
|
import {
|
|
hasRedrawable,
|
|
recoveryScopeOf,
|
|
redrawFromSource,
|
|
resetDrawings,
|
|
saveDrawingsNow,
|
|
saveEditedDrawings,
|
|
} from "./B07_DesignDetail_UI_Save";
|
|
import { openDrawingSettings } from "./B07_DesignDetail_UI_Settings";
|
|
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
|
|
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
|
|
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
|
|
import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs";
|
|
|
|
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
|
interface DesignMeta {
|
|
kind: DesignDrawingItem["kind"];
|
|
/** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */
|
|
drawingId: string;
|
|
title: string;
|
|
info: string;
|
|
/** 참이면 CAD 읽기 전용 — 잠금 없음(요구 281)이라 늘 거짓 · 저장본도 그대로 고침. */
|
|
confirmed: boolean;
|
|
quantityTable: QuantityTable | null;
|
|
}
|
|
|
|
/** CAD 저장 응답 (도면 + 편집된 수량표). */
|
|
type SaveResult = CadSaveResult<CadDrawing, QuantityTable>;
|
|
|
|
/** locale 헬퍼 */
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 화면 조립
|
|
* -------------------------------------------------------------------------- */
|
|
/** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */
|
|
let reopenDrawingId: string | undefined;
|
|
/** 횡단 장 배치를 바꿔 다시 세울 때 — 보던 장 번호(배치가 바뀌면 장 id 가 바뀜 · 140-3). */
|
|
let reopenCrossSheet: number | undefined;
|
|
|
|
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
// 311 다시 세우면 옛 목록 접힘 그대로 — 기다리기 전에 읽음(처음 들어오면 B07 목록이 없어 기본값)
|
|
const folded = foldedGroups(root);
|
|
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;
|
|
} else
|
|
drawingError =
|
|
drawingResult.reason instanceof Error
|
|
? drawingResult.reason.message
|
|
: "도면 목록을 불러오지 못했습니다.";
|
|
}
|
|
|
|
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
|
|
title: L("B07_Design_Title"),
|
|
// 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281).
|
|
onChanged: (dirty) => {
|
|
// 받는 중(508-3b) = CAD 엔 앞 도면이 남아 있음 — 그 고침을 고른 탭에 묻히지 않음
|
|
if (frameEditor.isEditing() || pending) return;
|
|
cadDirty = dirty;
|
|
if (dirty) tabs.markDirty();
|
|
},
|
|
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
|
|
onGuideChange: (area) => frameEditor.guideChanged(area),
|
|
});
|
|
|
|
// 180-3 안내선 보이기 / 숨기기 — 숨김은 화면을 다시 세워도 이어 줌(`…_UI_GuideToggle` 모듈 값)
|
|
if (!guideShown()) cad.setGuideVisible(false);
|
|
const guideToggle = buildGuideToggle((visible) => cad.setGuideVisible(visible));
|
|
|
|
let currentDrawing: DesignDrawingItem | undefined;
|
|
let currentIndex = -1;
|
|
let drawingListEl: HTMLElement | undefined;
|
|
const infoPanelHost = document.createElement("div");
|
|
infoPanelHost.className = "b07-info-host";
|
|
|
|
// 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④).
|
|
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
|
guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만
|
|
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),
|
|
);
|
|
};
|
|
|
|
let crossLayoutRequest: Promise<CrossLayout> | undefined;
|
|
const crossLayout = (): Promise<CrossLayout> =>
|
|
(crossLayoutRequest ??= fetchCrossLayout(projectId as string)).catch((error) => {
|
|
crossLayoutRequest = undefined; // 실패는 남기지 않음 — 다음 ⚙ 에 다시 받음
|
|
throw error;
|
|
});
|
|
|
|
/** 장 배치 저장(140-3 · ⚙ 횡단 모달) — 서버 쓰기 → 화면 다시 세움 · 같은 장 번호를 다시 엶. */
|
|
const saveCrossLayoutAndRebuild = async (next: CrossLayout, sheet: number) => {
|
|
if (!projectId || !mayDiscardEdits()) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
await saveCrossLayout(projectId, next);
|
|
reopenCrossSheet = sheet || undefined;
|
|
reopenDrawingId = sheet ? undefined : currentDrawing?.id;
|
|
cad.destroy();
|
|
void renderB07DesignDetail(root);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나
|
|
* 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다
|
|
* (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다.
|
|
*/
|
|
let cadDirty = false;
|
|
|
|
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): DesignMeta => ({
|
|
kind: drawing.kind,
|
|
drawingId: drawing.id,
|
|
title: drawing.label,
|
|
info: drawing.kind === "cross" ? drawing.label : "",
|
|
confirmed: false, // 잠금 없음(요구 281)
|
|
quantityTable: response.quantity_table ?? null,
|
|
});
|
|
|
|
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
|
|
const mayDiscardEdits = (): boolean => {
|
|
if (!cadDirty && !tabs.anyDirty()) return true;
|
|
return window.confirm(
|
|
"저장하지 않은 편집이 있습니다.\n" +
|
|
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
|
|
"남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.",
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 고른 도면을 받는 중(508-3b) — 받는 동안 다른 도면 · 페이지로 옮길 수 있음 · 늦게 온 응답은
|
|
* 그 도면이 아직 고른 것일 때만 실음. 못 받으면 그대로 남김(CAD 엔 앞 도면 · 고침 안 묻힘).
|
|
*/
|
|
let pending: { id: string; stash?: CadDrawing } | undefined;
|
|
|
|
/** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */
|
|
const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => {
|
|
if (!projectId) return;
|
|
const mine = { id: drawing.id, stash };
|
|
pending = mine;
|
|
currentDrawing = undefined; // 저장 · 다시 그리기가 CAD 의 앞 도면을 이 장으로 안 씀
|
|
highlightActive(drawing.id);
|
|
const button = findButton(drawing.id);
|
|
if (button) button.dataset.loading = "true";
|
|
const ready = fetchState(drawing.id) === "ready";
|
|
cad.beginLoading(ready); // 받는 중엔 시계 없음 — 서버가 오래 걸려도 CAD 탓으로 안 끊음
|
|
if (!ready) showToast("도면을 받는 중입니다 — 다 받으면 바로 보여 줍니다.", "info");
|
|
try {
|
|
const response = await openDrawing(drawing);
|
|
if (pending !== mine) return; // 그 사이 다른 도면을 고름
|
|
pending = undefined;
|
|
if (!ready) cad.beginLoading(); // 이제 CAD 가 여는 몫 — 시계를 검
|
|
currentDrawing = drawing;
|
|
currentIndex = index;
|
|
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
|
|
updateInfoPanel(drawing, response);
|
|
// 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만)
|
|
const guide = response.guide ?? null;
|
|
const meta = buildMeta(drawing, response);
|
|
const recoveryScope = recoveryScopeOf(projectId, drawing.id);
|
|
cad.load(stash ?? response.drawing, meta, false, {}, { guide, recoveryScope });
|
|
if (guideShown() && guide?.marks?.length) {
|
|
showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning");
|
|
}
|
|
if (response.drawing.stale_layout) {
|
|
showToast(
|
|
"옛 배치로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.",
|
|
"info",
|
|
);
|
|
} else if (response.drawing.stale_scale) {
|
|
showToast(
|
|
"옛 표 배율로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 지금 배율로 다시 만듭니다.",
|
|
"info",
|
|
);
|
|
} else if (response.drawing.stale_layers) {
|
|
showToast(
|
|
"측점 도면층 앞에 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 측점마다 층(횡단 No.)으로 다시 만듭니다.",
|
|
"info",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (pending !== mine) return;
|
|
const reason = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
|
|
cad.fail(`${reason} — 목록의 「다시 받기」를 누르면 다시 받습니다.`);
|
|
} finally {
|
|
if (button) button.dataset.loading = "false";
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다.
|
|
* 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음.
|
|
*/
|
|
const tabs = createCadTabs<CadDrawing>({
|
|
features: { close: true },
|
|
// 받는 중이면 CAD 엔 앞 도면 — 고른 탭엔 제 편집본(없으면 빈 장 · 미저장 아님)을 둠
|
|
capture: async () =>
|
|
pending
|
|
? { drawing: pending.stash ?? { entities: [], layers: [] }, dirty: false }
|
|
: { drawing: (await requestCadDrawing()).drawing, dirty: cadDirty },
|
|
canLeave: () => {
|
|
if (!frameEditor.isEditing()) return true;
|
|
showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning");
|
|
return false;
|
|
},
|
|
show: async (tab: CadTab<CadDrawing>) => {
|
|
const index = drawings.findIndex((item) => item.id === tab.id);
|
|
// 기다리지 않음 — 받는 동안 다른 탭을 고를 수 있게(탭 줄은 show 동안 잠김)
|
|
if (index >= 0) void loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
|
|
},
|
|
});
|
|
const openTab = (drawing: DesignDrawingItem): Promise<boolean> =>
|
|
tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label });
|
|
|
|
const selectDrawing = (drawing: DesignDrawingItem) => {
|
|
if (drawing === currentDrawing) return;
|
|
// 지금 탭이 못 받은 도면 = 「다시 받기」(탭은 그대로라 탭 열기로는 안 다시 받음)
|
|
if (pending?.id === drawing.id && fetchState(drawing.id) === "error") {
|
|
void loadDrawing(drawing, drawings.indexOf(drawing), pending.stash);
|
|
return;
|
|
}
|
|
void openTab(drawing);
|
|
};
|
|
|
|
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
|
|
|
|
/** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */
|
|
const store = {
|
|
projectId: projectId as string,
|
|
drawings,
|
|
tabs,
|
|
current: () => currentDrawing,
|
|
cadDirty: () => cadDirty,
|
|
setCadClean: () => void (cadDirty = false),
|
|
requestCad: requestCadDrawing,
|
|
isFrameEditing: () => frameEditor.isEditing(),
|
|
forget: forgetDrawing,
|
|
reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)),
|
|
};
|
|
|
|
/**
|
|
* 지금 보고 있는 도면을 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<CadDrawing>({
|
|
api: {
|
|
fetch: () => fetchFrameTemplate(projectId as string),
|
|
save: (drawing) => saveFrameTemplate(projectId as string, drawing),
|
|
importFile: (file) => importFrameTemplate(projectId as string, file),
|
|
reset: () => resetFrameTemplate(projectId as string),
|
|
},
|
|
sendLoad: (drawing, meta, frameEdit, frameFields, extra) =>
|
|
cad.load(drawing, meta, frameEdit, frameFields, {
|
|
...extra,
|
|
recoveryScope: `b07-frame:${projectId}`,
|
|
}),
|
|
setGuideArea: (area) => cad.setGuideArea(area),
|
|
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
|
|
restoreDrawing: () => {
|
|
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
|
|
},
|
|
onSaved: forgetAll,
|
|
currentDrawingInfo: () =>
|
|
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
|
|
});
|
|
|
|
/** [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. */
|
|
const onPlanClosed = (changed: boolean, openId?: string): void => {
|
|
if (!changed) {
|
|
const target = drawings.find((item) => item.id === openId);
|
|
if (target) selectDrawing(target);
|
|
return;
|
|
}
|
|
if (!mayDiscardEdits()) return;
|
|
reopenDrawingId = openId ?? currentDrawing?.id;
|
|
cad.destroy();
|
|
void renderB07DesignDetail(root);
|
|
};
|
|
|
|
// 묶음 ⚙(280-5) — 평면도 = 평면도 관리 · 횡단도 = 장 배치 · 안내선 · 머리 [원본에서 다시 그리기]
|
|
const openSettings = (kind: DesignDrawingItem["kind"]): void =>
|
|
openDrawingSettings(kind, {
|
|
drawings,
|
|
current: () => currentDrawing,
|
|
canRedraw: () => hasRedrawable(store),
|
|
redraw: () => redrawFromSource(store),
|
|
plan: projectId ? { projectId, onClosed: onPlanClosed } : undefined,
|
|
cross: {
|
|
layout: crossLayout,
|
|
save: (next, sheet) => void saveCrossLayoutAndRebuild(next, sheet),
|
|
guideToggle,
|
|
},
|
|
});
|
|
|
|
const drawingPanel = buildDrawingSidePanel(
|
|
drawings,
|
|
selectDrawing,
|
|
drawingError,
|
|
devBypass,
|
|
projectId ? openSettings : undefined,
|
|
folded,
|
|
);
|
|
drawingListEl = drawingPanel;
|
|
// 미리받기 상태(508-3b) — 못 받은 장 = 「다시 받기」 표시(누르면 다시 받음)
|
|
const markFetch = (id: string, state: string): void => {
|
|
const button = findButton(id);
|
|
if (button) button.dataset.fetch = state;
|
|
};
|
|
drawings.forEach((item) => markFetch(item.id, fetchState(item.id)));
|
|
onFetchState(markFetch);
|
|
const actions = document.createElement("div");
|
|
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
|
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
|
|
actions.className = "b07-drawing-actions ui-sidebar-actions";
|
|
// 액션 칸은 세 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]
|
|
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움),
|
|
// 3행 [?] [<] [>] [저장] [초기화](298-2 · B05 · B06 과 같은 차례 · [?] [<] [>] 는 공용이 앞에 붙임).
|
|
const frameRow = document.createElement("div");
|
|
frameRow.className = "b07-drawing-actions__row";
|
|
frameRow.append(frameEditor.button);
|
|
const actionRow = document.createElement("div");
|
|
actionRow.className = "b07-drawing-actions__row";
|
|
actionRow.dataset.helpRow = "";
|
|
const refreshStages = (): void => {
|
|
if (!projectId) return;
|
|
clearWorkflowStateCache();
|
|
void fetchWorkflowState(projectId)
|
|
.then((state) => layout.setStages(state.stages))
|
|
.catch(() => undefined);
|
|
};
|
|
const saveButton = createButton({
|
|
label: L("Common_Btn_Save"),
|
|
variant: "ghost",
|
|
onClick: () => void saveDrawingsNow(store, refreshStages),
|
|
});
|
|
const resetButton = createButton({
|
|
label: L("Common_Btn_Reset"),
|
|
variant: "danger-ghost",
|
|
onClick: () => void resetDrawings(store),
|
|
});
|
|
saveButton.disabled = resetButton.disabled = !projectId;
|
|
actionRow.append(saveButton, resetButton);
|
|
actions.append(frameEditor.banner, frameRow, actionRow);
|
|
|
|
drawingPanel.append(infoPanelHost, actions);
|
|
|
|
const cadStack = document.createElement("div");
|
|
cadStack.className = "b07-cad-stack";
|
|
cadStack.append(tabs.element, cad.element);
|
|
// 탭 줄 겹판(280-6) — CAD 가 리본 [출력] 오른쪽 빈 자리를 `.cad-host` 에 `--cad-tab-slot-left/right`
|
|
// (px · CSS left · right 꼴)로 적으면 그 자리에 겹침 · 값이 없으면 CAD 위 지금 자리.
|
|
const placeTabs = (): void => {
|
|
const slotted = cad.element.style.getPropertyValue("--cad-tab-slot-left") !== "";
|
|
if (slotted === (tabs.element.parentElement === cad.element)) return;
|
|
if (slotted) cad.element.append(tabs.element);
|
|
else cadStack.prepend(tabs.element);
|
|
};
|
|
new MutationObserver(placeTabs).observe(cad.element, {
|
|
attributes: true,
|
|
attributeFilter: ["style"],
|
|
});
|
|
placeTabs();
|
|
|
|
/** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
|
|
let moving = false;
|
|
const moveToStage = async (stepIndex: number): Promise<void> => {
|
|
if (!projectId || moving) return;
|
|
moving = true;
|
|
try {
|
|
if (!(await saveEditedDrawings(store))) return;
|
|
if (stepIndex > 4 && drawings.length > 0) {
|
|
try {
|
|
await completeDesignStage(projectId);
|
|
} catch (error) {
|
|
showToast(
|
|
error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.",
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
|
} finally {
|
|
moving = false;
|
|
}
|
|
};
|
|
|
|
const layout = createWorkflowLayout({
|
|
title: L("B07_Design_Title"),
|
|
steps: workflowSteps(),
|
|
activeStep: 4,
|
|
leftPanel: drawingPanel,
|
|
mainContent: cadStack,
|
|
stages: workflowState?.stages,
|
|
// B08 = 옮길 때 단계 4 를 완료하므로 운영에서 막힌 상태여도 켬(281) — 공용 판은 이 단계를 켬
|
|
currentStage: 5,
|
|
routes: WORKFLOW_STEP_ROUTES,
|
|
// ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음)
|
|
onStepClick: (stepIndex) => void moveToStage(stepIndex),
|
|
});
|
|
layout.root.classList.add("b07-design-layout");
|
|
root.replaceChildren(layout.root);
|
|
|
|
// 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지.
|
|
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
|
|
// 목록 지문으로 받아 둔 도면을 맞춤 — 바뀐 도면만 버림(다른 페이지에서 돌던 받기는 이어 감)
|
|
if (projectId) syncDrawings(projectId, drawings);
|
|
if (drawings.length > 0) {
|
|
const crossSheets = drawings.filter(isCrossSheet);
|
|
const reopenId = reopenCrossSheet
|
|
? crossSheets[Math.min(reopenCrossSheet, crossSheets.length) - 1]?.id
|
|
: reopenDrawingId;
|
|
const reopen = drawings.findIndex((item) => item.id === reopenId);
|
|
reopenDrawingId = undefined;
|
|
reopenCrossSheet = undefined;
|
|
// 311 기억한 장이 없으면 표지
|
|
const cover = drawings.findIndex((item) => item.kind === "cover");
|
|
const first = reopen >= 0 ? reopen : Math.max(cover, 0);
|
|
// 첫 장을 고른 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다(고른 장을 받는 동안 쉼).
|
|
void openTab(drawings[first]).then(startPrefetch);
|
|
}
|
|
}
|