refactor(B03): 파일 입력 화면 700줄 초과 분리 — 판정 규칙·업로드 흐름
925줄 한 파일을 셋으로 나눔 (동작 불변). - `B03_FileInput_UI_Page.ts` 692줄 — 화면 조립·카드 렌더·현황 표시 - `B03_FileInput_UI_Page_Flow.ts` 262줄 — 중단 세션 이어올리기·보관함 이관·청크 업로드· 초기 계산 중 재잠금·서비스워커. 화면 상태는 `UploadFlowContext` 창구로만 받음 - `B03_FileInput_UI_Page_Rules.ts` 107줄 — 필수 카드 판정·파일 적합성·업로드 가능 판정· 서버 파일의 카드 매핑 (상태를 가두지 않는 순수 함수) 검증: 공용 브라우저에서 새 프로젝트 만들어 노선 5종 실제 선택·업로드 — 카드 5장 선택 후 [파일 업로드] 비활성, [LAS 없이 설계] 켜면 활성(판정 규칙 정상), 업로드 후 4장 완료 표시(20초). 남은 `route_prj` 오류·400 은 지형 자료가 없어 분리 전에도 같던 것. `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,19 +3,14 @@ import {
|
||||
ROUTES,
|
||||
UPLOAD_ALLOWED_EXT,
|
||||
UPLOAD_MAX_FILES,
|
||||
UPLOAD_MAX_MB,
|
||||
} from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import { fetchUploadOverview } from "./B03_FileInput_Api_Fetch";
|
||||
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
||||
import { createInputGuide } from "./B03_FileInput_UI_Guide";
|
||||
import { createTempPicker } from "./B03_FileInput_UI_TempPicker";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
@@ -25,23 +20,24 @@ import {
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { restoreB03ProjectState } from "./B03_FileInput_State";
|
||||
import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow";
|
||||
import {
|
||||
isSlotRequired,
|
||||
slotForOverviewFile,
|
||||
validateFileForSlot,
|
||||
validateSlots,
|
||||
} from "./B03_FileInput_UI_Page_Rules";
|
||||
import {
|
||||
readCrsLabel,
|
||||
readExtent,
|
||||
renderSlotPreview,
|
||||
type PreviewExtent,
|
||||
} from "./B03_FileInput_UI_Preview";
|
||||
import {
|
||||
confirmReplaceUpload,
|
||||
isInitialPipelineRunning,
|
||||
pollInitialPipeline,
|
||||
uploadOneFile,
|
||||
} from "./B03_FileInput_UI_Upload";
|
||||
import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload";
|
||||
import {
|
||||
createFileCardTemplate,
|
||||
formatBytes,
|
||||
formatEta,
|
||||
getExtension,
|
||||
initializeSlots,
|
||||
makeSessionKey,
|
||||
planSlotAssignments,
|
||||
@@ -51,7 +47,6 @@ import {
|
||||
TERRAIN_SLOTS,
|
||||
type FileSlot,
|
||||
type FileSlotState,
|
||||
type StoredUploadSession,
|
||||
type UploadStatus,
|
||||
} from "./B03_FileInput_UI_Support";
|
||||
import "./B03_FileInput_UI_Style.css";
|
||||
@@ -80,12 +75,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1"
|
||||
: false;
|
||||
|
||||
function clearDerivedCaches(projectId: string): void {
|
||||
clearRouteLatestCache(projectId);
|
||||
invalidateSectionDetail(projectId);
|
||||
clearPreloadMark();
|
||||
}
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "b03-file__subtitle";
|
||||
subtitle.textContent = L("B03_File_Subtitle");
|
||||
@@ -185,7 +174,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.textContent = "";
|
||||
return;
|
||||
}
|
||||
const validation = validateSlots();
|
||||
const validation = validateSlots(slots, selectedStates(), activeProjectId, lasFreeDesign);
|
||||
uploadButton.disabled = validation !== null;
|
||||
// 「업로드할 파일을 선택하세요」는 버튼이 잠긴 것으로 이미 드러난다 — 고르기도 전에
|
||||
// 붉은 경고를 띄우지 않는다(2026-09-03 사용자 지시). 나머지 사유는 그대로 알린다.
|
||||
@@ -198,7 +187,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const extLabel = state.extensions.join(", ");
|
||||
const target = card.querySelector(".b03-file__card-ext");
|
||||
if (!target) return;
|
||||
target.textContent = isSlotRequired(state)
|
||||
target.textContent = isSlotRequired(state, slots, lasFreeDesign)
|
||||
? extLabel
|
||||
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
|
||||
}
|
||||
@@ -295,14 +284,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function validateFileForSlot(file: File, state: FileSlotState): string | null {
|
||||
const extension = getExtension(file.name);
|
||||
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
|
||||
const state = targetSlot ? slots.get(targetSlot) : undefined;
|
||||
if (!state) {
|
||||
@@ -410,48 +391,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
/** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */
|
||||
function routeIsShapefile(): boolean {
|
||||
const state = slots.get("csv");
|
||||
const name = state?.file?.name ?? state?.serverUploaded?.name;
|
||||
return getExtension(name ?? "") === ".shp";
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 카드가 지금 필수인가.
|
||||
*
|
||||
* shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 —
|
||||
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
|
||||
*/
|
||||
function isSlotRequired(state: FileSlotState): boolean {
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile();
|
||||
// LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다.
|
||||
if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired;
|
||||
return state.isRequired;
|
||||
}
|
||||
|
||||
function validateSlots(): string | null {
|
||||
if (!activeProjectId) return L("B03_File_Error_Project");
|
||||
const selected = selectedStates();
|
||||
if (selected.length === 0) return L("B03_File_Error_Required");
|
||||
if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
|
||||
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) => isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
if (!lasFreeDesign) {
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
|
||||
}
|
||||
for (const state of selected) {
|
||||
if (state.error) return state.error;
|
||||
const validation = validateFileForSlot(state.file!, state);
|
||||
if (validation) return validation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크
|
||||
* 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다.
|
||||
@@ -467,23 +406,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// id 오름차순이므로 마지막 것이 남는다).
|
||||
for (const state of slots.values()) state.serverUploaded = undefined;
|
||||
for (const file of overview.files) {
|
||||
const extension = `.${file.file_type.toLowerCase()}`;
|
||||
// PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로
|
||||
// 저장 경로로 가린다(2026-08-31).
|
||||
const inRouteSet = (file.relative_path ?? "").includes("/input/shp/");
|
||||
const slot: FileSlot | undefined =
|
||||
extension === ".prj"
|
||||
? inRouteSet
|
||||
? "route_prj"
|
||||
: "prj"
|
||||
: // 옛 프로젝트의 노선은 `.csv`로 올라가 있다 — 이제 받지는 않지만(2026-09-03)
|
||||
// 이미 올라간 것은 계획노선 도형 카드에 그대로 보여야 한다.
|
||||
extension === ".csv"
|
||||
? "csv"
|
||||
: Array.from(slots.values()).find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
const slot = slotForOverviewFile(file, slots);
|
||||
const state = slot ? slots.get(slot) : undefined;
|
||||
if (state) {
|
||||
state.serverUploaded = {
|
||||
@@ -576,184 +499,28 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return group;
|
||||
}
|
||||
|
||||
async function detectPausedUploads(): Promise<void> {
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
resumeBanner.replaceChildren();
|
||||
resumeBanner.classList.remove("is-visible");
|
||||
if (!activeProjectId) return;
|
||||
|
||||
for (const state of selectedStates()) {
|
||||
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
|
||||
if (!stored) continue;
|
||||
const session = JSON.parse(stored) as StoredUploadSession;
|
||||
state.uploadSessionId = session.uploadSessionId;
|
||||
state.progressBytes = session.completedChunks * session.chunkSizeBytes;
|
||||
renderSlot(state.slot);
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`;
|
||||
const resume = createButton({
|
||||
label: L("B03_File_Resume_Button"),
|
||||
variant: "ghost",
|
||||
onClick: () => void startChunkedUpload([state]),
|
||||
});
|
||||
const fresh = createButton({
|
||||
label: L("B03_File_New_Button"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
localStorage.removeItem(session.key);
|
||||
state.uploadSessionId = undefined;
|
||||
state.progressBytes = 0;
|
||||
renderSlot(state.slot);
|
||||
resumeBanner.replaceChildren();
|
||||
resumeBanner.classList.remove("is-visible");
|
||||
},
|
||||
});
|
||||
resumeBanner.append(text, resume, fresh);
|
||||
resumeBanner.classList.add("is-visible");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */
|
||||
const pollAnalysis = (projectId: string): Promise<boolean> =>
|
||||
pollInitialPipeline(projectId, (message) => {
|
||||
pageError.textContent = message;
|
||||
showToast(message, "warning");
|
||||
});
|
||||
|
||||
/**
|
||||
* 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다.
|
||||
* 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지
|
||||
* 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다.
|
||||
*/
|
||||
async function attachSelectedTempBatch(): Promise<void> {
|
||||
const batch = tempPicker.selected();
|
||||
if (!batch) return;
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
if (!activeProjectId) {
|
||||
pageError.textContent = L("B03_File_Error_Project");
|
||||
return;
|
||||
}
|
||||
pageError.textContent = "";
|
||||
try {
|
||||
const result = await attachTempBatch(activeProjectId, batch.batch_id);
|
||||
clearDerivedCaches(activeProjectId);
|
||||
tempPicker.clear();
|
||||
showToast(L("B03_Temp_Attach_Success"), "success");
|
||||
await applyUploadOverview();
|
||||
if (!result.analysis_started) {
|
||||
showToast(L("B03_Temp_Attach_NoAnalysis"), "warning");
|
||||
return;
|
||||
}
|
||||
showToast(L("B03_File_Analysis_InProgress"), "info");
|
||||
const analysisComplete = await pollAnalysis(activeProjectId);
|
||||
if (analysisComplete) {
|
||||
navigateTo(completionRoute);
|
||||
} else {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
|
||||
showToast(L("B03_Temp_Attach_Failed"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다.
|
||||
* 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다.
|
||||
*/
|
||||
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;
|
||||
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
||||
if (tempPicker.selected()) {
|
||||
setUploading(true);
|
||||
try {
|
||||
await attachSelectedTempBatch();
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
const validation = validateSlots();
|
||||
if (validation) {
|
||||
pageError.textContent = validation;
|
||||
return;
|
||||
}
|
||||
|
||||
pageError.textContent = "";
|
||||
setUploading(true);
|
||||
try {
|
||||
for (let index = 0; index < targetStates.length; index += 1) {
|
||||
const state = targetStates[index];
|
||||
await uploadOneFile(
|
||||
activeProjectId,
|
||||
state,
|
||||
index === targetStates.length - 1,
|
||||
() => renderSlot(state.slot),
|
||||
lasFreeDesign,
|
||||
);
|
||||
}
|
||||
clearDerivedCaches(activeProjectId);
|
||||
// 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지
|
||||
// 않는다(2026-09-03 사용자 지시).
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
|
||||
showToast(L("B03_File_Analysis_InProgress"), "info");
|
||||
const analysisComplete = await pollAnalysis(activeProjectId);
|
||||
|
||||
if (analysisComplete) {
|
||||
navigateTo(completionRoute);
|
||||
} else {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
if (failed) showErrorMessage(failed.slot, detail);
|
||||
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerB03ServiceWorker(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
showToast(L("B03_File_ServiceWorker_Unavailable"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
registration.active?.postMessage({ type: "B03_SW_PING" });
|
||||
showToast(L("B03_File_ServiceWorker_Ready"), "info");
|
||||
} catch {
|
||||
showToast(L("B03_File_ServiceWorker_Unavailable"), "warning");
|
||||
}
|
||||
}
|
||||
// 업로드 흐름(중단 세션·보관함 이관·청크 업로드·재잠금·서비스워커)은 파일이
|
||||
// 700줄을 넘어 떼어냈다(2026-09-04). 화면 상태는 아래 창구로만 넘긴다.
|
||||
const flow = createUploadFlow({
|
||||
projectId: () => activeProjectId,
|
||||
setProjectId: (value) => {
|
||||
activeProjectId = value;
|
||||
},
|
||||
slots,
|
||||
selectedStates,
|
||||
lasFreeDesign: () => lasFreeDesign,
|
||||
isUploading: () => isUploading,
|
||||
setUploading,
|
||||
renderSlot,
|
||||
showErrorMessage,
|
||||
applyUploadOverview,
|
||||
tempPicker,
|
||||
pageError,
|
||||
resumeBanner,
|
||||
completionRoute,
|
||||
});
|
||||
const { detectPausedUploads, pollAnalysis, relockWhileInitialPipelineRuns, startChunkedUpload } =
|
||||
flow;
|
||||
|
||||
function onB03_File_Select_Change(): void {
|
||||
onFileSelected(fileInput.files ? Array.from(fileInput.files) : []);
|
||||
@@ -910,7 +677,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
applyLasFreeState();
|
||||
void relockWhileInitialPipelineRuns();
|
||||
void registerB03ServiceWorker();
|
||||
void flow.registerB03ServiceWorker();
|
||||
void applyUploadOverview();
|
||||
void detectPausedUploads();
|
||||
if (activeProjectId) {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Page_Flow.ts
|
||||
* 파일 입력 화면의 업로드 흐름 — 중단 세션 이어올리기, 보관함 자료 옮기기,
|
||||
* 청크 업로드 시작, 초기 계산 중 재잠금, 서비스워커 등록.
|
||||
*
|
||||
* 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
* 화면이 들고 있는 상태는 `ctx` 로 받아 쓰기만 한다 — 동작·순서는 종전과 같다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
|
||||
import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
||||
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
|
||||
import { validateSlots } from "./B03_FileInput_UI_Page_Rules";
|
||||
import {
|
||||
isInitialPipelineRunning,
|
||||
pollInitialPipeline,
|
||||
uploadOneFile,
|
||||
} from "./B03_FileInput_UI_Upload";
|
||||
import {
|
||||
makeSessionKey,
|
||||
type FileSlot,
|
||||
type FileSlotState,
|
||||
type StoredUploadSession,
|
||||
} from "./B03_FileInput_UI_Support";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 화면이 들고 있는 상태·조작을 흐름 쪽에 넘겨 주는 창구. */
|
||||
export interface UploadFlowContext {
|
||||
projectId: () => string;
|
||||
setProjectId: (value: string) => void;
|
||||
slots: Map<FileSlot, FileSlotState>;
|
||||
selectedStates: () => FileSlotState[];
|
||||
lasFreeDesign: () => boolean;
|
||||
isUploading: () => boolean;
|
||||
setUploading: (busy: boolean) => void;
|
||||
renderSlot: (slot: FileSlot) => void;
|
||||
showErrorMessage: (slot: FileSlot, error: string) => void;
|
||||
applyUploadOverview: () => Promise<void>;
|
||||
tempPicker: { selected: () => { batch_id: string } | null; clear: () => void };
|
||||
pageError: HTMLElement;
|
||||
resumeBanner: HTMLElement;
|
||||
completionRoute: RoutePath;
|
||||
}
|
||||
|
||||
export interface UploadFlowHandle {
|
||||
/** 분석 대기 — 재접속 복원 표시에서도 쓴다. */
|
||||
pollAnalysis: (projectId: string) => Promise<boolean>;
|
||||
detectPausedUploads: () => Promise<void>;
|
||||
relockWhileInitialPipelineRuns: () => Promise<void>;
|
||||
startChunkedUpload: (targetStates?: FileSlotState[]) => Promise<void>;
|
||||
registerB03ServiceWorker: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 새 자료가 들어오면 이 프로젝트로 만들어 둔 파생 캐시를 버린다. */
|
||||
function clearDerivedCaches(projectId: string): void {
|
||||
clearRouteLatestCache(projectId);
|
||||
invalidateSectionDetail(projectId);
|
||||
clearPreloadMark();
|
||||
}
|
||||
|
||||
export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
|
||||
async function detectPausedUploads(): Promise<void> {
|
||||
ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "");
|
||||
ctx.resumeBanner.replaceChildren();
|
||||
ctx.resumeBanner.classList.remove("is-visible");
|
||||
if (!ctx.projectId()) return;
|
||||
|
||||
for (const state of ctx.selectedStates()) {
|
||||
const stored = localStorage.getItem(makeSessionKey(ctx.projectId(), state.file!));
|
||||
if (!stored) continue;
|
||||
const session = JSON.parse(stored) as StoredUploadSession;
|
||||
state.uploadSessionId = session.uploadSessionId;
|
||||
state.progressBytes = session.completedChunks * session.chunkSizeBytes;
|
||||
ctx.renderSlot(state.slot);
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`;
|
||||
const resume = createButton({
|
||||
label: L("B03_File_Resume_Button"),
|
||||
variant: "ghost",
|
||||
onClick: () => void startChunkedUpload([state]),
|
||||
});
|
||||
const fresh = createButton({
|
||||
label: L("B03_File_New_Button"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
localStorage.removeItem(session.key);
|
||||
state.uploadSessionId = undefined;
|
||||
state.progressBytes = 0;
|
||||
ctx.renderSlot(state.slot);
|
||||
ctx.resumeBanner.replaceChildren();
|
||||
ctx.resumeBanner.classList.remove("is-visible");
|
||||
},
|
||||
});
|
||||
ctx.resumeBanner.append(text, resume, fresh);
|
||||
ctx.resumeBanner.classList.add("is-visible");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */
|
||||
const pollAnalysis = (projectId: string): Promise<boolean> =>
|
||||
pollInitialPipeline(projectId, (message: string) => {
|
||||
ctx.pageError.textContent = message;
|
||||
showToast(message, "warning");
|
||||
});
|
||||
|
||||
/**
|
||||
* 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다.
|
||||
* 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지
|
||||
* 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다.
|
||||
*/
|
||||
async function attachSelectedTempBatch(): Promise<void> {
|
||||
const batch = ctx.tempPicker.selected();
|
||||
if (!batch) return;
|
||||
ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "");
|
||||
if (!ctx.projectId()) {
|
||||
ctx.pageError.textContent = L("B03_File_Error_Project");
|
||||
return;
|
||||
}
|
||||
ctx.pageError.textContent = "";
|
||||
try {
|
||||
const result = await attachTempBatch(ctx.projectId(), batch.batch_id);
|
||||
clearDerivedCaches(ctx.projectId());
|
||||
ctx.tempPicker.clear();
|
||||
showToast(L("B03_Temp_Attach_Success"), "success");
|
||||
await ctx.applyUploadOverview();
|
||||
if (!result.analysis_started) {
|
||||
showToast(L("B03_Temp_Attach_NoAnalysis"), "warning");
|
||||
return;
|
||||
}
|
||||
showToast(L("B03_File_Analysis_InProgress"), "info");
|
||||
const analysisComplete = await pollAnalysis(ctx.projectId());
|
||||
if (analysisComplete) {
|
||||
navigateTo(ctx.completionRoute);
|
||||
} else {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
ctx.pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
|
||||
showToast(L("B03_Temp_Attach_Failed"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다.
|
||||
* 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다.
|
||||
*/
|
||||
async function relockWhileInitialPipelineRuns(): Promise<void> {
|
||||
if (!ctx.projectId() || ctx.isUploading()) return;
|
||||
try {
|
||||
const state = await fetchWorkflowState(ctx.projectId());
|
||||
if (!isInitialPipelineRunning(state)) return;
|
||||
} catch {
|
||||
return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다.
|
||||
}
|
||||
ctx.setUploading(true);
|
||||
showToast(L("B03_File_Analysis_InProgress"), "info");
|
||||
try {
|
||||
const done = await pollAnalysis(ctx.projectId());
|
||||
if (done) showToast(L("B03_File_Upload_Success"), "success");
|
||||
} finally {
|
||||
ctx.setUploading(false);
|
||||
void ctx.applyUploadOverview();
|
||||
}
|
||||
}
|
||||
|
||||
async function startChunkedUpload(targetStates = ctx.selectedStates()): Promise<void> {
|
||||
if (ctx.isUploading()) return;
|
||||
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
||||
if (ctx.tempPicker.selected()) {
|
||||
ctx.setUploading(true);
|
||||
try {
|
||||
await attachSelectedTempBatch();
|
||||
} finally {
|
||||
ctx.setUploading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "");
|
||||
const validation = validateSlots(
|
||||
ctx.slots,
|
||||
ctx.selectedStates(),
|
||||
ctx.projectId(),
|
||||
ctx.lasFreeDesign(),
|
||||
);
|
||||
if (validation) {
|
||||
ctx.pageError.textContent = validation;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.pageError.textContent = "";
|
||||
ctx.setUploading(true);
|
||||
try {
|
||||
for (let index = 0; index < targetStates.length; index += 1) {
|
||||
const state = targetStates[index];
|
||||
await uploadOneFile(
|
||||
ctx.projectId(),
|
||||
state,
|
||||
index === targetStates.length - 1,
|
||||
() => ctx.renderSlot(state.slot),
|
||||
ctx.lasFreeDesign(),
|
||||
);
|
||||
}
|
||||
clearDerivedCaches(ctx.projectId());
|
||||
// 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지
|
||||
// 않는다(2026-09-03 사용자 지시).
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
|
||||
showToast(L("B03_File_Analysis_InProgress"), "info");
|
||||
const analysisComplete = await pollAnalysis(ctx.projectId());
|
||||
|
||||
if (analysisComplete) {
|
||||
navigateTo(ctx.completionRoute);
|
||||
} else {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
if (failed) ctx.showErrorMessage(failed.slot, detail);
|
||||
ctx.pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
} finally {
|
||||
ctx.setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerB03ServiceWorker(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
showToast(L("B03_File_ServiceWorker_Unavailable"), "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
registration.active?.postMessage({ type: "B03_SW_PING" });
|
||||
showToast(L("B03_File_ServiceWorker_Ready"), "info");
|
||||
} catch {
|
||||
showToast(L("B03_File_ServiceWorker_Unavailable"), "warning");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pollAnalysis,
|
||||
detectPausedUploads,
|
||||
relockWhileInitialPipelineRuns,
|
||||
startChunkedUpload,
|
||||
registerB03ServiceWorker,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Page_Rules.ts
|
||||
* 파일 입력 화면의 판정 규칙 — 어떤 카드가 지금 필수인가, 고른 파일이 그 자리에 맞는가,
|
||||
* 업로드를 시작해도 되는가, 서버 파일이 어느 카드로 가는가.
|
||||
*
|
||||
* 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
* 화면 상태를 가두지 않고 **인자로 받는 순수 함수**만 둔다 — 판정 결과는 종전과 같다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch";
|
||||
import {
|
||||
getExtension,
|
||||
SHAPEFILE_DEPENDENT_SLOTS,
|
||||
TERRAIN_SLOTS,
|
||||
type FileSlot,
|
||||
type FileSlotState,
|
||||
} from "./B03_FileInput_UI_Support";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export type SlotMap = Map<FileSlot, FileSlotState>;
|
||||
|
||||
/** 노선 도형 카드에 shapefile이 들어와 있는가. */
|
||||
export function routeIsShapefile(slots: SlotMap): boolean {
|
||||
const state = slots.get("csv");
|
||||
const name = state?.file?.name ?? state?.serverUploaded?.name;
|
||||
return getExtension(name ?? "") === ".shp";
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 카드가 지금 필수인가.
|
||||
*
|
||||
* shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 —
|
||||
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
|
||||
*/
|
||||
export function isSlotRequired(
|
||||
state: FileSlotState,
|
||||
slots: SlotMap,
|
||||
lasFreeDesign: boolean,
|
||||
): boolean {
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(slots);
|
||||
// LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다.
|
||||
if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired;
|
||||
return state.isRequired;
|
||||
}
|
||||
|
||||
/** 고른 파일이 그 카드의 확장자·크기 규칙에 맞는가. 어긋나면 안내 문구를 돌려준다. */
|
||||
export function validateFileForSlot(file: File, state: FileSlotState): string | null {
|
||||
const extension = getExtension(file.name);
|
||||
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 업로드를 시작해도 되는가. 안 되면 첫 번째 사유를 돌려준다. */
|
||||
export function validateSlots(
|
||||
slots: SlotMap,
|
||||
selected: FileSlotState[],
|
||||
activeProjectId: string,
|
||||
lasFreeDesign: boolean,
|
||||
): string | null {
|
||||
if (!activeProjectId) return L("B03_File_Error_Project");
|
||||
if (selected.length === 0) return L("B03_File_Error_Required");
|
||||
if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
|
||||
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) => isSlotRequired(state, slots, lasFreeDesign) && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
if (!lasFreeDesign) {
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
|
||||
}
|
||||
for (const state of selected) {
|
||||
if (state.error) return state.error;
|
||||
const validation = validateFileForSlot(state.file!, state);
|
||||
if (validation) return validation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 현황의 파일 한 건이 어느 카드로 가는가.
|
||||
*
|
||||
* PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 저장 경로로
|
||||
* 가린다(2026-08-31). 옛 프로젝트의 노선은 `.csv`로 올라가 있어 계획노선 도형 카드로
|
||||
* 보낸다(이제 새로 받지는 않는다, 2026-09-03).
|
||||
*/
|
||||
export function slotForOverviewFile(
|
||||
file: UploadOverviewFile,
|
||||
slots: SlotMap,
|
||||
): FileSlot | undefined {
|
||||
const extension = `.${file.file_type.toLowerCase()}`;
|
||||
if (extension === ".prj") {
|
||||
return (file.relative_path ?? "").includes("/input/shp/") ? "route_prj" : "prj";
|
||||
}
|
||||
if (extension === ".csv") return "csv";
|
||||
return Array.from(slots.values()).find(
|
||||
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
}
|
||||
Reference in New Issue
Block a user