/* ============================================================================= * 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; selectedStates: () => FileSlotState[]; lasFreeDesign: () => boolean; isUploading: () => boolean; setUploading: (busy: boolean) => void; renderSlot: (slot: FileSlot) => void; showErrorMessage: (slot: FileSlot, error: string) => void; applyUploadOverview: () => Promise; tempPicker: { selected: () => { batch_id: string } | null; clear: () => void }; pageError: HTMLElement; resumeBanner: HTMLElement; completionRoute: RoutePath; } export interface UploadFlowHandle { /** 분석 대기 — 재접속 복원 표시에서도 쓴다. */ pollAnalysis: (projectId: string) => Promise; detectPausedUploads: () => Promise; relockWhileInitialPipelineRuns: () => Promise; startChunkedUpload: (targetStates?: FileSlotState[]) => Promise; registerB03ServiceWorker: () => Promise; } /** 새 자료가 들어오면 이 프로젝트로 만들어 둔 파생 캐시를 버린다. */ function clearDerivedCaches(projectId: string): void { clearRouteLatestCache(projectId); invalidateSectionDetail(projectId); clearPreloadMark(); } export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { async function detectPausedUploads(): Promise { 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 => pollInitialPipeline(projectId, (message: string) => { ctx.pageError.textContent = message; showToast(message, "warning"); }); /** * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. */ async function attachSelectedTempBatch(): Promise { 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 { 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 { 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 { // 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다. const jobs = targetStates.flatMap((state) => [state.file!, ...(state.extraFiles ?? [])].map((file) => ({ state, file })), ); for (let index = 0; index < jobs.length; index += 1) { const { state, file } = jobs[index]; if (file !== state.file) { // 앞 파일이 쓰던 전송 세션·진행률을 물려받지 않게 되돌린다. state.uploadSessionId = undefined; state.progressBytes = 0; } await uploadOneFile( ctx.projectId(), state, index === jobs.length - 1, () => ctx.renderSlot(state.slot), ctx.lasFreeDesign(), file, ); } 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 { 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, }; }