Files
Aislo/B03_FileInput/B03_FileInput_UI_Page.ts
T
eomsangdonandClaude Fable 5 17834d8189 feat(B01,B03): 프로젝트 생성 전 임시 보관함 (temp upload)
라이다 원본은 업로드에 오래 걸려 프로젝트 정보 확정 전에 미리 올릴 수 있어야 한다.
계정에 묶인 임시 보관함을 만들고, 나중에 만든 프로젝트로 자료를 옮겨 쓴다.

저장·DB
- storage/tmp/{user_id}/{batch_id}/ 아래에 프로젝트 저장소와 동일한 구조를 써서
  청크 저장·병합 엔진(resolve_upload_destination/merge_upload_chunks)을 그대로 재사용
- 010_temp_upload.sql: temp_upload_batches / temp_upload_files 신설,
  upload_sessions.project_id NULL 허용 + temp_batch_id 추가(FK명 조회 후 재생성)
- config: TEMP_UPLOAD_DIR_NAME / TEMP_UPLOAD_RETENTION_DAYS(30) /
  TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS(6)

백엔드
- B03_FileInput_Router_Temp.py: 묶음 생성·목록·삭제, 일반/청크 업로드, finalize,
  이어올리기 상태 조회, 프로젝트 연결(attach)
- attach: 파일 이동 후 input_files 등록, stage 0 완료, WF1·자동 설계 체인 트리거
- common_util_temp_cleanup.py: 완료 시각 기준 만료분 주기 삭제(서버 시작 시 1회 포함)

프론트엔드
- B01 대시보드 임시 보관함 섹션: 프로젝트 등록과 같은 폼 + 보관 목록.
  진행률은 모달이 아니라 리스트 행에 표시, 새로고침 후 이어올리기 지원
- B03 업로드 컨테이너 내부 불러오기 버튼과 선택 모달.
  완료된 묶음만 노출하고, 선택 후 업로드를 누르면 이동과 분석으로 이어짐

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:55:14 +09:00

781 lines
30 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,
PROGRESS_UPDATE_INTERVAL_MS,
ROUTES,
UPLOAD_ALLOWED_EXT,
UPLOAD_CHUNK_SIZE_MB,
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 {
checkWF1AnalysisStatus,
createUploadSession,
fetchUploadOverview,
finalizeUploadSession,
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { navigateTo } from "../A00_Common/router";
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
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,
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
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) ?? "";
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");
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 updateUploadButton(): void {
// 보관함 자료를 지정했으면 슬롯 검사와 무관하게 업로드(=이동)를 열어 준다.
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 clearErrorMessage(slot: FileSlot): void {
const state = slots.get(slot);
if (!state) return;
state.error = undefined;
if (state.uploadStatus === "failed") state.uploadStatus = "pending";
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(files: readonly File[], targetSlot?: FileSlot): void {
if (files.length === 0) return;
// 이미 고른 슬롯을 다시 고르는 것은 "교체"라 총 개수가 늘지 않는다 — 그 슬롯을 빼고 센다.
// (빼지 않으면 5개를 다 고른 뒤 하나만 바꾸려 할 때 개수 초과로 막힌다.)
const occupied = selectedStates().filter((state) => state.slot !== targetSlot).length;
if (occupied + files.length > UPLOAD_MAX_FILES) {
pageError.textContent = L("B03_File_Error_Count");
return;
}
pageError.textContent = "";
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,
);
if (missingRequired) return L("B03_File_Error_RequiredSlots");
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;
}
/**
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div");
modal.className = "b03-file__modal";
modal.setAttribute("role", "alertdialog");
modal.setAttribute("aria-modal", "true");
const title = document.createElement("strong");
title.textContent = L("B03_File_Replace_Title");
const message = document.createElement("p");
message.textContent = `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`;
const actions = document.createElement("div");
actions.className = "b03-file__modal-actions";
const done = (accepted: boolean): void => {
backdrop.remove();
resolve(accepted);
};
const cancel = createButton({
label: L("B03_File_Replace_Cancel"),
variant: "ghost",
onClick: () => done(false),
});
const accept = createButton({
label: L("B03_File_Replace_Confirm"),
variant: "filled",
onClick: () => done(true),
});
actions.append(cancel, accept);
modal.append(title, message, actions);
backdrop.append(modal);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) done(false);
});
document.body.append(backdrop);
accept.focus();
});
}
/**
* 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크
* 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다.
* 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);
card.querySelector(".b03-file__card-ext")!.textContent = state.extensions.join(", ");
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;
}
function renderUploadResults(results: readonly UploadedFileResult[]): void {
resultList.replaceChildren();
for (const result of results) {
const item = document.createElement("li");
const filename = document.createElement("strong");
filename.textContent = result.original_filename;
const path = document.createElement("span");
path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`;
item.append(filename, path);
resultList.append(item);
}
}
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;
}
}
async function uploadOneFile(
projectId: string,
state: FileSlotState,
completeUpload: boolean,
): Promise<UploadedFileResult[]> {
const file = state.file;
if (!file) return [];
clearErrorMessage(state.slot);
state.uploadStatus = "uploading";
renderSlot(state.slot);
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
const session =
state.uploadSessionId ??
(await createUploadSession(projectId, file, chunkSizeBytes)).upload_session_id;
state.uploadSessionId = session;
const totalChunks = Math.max(1, Math.ceil(file.size / chunkSizeBytes));
const storageKey = makeSessionKey(projectId, file);
const startedAt = performance.now();
let lastPaintAt = 0;
for (
let chunkIndex = Math.floor(state.progressBytes / chunkSizeBytes);
chunkIndex < totalChunks;
chunkIndex += 1
) {
const start = chunkIndex * chunkSizeBytes;
const end = Math.min(file.size, start + chunkSizeBytes);
const chunkStartedAt = performance.now();
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
state.progressBytes = end;
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
state.etaSeconds =
state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
const stored: StoredUploadSession = {
key: storageKey,
projectId,
slot: state.slot,
fileName: file.name,
fileSize: file.size,
uploadSessionId: session,
chunkSizeBytes,
totalChunks,
completedChunks: chunkIndex + 1,
updatedAt: Date.now(),
};
localStorage.setItem(storageKey, JSON.stringify(stored));
const now = performance.now();
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
lastPaintAt = now;
renderSlot(state.slot);
}
}
const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
slot: state.slot,
fileName: file.name,
fileSize: file.size,
});
state.progressBytes = file.size;
state.speedMbs =
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
state.etaSeconds = 0;
state.uploadStatus = "completed";
renderSlot(state.slot);
return response.files;
}
async function pollWF1Analysis(projectId: string, maxAttempts = 360): Promise<boolean> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const status = await checkWF1AnalysisStatus(projectId);
updateB03AnalysisState(projectId, status.status, status.progress_percent);
if (status.status === "completed") {
return true;
}
if (
status.current_stage === "awaiting_confirmation" &&
status.message.includes("자동 확정 보류")
) {
pageError.textContent = status.message;
showToast(status.message, "warning");
return false;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return false;
}
/**
* 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다.
* 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지
* 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다.
*/
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);
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 pollWF1Analysis(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 startChunkedUpload(targetStates = selectedStates()): Promise<void> {
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
if (tempPicker.selected()) {
await attachSelectedTempBatch();
return;
}
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const validation = validateSlots();
if (validation) {
pageError.textContent = validation;
return;
}
pageError.textContent = "";
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)),
);
}
renderUploadResults(uploaded);
showToast(L("B03_File_Upload_Success"), "success");
showToast(L("B03_File_Analysis_InProgress"), "info");
const analysisComplete = await pollWF1Analysis(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");
}
}
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,
);
const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]);
routeGroup.classList.add("b03-file__group--route");
const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]);
const cardsContainer = document.createElement("div");
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(routeGroup, filesGroup);
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]);
}
},
});
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);
root.replaceChildren(layout.root);
for (const slot of slots.keys()) renderSlot(slot);
void registerB03ServiceWorker();
void applyUploadOverview();
void detectPausedUploads();
if (activeProjectId) {
restoreB03ProjectState({
projectId: activeProjectId,
label: L("B03_File_Restore_State"),
container: resumeBanner,
poll: pollWF1Analysis,
onComplete: () => navigateTo(completionRoute),
});
}
}