Files
Aislo/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts
eomsangdonandClaude Opus 5 a9694356cf feat(B01): 대시보드 목록 컨테이너를 4행까지만 보이고 나머지는 안쪽 스크롤
프로젝트·임시 보관함·사용자 관리·가입 요청·회사 관리·시스템 로그 컨테이너는 자료가
쌓일수록 세로로 계속 길어져 페이지가 늘어난다. 4행 높이에서 자르고 나머지는 컨테이너
안에서 스크롤하게 했다.

행 높이를 상수로 박지 않았다. 프로젝트 표는 셀 안에 단계 막대가 들어가고 보관함 묶음은
접힘 상태에 따라 높이가 제각각이라 4 x 고정값으로는 어긋난다. limitVisibleRows()가
4번째 항목의 실제 아래쪽 좌표를 재서 max-height를 넣는다 — 표든 묶음이든 같은 코드로
맞는다. 항목이 4개 이하면 max-height를 지워 스크롤바를 만들지 않는다.

스크롤 중 어느 열인지 알아야 하므로 표 머리행은 sticky로 붙였다. 배경이 투명하면 아래
행이 비쳐 보여 불투명 배경도 함께 준다.

임시 보관함은 표가 아니라 묶음이 한 행이다. 묶음 안 파일 표는 접혀 있어 제외했다.
묶음을 펼치면 높이가 달라지므로 토글 핸들러에서 다시 잰다. 목록을 다시 그릴 때마다
resize 청취자가 쌓이던 문제는 WeakMap으로 이전 것을 걷어내고 다시 등록해 막았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 22:16:55 +09:00

470 lines
18 KiB
TypeScript

/* =============================================================================
* 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<string, UploadTask[]>();
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
/** 펼쳐 둔 그룹 — 기본은 접힘, 사용자가 연 그룹만 목록을 다시 그려도 열린 상태를 유지한다. */
const openGroups = new Set<string>();
/** 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<void> {
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<HTMLElement>(":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<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]);
// 올리는 동안은 진행률이 보여야 하므로 해당 그룹을 펼친 상태로 둔다.
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;
}