import { CURRENT_PROJECT_ID_KEY, 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 { 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, type UploadedFileResult } 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 { createTempPicker } from "./B03_FileInput_UI_TempPicker"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { restoreB03ProjectState } from "./B03_FileInput_State"; import { confirmReplaceUpload, isInitialPipelineRunning, pollInitialPipeline, renderUploadResults, uploadOneFile, } from "./B03_FileInput_UI_Upload"; 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 { // 업로드가 끝나면 역할과 무관하게 종단설계로 보낸다 — 전처리(B04)는 자동 확정까지 // 끝난 관리자 전용 점검 화면이라 일반 진행 경로가 아니다(2026-08-08 사용자 지시). const completionRoute = ROUTES.B05_PROFILE; const slots = initializeSlots(); // 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다. const tempPicker = createTempPicker(() => updateUploadButton()); 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) ?? ""; // 업로드가 도는 동안은 화면 전체를 잠근다 — 같은 파일을 두 번 올리거나 도중에 다른 // 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시). let pageRoot: HTMLElement | null = null; let isUploading = 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"); 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"); // 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 크기로 줄여 쓴다. const uploadSpinner = document.createElement("span"); uploadSpinner.className = "ui-spinner b03-file__upload-spinner"; uploadSpinner.setAttribute("aria-hidden", "true"); 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 setUploading(busy: boolean): void { isUploading = busy; pageRoot?.classList.toggle("b03-file--busy", busy); const label = uploadButton.querySelector(".ui-btn__label"); if (busy) { uploadButton.disabled = true; uploadButton.prepend(uploadSpinner); if (label) label.textContent = L("B03_File_Upload_Busy"); return; } uploadSpinner.remove(); if (label) label.textContent = L("B03_File_Upload_Button"); updateUploadButton(); } function updateUploadButton(): void { // 업로드가 도는 동안에는 어떤 이유로도 다시 눌리면 안 된다. if (isUploading) { uploadButton.disabled = true; return; } // 보관함 자료를 지정했으면 슬롯 검사와 무관하게 업로드(=이동)를 열어 준다. if (tempPicker.selected()) { uploadButton.disabled = false; pageError.textContent = ""; return; } 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 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개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). const occupied = new Set(selectedStates().map((state) => state.slot)); for (const file of files) { const extension = getExtension(file.name); const slot = targetSlot ?? Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension)) ?.slot; if (slot) occupied.add(slot); } if (occupied.size > 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; } /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. * 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); // 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다. const extLabel = state.extensions.join(", "); card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired ? extLabel : `${extLabel} · ${L("B03_File_Card_Optional")}`; 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; } 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; } } /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ const pollAnalysis = (projectId: string): Promise => pollInitialPipeline(projectId, (message) => { pageError.textContent = message; showToast(message, "warning"); }); /** * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. */ async function attachSelectedTempBatch(): Promise { 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 { 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 { 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); 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, () => renderSlot(state.slot), )), ); } clearDerivedCaches(activeProjectId); renderUploadResults(resultList, uploaded); 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 { 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, // 대시보드 임시 보관함에서 자료를 끌어오는 자리 — 업로드 컨테이너 안에 둔다. tempPicker.root, resumeBanner, pageError, uploadButton, resultList, ); // 계획노선과 지형 자료는 지형 래스터(tif)만 빼면 모두 필수라 따로 묶지 않는다 // (2026-08-08 사용자 지시). const inputsGroup = createCardGroup(L("B03_File_Group_Inputs"), [ "csv", "las_laz", "prj", "tfw", "tif", ]); const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; cardsContainer.append(inputsGroup); 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]); } }, // B03은 일반 레이아웃을 쓰느라 오버레이를 직접 조립한다 — createWorkflowLayout이 // 넘겨 주던 대시보드 탈출구를 여기서도 똑같이 달아야 다른 단계 화면과 어긋나지 않는다. homeButton: true, }); 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); pageRoot = layout.root; root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); void relockWhileInitialPipelineRuns(); void registerB03ServiceWorker(); void applyUploadOverview(); void detectPausedUploads(); if (activeProjectId) { restoreB03ProjectState({ projectId: activeProjectId, label: L("B03_File_Restore_State"), container: resumeBanner, poll: pollAnalysis, onComplete: () => navigateTo(completionRoute), }); } }