계획서 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>
692 lines
29 KiB
TypeScript
692 lines
29 KiB
TypeScript
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),
|
||
});
|
||
}
|
||
}
|