/* ============================================================================= * B01_Dashboard_UI_TempUpload.ts * 임시 보관함 본체(549-1) — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳. * 목록 = 묶음 표(이름 · 상태 · 남은 날 막대 · 올린 날 · 줄 끝 ⋯) · 줄을 펼치면 그 안 파일 · 진행률 * ⋯ = 파일 추가 · 삭제(확인) · 파일 줄 ⋯ = 삭제(확인) · 삭제하면 임시 저장소 실제 파일도 지움 * 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있음 * 화면 틀(머리 · 주 단추)은 `B01_Dashboard_UI_View_Temp.ts` * ========================================================================== */ import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { icon } from "@ui/ui_template_icons"; import { bytesText, daysLeft, EXPIRY_WARN_DAYS, isExpiringSoon, leftPercent, } from "./logic/B01_Dashboard_Logic_Pages"; import { createTempBatch, createTempUploadSession, deleteTempBatch, deleteTempBatchFile, fetchTempBatches, finalizeTempUpload, fetchTempUploadStatus, uploadTempBatchFiles, uploadTempChunk, type TempBatchItem, } from "./B01_Dashboard_Api_Temp"; import { dateText } from "../common_util/common_util_time"; import { L } from "./B01_Dashboard_UI_Common"; import { badge, emptyState, nameCell, primaryButton, rowMenu, tableRow, tableShell, type Column, } from "./B01_Dashboard_UI_PageKit"; import { 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}`; } const COLUMNS: Column[] = [ { label: "보관 이름" }, { label: "상태", width: "120px" }, { label: "남은 기간", width: "210px" }, { label: "올린 날", width: "110px" }, { label: "", width: "44px" }, ]; export interface TempBox { root: HTMLElement; /** 새 자료 등록 모달 — 머리 주 단추가 부름 */ openCreate: () => void; } /** 보관함 본체 — 표 · 빈 상태 · 업로드를 한 덩이로 · 목록은 안에서 다시 그림(전체 페이지 재렌더 없음) */ export function buildTempBox(onGuide: () => void): TempBox { const root = document.createElement("div"); root.className = "b01-pg__body"; const note = document.createElement("p"); note.className = "b01-pg-note"; const listHost = document.createElement("div"); root.append(note, listHost); /** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */ const tasks = new Map(); /** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */ const progressNodes = new Map(); /** 펼쳐 둔 묶음 — 목록을 다시 그려도 열린 상태를 유지한다. */ const openGroups = new Set(); let retentionDays = 30; let lastBatches: TempBatchItem[] = []; /* ── 행 구성 ─────────────────────────────────────────────────────────── */ function progressCell(key: string, percent: number, label: string): HTMLElement { const wrap = document.createElement("span"); wrap.className = "b01-pg-progress"; const caption = document.createElement("span"); caption.textContent = `${label} ${Math.round(percent)}%`; const bar = document.createElement("span"); bar.className = "b01-pg-progress__bar"; const fill = document.createElement("i"); 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 fileLine(cells: (string | Node)[]): HTMLElement { const tr = document.createElement("tr"); for (const cell of cells) { const td = document.createElement("td"); td.append(cell); tr.append(td); } return tr; } /** 펼친 묶음 안 파일 표 — 올린 파일 · 올리는 중 · 이어올리기 대기 */ function filesTable(batch: TempBatchItem): HTMLElement { const table = document.createElement("table"); table.className = "b01-pg-files"; const body = document.createElement("tbody"); for (const file of batch.files) { body.append( fileLine([ file.file_type.toUpperCase(), file.original_filename, bytesText(file.file_size_bytes), badge(L("B01_Temp_File_Status_Stored"), "success"), rowMenu([ { label: L("Common_Btn_Delete"), danger: true, confirm: `${file.original_filename}\n${L("B01_Temp_File_Delete_Confirm")}`, onClick: () => void removeFile(batch, file.file_type), }, ]), ]), ); } const running = tasks.get(batch.batch_id) ?? []; for (const task of running) { const status = task.status === "failed" ? badge(L("B01_Temp_File_Status_Failed"), "danger") : task.status === "waiting" ? badge(L("B01_Temp_File_Status_Waiting"), "neutral") : progressCell( `${batch.batch_id}::${task.key}`, task.percent, L("B01_Temp_File_Status_Uploading"), ); body.append( fileLine([ task.fileType.toUpperCase(), task.fileName, bytesText(task.sizeBytes), status, "", ]), ); } // 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다. for (const pending of batch.pending_sessions) { if (running.some((task) => task.fileName === pending.original_filename)) continue; body.append( fileLine([ tempFileType(pending.original_filename).toUpperCase(), pending.original_filename, bytesText(pending.file_size_bytes), progressCell( `${batch.batch_id}::pending::${pending.upload_session_id}`, pending.progress_percent, L("B01_Temp_File_Status_Paused"), ), "", ]), ); } if (!body.children.length) body.append(fileLine(["", "파일 없음 · 묶음 ⋯ 에서 파일 추가", "", "", ""])); table.append(body); return table; } /** 남은 날 칸 — 막대 + 글 · 7 일 안 = 빨강 */ function leftCell(batch: TempBatchItem): HTMLElement { const left = daysLeft(batch.expires_at); const cell = document.createElement("span"); // 다 올리기 전에는 만료가 정해지지 않음 — 막대 없이 「-」 if (left === null) { cell.className = "b01-pg-meta"; cell.textContent = "-"; return cell; } cell.className = `b01-pg-left${isExpiringSoon(left) ? " is-soon" : ""}`; const bar = document.createElement("span"); bar.className = "b01-pg-left__bar"; const fill = document.createElement("i"); fill.style.width = `${leftPercent(left, retentionDays)}%`; bar.append(fill); const label = document.createElement("span"); label.textContent = left === 0 ? "오늘 만료" : `${left}일 남음`; cell.append(bar, label); return cell; } function nameWithCaret(batch: TempBatchItem, opened: boolean): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-pg-person"; const caret = document.createElement("button"); caret.type = "button"; caret.className = `b01-pg-caret${opened ? " is-open" : ""}`; caret.title = caret.ariaLabel = "파일 펼치기"; caret.setAttribute("aria-expanded", String(opened)); caret.append(icon("chevronDown", 14)); caret.addEventListener("click", () => { if (!openGroups.delete(batch.batch_id)) openGroups.add(batch.batch_id); draw(lastBatches); }); const sub = `${batch.files.length}${L("B01_Temp_Meta_FileCount")} · ${bytesText(batch.total_size_bytes)}`; wrap.append(caret, nameCell(batch.name, sub)); return wrap; } function renderBatch(batch: TempBatchItem, tbody: HTMLElement): void { const opened = openGroups.has(batch.batch_id); const status = batch.required_complete ? badge(L("B01_Temp_Status_Ready"), "success") : badge(L("B01_Temp_Status_Uploading"), "warning"); tbody.append( tableRow(COLUMNS, [ nameWithCaret(batch, opened), status, leftCell(batch), dateText(batch.created_at), rowMenu([ { label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) }, { label: L("Common_Btn_Delete"), danger: true, confirm: `${batch.name}\n${L("B01_Temp_Delete_Confirm")}`, onClick: () => void removeBatch(batch), }, ]), ]), ); if (!opened) return; const sub = document.createElement("tr"); sub.className = "is-sub"; const cell = document.createElement("td"); cell.colSpan = COLUMNS.length; cell.append(filesTable(batch)); sub.append(cell); tbody.append(sub); } function draw(batches: TempBatchItem[]): void { lastBatches = batches; progressNodes.clear(); note.textContent = `보관 기간 ${retentionDays}일 · 만료 ${EXPIRY_WARN_DAYS}일 전부터 빨강`; note.hidden = batches.length === 0; if (batches.length === 0) { listHost.replaceChildren( emptyState({ icon: "archive", text: "임시 보관함이 비어 있음", hint: "프로젝트를 만들기 전에 측량 파일을 먼저 올려 둘 수 있음", action: primaryButton("자료 올리기", openCreateModal, "upload"), onGuide, }), ); return; } const shell = tableShell(COLUMNS); for (const batch of batches) renderBatch(batch, shell.tbody); listHost.replaceChildren(shell.root); } async function refresh(): Promise { try { const response = await fetchTempBatches(); retentionDays = response.retention_days; draw(response.batches); } catch (error) { showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error"); } } /* ── 삭제 — 확인은 ⋯ 메뉴가 먼저 받음 ─────────────────────────────────── */ async function removeBatch(batch: TempBatchItem): Promise { try { await deleteTempBatch(batch.batch_id); tasks.delete(batch.batch_id); openGroups.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): Promise { 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(); return { root, openCreate: openCreateModal }; }