feat(B03): 재접속 업로드 현황(10) + B05·B06 자동 설계 체인(9)
10. 재접속 현황·완료 표시·재업로드 경고:
- GET /projects/{id}/upload-overview 신설 — 완료 파일 목록(input_files 정본),
중단 청크 세션(진행률), 필수 파일·WF1 분석 완료 여부
- 진입 시 서버 정본으로 슬롯 카드 표시(serverUploaded), localStorage는 보조로 강등
- 파일 재선택 전에도 중단 세션 이어올리기 안내 배너
- 전체 완료 배지 + 완료 슬롯 재업로드 시 교체 확인 모달(승인 시에만 진행)
- 필수 슬롯 검증: 서버 업로드분 있으면 충족 — 단일 파일 교체 업로드 허용
9. 자동 설계 체인 연장 (B03_FileInput_Service_Chain.py):
- WF1 자동 확정 후 같은 백그라운드 태스크에서 ① 계획노선 CSV 기반 B05 기본 경로
계산(solve) ② 경로 확정(stage 2) ③ B06 기본 횡단 설계 확정(stage 3)까지 진행
- 수동 이력 보호: 프로젝트에 경로가 이미 있으면 건너뜀
- 단계별 실패 격리: 실패 단계에서 멈추고 로그·workflow 상태로만 기록
- AUTO_DESIGN_CHAIN_ENABLED config 플래그(기본 True)
typecheck·ruff·B03 unittest(7건) 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import {
|
||||
checkWF1AnalysisStatus,
|
||||
createUploadSession,
|
||||
fetchUploadOverview,
|
||||
finalizeUploadSession,
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
@@ -88,6 +89,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
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[] {
|
||||
@@ -146,8 +151,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
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 ?? "";
|
||||
if (fileSize) fileSize.textContent = state.file ? formatBytes(state.file.size) : "";
|
||||
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
|
||||
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(
|
||||
@@ -166,7 +178,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (remove) remove.hidden = !state.file;
|
||||
|
||||
if (state.error) setCardState(slot, "failed");
|
||||
else if (!state.file) setCardState(slot, "empty");
|
||||
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
|
||||
updateUploadButton();
|
||||
}
|
||||
@@ -195,7 +207,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return null;
|
||||
}
|
||||
|
||||
function assignFileToSlot(file: File, targetSlot?: FileSlot): void {
|
||||
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
|
||||
const extension = getExtension(file.name);
|
||||
const state = targetSlot
|
||||
? slots.get(targetSlot)
|
||||
@@ -213,6 +225,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
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;
|
||||
@@ -230,8 +248,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
pageError.textContent = L("B03_File_Error_Count");
|
||||
return;
|
||||
}
|
||||
for (const file of files) assignFileToSlot(file, targetSlot);
|
||||
void detectPausedUploads();
|
||||
void (async () => {
|
||||
for (const file of files) await assignFileToSlot(file, targetSlot);
|
||||
await detectPausedUploads();
|
||||
})();
|
||||
}
|
||||
|
||||
function removeFile(slot: FileSlot): void {
|
||||
@@ -255,12 +275,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const selected = selectedStates();
|
||||
if (selected.length === 0) return L("B03_File_Error_Required");
|
||||
if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
|
||||
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) => state.isRequired && !state.file,
|
||||
(state) => state.isRequired && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file) return L("B03_File_Error_Las");
|
||||
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);
|
||||
@@ -269,6 +291,98 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
|
||||
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
|
||||
*/
|
||||
function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "b03-file__modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b03-file__modal";
|
||||
modal.setAttribute("role", "alertdialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = L("B03_File_Replace_Title");
|
||||
const message = document.createElement("p");
|
||||
message.textContent = `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b03-file__modal-actions";
|
||||
const done = (accepted: boolean): void => {
|
||||
backdrop.remove();
|
||||
resolve(accepted);
|
||||
};
|
||||
const cancel = createButton({
|
||||
label: L("B03_File_Replace_Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => done(false),
|
||||
});
|
||||
const accept = createButton({
|
||||
label: L("B03_File_Replace_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: () => done(true),
|
||||
});
|
||||
actions.append(cancel, accept);
|
||||
modal.append(title, message, actions);
|
||||
backdrop.append(modal);
|
||||
backdrop.addEventListener("click", (event) => {
|
||||
if (event.target === backdrop) done(false);
|
||||
});
|
||||
document.body.append(backdrop);
|
||||
accept.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크
|
||||
* 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다.
|
||||
* localStorage 기반 표시(restoreB03ProjectState)는 보조로 유지된다.
|
||||
*/
|
||||
async function applyUploadOverview(): Promise<void> {
|
||||
if (!activeProjectId) return;
|
||||
overviewBanner.replaceChildren();
|
||||
overviewBanner.classList.remove("is-visible");
|
||||
try {
|
||||
const overview = await fetchUploadOverview(activeProjectId);
|
||||
// 확장자 → 슬롯 매핑으로 서버 파일을 카드에 얹는다(같은 슬롯이면 최신 것 우선 — 목록이
|
||||
// id 오름차순이므로 마지막 것이 남는다).
|
||||
for (const state of slots.values()) state.serverUploaded = undefined;
|
||||
for (const file of overview.files) {
|
||||
const extension = `.${file.file_type.toLowerCase()}`;
|
||||
const state = Array.from(slots.values()).find((candidate) =>
|
||||
candidate.extensions.includes(extension),
|
||||
);
|
||||
if (state) {
|
||||
state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb };
|
||||
}
|
||||
}
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
|
||||
const notes: HTMLElement[] = [];
|
||||
if (overview.required_complete && overview.analysis_complete) {
|
||||
const complete = document.createElement("p");
|
||||
complete.className = "b03-file__overview-complete";
|
||||
complete.textContent = L("B03_File_Overview_Complete");
|
||||
notes.push(complete);
|
||||
}
|
||||
for (const session of overview.pending_sessions) {
|
||||
const pending = document.createElement("p");
|
||||
pending.className = "b03-file__overview-pending";
|
||||
pending.textContent =
|
||||
`${session.original_filename} — ${session.progress_percent}% ` +
|
||||
L("B03_File_Overview_Pending");
|
||||
notes.push(pending);
|
||||
}
|
||||
if (notes.length) {
|
||||
overviewBanner.append(...notes);
|
||||
overviewBanner.classList.add("is-visible");
|
||||
}
|
||||
} catch {
|
||||
// 현황 조회 실패는 업로드 자체를 막지 않는다 — localStorage 보조 표시로만 동작.
|
||||
}
|
||||
}
|
||||
|
||||
function createFileCard(state: FileSlotState): HTMLElement {
|
||||
const fragment = template.content.cloneNode(true) as DocumentFragment;
|
||||
const card = fragment.querySelector<HTMLElement>(".b03-file__card");
|
||||
@@ -546,7 +660,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
|
||||
const uploadControlPanel = document.createElement("div");
|
||||
uploadControlPanel.className = "b03-file__control-panel";
|
||||
uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList);
|
||||
uploadControlPanel.append(
|
||||
subtitle,
|
||||
overviewBanner,
|
||||
dropzone,
|
||||
resumeBanner,
|
||||
pageError,
|
||||
uploadButton,
|
||||
resultList,
|
||||
);
|
||||
|
||||
const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]);
|
||||
routeGroup.classList.add("b03-file__group--route");
|
||||
@@ -587,6 +709,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
void registerB03ServiceWorker();
|
||||
void applyUploadOverview();
|
||||
void detectPausedUploads();
|
||||
if (activeProjectId) {
|
||||
restoreB03ProjectState({
|
||||
|
||||
Reference in New Issue
Block a user