Files
Aislo/B03_FileInput/B03_FileInput_UI_Page.ts
T
eomsangdonandClaude Opus 5 018ebe7f40 feat(B03): 업로드 잠금에 이름을 붙임 — 지금 누가 올리는 중인지 화면에 뜸
계획서 0-8 의 남은 하나. 서버는 분석이 도는 동안 새 자료를 이미 막고 있었고
(`is_analysis_running`), 없던 것은 **막힌 까닭을 사람에게 보이는 것**이었음.
여럿이 한 프로젝트를 보면 「왜 안 올라가지」가 됨.

- 분석을 띄우는 **경로 넷 모두** 시작한 사람을 `params.started_by` 로 담음.
- `analysis_lock_owner` 가 그 id 로 이름을 붙여 냄 — 옛 자료는 id 가 없어
  **이름 없이 잠금만** 돎(막는 것이 먼저).
- `/upload-overview` 응답에 `analysis_lock` 을 실어 B03 화면이 띠로 보임.

화면 실측 — 「엄상돈 님이 올린 자료를 분석하는 중입니다」 띠가 경고색으로 뜸(응답을
잠깐 가로채 확인하고 곧바로 되돌림). 지금 도는 분석이 없어 실제 잠금 화면은 다음 업로드 때 볼 것.
시험 넷 추가(도는 중 아님·이름 붙음·옛 자료·경로 넷 대조), 전체 1211 통과·실패 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 22:43:28 +09:00

692 lines
29 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,
} from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createTag } 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 { navigateTo } from "../A00_Common/router";
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 { createUploadFlow } from "./B03_FileInput_UI_Page_Flow";
import {
isSlotRequired,
slotForOverviewFile,
terrainCoverage,
validateFileForSlot,
validateSlots,
} from "./B03_FileInput_UI_Page_Rules";
import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview";
import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload";
import {
createFileCardTemplate,
formatBytes,
formatEta,
initializeSlots,
makeSessionKey,
planSlotAssignments,
pushExtraFile,
ROUTE_SLOTS,
SHAPEFILE_DEPENDENT_SLOTS,
slotConfigs,
slotFileLabel,
TERRAIN_SLOTS,
type FileSlot,
type FileSlotState,
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;
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(slots, selectedStates(), activeProjectId, lasFreeDesign);
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, slots, lasFreeDesign)
? 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 = slotFileLabel(state);
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);
const preview = card.querySelector<HTMLDivElement>(".b03-file__preview");
if (preview) {
const terrain = terrainCoverage(slots);
renderSlotPreview(preview, {
metadata: state.serverUploaded?.metadata,
// 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시).
completed: Boolean(state.serverUploaded),
terrainExtent: ROUTE_SLOTS.includes(slot) ? terrain.extent : null,
terrainCrs: terrain.crs,
crsFallback: readCrsLabel(slots.get("route_prj")?.serverUploaded?.metadata),
});
}
updateUploadButton();
}
/**
* 지형 자료(라이다·GeoTIFF)가 덮는 범위를 하나로 합친다 — 계획노선이 그 안에
* 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라
* 카드에서 바로 보이게 한다(2026-09-04).
*/
function showErrorMessage(slot: FileSlot, error: string): void {
const state = slots.get(slot);
if (!state) return;
state.error = error;
state.uploadStatus = "failed";
renderSlot(slot);
}
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 (state.file && state.file.name !== file.name) {
if (!pushExtraFile(state, file)) {
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
return;
}
renderSlot(state.slot);
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.extraFiles = 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인가 — 로컬 선택과 서버 정본을 함께 본다. */
/**
* 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크
* 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다.
* 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 slot = slotForOverviewFile(file, slots);
const state = slot ? slots.get(slot) : undefined;
if (state) {
state.serverUploaded = {
name: file.original_filename,
sizeMb: file.file_size_mb,
metadata: file.metadata ?? undefined,
};
}
}
for (const slot of slots.keys()) renderSlot(slot);
const notes: HTMLElement[] = [];
// 잠금 표시 — 서버가 「분석 중이라 못 받는다」로 막는 자리를 **화면이 먼저 알린다**.
// 여럿이 한 프로젝트를 볼 때 「왜 안 올라가지」가 아니라 「누가 올리는 중이구나」가
// 되게 이름을 함께 적는다(2026-09-09, 계획서 0-8).
const lock = overview.analysis_lock;
if (lock?.running) {
const who = lock.name ?? lock.email ?? null;
const busy = document.createElement("p");
busy.className = "b03-file__overview-lock";
busy.textContent = who
? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.`
: "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.";
notes.push(busy);
}
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;
}
// 업로드 흐름(중단 세션·보관함 이관·청크 업로드·재잠금·서비스워커)은 파일이
// 700줄을 넘어 떼어냈다(2026-09-04). 화면 상태는 아래 창구로만 넘긴다.
const flow = createUploadFlow({
projectId: () => activeProjectId,
setProjectId: (value) => {
activeProjectId = value;
},
slots,
selectedStates,
lasFreeDesign: () => lasFreeDesign,
isUploading: () => isUploading,
setUploading,
renderSlot,
showErrorMessage,
applyUploadOverview,
tempPicker,
pageError,
resumeBanner,
completionRoute,
});
const { detectPausedUploads, pollAnalysis, relockWhileInitialPipelineRuns, startChunkedUpload } =
flow;
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 flow.registerB03ServiceWorker();
void applyUploadOverview();
void detectPausedUploads();
if (activeProjectId) {
restoreB03ProjectState({
projectId: activeProjectId,
label: L("B03_File_Restore_State"),
container: resumeBanner,
poll: pollAnalysis,
onComplete: () => navigateTo(completionRoute),
});
}
}