프로젝트/사용자 관리 컨테이너와 같은 형태로 통일한다. 등록 폼을 화면에 상시
노출하지 않고, 우측 상단 [+] 모달로 받아 그룹(임시 프로젝트명) 아래 파일 표를
그린다.
프론트엔드
- B01_Dashboard_UI_TempModal.ts 신설: 등록/추가 모달(임시 프로젝트명 + 파일 선택,
고른 파일을 모달 안 표로 표시, 하단 [취소][확인] — 기존 대시보드 모달과 동일 규격)
- B01_Dashboard_UI_TempUpload.ts: 섹션 전체(제목·[+]·목록)를 반환하도록 변경.
그룹 카드 + 파일 표(종류/파일명/크기/상태/작업), 그룹별 [파일 추가],
파일별 [삭제], 진행률은 해당 파일 행 안에서 표시
- 청크마다 목록을 다시 조회하지 않고 막대 DOM만 갱신하도록 정리
- 대시보드 배치: 프로젝트 컨테이너 바로 아래(역할 3분기 모두)
백엔드
- DELETE /api/temp-uploads/{batch_id}/files/{file_type}: DB 행과 임시 저장소
실제 파일을 함께 삭제. 필수 파일이 빠지면 상태를 uploading으로 되돌리고
만료일은 최초 완료 시점 기준을 유지
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* B01_Dashboard_UI_TempUpload.ts
|
|
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
|
*
|
|
* 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시):
|
|
* - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환
|
|
* - 그룹(임시 프로젝트명) 아래에 파일 리스트(표)
|
|
* - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음)
|
|
* - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다
|
|
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
|
|
* ========================================================================== */
|
|
|
|
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
|
import { createButton, showToast } from "@ui/ui_template_elements";
|
|
import { section, table, text } from "@ui/ui_template_general_blocks";
|
|
import {
|
|
createTempBatch,
|
|
createTempUploadSession,
|
|
deleteTempBatch,
|
|
deleteTempBatchFile,
|
|
fetchTempBatches,
|
|
finalizeTempUpload,
|
|
fetchTempUploadStatus,
|
|
uploadTempBatchFiles,
|
|
uploadTempChunk,
|
|
type TempBatchItem,
|
|
} from "./B01_Dashboard_Api_Temp";
|
|
import { L } from "./B01_Dashboard_UI_Common";
|
|
import { formatTempBytes, openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
|
|
import "./B01_Dashboard_UI_Style_Temp.css";
|
|
|
|
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
|
|
interface StoredTempSession {
|
|
batchId: string;
|
|
fileName: string;
|
|
fileSize: number;
|
|
sessionId: string;
|
|
chunkSizeBytes: number;
|
|
totalChunks: number;
|
|
}
|
|
|
|
/** 이 브라우저에서 지금 올리고 있는 파일 1건. 서버 목록에 잡히기 전까지 행을 채운다. */
|
|
interface UploadTask {
|
|
key: string;
|
|
fileName: string;
|
|
fileType: string;
|
|
sizeBytes: number;
|
|
percent: number;
|
|
status: "waiting" | "uploading" | "failed";
|
|
}
|
|
|
|
const CHUNK_UPLOAD_EXT = new Set(["las", "laz"]);
|
|
|
|
function sessionKey(batchId: string, file: File): string {
|
|
return `temp:session:${batchId}:${file.name}:${file.size}`;
|
|
}
|
|
|
|
function taskKey(file: File): string {
|
|
return `${file.name}::${file.size}`;
|
|
}
|
|
|
|
function formatDate(value: string | null): string {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
|
|
}
|
|
|
|
/**
|
|
* 보관함 섹션(제목 + [+] 버튼 + 그룹 목록)을 통째로 만든다.
|
|
* 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음).
|
|
*/
|
|
export function buildTempUploadSection(): HTMLElement {
|
|
const panel = document.createElement("div");
|
|
panel.className = "b01-temp";
|
|
|
|
const hint = document.createElement("p");
|
|
hint.className = "b01-temp__hint";
|
|
hint.textContent = L("B01_Temp_Hint");
|
|
|
|
const listHost = document.createElement("div");
|
|
listHost.className = "b01-temp__list";
|
|
panel.append(hint, listHost);
|
|
|
|
/** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */
|
|
const tasks = new Map<string, UploadTask[]>();
|
|
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
|
|
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
|
|
|
|
/* ── 행 구성 ─────────────────────────────────────────────────────────── */
|
|
|
|
function progressCell(key: string, percent: number, label: string): HTMLElement {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "b01-temp__progress";
|
|
const caption = document.createElement("span");
|
|
caption.textContent = `${label} ${Math.round(percent)}%`;
|
|
const bar = document.createElement("div");
|
|
bar.className = "b01-temp__progress-bar";
|
|
const fill = document.createElement("div");
|
|
fill.style.width = `${percent}%`;
|
|
bar.append(fill);
|
|
wrap.append(caption, bar);
|
|
progressNodes.set(key, { fill, caption });
|
|
return wrap;
|
|
}
|
|
|
|
function paintTask(batchId: string, task: UploadTask): void {
|
|
const node = progressNodes.get(`${batchId}::${task.key}`);
|
|
if (!node) return;
|
|
node.fill.style.width = `${task.percent}%`;
|
|
node.caption.textContent = `${L("B01_Temp_File_Status_Uploading")} ${Math.round(task.percent)}%`;
|
|
}
|
|
|
|
function fileRows(batch: TempBatchItem): HTMLElement[][] {
|
|
const rows: HTMLElement[][] = [];
|
|
const linked = batch.status === "linked";
|
|
|
|
for (const file of batch.files) {
|
|
const action = linked
|
|
? text("-")
|
|
: createButton({
|
|
label: L("Common_Btn_Delete"),
|
|
variant: "danger",
|
|
onClick: () => void removeFile(batch, file.file_type, file.original_filename),
|
|
});
|
|
rows.push([
|
|
text(file.file_type.toUpperCase()),
|
|
text(file.original_filename),
|
|
text(formatTempBytes(file.file_size_bytes)),
|
|
text(L("B01_Temp_File_Status_Stored")),
|
|
action,
|
|
]);
|
|
}
|
|
|
|
const running = tasks.get(batch.batch_id) ?? [];
|
|
for (const task of running) {
|
|
const status =
|
|
task.status === "failed"
|
|
? text(L("B01_Temp_File_Status_Failed"))
|
|
: task.status === "waiting"
|
|
? text(L("B01_Temp_File_Status_Waiting"))
|
|
: progressCell(
|
|
`${batch.batch_id}::${task.key}`,
|
|
task.percent,
|
|
L("B01_Temp_File_Status_Uploading"),
|
|
);
|
|
rows.push([
|
|
text(task.fileType.toUpperCase()),
|
|
text(task.fileName),
|
|
text(formatTempBytes(task.sizeBytes)),
|
|
status,
|
|
text("-"),
|
|
]);
|
|
}
|
|
|
|
// 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다.
|
|
for (const pending of batch.pending_sessions) {
|
|
const known = running.some((task) => task.fileName === pending.original_filename);
|
|
if (known) continue;
|
|
rows.push([
|
|
text(tempFileType(pending.original_filename).toUpperCase()),
|
|
text(pending.original_filename),
|
|
text(formatTempBytes(pending.file_size_bytes)),
|
|
progressCell(
|
|
`${batch.batch_id}::pending::${pending.upload_session_id}`,
|
|
pending.progress_percent,
|
|
L("B01_Temp_File_Status_Paused"),
|
|
),
|
|
text("-"),
|
|
]);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function renderGroup(batch: TempBatchItem): HTMLElement {
|
|
const group = document.createElement("div");
|
|
group.className = `b01-temp__group is-${batch.status}`;
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "b01-temp__group-head";
|
|
|
|
const info = document.createElement("div");
|
|
info.className = "b01-temp__group-info";
|
|
const titleRow = document.createElement("div");
|
|
titleRow.className = "b01-temp__group-title";
|
|
const title = document.createElement("strong");
|
|
title.textContent = batch.name;
|
|
const badge = document.createElement("span");
|
|
badge.className = "b01-temp__badge";
|
|
badge.textContent =
|
|
batch.status === "linked"
|
|
? L("B01_Temp_Status_Linked")
|
|
: batch.required_complete
|
|
? L("B01_Temp_Status_Ready")
|
|
: L("B01_Temp_Status_Uploading");
|
|
titleRow.append(title, badge);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "b01-temp__group-meta";
|
|
const parts = [
|
|
`${batch.files.length}${L("B01_Temp_Meta_FileCount")}`,
|
|
formatTempBytes(batch.total_size_bytes),
|
|
];
|
|
if (batch.status === "linked") {
|
|
parts.push(L("B01_Temp_Meta_Linked"));
|
|
} else if (batch.expires_at) {
|
|
parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`);
|
|
}
|
|
meta.textContent = parts.join(" · ");
|
|
info.append(titleRow, meta);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "b01-dashboard__actions";
|
|
if (batch.status !== "linked") {
|
|
actions.append(
|
|
createButton({
|
|
label: L("B01_Temp_Btn_Add"),
|
|
variant: "ghost",
|
|
onClick: () => openAddModal(batch),
|
|
}),
|
|
createButton({
|
|
label: L("Common_Btn_Delete"),
|
|
variant: "danger",
|
|
onClick: () => void removeBatch(batch),
|
|
}),
|
|
);
|
|
}
|
|
head.append(info, actions);
|
|
|
|
group.append(
|
|
head,
|
|
table(
|
|
[
|
|
L("B01_Temp_Table_Type"),
|
|
L("B01_Temp_Table_Name"),
|
|
L("B01_Temp_Table_Size"),
|
|
L("B01_Temp_Table_Status"),
|
|
L("B01_Temp_Table_Action"),
|
|
],
|
|
fileRows(batch),
|
|
),
|
|
);
|
|
return group;
|
|
}
|
|
|
|
async function refresh(): Promise<void> {
|
|
try {
|
|
const response = await fetchTempBatches();
|
|
hint.textContent = `${L("B01_Temp_Hint")} (${response.retention_days}${L("B01_Temp_Hint_Days")})`;
|
|
progressNodes.clear();
|
|
listHost.replaceChildren();
|
|
if (response.batches.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "b01-temp__empty";
|
|
empty.textContent = L("B01_Temp_Empty");
|
|
listHost.append(empty);
|
|
return;
|
|
}
|
|
for (const batch of response.batches) listHost.append(renderGroup(batch));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
|
|
}
|
|
}
|
|
|
|
/* ── 삭제 ────────────────────────────────────────────────────────────── */
|
|
|
|
async function removeBatch(batch: TempBatchItem): Promise<void> {
|
|
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
|
|
try {
|
|
await deleteTempBatch(batch.batch_id);
|
|
tasks.delete(batch.batch_id);
|
|
showToast(L("B01_Temp_Delete_Success"), "success");
|
|
await refresh();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("B01_Temp_Delete_Failed"), "error");
|
|
}
|
|
}
|
|
|
|
async function removeFile(
|
|
batch: TempBatchItem,
|
|
fileType: string,
|
|
fileName: string,
|
|
): Promise<void> {
|
|
if (!window.confirm(`${fileName}\n${L("B01_Temp_File_Delete_Confirm")}`)) return;
|
|
try {
|
|
await deleteTempBatchFile(batch.batch_id, fileType);
|
|
showToast(L("B01_Temp_File_Delete_Success"), "success");
|
|
await refresh();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("B01_Temp_File_Delete_Failed"), "error");
|
|
}
|
|
}
|
|
|
|
/* ── 업로드 ──────────────────────────────────────────────────────────── */
|
|
|
|
/** 대용량 파일 1건을 청크로 올린다(이어올리기 포함). 진행률은 콜백으로 행에 반영. */
|
|
async function uploadLargeFile(
|
|
batchId: string,
|
|
file: File,
|
|
onProgress: (percent: number) => void,
|
|
): Promise<void> {
|
|
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
|
const key = sessionKey(batchId, file);
|
|
let stored: StoredTempSession | null = null;
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
stored = raw ? (JSON.parse(raw) as StoredTempSession) : null;
|
|
} catch {
|
|
stored = null;
|
|
}
|
|
|
|
let sessionId = stored?.sessionId ?? "";
|
|
let totalChunks = stored?.totalChunks ?? 0;
|
|
let done = new Set<number>();
|
|
if (sessionId) {
|
|
try {
|
|
const status = await fetchTempUploadStatus(batchId, sessionId);
|
|
done = new Set(status.completed_chunk_indexes);
|
|
totalChunks = status.total_chunks;
|
|
} catch {
|
|
sessionId = "";
|
|
}
|
|
}
|
|
if (!sessionId) {
|
|
const created = await createTempUploadSession(batchId, file, chunkSizeBytes);
|
|
sessionId = created.upload_session_id;
|
|
totalChunks = created.total_chunks;
|
|
done = new Set();
|
|
}
|
|
localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
batchId,
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
sessionId,
|
|
chunkSizeBytes,
|
|
totalChunks,
|
|
} satisfies StoredTempSession),
|
|
);
|
|
|
|
onProgress((done.size / Math.max(1, totalChunks)) * 100);
|
|
for (let index = 0; index < totalChunks; index += 1) {
|
|
if (done.has(index)) continue;
|
|
const start = index * chunkSizeBytes;
|
|
const end = Math.min(file.size, start + chunkSizeBytes);
|
|
await uploadTempChunk(batchId, sessionId, index, file.slice(start, end));
|
|
done.add(index);
|
|
onProgress((done.size / Math.max(1, totalChunks)) * 100);
|
|
}
|
|
await finalizeTempUpload(batchId, sessionId, totalChunks);
|
|
localStorage.removeItem(key);
|
|
}
|
|
|
|
/** 고른 파일들을 한 그룹에 차례로 올린다. 끝난 파일은 서버 목록으로 넘어간다. */
|
|
async function runUpload(batchId: string, files: File[]): Promise<void> {
|
|
const queued: UploadTask[] = files.map((file) => ({
|
|
key: taskKey(file),
|
|
fileName: file.name,
|
|
fileType: tempFileType(file.name),
|
|
sizeBytes: file.size,
|
|
percent: 0,
|
|
status: "waiting",
|
|
}));
|
|
// 지난번 실패 행은 새로 올리기 시작하면 치운다.
|
|
const previous = (tasks.get(batchId) ?? []).filter((item) => item.status !== "failed");
|
|
tasks.set(batchId, [...previous, ...queued]);
|
|
await refresh();
|
|
|
|
let failed = 0;
|
|
for (const [index, file] of files.entries()) {
|
|
const task = queued[index];
|
|
task.status = "uploading";
|
|
await refresh();
|
|
try {
|
|
if (CHUNK_UPLOAD_EXT.has(task.fileType)) {
|
|
await uploadLargeFile(batchId, file, (percent) => {
|
|
task.percent = percent;
|
|
paintTask(batchId, task);
|
|
});
|
|
} else {
|
|
await uploadTempBatchFiles(batchId, [file]);
|
|
}
|
|
// 저장이 끝난 파일은 서버 목록에 나타나므로 작업표에서 뺀다.
|
|
const list = tasks.get(batchId) ?? [];
|
|
tasks.set(
|
|
batchId,
|
|
list.filter((item) => item !== task),
|
|
);
|
|
} catch (error) {
|
|
failed += 1;
|
|
task.status = "failed";
|
|
showToast(error instanceof Error ? error.message : L("B01_Temp_Upload_Failed"), "error");
|
|
}
|
|
await refresh();
|
|
}
|
|
if (failed === 0) showToast(L("B01_Temp_Upload_Success"), "success");
|
|
}
|
|
|
|
/* ── 모달 ────────────────────────────────────────────────────────────── */
|
|
|
|
function openCreateModal(): void {
|
|
openTempFileModal({
|
|
title: L("B01_Temp_Modal_Create"),
|
|
askName: true,
|
|
onConfirm: ({ name, files }) => {
|
|
void (async () => {
|
|
try {
|
|
const batch = await createTempBatch(name);
|
|
await refresh();
|
|
await runUpload(batch.batch_id, files);
|
|
} catch (error) {
|
|
showToast(
|
|
error instanceof Error ? error.message : L("B01_Temp_Upload_Failed"),
|
|
"error",
|
|
);
|
|
await refresh();
|
|
}
|
|
})();
|
|
},
|
|
});
|
|
}
|
|
|
|
function openAddModal(batch: TempBatchItem): void {
|
|
openTempFileModal({
|
|
title: `${L("B01_Temp_Modal_Add")} — ${batch.name}`,
|
|
askName: false,
|
|
onConfirm: ({ files }) => {
|
|
void runUpload(batch.batch_id, files);
|
|
},
|
|
});
|
|
}
|
|
|
|
void refresh();
|
|
|
|
return section(L("B01_Temp_Section"), panel, true, [
|
|
createButton({ label: "+", onClick: openCreateModal }),
|
|
]);
|
|
}
|