10. 재접속 현황·완료 표시·재업로드 경고:
- GET /projects/{id}/upload-overview 신설 — 완료 파일 목록(input_files 정본),
중단 청크 세션(진행률), 필수 파일·WF1 분석 완료 여부
- 진입 시 서버 정본으로 슬롯 카드 표시(serverUploaded), localStorage는 보조로 강등
- 파일 재선택 전에도 중단 세션 이어올리기 안내 배너
- 전체 완료 배지 + 완료 슬롯 재업로드 시 교체 확인 모달(승인 시에만 진행)
- 필수 슬롯 검증: 서버 업로드분 있으면 충족 — 단일 파일 교체 업로드 허용
9. 자동 설계 체인 연장 (B03_FileInput_Service_Chain.py):
- WF1 자동 확정 후 같은 백그라운드 태스크에서 ① 계획노선 CSV 기반 B05 기본 경로
계산(solve) ② 경로 확정(stage 2) ③ B06 기본 횡단 설계 확정(stage 3)까지 진행
- 수동 이력 보호: 프로젝트에 경로가 이미 있으면 건너뜀
- 단계별 실패 격리: 실패 단계에서 멈추고 로그·workflow 상태로만 기록
- AUTO_DESIGN_CHAIN_ENABLED config 플래그(기본 True)
typecheck·ruff·B03 unittest(7건) 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
724 lines
27 KiB
TypeScript
724 lines
27 KiB
TypeScript
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 { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||
import {
|
||
checkWF1AnalysisStatus,
|
||
createUploadSession,
|
||
fetchUploadOverview,
|
||
finalizeUploadSession,
|
||
uploadFileChunk,
|
||
type UploadedFileResult,
|
||
} from "./B03_FileInput_Api_Fetch";
|
||
import { navigateTo } from "../A00_Common/router";
|
||
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> {
|
||
const dashboardUser = await fetchDashboardMe();
|
||
const completionRoute =
|
||
dashboardUser.role === "SYSTEM_ADMIN" ? ROUTES.B04_WF1_SURFACE : ROUTES.B05_WF2_ROUTE;
|
||
const slots = initializeSlots();
|
||
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 {
|
||
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;
|
||
if (selectedStates().length + files.length > UPLOAD_MAX_FILES) {
|
||
pageError.textContent = L("B03_File_Error_Count");
|
||
return;
|
||
}
|
||
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 startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||
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,
|
||
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),
|
||
});
|
||
}
|
||
}
|