- ui_template setLoadingOverlayProgress(ratio, label) — 잠금판 스피너 자리에 진행 원 · 참조가 모두 풀리면 함께 걷힘
- b_workflow_nav moveStage(projectId, 주소 또는 이동 함수, steps) — 단계 { 이름 · 실행 · 선택 서버 진행 묻기 } · 거짓 · 던짐 = 잠금 풀고 머묾(던짐은 까닭 토스트) · 넘기는 중 다시 누름 무시
- goToWorkflowStage 가 Promise 를 돌려줌 — 잠금이 이동 뒤에 풀림
- 낡은 시험 고침 — 298-2 · 481-2 가짜 의존에 moveStage · 290-1 · 280-5 글 약속을 새 길로 · 520-3 시험 새로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
8.3 KiB
TypeScript
227 lines
8.3 KiB
TypeScript
import {
|
|
API_BASE_URL,
|
|
CURRENT_PROJECT_ID_KEY,
|
|
ROUTES,
|
|
type RoutePath,
|
|
} from "@config/config_frontend";
|
|
import {
|
|
hideLoadingOverlay,
|
|
setLoadingOverlayProgress,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
|
|
import {
|
|
fetchConfirmedSurface,
|
|
fetchSurfaceStatus,
|
|
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
|
|
import { enterProject } from "./b_project_switch";
|
|
import { navigateTo } from "./router";
|
|
|
|
export interface WorkflowState {
|
|
project_id: string;
|
|
/** 좌측 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). */
|
|
project_name?: string | null;
|
|
current_stage: number;
|
|
stages: WorkflowStage[];
|
|
}
|
|
|
|
export const WORKFLOW_STEP_ROUTES: readonly RoutePath[] = [
|
|
ROUTES.B03_FILE_INPUT,
|
|
ROUTES.B04_PREPROCESS,
|
|
ROUTES.B05_PROFILE,
|
|
ROUTES.B06_SECTION,
|
|
ROUTES.B07_DESIGN_DETAIL,
|
|
ROUTES.B08_QUANTITY,
|
|
ROUTES.B09_ESTIMATION,
|
|
];
|
|
|
|
/* 워크플로 상태는 한 화면을 여는 동안 준비 화면 가드와 페이지 본체가 각각 부른다 —
|
|
실측 B05 진입에서 두 번 나갔다(2026-09-06). 짧은 시간 동안 한 번만 부르고 나눠 쓴다.
|
|
단계가 바뀌는 자리(`goToWorkflowStage`)에서는 즉시 버린다. */
|
|
const WORKFLOW_CACHE_MS = 5000;
|
|
let workflowCache: { at: number; projectId: string; value: Promise<WorkflowState> } | null = null;
|
|
|
|
/** 워크플로 상태 캐시를 버린다 — 단계가 바뀌는 자리에서 부른다. */
|
|
export function clearWorkflowStateCache(): void {
|
|
workflowCache = null;
|
|
}
|
|
|
|
export async function fetchWorkflowState(projectId: string): Promise<WorkflowState> {
|
|
const now = Date.now();
|
|
if (
|
|
workflowCache &&
|
|
workflowCache.projectId === projectId &&
|
|
now - workflowCache.at < WORKFLOW_CACHE_MS
|
|
) {
|
|
return workflowCache.value;
|
|
}
|
|
const value = (async (): Promise<WorkflowState> => {
|
|
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, {
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) {
|
|
throw Object.assign(new Error(`Workflow state request failed: ${response.status}`), {
|
|
status: response.status,
|
|
});
|
|
}
|
|
const data = await response.json();
|
|
return data.workflow_state ?? data;
|
|
})();
|
|
workflowCache = { at: now, projectId, value };
|
|
value.catch(() => clearWorkflowStateCache());
|
|
return value;
|
|
}
|
|
|
|
/** 기억한 현재 프로젝트가 서버에 없으면(지워짐 · 권한 없음) 기억을 비우고 true.
|
|
* 그 밖의 실패(서버 오류 · 끊김)는 false — 기억을 지우지 않고 페이지가 하던 대로 둔다. */
|
|
export async function dropGoneProject(): Promise<boolean> {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
if (!projectId) return false;
|
|
try {
|
|
await fetchWorkflowState(projectId);
|
|
return false;
|
|
} catch (error) {
|
|
const status = (error as { status?: number }).status;
|
|
if (status !== 403 && status !== 404) return false;
|
|
localStorage.removeItem(CURRENT_PROJECT_ID_KEY);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 담아 둔 지표면 자료를 실제로 쓰는 화면.
|
|
*
|
|
* 이 화면들만 준비 화면(B11)을 거친다. 파일 입력·수량·상세설계·설계도서는 3D 지표면을
|
|
* 쓰지 않으므로, 확정 지표면이 없다고 준비 화면에서 막히면 안 된다 — 파일을 아직 올리지
|
|
* 않은 새 프로젝트에서 파일 입력 화면조차 못 들어가던 문제(2026-08-08).
|
|
*/
|
|
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
|
|
|
|
export function goToWorkflowStage(projectId: string, route: RoutePath): Promise<void> {
|
|
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
|
|
return enterProject(
|
|
projectId,
|
|
async (id) => (await fetchWorkflowState(id)).project_name || id,
|
|
).then(() => {
|
|
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
|
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
|
clearWorkflowStateCache();
|
|
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
|
|
navigateTo(route);
|
|
return;
|
|
}
|
|
return routeAfterPreloadCheck(projectId, route);
|
|
});
|
|
}
|
|
|
|
/** 페이지 넘김 한 단계(520-3) — 잠금판에 「n/N 이름 · 지난 초」. */
|
|
export interface StageStep {
|
|
name: string;
|
|
/** false = 머묾(까닭은 단계가 알림) · 던지면 까닭 토스트 뒤 머묾 */
|
|
run: () => Promise<boolean | void>;
|
|
/** 서버 진행 묻기(선택 · 1 s 마다) — 비율 0~1 · 글 */
|
|
progress?: () => Promise<{ ratio: number | null; label?: string } | null>;
|
|
}
|
|
|
|
const PROGRESS_POLL_MS = 1000;
|
|
let moving = false;
|
|
|
|
/** 페이지 넘김 공용(520-3) — 잠금 → 단계 차례 → 실패면 잠금 풀고 머묾 → 성공이면 이동.
|
|
* target = 워크플로 단계 주소 또는 이동 함수(B09 → 인쇄 미리보기). 참 = 옮김. */
|
|
export async function moveStage(
|
|
projectId: string,
|
|
target: RoutePath | (() => void),
|
|
steps: readonly StageStep[],
|
|
): Promise<boolean> {
|
|
if (moving) return false;
|
|
moving = true;
|
|
const started = Date.now();
|
|
let index = 0;
|
|
let server: { ratio: number | null; label?: string } | null = null;
|
|
const paint = (): void => {
|
|
const step = steps[index];
|
|
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
const head = step ? `${index + 1}/${steps.length} ${step.name}` : "페이지 여는 중";
|
|
const tail = step && server?.label ? ` — ${server.label}` : "";
|
|
const ratio = step ? (index + (server?.ratio ?? 0)) / steps.length : 1;
|
|
setLoadingOverlayProgress(ratio, `${head}${tail} · ${seconds}초`);
|
|
};
|
|
showLoadingOverlay();
|
|
let asking = false;
|
|
const timer = window.setInterval(() => {
|
|
const at = index;
|
|
const ask = steps[at]?.progress;
|
|
if (!ask) return paint();
|
|
if (asking) return;
|
|
asking = true;
|
|
void ask()
|
|
.then(
|
|
(next) => {
|
|
if (at === index) server = next;
|
|
},
|
|
() => undefined,
|
|
)
|
|
.finally(() => {
|
|
asking = false;
|
|
paint();
|
|
});
|
|
}, PROGRESS_POLL_MS);
|
|
try {
|
|
for (; index < steps.length; index += 1) {
|
|
server = null;
|
|
paint();
|
|
if ((await steps[index].run()) === false) return false;
|
|
}
|
|
paint();
|
|
if (typeof target === "function") target();
|
|
else await goToWorkflowStage(projectId, target);
|
|
return true;
|
|
} catch (error) {
|
|
const name = steps[index]?.name ?? "페이지 이동";
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
showToast(`${name}에서 멈췄습니다 — ${reason}`, "error");
|
|
return false;
|
|
} finally {
|
|
window.clearInterval(timer);
|
|
hideLoadingOverlay();
|
|
moving = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 담아 둔 자료가 지금 확정본과 같은지 확인하고 화면을 정한다.
|
|
*
|
|
* 확정 구성을 묻는 요청은 수 KB라 이동할 때마다 물어도 부담이 없다. 담아 둔 표식과 다르면
|
|
* (관리자가 B04에서 다시 확정했거나, 새 브라우저이거나, 다른 프로젝트를 들렀다 온 경우)
|
|
* 준비 화면(B11)을 거쳐 최신 자료를 담고 원래 가려던 화면으로 넘어간다(2026-08-01 사용자 지시).
|
|
* 확인에 실패하면 준비 화면으로 보내 거기서 사유를 안내한다.
|
|
*/
|
|
async function routeAfterPreloadCheck(projectId: string, route: RoutePath): Promise<void> {
|
|
// 초기 설계 계산이 끝나기 전에는 담아 둔 자료가 있어도 들여보내지 않는다 — 반쯤 계산된
|
|
// 화면을 만지면 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정). 준비 화면이
|
|
// 끝날 때까지 기다렸다가 목표 화면으로 넘긴다.
|
|
try {
|
|
if ((await fetchSurfaceStatus(projectId)).current_stage === "initial_design") {
|
|
setPreloadTarget(route);
|
|
navigateTo(ROUTES.B11_LOADING);
|
|
return;
|
|
}
|
|
} catch {
|
|
/* 상태 조회 실패는 막지 않는다 — 종전 판정(확정 지표면 서명)으로 이어간다. */
|
|
}
|
|
let signature: string | null = null;
|
|
try {
|
|
signature = (await fetchConfirmedSurface(projectId)).signature;
|
|
} catch {
|
|
signature = null;
|
|
}
|
|
if (signature && isProjectPreloaded(projectId, signature)) {
|
|
navigateTo(route);
|
|
return;
|
|
}
|
|
setPreloadTarget(route);
|
|
navigateTo(ROUTES.B11_LOADING);
|
|
}
|