Files
Aislo/B03_FileInput/B03_FileInput_UI_Page.ts
T
eomsangdonandClaude Opus 5 7c3a65e814 feat(B03): 선택 영역 설명 이관 · 임시 보관함 이름·테두리 정리 · 계획노선은 shapefile만
사용자 지시 4건(2026-09-03).

- 선택 영역의 설명 문구를 좌측 안내 패널로 옮기고 본문에는 「입력 파일 선택」만 남김.
- [임시 보관함에서 불러오기] → [임시 보관함]. 버튼을 감싸던 점선 테두리 삭제 —
  버튼 자신의 테두리와 이중이었음.
- 계획노선 도형 카드는 `.shp` 만 받음. CSV 는 내부 계산이 만드는 파일이라 사용자가 넣는
  자료가 아님(사용자 확인). 컨테이너의 CSV 안내 문구도 삭제.
- 이미 `.csv` 로 올라간 옛 프로젝트가 「미등록」으로 보이지 않도록 **서버 파일 매핑에만**
  `.csv → 계획노선 도형` 예외를 둠. 새로 받는 확장자와 이미 받은 것을 갈라 둔다.

좌측 안내 문구도 함께 갱신 — CSV 언급 제거, 버튼 새 이름 반영.

검증: 실측 — 선택 영역 텍스트 「입력 파일 선택」, 보관함 테두리 none·라벨 「임시 보관함」,
계획노선 안내 문구 없음, 카드 확장자 `.shp`, 옛 CSV 는 「완료 · 용화_계획노선.csv」 유지.
typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:02:19 +09:00

883 lines
36 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 } 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,
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>();
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");
// 설명은 좌측 안내 패널 몫이다(2026-09-03 사용자 지시) — 여기는 자리 이름만 남긴다.
dropzone.append(dropzoneLabel, 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;
// 「업로드할 파일을 선택하세요」는 버튼이 잠긴 것으로 이미 드러난다 — 고르기도 전에
// 붉은 경고를 띄우지 않는다(2026-09-03 사용자 지시). 나머지 사유는 그대로 알린다.
pageError.textContent =
validation === null || validation === L("B03_File_Error_Required") ? "" : 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;
// 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다.
// 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른
// 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08).
// 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이
// 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시).
const routeFile = slots.get("csv")?.file?.name;
const planned = planSlotAssignments(
selection,
slotConfigs(),
routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined,
);
// LAS 없이 설계를 켜면 **지형 자료를 통째로** 받지 않는다(2026-09-03 사용자 지시 —
// 종전에는 포인트클라우드만 걸렀다). 확장자가 아니라 **배정된 카드**로 거른다: `.prj`는
// 노선·지형이 같은 확장자라 확장자로 거르면 노선 좌표계까지 함께 떨어진다.
const assignments = lasFreeDesign
? planned.filter((item) => {
const slot = targetSlot ?? item.slot;
return slot === undefined || !TERRAIN_SLOTS.includes(slot);
})
: planned;
const blocked = assignments.length !== planned.length;
if (assignments.length === 0) {
pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : "";
return;
}
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();
// LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다.
if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired;
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"
: // 옛 프로젝트의 노선은 `.csv`로 올라가 있다 — 이제 받지는 않지만(2026-09-03)
// 이미 올라간 것은 계획노선 도형 카드에 그대로 보여야 한다.
extension === ".csv"
? "csv"
: 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);
try {
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
await uploadOneFile(
activeProjectId,
state,
index === targetStates.length - 1,
() => renderSlot(state.slot),
lasFreeDesign,
);
}
clearDerivedCaches(activeProjectId);
// 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지
// 않는다(2026-09-03 사용자 지시).
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,
});
// 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함에서 불러오기] · [파일 업로드]
// (2026-09-03 사용자 지시). 셋이 한 동작의 앞뒤라 흩어 놓을 이유가 없다.
const pickRow = document.createElement("div");
pickRow.className = "b03-file__pick-row";
pickRow.append(dropzone, tempPicker.root, uploadButton);
const uploadControlPanel = document.createElement("div");
uploadControlPanel.className = "b03-file__control-panel";
uploadControlPanel.append(subtitle, pickRow, resumeBanner, pageError);
// 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터).
// 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지
// 화면만 보고 안다(2026-08-31 사용자 지시).
// 안내 문구 없음 — CSV 관련 설명이 붙어 있었으나 CSV는 사용자가 넣는 자료가 아니다
// (2026-09-03 사용자 지시: 내부 계산 파일).
const routeGroup = createCardGroup(
L("B03_File_Group_Route"),
ROUTE_SLOTS,
"b03-file__group--route",
);
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;
terrainGroup.classList.toggle("b03-file__group--disabled", lasFreeDesign);
for (const slot of TERRAIN_SLOTS) {
const card = cardMap.get(slot);
card?.classList.toggle("b03-file__card--disabled", lasFreeDesign);
// 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다.
card
?.querySelectorAll<HTMLButtonElement | HTMLInputElement>(
".b03-file__card-select, .b03-file__card-remove, .b03-file__slot-input",
)
.forEach((element) => {
element.disabled = lasFreeDesign;
});
// 켜기 전에 골라 둔 로컬 파일은 내린다 — 켠 채로 남아 올라가는 사고를 막는다.
// 이미 서버에 올라간 자료(재입력)는 그대로 두고 조작만 잠근다(2026-09-03 사용자 지시).
if (lasFreeDesign && slots.get(slot)?.file) removeFile(slot);
}
}
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 토글은 **파일 입력 컨테이너**의 것이다(2026-09-03 사용자 지시) — 무엇을 받을지
// 정하는 스위치라 고르는 자리 옆에 선다. 켜면 지형 컨테이너가 통째로 잠긴다.
uploadControlPanel.insertBefore(lasFreeRow, resumeBanner);
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(overviewBanner),
// 공용 워크플로 레이아웃(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),
});
}
}