import { CURRENT_PROJECT_ID_KEY, PROGRESS_UPDATE_INTERVAL_MS, ROUTES, UPLOAD_ALLOWED_EXT, UPLOAD_CHUNK_SIZE_MB, 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 { 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 { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { checkWF1AnalysisStatus, createUploadSession, fetchUploadOverview, finalizeUploadSession, uploadFileChunk, type UploadedFileResult, } from "./B03_FileInput_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { restoreB03ProjectState, saveB03UploadedFile, updateB03AnalysisState, } from "./B03_FileInput_State"; import { createFileCardTemplate, formatBytes, formatEta, getExtension, initializeSlots, makeSessionKey, type FileSlot, type FileSlotState, type StoredUploadSession, type UploadStatus, } from "./B03_FileInput_UI_Support"; import "./B03_FileInput_UI_Style.css"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export async function renderB03FileInput(root: HTMLElement): Promise { const dashboardUser = await fetchDashboardMe(); const completionRoute = dashboardUser.role === "SYSTEM_ADMIN" ? ROUTES.B04_PREPROCESS : ROUTES.B05_PROFILE; const slots = initializeSlots(); const cardMap = new Map(); const resultList = document.createElement("ul"); resultList.className = "b03-file__results"; let uploadButton: HTMLButtonElement; let resumeBanner: HTMLDivElement; let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const subtitle = document.createElement("p"); subtitle.className = "b03-file__subtitle"; subtitle.textContent = L("B03_File_Subtitle"); const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.multiple = true; fileInput.accept = UPLOAD_ALLOWED_EXT.join(","); fileInput.className = "b03-file__native-input"; const dropzone = document.createElement("div"); dropzone.className = "b03-file__dropzone"; dropzone.tabIndex = 0; const dropzoneLabel = document.createElement("strong"); dropzoneLabel.textContent = L("B03_File_Select_Label"); const dropzoneHint = document.createElement("span"); dropzoneHint.textContent = L("B03_File_Select_Hint"); dropzone.append(dropzoneLabel, dropzoneHint, fileInput); const pageError = document.createElement("p"); pageError.className = "b03-file__error"; pageError.setAttribute("role", "alert"); resumeBanner = document.createElement("div"); resumeBanner.className = "b03-file__resume"; // 재접속 현황(서버 정본) 표시 — 전체 완료 배지와 중단 세션 안내가 여기 붙는다. const overviewBanner = document.createElement("div"); overviewBanner.className = "b03-file__overview"; const template = createFileCardTemplate(); function selectedStates(): FileSlotState[] { return Array.from(slots.values()).filter((state) => state.file); } function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void { const card = cardMap.get(slot); if (!card) return; card.classList.remove( "b03-file__card--empty", "b03-file__card--selected", "b03-file__card--uploading", "b03-file__card--completed", "b03-file__card--failed", "b03-file__card--error", ); const cssState = stateName === "failed" ? "error" : stateName; card.classList.add(`b03-file__card--${cssState}`); const badgeContainer = card.querySelector(".b03-file__card-badge-container"); if (badgeContainer) { badgeContainer.replaceChildren(); if (stateName === "empty") { badgeContainer.append(createTag("미등록", "neutral")); } else if (stateName === "selected") { badgeContainer.append(createTag("대기중", "neutral")); } else if (stateName === "uploading") { badgeContainer.append(createTag("업로드중", "warning")); } else if (stateName === "completed") { badgeContainer.append(createTag("완료", "success")); } else if (stateName === "failed") { badgeContainer.append(createTag("오류", "danger")); } } } function updateUploadButton(): void { const validation = validateSlots(); uploadButton.disabled = validation !== null; pageError.textContent = validation ?? ""; } function renderSlot(slot: FileSlot): void { const state = slots.get(slot); const card = cardMap.get(slot); if (!state || !card) return; const fileName = card.querySelector(".b03-file__file-name"); const fileSize = card.querySelector(".b03-file__file-size"); const progress = card.querySelector(".b03-file__progress-bar"); const progressBytes = card.querySelector(".b03-file__progress-bytes"); const progressSpeed = card.querySelector(".b03-file__progress-speed"); const progressEta = card.querySelector(".b03-file__progress-eta"); const error = card.querySelector(".b03-file__error-message"); const remove = card.querySelector(".b03-file__card-remove"); const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; // 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다. if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? ""; if (fileSize) { fileSize.textContent = state.file ? formatBytes(state.file.size) : state.serverUploaded ? `${state.serverUploaded.sizeMb.toFixed(2)} MB` : ""; } if (progress) progress.style.width = `${percent}%`; if (progressBytes) { progressBytes.textContent = `${L("B03_File_Progress_Bytes")}: ${formatBytes( state.progressBytes, )} / ${state.file ? formatBytes(state.file.size) : "-"}`; } if (progressSpeed) { progressSpeed.textContent = `${L("B03_File_Progress_Speed")}: ${state.speedMbs.toFixed( 1, )} MB/s`; } if (progressEta) { progressEta.textContent = `${L("B03_File_Progress_Eta")}: ${formatEta(state.etaSeconds)}`; } if (error) error.textContent = state.error ?? ""; if (remove) remove.hidden = !state.file; if (state.error) setCardState(slot, "failed"); else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty"); else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus); updateUploadButton(); } function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; state.error = error; state.uploadStatus = "failed"; renderSlot(slot); } function clearErrorMessage(slot: FileSlot): void { const state = slots.get(slot); if (!state) return; state.error = undefined; if (state.uploadStatus === "failed") state.uploadStatus = "pending"; 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 { const extension = getExtension(file.name); const state = targetSlot ? slots.get(targetSlot) : Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension)); if (!state) { pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`; return; } const validation = validateFileForSlot(file, state); if (validation) { showErrorMessage(state.slot, `${validation} ${file.name}`); return; } if (!targetSlot && state.file && state.file.name !== file.name) { showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); return; } // 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로 // 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다. if (state.serverUploaded) { const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name); if (!accepted) return; } state.file = file; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; state.speedMbs = 0; state.etaSeconds = null; state.error = undefined; renderSlot(state.slot); } function onFileSelected(files: readonly File[], targetSlot?: FileSlot): void { if (files.length === 0) return; // 이미 고른 슬롯을 다시 고르는 것은 "교체"라 총 개수가 늘지 않는다 — 그 슬롯을 빼고 센다. // (빼지 않으면 5개를 다 고른 뒤 하나만 바꾸려 할 때 개수 초과로 막힌다.) const occupied = selectedStates().filter((state) => state.slot !== targetSlot).length; if (occupied + files.length > UPLOAD_MAX_FILES) { pageError.textContent = L("B03_File_Error_Count"); return; } pageError.textContent = ""; void (async () => { for (const file of files) await assignFileToSlot(file, targetSlot); await detectPausedUploads(); })(); } function removeFile(slot: FileSlot): void { const state = slots.get(slot); if (!state) return; if (activeProjectId && state.file) { localStorage.removeItem(makeSessionKey(activeProjectId, state.file)); } state.file = undefined; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; state.speedMbs = 0; state.etaSeconds = null; state.error = undefined; renderSlot(slot); } 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) => state.isRequired && !state.file && !state.serverUploaded, ); if (missingRequired) return L("B03_File_Error_RequiredSlots"); 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; } /** * 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의 * 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true). */ function confirmReplaceUpload(slotLabel: string, fileName: string): Promise { return new Promise((resolve) => { const backdrop = document.createElement("div"); backdrop.className = "b03-file__modal-backdrop"; const modal = document.createElement("div"); modal.className = "b03-file__modal"; modal.setAttribute("role", "alertdialog"); modal.setAttribute("aria-modal", "true"); const title = document.createElement("strong"); title.textContent = L("B03_File_Replace_Title"); const message = document.createElement("p"); message.textContent = `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`; const actions = document.createElement("div"); actions.className = "b03-file__modal-actions"; const done = (accepted: boolean): void => { backdrop.remove(); resolve(accepted); }; const cancel = createButton({ label: L("B03_File_Replace_Cancel"), variant: "ghost", onClick: () => done(false), }); const accept = createButton({ label: L("B03_File_Replace_Confirm"), variant: "filled", onClick: () => done(true), }); actions.append(cancel, accept); modal.append(title, message, actions); backdrop.append(modal); backdrop.addEventListener("click", (event) => { if (event.target === backdrop) done(false); }); document.body.append(backdrop); accept.focus(); }); } /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. * localStorage 기반 표시(restoreB03ProjectState)는 보조로 유지된다. */ async function applyUploadOverview(): Promise { if (!activeProjectId) return; overviewBanner.replaceChildren(); overviewBanner.classList.remove("is-visible"); try { const overview = await fetchUploadOverview(activeProjectId); // 확장자 → 슬롯 매핑으로 서버 파일을 카드에 얹는다(같은 슬롯이면 최신 것 우선 — 목록이 // id 오름차순이므로 마지막 것이 남는다). for (const state of slots.values()) state.serverUploaded = undefined; for (const file of overview.files) { const extension = `.${file.file_type.toLowerCase()}`; const state = Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension), ); if (state) { state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb }; } } for (const slot of slots.keys()) renderSlot(slot); const notes: HTMLElement[] = []; if (overview.required_complete && overview.analysis_complete) { const complete = document.createElement("p"); complete.className = "b03-file__overview-complete"; complete.textContent = L("B03_File_Overview_Complete"); notes.push(complete); } for (const session of overview.pending_sessions) { const pending = document.createElement("p"); pending.className = "b03-file__overview-pending"; pending.textContent = `${session.original_filename} — ${session.progress_percent}% ` + L("B03_File_Overview_Pending"); notes.push(pending); } if (notes.length) { overviewBanner.append(...notes); overviewBanner.classList.add("is-visible"); } } catch { // 현황 조회 실패는 업로드 자체를 막지 않는다 — localStorage 보조 표시로만 동작. } } function createFileCard(state: FileSlotState): HTMLElement { const fragment = template.content.cloneNode(true) as DocumentFragment; const card = fragment.querySelector(".b03-file__card"); if (!card) throw new Error("file-card-template is invalid"); card.dataset.slotId = state.slot; card.querySelector(".b03-file__card-icon")!.textContent = state.icon; card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey); card.querySelector(".b03-file__card-ext")!.textContent = state.extensions.join(", "); const input = card.querySelector(".b03-file__slot-input")!; input.accept = state.extensions.join(","); const select = card.querySelector(".b03-file__card-select")!; select.textContent = L("B03_File_Card_Select"); select.addEventListener("click", () => input.click()); input.addEventListener("change", () => { onFileSelected(input.files ? Array.from(input.files) : [], state.slot); input.value = ""; }); const remove = card.querySelector(".b03-file__card-remove")!; remove.textContent = "×"; remove.title = L("B03_File_Card_Remove"); remove.setAttribute("aria-label", L("B03_File_Card_Remove")); remove.addEventListener("click", () => removeFile(state.slot)); cardMap.set(state.slot, card); return card; } function createCardGroup(title: string, groupSlots: readonly FileSlot[]): HTMLElement { const group = document.createElement("section"); group.className = "b03-file__group"; if (title) { const groupTitle = document.createElement("h3"); groupTitle.className = "b03-file__group-title"; groupTitle.textContent = title; group.append(groupTitle); } const content = document.createElement("div"); content.className = "b03-file__group-content"; for (const slot of groupSlots) { const state = slots.get(slot); if (state) content.append(createFileCard(state)); } group.append(content); return group; } function renderUploadResults(results: readonly UploadedFileResult[]): void { resultList.replaceChildren(); for (const result of results) { const item = document.createElement("li"); const filename = document.createElement("strong"); filename.textContent = result.original_filename; const path = document.createElement("span"); path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`; item.append(filename, path); resultList.append(item); } } async function detectPausedUploads(): Promise { 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; } } async function uploadOneFile( projectId: string, state: FileSlotState, completeUpload: boolean, ): Promise { const file = state.file; if (!file) return []; clearErrorMessage(state.slot); state.uploadStatus = "uploading"; renderSlot(state.slot); const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024; const session = state.uploadSessionId ?? (await createUploadSession(projectId, file, chunkSizeBytes)).upload_session_id; state.uploadSessionId = session; const totalChunks = Math.max(1, Math.ceil(file.size / chunkSizeBytes)); const storageKey = makeSessionKey(projectId, file); const startedAt = performance.now(); let lastPaintAt = 0; for ( let chunkIndex = Math.floor(state.progressBytes / chunkSizeBytes); chunkIndex < totalChunks; chunkIndex += 1 ) { const start = chunkIndex * chunkSizeBytes; const end = Math.min(file.size, start + chunkSizeBytes); const chunkStartedAt = performance.now(); await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end)); const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000); state.progressBytes = end; state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec; state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null; const stored: StoredUploadSession = { key: storageKey, projectId, slot: state.slot, fileName: file.name, fileSize: file.size, uploadSessionId: session, chunkSizeBytes, totalChunks, completedChunks: chunkIndex + 1, updatedAt: Date.now(), }; localStorage.setItem(storageKey, JSON.stringify(stored)); const now = performance.now(); if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) { lastPaintAt = now; renderSlot(state.slot); } } const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { slot: state.slot, fileName: file.name, fileSize: file.size, }); state.progressBytes = file.size; state.speedMbs = file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000); state.etaSeconds = 0; state.uploadStatus = "completed"; renderSlot(state.slot); return response.files; } async function pollWF1Analysis(projectId: string, maxAttempts = 360): Promise { for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { const status = await checkWF1AnalysisStatus(projectId); updateB03AnalysisState(projectId, status.status, status.progress_percent); if (status.status === "completed") { return true; } if ( status.current_stage === "awaiting_confirmation" && status.message.includes("자동 확정 보류") ) { pageError.textContent = status.message; showToast(status.message, "warning"); return false; } } catch {} await new Promise((resolve) => setTimeout(resolve, 5000)); } return false; } async function startChunkedUpload(targetStates = selectedStates()): Promise { activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const validation = validateSlots(); if (validation) { pageError.textContent = validation; return; } pageError.textContent = ""; const uploaded: UploadedFileResult[] = []; try { for (let index = 0; index < targetStates.length; index += 1) { const state = targetStates[index]; uploaded.push( ...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)), ); } renderUploadResults(uploaded); showToast(L("B03_File_Upload_Success"), "success"); showToast(L("B03_File_Analysis_InProgress"), "info"); const analysisComplete = await pollWF1Analysis(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"); } } 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"); } } function onB03_File_Select_Change(): void { onFileSelected(fileInput.files ? Array.from(fileInput.files) : []); fileInput.value = ""; } function onB03_File_Drop(event: DragEvent): void { event.preventDefault(); dropzone.classList.remove("is-dragging"); onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []); } fileInput.addEventListener("change", onB03_File_Select_Change); dropzone.addEventListener("click", () => fileInput.click()); dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") fileInput.click(); }); dropzone.addEventListener("dragover", (event) => { event.preventDefault(); dropzone.classList.add("is-dragging"); }); dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging")); dropzone.addEventListener("drop", onB03_File_Drop); uploadButton = createButton({ label: L("B03_File_Upload_Button"), variant: "filled", onClick: () => void startChunkedUpload(), disabled: true, }); const uploadControlPanel = document.createElement("div"); uploadControlPanel.className = "b03-file__control-panel"; uploadControlPanel.append( subtitle, overviewBanner, dropzone, resumeBanner, pageError, uploadButton, resultList, ); const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]); routeGroup.classList.add("b03-file__group--route"); const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]); const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; cardsContainer.append(routeGroup, filesGroup); const workflowState = activeProjectId ? await fetchWorkflowState(activeProjectId).catch(() => undefined) : undefined; const steps = workflowSteps(); const progressContent = createStepBar(steps, 0, { stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, orientation: "vertical", icons: WORKFLOW_STEP_ICONS, onStepClick: (stepIndex) => { if (activeProjectId) { goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]); } }, }); const layout = createGeneralLayout({ pageClass: "b03-file", content: [uploadControlPanel, cardsContainer], }); layout.content.classList.add("b03-file__main-layout"); const overlays = createWorkflowOverlays({ title: L("B03_File_Title"), progressContent, showTitlePanel: false, }); layout.root.append(overlays.root); root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); void registerB03ServiceWorker(); void applyUploadOverview(); void detectPausedUploads(); if (activeProjectId) { restoreB03ProjectState({ projectId: activeProjectId, label: L("B03_File_Restore_State"), container: resumeBanner, poll: pollWF1Analysis, onComplete: () => navigateTo(completionRoute), }); } }