/* ============================================================================= * B01_Dashboard_UI_TempUpload.ts * 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳. * * 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시): * - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환 * - 그룹(임시 프로젝트명) 아래에 파일 리스트(표) * - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음) * - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다 * 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다. * ========================================================================== */ import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; import { createButton, createTag, showToast } from "@ui/ui_template_elements"; import { limitVisibleRows, 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 { DASHBOARD_VISIBLE_ROWS, 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 retentionTag = createTag(`${30}${L("B01_Temp_Hint_Days")}`, "neutral"); const listHost = document.createElement("div"); listHost.className = "b01-temp__list"; panel.append(listHost); /** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */ const tasks = new Map(); /** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */ const progressNodes = new Map(); /** 펼쳐 둔 그룹 — 기본은 접힘, 사용자가 연 그룹만 목록을 다시 그려도 열린 상태를 유지한다. */ const openGroups = new Set(); /** 4개 높이 제한을 다시 재는 함수 — 그룹을 펼치면 높이가 달라져 다시 재야 한다. */ let remeasureGroups: (() => void) | null = null; /* ── 행 구성 ─────────────────────────────────────────────────────────── */ 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[][] = []; for (const file of batch.files) { // 목록에는 아직 프로젝트에 안 쓴 묶음만 온다 — 항상 지울 수 있다. const action = 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 opened = openGroups.has(batch.batch_id); group.classList.toggle("is-open", opened); const head = document.createElement("div"); head.className = "b01-temp__group-head"; // 제목 행 = 접기/펼치기 트리거. 이름·상태·요약이 한 줄에 들어간다. const titleRow = document.createElement("div"); titleRow.className = "b01-temp__group-title"; const caret = document.createElement("span"); caret.className = "b01-temp__caret"; caret.textContent = opened ? "▾" : "▸"; const title = document.createElement("strong"); title.textContent = batch.name; const badge = document.createElement("span"); badge.className = "b01-temp__badge"; badge.textContent = batch.required_complete ? L("B01_Temp_Status_Ready") : L("B01_Temp_Status_Uploading"); const meta = document.createElement("span"); meta.className = "b01-temp__group-meta"; const parts = [ `${batch.files.length}${L("B01_Temp_Meta_FileCount")}`, formatTempBytes(batch.total_size_bytes), ]; if (batch.expires_at) { parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`); } meta.textContent = parts.join(" · "); titleRow.append(caret, title, badge, meta); const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; // 버튼 클릭이 접기/펼치기까지 건드리지 않게 막는다. actions.addEventListener("click", (event) => event.stopPropagation()); 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(titleRow, actions); head.setAttribute("aria-expanded", String(opened)); head.addEventListener("click", () => { const nowOpen = !group.classList.contains("is-open"); group.classList.toggle("is-open", nowOpen); head.setAttribute("aria-expanded", String(nowOpen)); caret.textContent = nowOpen ? "▾" : "▸"; // 목록을 다시 그려도 열어 둔 그룹은 계속 열려 있어야 한다(업로드 중 진행률 확인). if (nowOpen) openGroups.add(batch.batch_id); else openGroups.delete(batch.batch_id); // 펼치면 그 묶음이 커진다 — 4개까지 보이는 높이를 다시 잡는다. remeasureGroups?.(); }); const body = document.createElement("div"); body.className = "b01-temp__group-body"; body.append( 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), ), ); group.append(head, body); return group; } async function refresh(): Promise { try { const response = await fetchTempBatches(); retentionTag.textContent = `${response.retention_days}${L("B01_Temp_Hint_Days")}`; progressNodes.clear(); listHost.replaceChildren(); if (response.batches.length === 0) { // 빈 상태 문구·서식은 프로젝트 컨테이너와 같게 공용 표 블록을 그대로 쓴다. listHost.append(table([], [])); return; } for (const batch of response.batches) listHost.append(renderGroup(batch)); // 묶음이 쌓여도 컨테이너는 4개 높이까지만 — 나머지는 안쪽 스크롤. // 표가 아니라 묶음이 한 행이다. 묶음 안 파일 표는 접혀 있어 대상이 아니다. remeasureGroups = limitVisibleRows( listHost, () => [...listHost.querySelectorAll(":scope > .b01-temp__group")], DASHBOARD_VISIBLE_ROWS, ); } catch (error) { showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error"); } } /* ── 삭제 ────────────────────────────────────────────────────────────── */ async function removeBatch(batch: TempBatchItem): Promise { 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 { 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 { 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(); 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 { 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]); // 올리는 동안은 진행률이 보여야 하므로 해당 그룹을 펼친 상태로 둔다. openGroups.add(batchId); 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(); const root = section(L("B01_Temp_Section"), panel, true, [ createButton({ label: "+", onClick: openCreateModal }), ]); // 공용 섹션 헤더는 [제목 | 액션] 2단이라, 보관 기간 태그를 제목과 한 묶음으로 감싼다. const heading = root.querySelector("h3"); if (heading) { const titleWrap = document.createElement("div"); titleWrap.className = "b01-temp__section-title"; heading.replaceWith(titleWrap); titleWrap.append(heading, retentionTag); } return root; }