Files
Aislo/B03_FileInput/B03_FileInput_UI_Page.ts
T
eomsangdon 3ea6aa5b13 fix(B03): LAS 없이 설계를 켜면 LAS/LAZ를 아예 받지 않는다
사용자 보고(2026-08-30): 체크한 상태에서 실수로 넣은 LAS가 그대로 업로드됐다.
카드에 --disabled 클래스만 걸려 있어 회색으로 보일 뿐 선택 버튼과 input이
살아 있었고, 서버도 las_free일 때 LAS 1개를 정상 경로로 받아 줬다.

- 화면: 선택 버튼·file input을 실제로 잠그고, 켤 때 이미 고른 LAS는 내린다.
  파일 선택 영역·드롭으로 들어오는 것은 onFileSelected에서 걸러 낸다.
- 서버(신뢰 경계): /files는 las_free면 LAS 0개만 받는다. 큰 LAS가 타는
  청크 업로드 세션 생성도 같은 검사를 건다 — 여기서 막지 않으면 /files 검사를
  통째로 비켜 간다.
2026-08-30 18:11:19 +09:00

871 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
// 업로드가 끝나면 역할과 무관하게 종단설계로 보낸다 — 전처리(B04)는 자동 확정까지
// 끝난 관리자 전용 점검 화면이라 일반 진행 경로가 아니다(2026-08-08 사용자 지시).
const completionRoute = ROUTES.B05_PROFILE;
const slots = initializeSlots();
// 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다.
const tempPicker = createTempPicker(() => updateUploadButton());
const cardMap = new Map<FileSlot, HTMLElement>();
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<HTMLDivElement>(
".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<HTMLSpanElement>(".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<HTMLSpanElement>(
".b03-file__file-name",
);
const fileSize = card.querySelector<HTMLSpanElement>(
".b03-file__file-size",
);
const progress = card.querySelector<HTMLDivElement>(
".b03-file__progress-bar",
);
const progressBytes = card.querySelector<HTMLSpanElement>(
".b03-file__progress-bytes",
);
const progressSpeed = card.querySelector<HTMLSpanElement>(
".b03-file__progress-speed",
);
const progressEta = card.querySelector<HTMLSpanElement>(
".b03-file__progress-eta",
);
const error = card.querySelector<HTMLDivElement>(
".b03-file__error-message",
);
const remove = card.querySelector<HTMLButtonElement>(
".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<void> {
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(
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).
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 = blocked ? L("B03_File_Error_LasFreeBlocked") : "";
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 &&
// LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다.
!(lasFreeDesign && state.slot === "las_laz"),
);
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<void> {
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<HTMLElement>(".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<HTMLInputElement>(
".b03-file__slot-input",
)!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(
".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<HTMLButtonElement>(
".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<void> {
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<boolean> =>
pollInitialPipeline(projectId, (message) => {
pageError.textContent = message;
showToast(message, "warning");
});
/**
* 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다.
* 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지
* 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다.
*/
async function attachSelectedTempBatch(): Promise<void> {
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<void> {
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<void> {
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<void> {
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",
]);
// 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<HTMLButtonElement | HTMLInputElement>(
".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 = "";
});
const cardsContainer = document.createElement("div");
cardsContainer.className =
"b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(lasFreeRow, 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);
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),
});
}
}