feat(B11): 자료 준비 화면 신설 — 작업 화면 진입 전 3D·등고선 선적재
- B11_Status_UI_Loading + 라우트 b11-loading: 공통 프로그레스 서클로 단계·진행률 표시, 끝나면 원래 가려던 화면으로 자동 이동 - goToWorkflowStage: 아직 준비하지 않은 프로젝트면 준비 화면 경유, 같은 프로젝트로 다시 들어오면 건너뜀(탭 단위 기억). 다른 프로젝트면 보관분 교체 - 선적재 범위: 확정 지표면 3D + 그 등고선만 (종단·횡단은 0.86MB·0.06초로 이미 즉시라 제외, 배수유역·포인트클라우드도 제외) - 실패 시 자동 이동하지 않고 담당자 연락 안내 + [그래도 이동]/[대시보드로] - B04 등고선 간격 시작값을 B05 저장값과 공유(B04 변경은 DB에 쓰지 않음) - 새 문구는 ui_locales에 한국어·영어 등록
This commit is contained in:
+93
-27
@@ -204,35 +204,101 @@ export async function fetchCachedJson<T>(
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as T;
|
||||
}
|
||||
|
||||
/** 화면에 쓰기 전에 미리 받아 둔다(대시보드에서 B그룹으로 들어갈 때). 실패는 무시한다. */
|
||||
export function prefetchAsset(projectId: string, url: string): void {
|
||||
void fetchCachedBytes(projectId, url).catch(() => {
|
||||
/* 미리 받기 실패는 화면 동작에 영향을 주지 않는다. */
|
||||
});
|
||||
}
|
||||
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
|
||||
* 어느 프로젝트를 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
|
||||
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). */
|
||||
const PRELOADED_PROJECT_KEY = "frd_preloaded_project";
|
||||
const PRELOAD_TARGET_KEY = "frd_preload_target";
|
||||
|
||||
/** 확정된 지표면의 3D 파일과 등고선을 미리 받아 둔다.
|
||||
*
|
||||
* 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다(포인트클라우드는 제외 — 2026-08-01
|
||||
* 사용자 지시). 이미 보관함에 있으면 아무 것도 하지 않는다. */
|
||||
export async function prefetchSurfaceAssets(projectId: string): Promise<void> {
|
||||
export function isProjectPreloaded(projectId: string): boolean {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json()) as {
|
||||
models?: Array<{ id: number; model_type?: string; status?: string }>;
|
||||
};
|
||||
const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED");
|
||||
if (!confirmed) return;
|
||||
// 스무딩 지원 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다.
|
||||
const method = (confirmed.model_type ?? "").toLowerCase();
|
||||
const smooth = method === "dtm" || method === "tin";
|
||||
const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`;
|
||||
prefetchAsset(projectId, `${base}/preview?smooth=${smooth}`);
|
||||
prefetchAsset(projectId, `${base}/contour?interval=1&smooth=${smooth}&recalculate=false`);
|
||||
return window.sessionStorage.getItem(PRELOADED_PROJECT_KEY) === projectId;
|
||||
} catch {
|
||||
/* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markProjectPreloaded(projectId: string): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(PRELOADED_PROJECT_KEY, projectId);
|
||||
} catch {
|
||||
/* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */
|
||||
}
|
||||
}
|
||||
|
||||
export function setPreloadTarget(route: string): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route);
|
||||
} catch {
|
||||
/* 저장 실패 시 준비 화면이 기본 화면으로 보낸다. */
|
||||
}
|
||||
}
|
||||
|
||||
export function readPreloadTarget(): string | null {
|
||||
try {
|
||||
return window.sessionStorage.getItem(PRELOAD_TARGET_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 준비 화면이 표시할 단계 안내. ratio가 null이면 진행률을 모른다는 뜻이다. */
|
||||
export type PreloadReporter = (label: string, ratio: number | null) => void;
|
||||
|
||||
/** 사용자가 고른 등고선 간격(B05에서 저장한 값). 없으면 1.0m.
|
||||
* B04(관리자 확인용 화면)도 이 값을 시작값으로 쓴다 — 사용자가 정한 값이 우선이다. */
|
||||
export async function fetchUserContourInterval(projectId: string): Promise<number> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/latest`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) return 1.0;
|
||||
const data = (await response.json()) as {
|
||||
surface_params?: { contour_interval_m?: number };
|
||||
};
|
||||
const interval = data.surface_params?.contour_interval_m;
|
||||
return typeof interval === "number" && interval > 0 ? interval : 1.0;
|
||||
} catch {
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출).
|
||||
*
|
||||
* 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외
|
||||
* (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다.
|
||||
* 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. */
|
||||
export async function preloadSurfaceAssets(
|
||||
projectId: string,
|
||||
report: PreloadReporter = () => {},
|
||||
): Promise<void> {
|
||||
report("확정된 지표면을 확인하는 중…", null);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) throw new Error("지표면 목록을 불러오지 못했습니다.");
|
||||
const data = (await response.json()) as {
|
||||
models?: Array<{ id: number; model_type?: string; status?: string }>;
|
||||
};
|
||||
const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED");
|
||||
if (!confirmed) throw new Error("확정된 지표면 모델이 없습니다.");
|
||||
|
||||
// 스무딩을 지원하는 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다.
|
||||
const method = (confirmed.model_type ?? "").toLowerCase();
|
||||
const smooth = method === "dtm" || method === "tin";
|
||||
const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`;
|
||||
const interval = await fetchUserContourInterval(projectId);
|
||||
|
||||
report("3D 지표면을 준비하는 중…", 0);
|
||||
await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, {
|
||||
onProgress: (ratio) => report("3D 지표면을 준비하는 중…", ratio),
|
||||
});
|
||||
|
||||
report("등고선을 준비하는 중…", null);
|
||||
await fetchCachedBytes(
|
||||
projectId,
|
||||
`${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`,
|
||||
{ onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) },
|
||||
);
|
||||
report("준비 완료", 1);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type RoutePath,
|
||||
} from "@config/config_frontend";
|
||||
import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
|
||||
import { prefetchSurfaceAssets, purgeOtherProjects } from "./b_asset_cache";
|
||||
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
|
||||
import { navigateTo } from "./router";
|
||||
|
||||
export interface WorkflowState {
|
||||
@@ -37,8 +37,13 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
|
||||
|
||||
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||
// 다른 프로젝트 자료를 지우고, 이 프로젝트의 지표면 3D·등고선을 미리 받아 둔다.
|
||||
// 화면 이동을 막지 않도록 뒤에서 돌린다.
|
||||
void purgeOtherProjects(projectId).then(() => prefetchSurfaceAssets(projectId));
|
||||
// 이 프로젝트를 아직 준비하지 않았으면 준비 화면(B11)을 먼저 거친다. 거기서 3D 지표면과
|
||||
// 등고선을 브라우저에 담고 원래 가려던 화면으로 넘겨준다. 같은 프로젝트로 다시 들어오면
|
||||
// 건너뛴다(2026-08-01 사용자 지시).
|
||||
if (!isProjectPreloaded(projectId)) {
|
||||
setPreloadTarget(route);
|
||||
navigateTo(ROUTES.B11_LOADING);
|
||||
return;
|
||||
}
|
||||
navigateTo(route);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
|
||||
[ROUTES.B11_STATUS]: async () =>
|
||||
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
|
||||
[ROUTES.B11_LOADING]: async () =>
|
||||
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
|
||||
};
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
@@ -112,6 +114,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
ROUTES.B09_WF6_ESTIMATION,
|
||||
ROUTES.B10_PAYMENT,
|
||||
ROUTES.B11_STATUS,
|
||||
ROUTES.B11_LOADING,
|
||||
];
|
||||
|
||||
if (workflowRoutes.includes(route)) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { purgeOtherProjects } from "../A00_Common/b_asset_cache";
|
||||
import { fetchUserContourInterval, purgeOtherProjects } from "../A00_Common/b_asset_cache";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
@@ -320,12 +320,16 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
async function loadProjectData(projectId: string): Promise<void> {
|
||||
const [inputs, status, modelResponse] = await Promise.all([
|
||||
const [inputs, status, modelResponse, contourInterval] = await Promise.all([
|
||||
listSurfaceInputFiles(projectId),
|
||||
fetchSurfaceStatus(projectId),
|
||||
listSurfaceModels(projectId),
|
||||
// 등고선 간격은 사용자가 B05에서 저장한 값을 시작값으로 쓴다. 여기서 바꿔도 DB에는
|
||||
// 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01).
|
||||
fetchUserContourInterval(projectId),
|
||||
]);
|
||||
models = modelResponse.models;
|
||||
terrainViewer.setContourInterval(contourInterval);
|
||||
renderInputFiles(inputs.files);
|
||||
renderStatus(status);
|
||||
viewer.setLoading("포인트 데이터 로딩 중…");
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface SurfaceTerrainViewer {
|
||||
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
|
||||
isSmoothingEnabled: () => boolean;
|
||||
getContourInterval: () => number;
|
||||
/** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */
|
||||
setContourInterval: (interval: number) => void;
|
||||
resetOptions: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
@@ -701,6 +703,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
isSmoothingEnabled() {
|
||||
return !smoothCheck.disabled && smoothCheck.checked;
|
||||
},
|
||||
setContourInterval(interval) {
|
||||
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
|
||||
},
|
||||
getContourInterval() {
|
||||
return Number.parseFloat(intervalInput.value);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import {
|
||||
markProjectPreloaded,
|
||||
preloadSurfaceAssets,
|
||||
purgeOtherProjects,
|
||||
readPreloadTarget,
|
||||
} from "../A00_Common/b_asset_cache";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import "./B11_Status_UI_Style.css";
|
||||
|
||||
/* =============================================================================
|
||||
* 자료 준비 화면 (B11)
|
||||
*
|
||||
* 대시보드에서 프로젝트를 골라 작업 화면으로 들어갈 때, 먼저 이 화면이 3D 지표면과
|
||||
* 등고선을 브라우저에 담아 둔다. 담아 두면 B04·B05가 그것을 그대로 쓰므로 화면이 바로 뜬다.
|
||||
* 이미 담겨 있으면 순식간에 지나간다.
|
||||
*
|
||||
* 자료를 준비하지 못하면(분석 실패·저장 경로 문제) 넘어가지 않고 사유를 알린다.
|
||||
* ========================================================================== */
|
||||
|
||||
export async function renderB11Loading(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath;
|
||||
|
||||
const progress = createProgressCircle({ label: t("B11_Loading_Start"), size: 96 });
|
||||
const message = document.createElement("p");
|
||||
message.className = "b11-loading__message";
|
||||
message.textContent = t("B11_Loading_Message");
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b11-loading__actions";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "b11-loading__body";
|
||||
body.append(progress.root, message, actions);
|
||||
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b11-status",
|
||||
title: t("B11_Loading_Title"),
|
||||
subtitle: t("B11_Loading_Subtitle"),
|
||||
content: [body],
|
||||
});
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
if (!projectId) {
|
||||
showFailure(t("B11_Loading_NoProject"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다.
|
||||
await purgeOtherProjects(projectId);
|
||||
await preloadSurfaceAssets(projectId, (label, ratio) => {
|
||||
progress.set(ratio, label);
|
||||
});
|
||||
markProjectPreloaded(projectId);
|
||||
navigateTo(target);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` (${error.message})` : "";
|
||||
showFailure(`${t("B11_Loading_Failed")}${detail}`);
|
||||
}
|
||||
|
||||
function showFailure(text: string): void {
|
||||
progress.root.hidden = true;
|
||||
message.textContent = text;
|
||||
message.classList.add("b11-loading__message--error");
|
||||
actions.replaceChildren(
|
||||
createButton({
|
||||
label: t("B11_Loading_Btn_Continue"),
|
||||
variant: "ghost",
|
||||
// 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다.
|
||||
onClick: () => {
|
||||
if (projectId) markProjectPreloaded(projectId);
|
||||
navigateTo(target);
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: t("B11_Loading_Btn_Dashboard"),
|
||||
variant: "filled",
|
||||
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,40 @@
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 자료 준비 화면 — 서클과 안내 문구를 가운데 모아 둔다. */
|
||||
.b11-loading__body {
|
||||
display: flex;
|
||||
min-height: 320px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-24);
|
||||
padding: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b11-loading__message {
|
||||
max-width: 46ch;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.b11-loading__message--error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.b11-loading__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b11-loading__actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b11-status__flow {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -92,6 +92,8 @@ export const ROUTES = {
|
||||
B09_WF6_ESTIMATION: "b09-wf6-estimation",
|
||||
B10_PAYMENT: "b10-payment",
|
||||
B11_STATUS: "b11-status",
|
||||
// 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면.
|
||||
B11_LOADING: "b11-loading",
|
||||
} as const;
|
||||
|
||||
export type RouteKey = keyof typeof ROUTES;
|
||||
@@ -113,6 +115,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B09_WF6_ESTIMATION,
|
||||
ROUTES.B10_PAYMENT,
|
||||
ROUTES.B11_STATUS,
|
||||
ROUTES.B11_LOADING,
|
||||
];
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
|
||||
@@ -988,6 +988,24 @@ export const ui_locales = {
|
||||
"결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.",
|
||||
"Check payment and document status, and download results.",
|
||||
],
|
||||
// 자료 준비 화면 (대시보드 → 작업 화면 진입 시 3D·등고선 선적재)
|
||||
B11_Loading_Title: ["자료 준비 중", "Preparing data"],
|
||||
B11_Loading_Subtitle: ["잠시만 기다려 주세요.", "This will take a moment."],
|
||||
B11_Loading_Message: [
|
||||
"작업 화면에서 바로 쓸 수 있도록 3D 지표면과 등고선을 준비합니다.",
|
||||
"Loading the 3D surface and contours so the workspace opens instantly.",
|
||||
],
|
||||
B11_Loading_Start: ["자료를 준비하는 중…", "Preparing data…"],
|
||||
B11_Loading_NoProject: [
|
||||
"프로젝트가 선택되지 않았습니다. 대시보드에서 프로젝트를 먼저 고르세요.",
|
||||
"No project selected. Choose a project on the dashboard first.",
|
||||
],
|
||||
B11_Loading_Failed: [
|
||||
"자료를 준비하지 못했습니다. 분석 결과나 저장 경로에 문제가 있을 수 있습니다. 담당자에게 연락해 주세요.",
|
||||
"Could not prepare the data. The analysis result or storage path may be broken. Please contact support.",
|
||||
],
|
||||
B11_Loading_Btn_Continue: ["그래도 화면으로 이동", "Continue anyway"],
|
||||
B11_Loading_Btn_Dashboard: ["대시보드로", "Back to dashboard"],
|
||||
B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"],
|
||||
B11_Status_Step_Request: ["발행 요청", "Invoice Requested"],
|
||||
B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"],
|
||||
|
||||
Reference in New Issue
Block a user