feat(B03,B05,B06): 초기 계산 끝까지 업로드 잠금 + 자료 없으면 대시보드로 (결함 3·6 화면측)

업로드 잠금(결함 3)
- 종전에는 전처리(B04)만 끝나면 버튼이 풀려, 노선·횡단이 도는 동안 자료를 또 올릴 수
  있었다. isInitialPipelineRunning()/pollInitialPipeline()을 두어 **횡단 단계가 열릴
  때까지**(3단계가 NOT_STARTED를 벗어날 때까지) 잠근다.
- relockWhileInitialPipelineRuns(): 새로고침·재진입해도 계산이 도는 중이면 다시 잠그고
  스피너를 붙인다. 잠금이 화면 상태로만 있으면 새로고침 한 번에 풀렸다.
- 보관함 연결 경로도 같은 대기 흐름을 탄다.

자료 없으면 대시보드(결함 6)
- b_missing_data_guard 신설 — 근거 자료가 없으면 한 줄 안내 후 B01로 보낸다. 새 자료가
  올라오면 서버가 옛 산출물을 지우므로, 열어 둔 뒷단계 화면은 빈 화면이 아니라
  대시보드로 나가야 한다(2026-08-08 사용자 지시).
- B05: 확정 지표면이 없으면 이동. B06: 노선 또는 종단면이 없으면 이동.

검증: 자료가 없는 신규 프로젝트로 B05 진입 → 안내 후 #/b01-account 로 이동함을
헤드리스 크롬으로 확인. typecheck·prettier 통과, 정적 번들 재빌드.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:50:27 +09:00
co-authored by Claude Opus 5
parent 37b4d41e0b
commit 431791c257
5 changed files with 126 additions and 5 deletions
+48
View File
@@ -0,0 +1,48 @@
/* =============================================================================
* b_missing_data_guard.ts
* "불러올 자료가 없으면 대시보드로" — B04 이후 화면 공용 규칙.
*
* 새 입력 자료를 올리면 서버가 옛 계산 결과를 통째로 지우고 B06까지 다시 계산한다.
* 그래서 사용자가 열어 둔 뒷단계 화면은 근거 자료가 사라진 상태가 된다. 그 화면에서
* 빈 그래프를 보여주거나 오류만 띄우면 사용자는 무엇이 잘못됐는지 알 수 없다.
*
* 이 화면들은 자료를 못 찾으면 **한 줄 안내 후 대시보드로 돌려보낸다**(2026-08-08 사용자
* 지시). 사용자는 대시보드에서 그 프로젝트가 다시 계산 중임을 보고 기다리면 된다.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { navigateTo } from "./router";
const DEFAULT_MESSAGE = "입력 자료가 새로 올라와 이 화면의 자료가 없습니다. 대시보드로 돌아갑니다.";
let redirecting = false;
/**
* 자료가 없어 화면을 세울 수 없을 때 호출한다. 안내를 띄우고 대시보드로 보낸다.
* 여러 곳에서 동시에 불려도 이동은 한 번만 한다.
*/
export function leaveForDashboard(message: string = DEFAULT_MESSAGE): void {
if (redirecting) return;
redirecting = true;
showToast(message, "warning");
// 토스트를 읽을 시간을 조금 준다.
window.setTimeout(() => {
redirecting = false;
navigateTo(ROUTES.B01_ACCOUNT);
}, 1200);
}
/**
* 필수 자료가 비었으면 대시보드로 보내고 `true`를 돌려준다.
* 호출부는 `if (missingDataLeaves(x)) return;` 형태로 바로 빠져나가면 된다.
*/
export function missingDataLeaves(value: unknown, message?: string): boolean {
const empty =
value === null ||
value === undefined ||
(Array.isArray(value) && value.length === 0) ||
value === false;
if (empty) leaveForDashboard(message);
return empty;
}
+27 -2
View File
@@ -23,7 +23,8 @@ import {
import { restoreB03ProjectState } from "./B03_FileInput_State";
import {
confirmReplaceUpload,
pollWF1Analysis,
isInitialPipelineRunning,
pollInitialPipeline,
renderUploadResults,
uploadOneFile,
} from "./B03_FileInput_UI_Upload";
@@ -466,7 +467,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
/** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */
const pollAnalysis = (projectId: string): Promise<boolean> =>
pollWF1Analysis(projectId, (message) => {
pollInitialPipeline(projectId, (message) => {
pageError.textContent = message;
showToast(message, "warning");
});
@@ -508,6 +509,29 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
/**
* 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다.
* 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다.
*/
async function relockWhileInitialPipelineRuns(): Promise<void> {
if (!activeProjectId || isUploading) return;
try {
const state = await fetchWorkflowState(activeProjectId);
if (!isInitialPipelineRunning(state)) return;
} catch {
return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다.
}
setUploading(true);
showToast(L("B03_File_Analysis_InProgress"), "info");
try {
const done = await pollAnalysis(activeProjectId);
if (done) showToast(L("B03_File_Upload_Success"), "success");
} finally {
setUploading(false);
void applyUploadOverview();
}
}
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
if (isUploading) return;
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
@@ -670,6 +694,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
root.replaceChildren(layout.root);
for (const slot of slots.keys()) renderSlot(slot);
void relockWhileInitialPipelineRuns();
void registerB03ServiceWorker();
void applyUploadOverview();
void detectPausedUploads();
+43
View File
@@ -7,6 +7,7 @@
* ========================================================================== */
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
import { createButton } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
@@ -168,6 +169,48 @@ export async function uploadOneFile(
* 지표면 분석(WF1)이 끝날 때까지 5초 간격으로 확인한다.
* 자동 확정이 보류되면 `onHold`로 사유를 넘기고 대기를 끝낸다.
*/
/**
* 초기 자동 계산이 아직 도는 중인지 — 업로드 버튼을 잠가 둘지 판단하는 기준.
*
* 자료를 올리면 서버가 전처리(B04) → 노선(B05) → 횡단(B06)까지 혼자 이어서 돈다. 그 사이에
* 자료를 또 올리면 분석 두 개가 같은 산출물 자리에서 부딪힌다. 그래서 **횡단 단계가
* 시작될 때까지**를 "도는 중"으로 본다 — 체인이 B06을 확정하면 3단계가 NOT_STARTED를
* 벗어난다(2026-08-08 사용자 지시).
*
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
*/
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
if (!state?.stages?.length) return false;
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
const fileInput = stageAt(0);
const preprocess = stageAt(1);
const section = stageAt(3);
if (fileInput?.state !== "COMPLETE") return false;
if (preprocess?.state === "FAILED") return false;
return section?.state === "NOT_STARTED";
}
/** 초기 자동 계산(B04~B06)이 끝날 때까지 기다린다. 끝나면 true. */
export async function pollInitialPipeline(
projectId: string,
onHold: (message: string) => void,
maxAttempts = 360,
): Promise<boolean> {
const analysisDone = await pollWF1Analysis(projectId, onHold, maxAttempts);
if (!analysisDone) return false;
// 전처리가 끝나도 노선·횡단이 이어서 돈다 — 3단계가 열릴 때까지 더 기다린다.
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const state = await fetchWorkflowState(projectId);
if (!isInitialPipelineRunning(state)) return true;
} catch {
/* 일시적인 조회 실패는 다음 주기에 다시 확인한다. */
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
return false;
}
export async function pollWF1Analysis(
projectId: string,
onHold: (message: string) => void,
+4 -1
View File
@@ -36,6 +36,7 @@ import {
} from "./B05_Profile_UI_Markers";
import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel";
import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { navigateTo } from "../A00_Common/router";
import { createSelectionSync } from "./B05_Profile_UI_Selection";
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
@@ -711,7 +712,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// ⑤ 3D 지형 — 가장 무거우므로 맨 마지막.
if (!confirmedSurface) {
showToast("확정된 지표면 모델이 없습니다.", "error");
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
leaveForDashboard();
return;
} else {
// 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
+4 -2
View File
@@ -1,4 +1,5 @@
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { navigateTo } from "../A00_Common/router";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
@@ -679,7 +680,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
standardPanelSlot.append(standardPanel.root);
if (context.route_id === null) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
// 자료가 통째로 없으면(새 자료가 올라와 옛 결과가 지워진 경우) 대시보드로 돌려보낸다.
leaveForDashboard();
return;
}
@@ -689,7 +691,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
leaveForDashboard();
return;
}
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).