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, planSlotAssignments, ROUTE_SLOTS, SHAPEFILE_DEPENDENT_SLOTS, slotConfigs, TERRAIN_SLOTS, 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; // LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 프로젝트별로 기억한다. let lasFreeDesign = activeProjectId ? 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"); 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 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) ? 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 = 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 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 (!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); // 노선 도형이 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; // LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) — // 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다. const pointCloudExtensions = slots.get("las_laz")?.extensions ?? []; const files = lasFreeDesign ? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name))) : selection; const blocked = files.length !== selection.length; if (blocked && files.length === 0) { pageError.textContent = L("B03_File_Error_LasFreeBlocked"); return; } // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). // 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이 // 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시). const routeFile = slots.get("csv")?.file?.name; const assignments = planSlotAssignments( files, slotConfigs(), routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined, ); 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.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; state.speedMbs = 0; state.etaSeconds = null; state.error = undefined; renderSlot(slot); if (slot === "csv") renderRouteDependentSlots(); } /** 노선 도형이 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(); if (state.slot === "las_laz") return !lasFreeDesign; 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; } /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. * 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()}`; // PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 // 저장 경로로 가린다(2026-08-31). const inRouteSet = (file.relative_path ?? "").includes("/input/shp/"); const slot: FileSlot | undefined = extension === ".prj" ? inRouteSet ? "route_prj" : "prj" : Array.from(slots.values()).find( (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), )?.slot; const state = slot ? slots.get(slot) : undefined; 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); 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; } 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), lasFreeDesign, )), ); } 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, ); // 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터). // 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지 // 화면만 보고 안다(2026-08-31 사용자 지시). const routeGroup = createCardGroup( L("B03_File_Group_Route"), ROUTE_SLOTS, "b03-file__group--route", L("B03_File_Group_Route_Hint"), ); 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; const card = cardMap.get("las_laz"); card?.classList.toggle("b03-file__card--disabled", lasFreeDesign); // 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다. card ?.querySelectorAll( ".b03-file__card-select, .b03-file__slot-input", ) .forEach((element) => { element.disabled = lasFreeDesign; }); // 켜기 전에 이미 골라 둔 LAS는 내린다 — 켠 채로 남아 올라가는 사고를 막는다. if (lasFreeDesign && slots.get("las_laz")?.file) removeFile("las_laz"); } 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 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다. terrainGroup.append(lasFreeRow); 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"); 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); applyLasFreeState(); 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), }); } }