From f541ab4480bc4ea7e12cd97beb9aa13eced682fc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:19:04 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B03):=20=ED=8C=8C=EC=9D=BC=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=ED=99=94=EB=A9=B4=20700=EC=A4=84=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=C2=B7=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 925줄 한 파일을 셋으로 나눔 (동작 불변). - `B03_FileInput_UI_Page.ts` 692줄 — 화면 조립·카드 렌더·현황 표시 - `B03_FileInput_UI_Page_Flow.ts` 262줄 — 중단 세션 이어올리기·보관함 이관·청크 업로드· 초기 계산 중 재잠금·서비스워커. 화면 상태는 `UploadFlowContext` 창구로만 받음 - `B03_FileInput_UI_Page_Rules.ts` 107줄 — 필수 카드 판정·파일 적합성·업로드 가능 판정· 서버 파일의 카드 매핑 (상태를 가두지 않는 순수 함수) 검증: 공용 브라우저에서 새 프로젝트 만들어 노선 5종 실제 선택·업로드 — 카드 5장 선택 후 [파일 업로드] 비활성, [LAS 없이 설계] 켜면 활성(판정 규칙 정상), 업로드 후 4장 완료 표시(20초). 남은 `route_prj` 오류·400 은 지형 자료가 없어 분리 전에도 같던 것. `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Page.ts | 303 +++---------------- B03_FileInput/B03_FileInput_UI_Page_Flow.ts | 262 ++++++++++++++++ B03_FileInput/B03_FileInput_UI_Page_Rules.ts | 107 +++++++ 3 files changed, 404 insertions(+), 268 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_UI_Page_Flow.ts create mode 100644 B03_FileInput/B03_FileInput_UI_Page_Rules.ts diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 307eaaf7..281ecb26 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -3,19 +3,14 @@ import { 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 { 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 { 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 { createInputGuide } from "./B03_FileInput_UI_Guide"; import { createTempPicker } from "./B03_FileInput_UI_TempPicker"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -25,23 +20,24 @@ import { 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, + validateFileForSlot, + validateSlots, +} from "./B03_FileInput_UI_Page_Rules"; import { readCrsLabel, readExtent, renderSlotPreview, type PreviewExtent, } from "./B03_FileInput_UI_Preview"; -import { - confirmReplaceUpload, - isInitialPipelineRunning, - pollInitialPipeline, - uploadOneFile, -} from "./B03_FileInput_UI_Upload"; +import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload"; import { createFileCardTemplate, formatBytes, formatEta, - getExtension, initializeSlots, makeSessionKey, planSlotAssignments, @@ -51,7 +47,6 @@ import { TERRAIN_SLOTS, type FileSlot, type FileSlotState, - type StoredUploadSession, type UploadStatus, } from "./B03_FileInput_UI_Support"; import "./B03_FileInput_UI_Style.css"; @@ -80,12 +75,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { ? 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"); @@ -185,7 +174,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; return; } - const validation = validateSlots(); + const validation = validateSlots(slots, selectedStates(), activeProjectId, lasFreeDesign); uploadButton.disabled = validation !== null; // 「업로드할 파일을 선택하세요」는 버튼이 잠긴 것으로 이미 드러난다 — 고르기도 전에 // 붉은 경고를 띄우지 않는다(2026-09-03 사용자 지시). 나머지 사유는 그대로 알린다. @@ -198,7 +187,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const extLabel = state.extensions.join(", "); const target = card.querySelector(".b03-file__card-ext"); if (!target) return; - target.textContent = isSlotRequired(state) + target.textContent = isSlotRequired(state, slots, lasFreeDesign) ? extLabel : `${extLabel} · ${L("B03_File_Card_Optional")}`; } @@ -295,14 +284,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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) { @@ -410,48 +391,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } /** 노선 도형이 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(); - // LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다. - if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired; - 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; - } - /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. @@ -467,23 +406,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 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" - : // 옛 프로젝트의 노선은 `.csv`로 올라가 있다 — 이제 받지는 않지만(2026-09-03) - // 이미 올라간 것은 계획노선 도형 카드에 그대로 보여야 한다. - extension === ".csv" - ? "csv" - : Array.from(slots.values()).find( - (candidate) => - candidate.slot !== "route_prj" && candidate.extensions.includes(extension), - )?.slot; + const slot = slotForOverviewFile(file, slots); const state = slot ? slots.get(slot) : undefined; if (state) { state.serverUploaded = { @@ -576,184 +499,28 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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); - try { - for (let index = 0; index < targetStates.length; index += 1) { - const state = targetStates[index]; - await uploadOneFile( - activeProjectId, - state, - index === targetStates.length - 1, - () => renderSlot(state.slot), - lasFreeDesign, - ); - } - clearDerivedCaches(activeProjectId); - // 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지 - // 않는다(2026-09-03 사용자 지시). - 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"); - } - } + // 업로드 흐름(중단 세션·보관함 이관·청크 업로드·재잠금·서비스워커)은 파일이 + // 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) : []); @@ -910,7 +677,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { for (const slot of slots.keys()) renderSlot(slot); applyLasFreeState(); void relockWhileInitialPipelineRuns(); - void registerB03ServiceWorker(); + void flow.registerB03ServiceWorker(); void applyUploadOverview(); void detectPausedUploads(); if (activeProjectId) { diff --git a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts new file mode 100644 index 00000000..eeda5ac1 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts @@ -0,0 +1,262 @@ +/* ============================================================================= + * B03_FileInput_UI_Page_Flow.ts + * 파일 입력 화면의 업로드 흐름 — 중단 세션 이어올리기, 보관함 자료 옮기기, + * 청크 업로드 시작, 초기 계산 중 재잠금, 서비스워커 등록. + * + * 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * 화면이 들고 있는 상태는 `ctx` 로 받아 쓰기만 한다 — 동작·순서는 종전과 같다. + * ========================================================================== */ + +import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { navigateTo } from "../A00_Common/router"; +import { clearPreloadMark } from "../A00_Common/b_asset_cache"; +import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; +import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; +import { validateSlots } from "./B03_FileInput_UI_Page_Rules"; +import { + isInitialPipelineRunning, + pollInitialPipeline, + uploadOneFile, +} from "./B03_FileInput_UI_Upload"; +import { + makeSessionKey, + type FileSlot, + type FileSlotState, + type StoredUploadSession, +} from "./B03_FileInput_UI_Support"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 화면이 들고 있는 상태·조작을 흐름 쪽에 넘겨 주는 창구. */ +export interface UploadFlowContext { + projectId: () => string; + setProjectId: (value: string) => void; + slots: Map; + selectedStates: () => FileSlotState[]; + lasFreeDesign: () => boolean; + isUploading: () => boolean; + setUploading: (busy: boolean) => void; + renderSlot: (slot: FileSlot) => void; + showErrorMessage: (slot: FileSlot, error: string) => void; + applyUploadOverview: () => Promise; + tempPicker: { selected: () => { batch_id: string } | null; clear: () => void }; + pageError: HTMLElement; + resumeBanner: HTMLElement; + completionRoute: RoutePath; +} + +export interface UploadFlowHandle { + /** 분석 대기 — 재접속 복원 표시에서도 쓴다. */ + pollAnalysis: (projectId: string) => Promise; + detectPausedUploads: () => Promise; + relockWhileInitialPipelineRuns: () => Promise; + startChunkedUpload: (targetStates?: FileSlotState[]) => Promise; + registerB03ServiceWorker: () => Promise; +} + +/** 새 자료가 들어오면 이 프로젝트로 만들어 둔 파생 캐시를 버린다. */ +function clearDerivedCaches(projectId: string): void { + clearRouteLatestCache(projectId); + invalidateSectionDetail(projectId); + clearPreloadMark(); +} + +export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { + async function detectPausedUploads(): Promise { + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + ctx.resumeBanner.replaceChildren(); + ctx.resumeBanner.classList.remove("is-visible"); + if (!ctx.projectId()) return; + + for (const state of ctx.selectedStates()) { + const stored = localStorage.getItem(makeSessionKey(ctx.projectId(), state.file!)); + if (!stored) continue; + const session = JSON.parse(stored) as StoredUploadSession; + state.uploadSessionId = session.uploadSessionId; + state.progressBytes = session.completedChunks * session.chunkSizeBytes; + ctx.renderSlot(state.slot); + const text = document.createElement("span"); + text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`; + const resume = createButton({ + label: L("B03_File_Resume_Button"), + variant: "ghost", + onClick: () => void startChunkedUpload([state]), + }); + const fresh = createButton({ + label: L("B03_File_New_Button"), + variant: "ghost", + onClick: () => { + localStorage.removeItem(session.key); + state.uploadSessionId = undefined; + state.progressBytes = 0; + ctx.renderSlot(state.slot); + ctx.resumeBanner.replaceChildren(); + ctx.resumeBanner.classList.remove("is-visible"); + }, + }); + ctx.resumeBanner.append(text, resume, fresh); + ctx.resumeBanner.classList.add("is-visible"); + break; + } + } + + /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ + const pollAnalysis = (projectId: string): Promise => + pollInitialPipeline(projectId, (message: string) => { + ctx.pageError.textContent = message; + showToast(message, "warning"); + }); + + /** + * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. + * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 + * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. + */ + async function attachSelectedTempBatch(): Promise { + const batch = ctx.tempPicker.selected(); + if (!batch) return; + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + if (!ctx.projectId()) { + ctx.pageError.textContent = L("B03_File_Error_Project"); + return; + } + ctx.pageError.textContent = ""; + try { + const result = await attachTempBatch(ctx.projectId(), batch.batch_id); + clearDerivedCaches(ctx.projectId()); + ctx.tempPicker.clear(); + showToast(L("B03_Temp_Attach_Success"), "success"); + await ctx.applyUploadOverview(); + if (!result.analysis_started) { + showToast(L("B03_Temp_Attach_NoAnalysis"), "warning"); + return; + } + showToast(L("B03_File_Analysis_InProgress"), "info"); + const analysisComplete = await pollAnalysis(ctx.projectId()); + if (analysisComplete) { + navigateTo(ctx.completionRoute); + } else { + showToast(L("B03_File_Analysis_StillRunning"), "warning"); + } + } catch (error) { + const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); + ctx.pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`; + showToast(L("B03_Temp_Attach_Failed"), "error"); + } + } + + /** + * 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다. + * 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다. + */ + async function relockWhileInitialPipelineRuns(): Promise { + if (!ctx.projectId() || ctx.isUploading()) return; + try { + const state = await fetchWorkflowState(ctx.projectId()); + if (!isInitialPipelineRunning(state)) return; + } catch { + return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다. + } + ctx.setUploading(true); + showToast(L("B03_File_Analysis_InProgress"), "info"); + try { + const done = await pollAnalysis(ctx.projectId()); + if (done) showToast(L("B03_File_Upload_Success"), "success"); + } finally { + ctx.setUploading(false); + void ctx.applyUploadOverview(); + } + } + + async function startChunkedUpload(targetStates = ctx.selectedStates()): Promise { + if (ctx.isUploading()) return; + // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. + if (ctx.tempPicker.selected()) { + ctx.setUploading(true); + try { + await attachSelectedTempBatch(); + } finally { + ctx.setUploading(false); + } + return; + } + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + const validation = validateSlots( + ctx.slots, + ctx.selectedStates(), + ctx.projectId(), + ctx.lasFreeDesign(), + ); + if (validation) { + ctx.pageError.textContent = validation; + return; + } + + ctx.pageError.textContent = ""; + ctx.setUploading(true); + try { + for (let index = 0; index < targetStates.length; index += 1) { + const state = targetStates[index]; + await uploadOneFile( + ctx.projectId(), + state, + index === targetStates.length - 1, + () => ctx.renderSlot(state.slot), + ctx.lasFreeDesign(), + ); + } + clearDerivedCaches(ctx.projectId()); + // 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지 + // 않는다(2026-09-03 사용자 지시). + showToast(L("B03_File_Upload_Success"), "success"); + + showToast(L("B03_File_Analysis_InProgress"), "info"); + const analysisComplete = await pollAnalysis(ctx.projectId()); + + if (analysisComplete) { + navigateTo(ctx.completionRoute); + } else { + showToast(L("B03_File_Analysis_StillRunning"), "warning"); + } + } catch (error) { + const failed = targetStates.find((state) => state.uploadStatus === "uploading"); + const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); + if (failed) ctx.showErrorMessage(failed.slot, detail); + ctx.pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; + showToast(L("B03_File_Upload_Failed"), "error"); + } finally { + ctx.setUploading(false); + } + } + + async function registerB03ServiceWorker(): Promise { + if (!("serviceWorker" in navigator)) { + showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); + return; + } + try { + const registration = await navigator.serviceWorker.register( + new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url), + { type: "module" }, + ); + registration.active?.postMessage({ type: "B03_SW_PING" }); + showToast(L("B03_File_ServiceWorker_Ready"), "info"); + } catch { + showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); + } + } + + return { + pollAnalysis, + detectPausedUploads, + relockWhileInitialPipelineRuns, + startChunkedUpload, + registerB03ServiceWorker, + }; +} diff --git a/B03_FileInput/B03_FileInput_UI_Page_Rules.ts b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts new file mode 100644 index 00000000..54e0ab3a --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B03_FileInput_UI_Page_Rules.ts + * 파일 입력 화면의 판정 규칙 — 어떤 카드가 지금 필수인가, 고른 파일이 그 자리에 맞는가, + * 업로드를 시작해도 되는가, 서버 파일이 어느 카드로 가는가. + * + * 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * 화면 상태를 가두지 않고 **인자로 받는 순수 함수**만 둔다 — 판정 결과는 종전과 같다. + * ========================================================================== */ + +import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch"; +import { + getExtension, + SHAPEFILE_DEPENDENT_SLOTS, + TERRAIN_SLOTS, + type FileSlot, + type FileSlotState, +} from "./B03_FileInput_UI_Support"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export type SlotMap = Map; + +/** 노선 도형 카드에 shapefile이 들어와 있는가. */ +export function routeIsShapefile(slots: SlotMap): 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). + */ +export function isSlotRequired( + state: FileSlotState, + slots: SlotMap, + lasFreeDesign: boolean, +): boolean { + if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(slots); + // LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다. + if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired; + return state.isRequired; +} + +/** 고른 파일이 그 카드의 확장자·크기 규칙에 맞는가. 어긋나면 안내 문구를 돌려준다. */ +export 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; +} + +/** 업로드를 시작해도 되는가. 안 되면 첫 번째 사유를 돌려준다. */ +export function validateSlots( + slots: SlotMap, + selected: FileSlotState[], + activeProjectId: string, + lasFreeDesign: boolean, +): string | null { + if (!activeProjectId) return L("B03_File_Error_Project"); + 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, slots, lasFreeDesign) && !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; +} + +/** + * 서버 현황의 파일 한 건이 어느 카드로 가는가. + * + * PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 저장 경로로 + * 가린다(2026-08-31). 옛 프로젝트의 노선은 `.csv`로 올라가 있어 계획노선 도형 카드로 + * 보낸다(이제 새로 받지는 않는다, 2026-09-03). + */ +export function slotForOverviewFile( + file: UploadOverviewFile, + slots: SlotMap, +): FileSlot | undefined { + const extension = `.${file.file_type.toLowerCase()}`; + if (extension === ".prj") { + return (file.relative_path ?? "").includes("/input/shp/") ? "route_prj" : "prj"; + } + if (extension === ".csv") return "csv"; + return Array.from(slots.values()).find( + (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), + )?.slot; +}