import { CURRENT_PROJECT_ID_KEY, ROUTES, UPLOAD_ALLOWED_EXT, UPLOAD_MAX_FILES, } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; 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 { navigateTo } from "../A00_Common/router"; import { createInputGuide } from "./B03_FileInput_UI_Guide"; 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 { createUploadFlow } from "./B03_FileInput_UI_Page_Flow"; import { isSlotRequired, slotForOverviewFile, terrainCoverage, validateFileForSlot, validateSlots, } from "./B03_FileInput_UI_Page_Rules"; import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview"; import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload"; import { createFileCardTemplate, formatBytes, formatEta, initializeSlots, makeSessionKey, planSlotAssignments, pushExtraFile, ROUTE_SLOTS, SHAPEFILE_DEPENDENT_SLOTS, slotConfigs, slotFileLabel, TERRAIN_SLOTS, type FileSlot, type FileSlotState, 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(); 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; // LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 프로젝트별로 기억한다. let lasFreeDesign = activeProjectId ? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1" : false; 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"); // 설명은 좌측 안내 패널 몫이다(2026-09-03 사용자 지시) — 여기는 자리 이름만 남긴다. dropzone.append(dropzoneLabel, 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(slots, selectedStates(), activeProjectId, lasFreeDesign); uploadButton.disabled = validation !== null; // 「업로드할 파일을 선택하세요」는 버튼이 잠긴 것으로 이미 드러난다 — 고르기도 전에 // 붉은 경고를 띄우지 않는다(2026-09-03 사용자 지시). 나머지 사유는 그대로 알린다. pageError.textContent = validation === null || validation === L("B03_File_Error_Required") ? "" : validation; } /** 확장자 줄 — 지금 필수인지에 따라 "· 선택" 꼬리표가 붙고 떨어진다. */ function renderExtensionLabel(card: HTMLElement, state: FileSlotState): void { const extLabel = state.extensions.join(", "); const target = card.querySelector(".b03-file__card-ext"); if (!target) return; target.textContent = isSlotRequired(state, slots, lasFreeDesign) ? extLabel : `${extLabel} · ${L("B03_File_Card_Optional")}`; } function renderSlot(slot: FileSlot): void { const state = slots.get(slot); const card = cardMap.get(slot); if (!state || !card) return; renderExtensionLabel(card, state); 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 = slotFileLabel(state); 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); const preview = card.querySelector(".b03-file__preview"); if (preview) { const terrain = terrainCoverage(slots); renderSlotPreview(preview, { metadata: state.serverUploaded?.metadata, // 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시). completed: Boolean(state.serverUploaded), terrainExtent: ROUTE_SLOTS.includes(slot) ? terrain.extent : null, terrainCrs: terrain.crs, crsFallback: readCrsLabel(slots.get("route_prj")?.serverUploaded?.metadata), }); } updateUploadButton(); } /** * 지형 자료(라이다·GeoTIFF)가 덮는 범위를 하나로 합친다 — 계획노선이 그 안에 * 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라 * 카드에서 바로 보이게 한다(2026-09-04). */ function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; state.error = error; state.uploadStatus = "failed"; renderSlot(slot); } async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise { const state = targetSlot ? slots.get(targetSlot) : undefined; 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 (state.file && state.file.name !== file.name) { if (!pushExtraFile(state, file)) { showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); return; } renderSlot(state.slot); 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); // 노선 도형이 CSV↔shapefile로 바뀌면 형제 카드의 필수 표시도 따라 바뀐다. if (state.slot === "csv") renderRouteDependentSlots(); } function renderRouteDependentSlots(): void { for (const slot of SHAPEFILE_DEPENDENT_SLOTS) renderSlot(slot); updateUploadButton(); } function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void { if (selection.length === 0) return; // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). // 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이 // 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시). const routeFile = slots.get("csv")?.file?.name; const planned = planSlotAssignments( selection, slotConfigs(), routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined, ); // LAS 없이 설계를 켜면 **지형 자료를 통째로** 받지 않는다(2026-09-03 사용자 지시 — // 종전에는 포인트클라우드만 걸렀다). 확장자가 아니라 **배정된 카드**로 거른다: `.prj`는 // 노선·지형이 같은 확장자라 확장자로 거르면 노선 좌표계까지 함께 떨어진다. const assignments = lasFreeDesign ? planned.filter((item) => { const slot = targetSlot ?? item.slot; return slot === undefined || !TERRAIN_SLOTS.includes(slot); }) : planned; const blocked = assignments.length !== planned.length; if (assignments.length === 0) { pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : ""; return; } const occupied = new Set(selectedStates().map((state) => state.slot)); for (const item of assignments) { const slot = targetSlot ?? item.slot; if (slot) occupied.add(slot); } if (occupied.size > UPLOAD_MAX_FILES) { pageError.textContent = L("B03_File_Error_Count"); return; } pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : ""; void (async () => { for (const item of assignments) { const slot = targetSlot ?? item.slot; if (!slot) { pageError.textContent = `${L("B03_File_Error_Extension")} ${item.file.name}`; continue; } await assignFileToSlot(item.file, slot); } 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.extraFiles = undefined; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; state.speedMbs = 0; state.etaSeconds = null; state.error = undefined; renderSlot(slot); if (slot === "csv") renderRouteDependentSlots(); } /** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */ /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. * 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 slot = slotForOverviewFile(file, slots); const state = slot ? slots.get(slot) : undefined; if (state) { state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb, metadata: file.metadata ?? undefined, }; } } 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); renderExtensionLabel(card, state); 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[], modifier?: string, hint?: string, ): HTMLElement { const group = document.createElement("section"); group.className = "b03-file__group"; if (modifier) group.classList.add(modifier); if (title) { const groupTitle = document.createElement("h3"); groupTitle.className = "b03-file__group-title"; groupTitle.textContent = title; group.append(groupTitle); } if (hint) { const groupHint = document.createElement("p"); groupHint.className = "b03-file__group-hint"; groupHint.textContent = hint; group.append(groupHint); } 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; } // 업로드 흐름(중단 세션·보관함 이관·청크 업로드·재잠금·서비스워커)은 파일이 // 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) : []); 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, }); // 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함에서 불러오기] · [파일 업로드] // (2026-09-03 사용자 지시). 셋이 한 동작의 앞뒤라 흩어 놓을 이유가 없다. const pickRow = document.createElement("div"); pickRow.className = "b03-file__pick-row"; pickRow.append(dropzone, tempPicker.root, uploadButton); const uploadControlPanel = document.createElement("div"); uploadControlPanel.className = "b03-file__control-panel"; uploadControlPanel.append(subtitle, pickRow, resumeBanner, pageError); // 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터). // 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지 // 화면만 보고 안다(2026-08-31 사용자 지시). // 안내 문구 없음 — CSV 관련 설명이 붙어 있었으나 CSV는 사용자가 넣는 자료가 아니다 // (2026-09-03 사용자 지시: 내부 계산 파일). const routeGroup = createCardGroup( L("B03_File_Group_Route"), ROUTE_SLOTS, "b03-file__group--route", ); const terrainGroup = createCardGroup( L("B03_File_Group_Terrain"), TERRAIN_SLOTS, "b03-file__group--terrain", L("B03_File_Group_Terrain_Hint"), ); // LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다. const lasFreeRow = document.createElement("label"); lasFreeRow.className = "b03-file__lasfree"; lasFreeRow.title = L("B03_File_LasFree_Hint"); const lasFreeCheck = document.createElement("input"); lasFreeCheck.type = "checkbox"; const lasFreeText = document.createElement("span"); lasFreeText.textContent = L("B03_File_LasFree_Toggle"); lasFreeRow.append(lasFreeCheck, lasFreeText); function applyLasFreeState(): void { lasFreeCheck.checked = lasFreeDesign; terrainGroup.classList.toggle("b03-file__group--disabled", lasFreeDesign); for (const slot of TERRAIN_SLOTS) { const card = cardMap.get(slot); card?.classList.toggle("b03-file__card--disabled", lasFreeDesign); // 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다. card ?.querySelectorAll( ".b03-file__card-select, .b03-file__card-remove, .b03-file__slot-input", ) .forEach((element) => { element.disabled = lasFreeDesign; }); // 켜기 전에 골라 둔 로컬 파일은 내린다 — 켠 채로 남아 올라가는 사고를 막는다. // 이미 서버에 올라간 자료(재입력)는 그대로 두고 조작만 잠근다(2026-09-03 사용자 지시). if (lasFreeDesign && slots.get(slot)?.file) removeFile(slot); } } lasFreeCheck.addEventListener("change", () => { lasFreeDesign = lasFreeCheck.checked; if (activeProjectId) { localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0"); } applyLasFreeState(); pageError.textContent = ""; // 이 토글이 LAS 카드의 필수 여부를 바꾼다 — 버튼 판정을 다시 돌리지 않으면 파일을 다 // 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고 // 토글을 나중에 켠 순서에서 재현). updateUploadButton(); }); // LAS 토글은 **파일 입력 컨테이너**의 것이다(2026-09-03 사용자 지시) — 무엇을 받을지 // 정하는 스위치라 고르는 자리 옆에 선다. 켜면 지형 컨테이너가 통째로 잠긴다. uploadControlPanel.insertBefore(lasFreeRow, resumeBanner); const routePanel = document.createElement("div"); routePanel.className = "b03-file__control-panel b03-file__cards-container-panel"; routePanel.append(routeGroup); const terrainPanel = document.createElement("div"); terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel"; terrainPanel.append(terrainGroup); const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__columns"; cardsContainer.append(routePanel, terrainPanel); 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"); // 고르는 방법·필요한 파일 안내는 좌측 패널로 뺐다 — 본문은 고르는 자리만 남긴다 // (2026-09-03 사용자 지시). 자리·여닫기는 B04·B05 좌측 패널과 같은 공용 오버레이다. const overlays = createWorkflowOverlays({ title: L("B03_File_Title"), progressContent, optionsContent: createInputGuide(overviewBanner), // 공용 워크플로 레이아웃(B04·B05)이 하는 일을 여기서도 한다 — 패널이 열리면 본문을 // 그만큼 밀어내지 않으면 안내가 카드 위를 덮는다. onOptionsOpenChange: (isOpen) => layout.root.classList.toggle("is-options-open", isOpen), }); layout.root.append(overlays.root); pageRoot = layout.root; root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); applyLasFreeState(); void relockWhileInitialPipelineRuns(); void flow.registerB03ServiceWorker(); void applyUploadOverview(); void detectPausedUploads(); if (activeProjectId) { restoreB03ProjectState({ projectId: activeProjectId, label: L("B03_File_Restore_State"), container: resumeBanner, poll: pollAnalysis, onComplete: () => navigateTo(completionRoute), }); } }