feat(B01,B03): 프로젝트 생성 전 임시 보관함 (temp upload)
라이다 원본은 업로드에 오래 걸려 프로젝트 정보 확정 전에 미리 올릴 수 있어야 한다.
계정에 묶인 임시 보관함을 만들고, 나중에 만든 프로젝트로 자료를 옮겨 쓴다.
저장·DB
- storage/tmp/{user_id}/{batch_id}/ 아래에 프로젝트 저장소와 동일한 구조를 써서
청크 저장·병합 엔진(resolve_upload_destination/merge_upload_chunks)을 그대로 재사용
- 010_temp_upload.sql: temp_upload_batches / temp_upload_files 신설,
upload_sessions.project_id NULL 허용 + temp_batch_id 추가(FK명 조회 후 재생성)
- config: TEMP_UPLOAD_DIR_NAME / TEMP_UPLOAD_RETENTION_DAYS(30) /
TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS(6)
백엔드
- B03_FileInput_Router_Temp.py: 묶음 생성·목록·삭제, 일반/청크 업로드, finalize,
이어올리기 상태 조회, 프로젝트 연결(attach)
- attach: 파일 이동 후 input_files 등록, stage 0 완료, WF1·자동 설계 체인 트리거
- common_util_temp_cleanup.py: 완료 시각 기준 만료분 주기 삭제(서버 시작 시 1회 포함)
프론트엔드
- B01 대시보드 임시 보관함 섹션: 프로젝트 등록과 같은 폼 + 보관 목록.
진행률은 모달이 아니라 리스트 행에 표시, 새로고침 후 이어올리기 지원
- B03 업로드 컨테이너 내부 불러오기 버튼과 선택 모달.
완료된 묶음만 노출하고, 선택 후 업로드를 누르면 이동과 분석으로 이어짐
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_TempPicker.ts
|
||||
* 임시 보관함 불러오기 — 대시보드에 미리 올려 둔 자료를 이 프로젝트로 가져온다.
|
||||
*
|
||||
* 파일 업로드 컨테이너 안에 버튼을 두고, 누르면 **완료된 보관 묶음만** 목록으로
|
||||
* 보여 준다. 선택 후 [확인]을 누르면 지정 상태가 되고, 실제 이동은 화면의
|
||||
* [업로드] 버튼을 눌렀을 때 일어난다(2026-08-08 사용자 지시).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { fetchTempBatches, type TempBatchItem } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
import "./B03_FileInput_UI_Style_Temp.css";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface TempPickerHandle {
|
||||
/** 업로드 컨테이너에 붙일 요소(버튼 + 선택 안내). */
|
||||
root: HTMLElement;
|
||||
/** 지금 선택된 보관 묶음(없으면 null). */
|
||||
selected(): TempBatchItem | null;
|
||||
/** 연결 완료 후 선택 표시를 지운다. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 불러오기 버튼과 선택 모달을 만든다.
|
||||
* `onSelected`는 선택이 바뀔 때마다 호출된다 — 호출측이 업로드 버튼 활성화를 조정한다.
|
||||
*/
|
||||
export function createTempPicker(
|
||||
onSelected: (batch: TempBatchItem | null) => void,
|
||||
): TempPickerHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b03-file__temp-picker";
|
||||
|
||||
const openButton = createButton({
|
||||
label: L("B03_Temp_Btn_Open"),
|
||||
variant: "ghost",
|
||||
onClick: () => void openModal(),
|
||||
});
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "b03-file__temp-summary";
|
||||
summary.textContent = L("B03_Temp_None");
|
||||
root.append(openButton, summary);
|
||||
|
||||
let selectedBatch: TempBatchItem | null = null;
|
||||
|
||||
function applySelection(batch: TempBatchItem | null): void {
|
||||
selectedBatch = batch;
|
||||
summary.textContent = batch
|
||||
? `${L("B03_Temp_Selected")} ${batch.name} (${batch.files.length}${L("B03_Temp_FileCount")})`
|
||||
: L("B03_Temp_None");
|
||||
summary.classList.toggle("is-active", Boolean(batch));
|
||||
onSelected(batch);
|
||||
}
|
||||
|
||||
async function openModal(): Promise<void> {
|
||||
let batches: TempBatchItem[] = [];
|
||||
try {
|
||||
const response = await fetchTempBatches();
|
||||
// 프로젝트에 넣을 수 있는 것은 필수 파일이 다 찬 미연결 묶음뿐이다.
|
||||
batches = response.batches.filter(
|
||||
(item) => item.required_complete && item.status !== "linked",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B03_Temp_Load_Failed"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "b03-file__modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b03-file__modal b03-file__temp-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = L("B03_Temp_Modal_Title");
|
||||
const list = document.createElement("div");
|
||||
list.className = "b03-file__temp-list";
|
||||
|
||||
let pending: TempBatchItem | null = selectedBatch;
|
||||
|
||||
if (batches.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b03-file__temp-empty";
|
||||
empty.textContent = L("B03_Temp_Modal_Empty");
|
||||
list.append(empty);
|
||||
}
|
||||
for (const batch of batches) {
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.className = "b03-file__temp-option";
|
||||
option.classList.toggle("is-selected", pending?.batch_id === batch.batch_id);
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = batch.name;
|
||||
const meta = document.createElement("span");
|
||||
meta.textContent = `${batch.files.length}${L("B03_Temp_FileCount")} · ${formatBytes(
|
||||
batch.total_size_bytes,
|
||||
)}`;
|
||||
const files = document.createElement("span");
|
||||
files.className = "b03-file__temp-option-files";
|
||||
files.textContent = batch.files.map((file) => file.file_type.toUpperCase()).join(", ");
|
||||
option.append(name, meta, files);
|
||||
option.addEventListener("click", () => {
|
||||
pending = batch;
|
||||
list
|
||||
.querySelectorAll(".b03-file__temp-option")
|
||||
.forEach((element) => element.classList.remove("is-selected"));
|
||||
option.classList.add("is-selected");
|
||||
});
|
||||
list.append(option);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b03-file__temp-modal-actions";
|
||||
const close = (): void => backdrop.remove();
|
||||
actions.append(
|
||||
createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: close }),
|
||||
createButton({
|
||||
label: L("Common_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
if (!pending) {
|
||||
showToast(L("B03_Temp_Select_Required"), "warning");
|
||||
return;
|
||||
}
|
||||
applySelection(pending);
|
||||
close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
modal.append(title, list, actions);
|
||||
backdrop.append(modal);
|
||||
backdrop.addEventListener("click", (event) => {
|
||||
if (event.target === backdrop) close();
|
||||
});
|
||||
document.body.append(backdrop);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
selected: () => selectedBatch,
|
||||
clear: () => applySelection(null),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user