refactor(B03): 파일 입력 화면 700줄 초과 분리 — 판정 규칙·업로드 흐름

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 11:19:04 +09:00
co-authored by Claude Opus 5
parent 41cfab22e9
commit f541ab4480
3 changed files with 404 additions and 268 deletions
@@ -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<FileSlot, FileSlotState>;
/** 노선 도형 카드에 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;
}