사용자 지적(2026-09-03) — 선택 항목이 너무 크고 대중없이 놓임. 안내는 B04·B05 와 같은 공용 좌측 패널로 빼고 본문은 고르는 자리만 남김. - `B03_FileInput_UI_Guide.ts` 신설 — 필요한 파일 / 고르는 방법 / 알아 둘 것 3묶음을 공용 오버레이 `optionsContent` 에 실음. 문단 구획은 공용 `ui-sidebar-section`. - 선택 영역 160px 블록 → 한 줄 바. 긴 문구는 패널로 옮기고 힌트는 한 줄로 교체. - 카드 `min-height: 220px` 폐지, [선택]을 제목 줄 오른쪽 끝 작은 버튼으로 이동, 빈 칸은 제목 줄만 남김. 진행바·속도·예상완료는 올라가는 동안만 표시. - 여백 정리 — 컨테이너 패딩 32 → 20, 그룹 제목 24 → 16px, 격자 간격 24 → 12. - 좌측 패널이 열리면 본문을 밀어내는 규칙 추가 — B03 은 일반 레이아웃이라 공용 워크플로 레이아웃의 padding 규칙이 없어 패널이 카드를 덮었음. 검증: 카드 높이 232 → 66px(빈 칸)·117px(파일 있음), 선택 영역 160 → 47px, 문서 높이 1408px 로 한 화면 남짓. typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
876 lines
35 KiB
TypeScript
876 lines
35 KiB
TypeScript
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 { createInputGuide } from "./B03_FileInput_UI_Guide";
|
||
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,
|
||
planSlotAssignments,
|
||
ROUTE_SLOTS,
|
||
SHAPEFILE_DEPENDENT_SLOTS,
|
||
slotConfigs,
|
||
TERRAIN_SLOTS,
|
||
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 renderExtensionLabel(card: HTMLElement, state: FileSlotState): void {
|
||
const extLabel = state.extensions.join(", ");
|
||
const target = card.querySelector(".b03-file__card-ext");
|
||
if (!target) return;
|
||
target.textContent = isSlotRequired(state)
|
||
? extLabel
|
||
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
|
||
}
|
||
|
||
function renderSlot(slot: FileSlot): void {
|
||
const state = slots.get(slot);
|
||
const card = cardMap.get(slot);
|
||
if (!state || !card) return;
|
||
renderExtensionLabel(card, state);
|
||
|
||
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 state = targetSlot ? slots.get(targetSlot) : undefined;
|
||
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);
|
||
// 노선 도형이 CSV↔shapefile로 바뀌면 형제 카드의 필수 표시도 따라 바뀐다.
|
||
if (state.slot === "csv") renderRouteDependentSlots();
|
||
}
|
||
|
||
function renderRouteDependentSlots(): void {
|
||
for (const slot of SHAPEFILE_DEPENDENT_SLOTS) renderSlot(slot);
|
||
updateUploadButton();
|
||
}
|
||
|
||
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).
|
||
// 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이
|
||
// 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시).
|
||
const routeFile = slots.get("csv")?.file?.name;
|
||
const assignments = planSlotAssignments(
|
||
files,
|
||
slotConfigs(),
|
||
routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined,
|
||
);
|
||
const occupied = new Set(selectedStates().map((state) => state.slot));
|
||
for (const item of assignments) {
|
||
const slot = targetSlot ?? item.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 item of assignments) {
|
||
const slot = targetSlot ?? item.slot;
|
||
if (!slot) {
|
||
pageError.textContent = `${L("B03_File_Error_Extension")} ${item.file.name}`;
|
||
continue;
|
||
}
|
||
await assignFileToSlot(item.file, slot);
|
||
}
|
||
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);
|
||
if (slot === "csv") renderRouteDependentSlots();
|
||
}
|
||
|
||
/** 노선 도형이 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();
|
||
if (state.slot === "las_laz") return !lasFreeDesign;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크
|
||
* 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다.
|
||
* 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()}`;
|
||
// PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로
|
||
// 저장 경로로 가린다(2026-08-31).
|
||
const inRouteSet = (file.relative_path ?? "").includes("/input/shp/");
|
||
const slot: FileSlot | undefined =
|
||
extension === ".prj"
|
||
? inRouteSet
|
||
? "route_prj"
|
||
: "prj"
|
||
: Array.from(slots.values()).find(
|
||
(candidate) =>
|
||
candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||
)?.slot;
|
||
const state = slot ? slots.get(slot) : undefined;
|
||
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);
|
||
renderExtensionLabel(card, state);
|
||
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[],
|
||
modifier?: string,
|
||
hint?: string,
|
||
): HTMLElement {
|
||
const group = document.createElement("section");
|
||
group.className = "b03-file__group";
|
||
if (modifier) group.classList.add(modifier);
|
||
if (title) {
|
||
const groupTitle = document.createElement("h3");
|
||
groupTitle.className = "b03-file__group-title";
|
||
groupTitle.textContent = title;
|
||
group.append(groupTitle);
|
||
}
|
||
if (hint) {
|
||
const groupHint = document.createElement("p");
|
||
groupHint.className = "b03-file__group-hint";
|
||
groupHint.textContent = hint;
|
||
group.append(groupHint);
|
||
}
|
||
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,
|
||
);
|
||
|
||
// 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터).
|
||
// 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지
|
||
// 화면만 보고 안다(2026-08-31 사용자 지시).
|
||
const routeGroup = createCardGroup(
|
||
L("B03_File_Group_Route"),
|
||
ROUTE_SLOTS,
|
||
"b03-file__group--route",
|
||
L("B03_File_Group_Route_Hint"),
|
||
);
|
||
const terrainGroup = createCardGroup(
|
||
L("B03_File_Group_Terrain"),
|
||
TERRAIN_SLOTS,
|
||
"b03-file__group--terrain",
|
||
L("B03_File_Group_Terrain_Hint"),
|
||
);
|
||
|
||
// 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 = "";
|
||
// 이 토글이 LAS 카드의 필수 여부를 바꾼다 — 버튼 판정을 다시 돌리지 않으면 파일을 다
|
||
// 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고
|
||
// 토글을 나중에 켠 순서에서 재현).
|
||
updateUploadButton();
|
||
});
|
||
|
||
// LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다.
|
||
terrainGroup.append(lasFreeRow);
|
||
|
||
const routePanel = document.createElement("div");
|
||
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||
routePanel.append(routeGroup);
|
||
|
||
const terrainPanel = document.createElement("div");
|
||
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||
terrainPanel.append(terrainGroup);
|
||
|
||
const cardsContainer = document.createElement("div");
|
||
cardsContainer.className = "b03-file__columns";
|
||
cardsContainer.append(routePanel, terrainPanel);
|
||
|
||
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");
|
||
// 고르는 방법·필요한 파일 안내는 좌측 패널로 뺐다 — 본문은 고르는 자리만 남긴다
|
||
// (2026-09-03 사용자 지시). 자리·여닫기는 B04·B05 좌측 패널과 같은 공용 오버레이다.
|
||
const overlays = createWorkflowOverlays({
|
||
title: L("B03_File_Title"),
|
||
progressContent,
|
||
optionsContent: createInputGuide(),
|
||
// 공용 워크플로 레이아웃(B04·B05)이 하는 일을 여기서도 한다 — 패널이 열리면 본문을
|
||
// 그만큼 밀어내지 않으면 안내가 카드 위를 덮는다.
|
||
onOptionsOpenChange: (isOpen) => layout.root.classList.toggle("is-options-open", isOpen),
|
||
});
|
||
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),
|
||
});
|
||
}
|
||
}
|