Files
Aislo/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts
T
eomsangdonandClaude Sonnet 5.5 7e34152de8 feat(B01): 받은 예정노선 · 임시 보관함 · 사람 · 회사 정보 · 회사 설계 기본값 세부 페이지 (549-1)
- 새 페이지 다섯(View_Assigned · Temp · People · Company · CompanyDefaults) · 등록부 다섯 줄 교체
- 표 줄 40 · 세로선 없음 · 줄 끝 ⋯ · 한글 배지 · 빈 상태 · 사람 탭 가입 요청 수 배지 · 보관함 남은 날 막대(7 일 안 빨강)
- 옛 구역 정리(Legacy · Company · AssignedPlans · TempUpload 다시 씀) · 시험 test_549 · test_546 stub 일반화

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:26:27 +09:00

489 lines
17 KiB
TypeScript

/* =============================================================================
* 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 { 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}`;
}
function formatDate(value: string | null): string {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
}
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<string, UploadTask[]>();
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
/** 펼쳐 둔 묶음 — 목록을 다시 그려도 열린 상태를 유지한다. */
const openGroups = new Set<string>();
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),
formatDate(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<void> {
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<void> {
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<void> {
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();
return { root, openCreate: openCreateModal };
}