diff --git a/.gitignore b/.gitignore index ce3664d8..31792cef 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ resources/ # 루트 임시 파일 (세션 도구 산출물) .tmp_* +*.log.err diff --git a/B01_Dashboard/B01_Dashboard_Api_Temp.ts b/B01_Dashboard/B01_Dashboard_Api_Temp.ts index b339c0a7..ebde6652 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Temp.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Temp.ts @@ -7,6 +7,7 @@ * GET /api/temp-uploads 내 보관함 목록 * DELETE /api/temp-uploads/{batch_id} 묶음 삭제 * POST /api/temp-uploads/{batch_id}/files 작은 파일 저장 + * DELETE /api/temp-uploads/{batch_id}/files/{type} 파일 1건 삭제 * POST /api/temp-uploads/{batch_id}/upload-sessions 청크 세션 생성 * POST /api/temp-uploads/{batch_id}/chunks 청크 저장 * POST /api/temp-uploads/{batch_id}/finalize 병합·완료 @@ -90,6 +91,14 @@ export async function deleteTempBatch(batchId: string): Promise { await requestJson(`/temp-uploads/${encodeURIComponent(batchId)}`, { method: "DELETE" }); } +/** 묶음 안 파일 1건 삭제 — 임시 저장소의 실제 파일도 함께 지워진다. */ +export async function deleteTempBatchFile(batchId: string, fileType: string): Promise { + await requestJson( + `/temp-uploads/${encodeURIComponent(batchId)}/files/${encodeURIComponent(fileType)}`, + { method: "DELETE" }, + ); +} + /** 작은 파일(csv·prj·tfw·tif)은 한 번에 보낸다. */ export async function uploadTempBatchFiles( batchId: string, diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index ee275b5b..215d7ba4 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -41,7 +41,7 @@ import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_M import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile"; import { projectTable } from "./B01_Dashboard_UI_Projects"; import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; -import { buildTempUploadPanel } from "./B01_Dashboard_UI_TempUpload"; +import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload"; import "./B01_Dashboard_UI_Style.css"; export interface DashboardState { @@ -143,6 +143,7 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), + buildTempUploadSection(), section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), @@ -157,6 +158,7 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), + buildTempUploadSection(), section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), @@ -168,14 +170,14 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), + // 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록 + // 바로 아래에 둔다(2026-08-08 사용자 지시). + buildTempUploadSection(), section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), ); } - // 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳이라 프로젝트 목록 - // 바로 다음에 둔다(2026-08-08 사용자 지시). grid.append( - section(L("B01_Temp_Section"), buildTempUploadPanel(), true), section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), section(L("B01_Account_Section_Security"), buildSecurityForm()), ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css b/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css index e41b0ac8..acaea18a 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Temp.css @@ -1,35 +1,12 @@ /* ============================================================================= * B01_Dashboard_UI_Style_Temp.css - * 임시 보관함 섹션 — 등록 폼 + 보관 목록(행에 진행률 표시) - * 색·간격은 theme.css 변수만 사용한다. + * 임시 보관함 섹션 — 그룹(임시 프로젝트명) + 하위 파일 표, 등록 모달 + * 표·모달 껍데기는 대시보드 공통 스타일을 그대로 쓰고, 여기서는 그룹 카드와 + * 진행률 막대만 정의한다. 색·간격은 theme.css 변수만 사용한다. * ========================================================================== */ -.b01-temp__form { - display: grid; - grid-template-columns: 1fr 1fr auto; - gap: var(--spacing-16); - align-items: end; -} - -.b01-temp__field { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - font-size: 13px; - color: var(--color-muted-text, var(--color-primary-text)); -} - -.b01-temp__field input { - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-paper); - color: var(--color-primary-text); - font-size: 13px; -} - .b01-temp__hint { - margin: var(--spacing-8) 0 var(--spacing-16); + margin: 0 0 var(--spacing-16); font-size: 12px; color: var(--color-muted-text, var(--color-primary-text)); } @@ -44,10 +21,11 @@ .b01-temp__list { display: flex; flex-direction: column; - gap: var(--spacing-8); + gap: var(--spacing-16); } -.b01-temp__row { +/* 그룹 = 임시 프로젝트명 1건. 아래에 파일 표가 붙는다. */ +.b01-temp__group { display: flex; flex-direction: column; gap: var(--spacing-8); @@ -57,16 +35,34 @@ background: var(--color-paper); } -/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 목록에서 현재 쓸 수 있는 자료와 구분한다. */ -.b01-temp__row.is-linked { +/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 지금 쓸 수 있는 자료와 구분한다. */ +.b01-temp__group.is-linked { opacity: 0.6; } -.b01-temp__row-head { +.b01-temp__group-head { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: var(--spacing-8); + flex-wrap: wrap; +} + +.b01-temp__group-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.b01-temp__group-title { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b01-temp__group-meta { + font-size: 12px; + color: var(--color-muted-text, var(--color-primary-text)); } .b01-temp__badge { @@ -77,40 +73,22 @@ white-space: nowrap; } -.b01-temp__row.is-completed .b01-temp__badge { +.b01-temp__group.is-completed .b01-temp__badge { border-color: var(--color-success); color: var(--color-success); } -.b01-temp__row.is-uploading .b01-temp__badge { +.b01-temp__group.is-uploading .b01-temp__badge { border-color: var(--color-warning); color: var(--color-warning); } -.b01-temp__row-meta { - font-size: 12px; - color: var(--color-muted-text, var(--color-primary-text)); -} - -.b01-temp__files { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - -.b01-temp__chip { - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - font-size: 11px; -} - -/* 업로드 진행률은 모달이 아니라 이 행 안에서 보여 준다(2026-08-08 사용자 지시). */ +/* 업로드 진행률은 모달이 아니라 해당 파일 행 안에서 보여 준다(2026-08-08 사용자 지시). */ .b01-temp__progress { display: flex; flex-direction: column; gap: 4px; - width: 100%; + min-width: 140px; font-size: 11px; } @@ -127,14 +105,22 @@ transition: width 0.2s ease; } -.b01-temp__row-actions { - display: flex; - justify-content: flex-end; - gap: var(--spacing-8); +/* ── 등록/추가 모달 ───────────────────────────────────────────────────── */ + +.b01-temp__hidden-input { + display: none; } -@media (max-width: 860px) { - .b01-temp__form { - grid-template-columns: 1fr; - } +.b01-temp__modal-pick { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + margin: var(--spacing-8) 0; + font-size: 13px; +} + +.b01-temp__modal-list { + max-height: 40vh; + overflow-y: auto; } diff --git a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts new file mode 100644 index 00000000..4700f358 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * B01_Dashboard_UI_TempModal.ts + * 임시 보관함 — 자료 등록/파일 추가 모달. + * + * 모달 껍데기는 대시보드의 다른 모달(B01_Dashboard_UI_Modals)과 같은 클래스를 써서 + * 생김새를 맞춘다. 파일을 고르면 모달 안에 리스트로 보여 주고, [확인]을 눌러야 + * 그룹 생성·업로드가 시작된다(2026-08-08 사용자 지시). + * + * 크기·확장자 표기 헬퍼도 여기에 둔다 — 보관함 본체(TempUpload)가 이 파일을 가져다 + * 쓰므로 import 방향이 한쪽으로만 흐른다. + * ========================================================================== */ + +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { table, text } from "@ui/ui_template_general_blocks"; +import { L } from "./B01_Dashboard_UI_Common"; + +/** 파일 확장자 = 보관함 슬롯 종류(csv·las·prj·tfw·tif). */ +export function tempFileType(fileName: string): string { + const index = fileName.lastIndexOf("."); + return index >= 0 ? fileName.slice(index + 1).toLowerCase() : ""; +} + +export function formatTempBytes(bytes: number): string { + const gb = bytes / 1024 / 1024 / 1024; + if (gb >= 1) return `${gb.toFixed(2)} GB`; + const mb = bytes / 1024 / 1024; + if (mb >= 1) return `${mb.toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +export interface TempModalOptions { + title: string; + /** 신규 등록이면 true — 그룹 이름을 함께 받는다. 기존 그룹 파일 추가면 false. */ + askName: boolean; + /** [확인] 눌렀을 때. 모달은 먼저 닫히고, 업로드는 목록에서 진행 상황을 보여 준다. */ + onConfirm: (payload: { name: string; files: File[] }) => void; +} + +export function openTempFileModal(options: TempModalOptions): void { + const modal = document.createElement("div"); + modal.className = "b01-dashboard__modal"; + const panel = document.createElement("div"); + panel.className = "b01-dashboard__modal-panel"; + const heading = document.createElement("h3"); + heading.className = "b01-dashboard__modal-title"; + heading.textContent = options.title; + + const nameField = createInputField({ + label: L("B01_Temp_Field_Name"), + placeholder: L("B01_Temp_Field_Name_Placeholder"), + required: true, + }); + + const picker = document.createElement("input"); + picker.type = "file"; + picker.multiple = true; + picker.accept = ".csv,.las,.laz,.tif,.tfw,.prj"; + picker.className = "b01-temp__hidden-input"; + + const pickRow = document.createElement("div"); + pickRow.className = "b01-temp__modal-pick"; + const caption = document.createElement("span"); + caption.textContent = L("B01_Temp_Field_Files"); + pickRow.append( + caption, + createButton({ + label: L("B01_Temp_Btn_Pick"), + variant: "ghost", + onClick: () => picker.click(), + }), + ); + + const listHost = document.createElement("div"); + listHost.className = "b01-temp__modal-list"; + const chosen: File[] = []; + + function renderList(): void { + listHost.replaceChildren( + table( + [ + L("B01_Temp_Table_Type"), + L("B01_Temp_Table_Name"), + L("B01_Temp_Table_Size"), + L("B01_Temp_Table_Action"), + ], + chosen.map((file) => [ + text(tempFileType(file.name).toUpperCase()), + text(file.name), + text(formatTempBytes(file.size)), + createButton({ + label: L("Common_Btn_Delete"), + variant: "ghost", + onClick: () => { + const index = chosen.indexOf(file); + if (index >= 0) chosen.splice(index, 1); + renderList(); + }, + }), + ]), + ), + ); + } + + picker.addEventListener("change", () => { + for (const file of Array.from(picker.files ?? [])) { + // 같은 파일을 두 번 고른 경우는 무시한다. + const duplicated = chosen.some((item) => item.name === file.name && item.size === file.size); + if (!duplicated) chosen.push(file); + } + picker.value = ""; + renderList(); + }); + + const actions = document.createElement("div"); + actions.className = "b01-dashboard__actions"; + actions.append( + createButton({ + label: L("Common_Btn_Cancel"), + variant: "ghost", + onClick: () => modal.remove(), + }), + createButton({ + label: L("Common_Btn_Confirm"), + onClick: () => { + const name = nameField.input.value.trim(); + if (options.askName && !name) { + showToast(L("B01_Temp_Error_Name"), "error"); + return; + } + if (chosen.length === 0) { + showToast(L("B01_Temp_Error_Files"), "error"); + return; + } + modal.remove(); + options.onConfirm({ name, files: [...chosen] }); + }, + }), + ); + + renderList(); + panel.append(heading); + if (options.askName) panel.append(nameField.root); + panel.append(pickRow, listHost, picker, actions); + modal.append(panel); + document.body.append(modal); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts index d277f59e..5d584873 100644 --- a/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts +++ b/B01_Dashboard/B01_Dashboard_UI_TempUpload.ts @@ -2,17 +2,22 @@ * B01_Dashboard_UI_TempUpload.ts * 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳. * - * 등록 폼은 프로젝트 등록(B02)과 같은 모양으로 두고, 진행 상황은 별도 모달이 아니라 - * 보관함 **리스트 행**에 표시한다(2026-08-08 사용자 지시). 대용량 라이다는 청크로 - * 나눠 올리고 새로고침 후에도 이어올릴 수 있다. + * 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(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, @@ -21,6 +26,7 @@ import { 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"; /** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */ @@ -33,16 +39,24 @@ interface StoredTempSession { totalChunks: number; } -const CHUNK_UPLOAD_EXT = new Set([".las", ".laz"]); +/** 이 브라우저에서 지금 올리고 있는 파일 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 formatBytes(bytes: number): string { - const gb = bytes / 1024 / 1024 / 1024; - if (gb >= 1) return `${gb.toFixed(2)} GB`; - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +function taskKey(file: File): string { + return `${file.name}::${file.size}`; } function formatDate(value: string | null): string { @@ -51,70 +65,123 @@ function formatDate(value: string | null): string { return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString(); } -function extensionOf(name: string): string { - const index = name.lastIndexOf("."); - return index >= 0 ? name.slice(index).toLowerCase() : ""; -} - /** - * 보관함 섹션을 만든다. 반환 요소는 대시보드 grid에 그대로 붙인다. + * 보관함 섹션(제목 + [+] 버튼 + 그룹 목록)을 통째로 만든다. * 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음). */ -export function buildTempUploadPanel(): HTMLElement { +export function buildTempUploadSection(): HTMLElement { const panel = document.createElement("div"); panel.className = "b01-temp"; - /* ── 등록 폼 (프로젝트 등록과 같은 모양) ───────────────────────────── */ - const form = document.createElement("div"); - form.className = "b01-temp__form"; - - const nameField = document.createElement("label"); - nameField.className = "b01-temp__field"; - const nameCaption = document.createElement("span"); - nameCaption.textContent = L("B01_Temp_Field_Name"); - const nameInput = document.createElement("input"); - nameInput.type = "text"; - nameInput.maxLength = 200; - nameInput.placeholder = L("B01_Temp_Field_Name_Placeholder"); - nameField.append(nameCaption, nameInput); - - const fileField = document.createElement("label"); - fileField.className = "b01-temp__field"; - const fileCaption = document.createElement("span"); - fileCaption.textContent = L("B01_Temp_Field_Files"); - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.multiple = true; - fileInput.accept = ".csv,.las,.laz,.tif,.tfw,.prj"; - fileField.append(fileCaption, fileInput); - const hint = document.createElement("p"); hint.className = "b01-temp__hint"; hint.textContent = L("B01_Temp_Hint"); - const submit = createButton({ - label: L("B01_Temp_Btn_Upload"), - variant: "filled", - onClick: () => void startUpload(), - }); - - form.append(nameField, fileField, submit); - panel.append(form, hint); - - /* ── 보관함 리스트 ─────────────────────────────────────────────────── */ const listHost = document.createElement("div"); listHost.className = "b01-temp__list"; - panel.append(listHost); + panel.append(hint, listHost); - let retentionDays = 30; + /** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */ + const tasks = new Map(); + /** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */ + const progressNodes = new Map(); - function renderRow(batch: TempBatchItem): HTMLElement { - const row = document.createElement("div"); - row.className = `b01-temp__row is-${batch.status}`; - row.dataset.batchId = batch.batch_id; + /* ── 행 구성 ─────────────────────────────────────────────────────────── */ + + 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__row-head"; + 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"); @@ -125,13 +192,13 @@ export function buildTempUploadPanel(): HTMLElement { : batch.required_complete ? L("B01_Temp_Status_Ready") : L("B01_Temp_Status_Uploading"); - head.append(title, badge); + titleRow.append(title, badge); const meta = document.createElement("div"); - meta.className = "b01-temp__row-meta"; + meta.className = "b01-temp__group-meta"; const parts = [ `${batch.files.length}${L("B01_Temp_Meta_FileCount")}`, - formatBytes(batch.total_size_bytes), + formatTempBytes(batch.total_size_bytes), ]; if (batch.status === "linked") { parts.push(L("B01_Temp_Meta_Linked")); @@ -139,34 +206,17 @@ export function buildTempUploadPanel(): HTMLElement { parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`); } meta.textContent = parts.join(" · "); - - const files = document.createElement("div"); - files.className = "b01-temp__files"; - for (const file of batch.files) { - const chip = document.createElement("span"); - chip.className = "b01-temp__chip"; - chip.textContent = `${file.file_type.toUpperCase()} · ${file.original_filename}`; - files.append(chip); - } - // 진행 중인 청크 세션은 같은 리스트 행에 진행률 막대로 보여 준다(모달 없음). - for (const pending of batch.pending_sessions) { - const progressRow = document.createElement("div"); - progressRow.className = "b01-temp__progress"; - const label = document.createElement("span"); - label.textContent = `${pending.original_filename} ${pending.progress_percent}%`; - const bar = document.createElement("div"); - bar.className = "b01-temp__progress-bar"; - const fill = document.createElement("div"); - fill.style.width = `${pending.progress_percent}%`; - bar.append(fill); - progressRow.append(label, bar); - files.append(progressRow); - } + info.append(titleRow, meta); const actions = document.createElement("div"); - actions.className = "b01-temp__row-actions"; + 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", @@ -174,16 +224,29 @@ export function buildTempUploadPanel(): HTMLElement { }), ); } + head.append(info, actions); - row.append(head, meta, files, actions); - return row; + 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 { try { const response = await fetchTempBatches(); - retentionDays = response.retention_days; - hint.textContent = `${L("B01_Temp_Hint")} (${retentionDays}${L("B01_Temp_Hint_Days")})`; + 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"); @@ -192,16 +255,19 @@ export function buildTempUploadPanel(): HTMLElement { listHost.append(empty); return; } - for (const batch of response.batches) listHost.append(renderRow(batch)); + 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 { 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) { @@ -209,8 +275,29 @@ export function buildTempUploadPanel(): HTMLElement { } } - /** 대용량 파일 1건을 청크로 올린다(이어올리기 포함). 진행률은 리스트 행에 반영. */ - async function uploadLargeFile(batchId: string, file: File): Promise { + 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; @@ -251,55 +338,101 @@ export function buildTempUploadPanel(): HTMLElement { } 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)); - // 리스트 행 진행률은 서버 정본을 다시 읽어 갱신한다(모달 없이 화면에서 확인). - if (index % 2 === 1 || index === totalChunks - 1) await refresh(); + done.add(index); + onProgress((done.size / Math.max(1, totalChunks)) * 100); } await finalizeTempUpload(batchId, sessionId, totalChunks); localStorage.removeItem(key); } - async function startUpload(): Promise { - const name = nameInput.value.trim(); - const selected = Array.from(fileInput.files ?? []); - if (!name) { - showToast(L("B01_Temp_Error_Name"), "error"); - return; - } - if (selected.length === 0) { - showToast(L("B01_Temp_Error_Files"), "error"); - return; - } + /** 고른 파일들을 한 그룹에 차례로 올린다. 끝난 파일은 서버 목록으로 넘어간다. */ + 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]); + await refresh(); - submit.disabled = true; - try { - const batch = await createTempBatch(name); - const small = selected.filter((file) => !CHUNK_UPLOAD_EXT.has(extensionOf(file.name))); - const large = selected.filter((file) => CHUNK_UPLOAD_EXT.has(extensionOf(file.name))); - - if (small.length > 0) { - await uploadTempBatchFiles(batch.batch_id, small); - await refresh(); - } - for (const file of large) { - await uploadLargeFile(batch.batch_id, file); - } - nameInput.value = ""; - fileInput.value = ""; - showToast(L("B01_Temp_Upload_Success"), "success"); + let failed = 0; + for (const [index, file] of files.entries()) { + const task = queued[index]; + task.status = "uploading"; await refresh(); - } catch (error) { - showToast(error instanceof Error ? error.message : L("B01_Temp_Upload_Failed"), "error"); + 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(); - } finally { - submit.disabled = false; } + 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 panel; + + return section(L("B01_Temp_Section"), panel, true, [ + createButton({ label: "+", onClick: openCreateModal }), + ]); } diff --git a/B03_FileInput/B03_FileInput_Repository_Temp.py b/B03_FileInput/B03_FileInput_Repository_Temp.py index e9ee5c83..efb3b70c 100644 --- a/B03_FileInput/B03_FileInput_Repository_Temp.py +++ b/B03_FileInput/B03_FileInput_Repository_Temp.py @@ -178,6 +178,63 @@ async def upsert_temp_batch_file( ) +async def get_temp_batch_file( + connection: aiomysql.Connection, + *, + batch_id: str, + file_type: str, +) -> dict[str, Any]: + """묶음 안 파일 1건(종류로 지정). 삭제 전 실제 저장 경로를 알아내는 용도.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT batch_id, file_type, original_filename, relative_path, file_size_bytes + FROM temp_upload_files + WHERE batch_id = %s AND file_type = %s + """, + (batch_id, file_type), + ) + row = await cursor.fetchone() + if not row: + raise LookupError("보관함 파일을 찾을 수 없습니다.") + return dict(row) + + +async def delete_temp_batch_file( + connection: aiomysql.Connection, + *, + batch_id: str, + file_type: str, +) -> None: + """묶음 안 파일 1건 삭제(행만 — 실제 파일은 라우터가 지운다).""" + async with connection.cursor() as cursor: + await cursor.execute( + "DELETE FROM temp_upload_files WHERE batch_id = %s AND file_type = %s", + (batch_id, file_type), + ) + + +async def mark_temp_batch_incomplete( + connection: aiomysql.Connection, + *, + batch_id: str, +) -> None: + """필수 파일이 빠지면 다시 '업로드 중'으로 되돌린다. + + 만료일(`expires_at`)은 손대지 않는다 — 보관 기한은 **처음 완료 시점** 기준이고, + 파일을 지웠다 다시 올리는 것으로 기한이 늘어나면 안 된다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE temp_upload_batches + SET status = 'uploading' + WHERE id = %s AND status = 'completed' + """, + (batch_id,), + ) + + async def get_temp_batch_file_types( connection: aiomysql.Connection, *, diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index 1ae2995a..3ed8adc8 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -36,7 +36,9 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( create_temp_batch, create_temp_upload_session, delete_temp_batch, + delete_temp_batch_file, get_temp_batch, + get_temp_batch_file, get_temp_batch_file_types, get_temp_upload_session, is_batch_required_complete, @@ -44,6 +46,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( list_temp_batch_sessions, list_temp_batches, mark_temp_batch_completed, + mark_temp_batch_incomplete, mark_temp_batch_linked, upsert_temp_batch_file, ) @@ -102,11 +105,14 @@ async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path: async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool: - """필수 파일이 다 찼으면 완료로 올린다. 완료 여부를 돌려준다.""" + """필수 파일 충족 여부에 맞춰 상태를 맞춘다. 완료 여부를 돌려준다.""" file_types = await get_temp_batch_file_types(connection, batch_id=batch_id) complete = is_batch_required_complete(file_types) if complete: await mark_temp_batch_completed(connection, batch_id=batch_id) + else: + # 파일을 지워 필수 조건이 깨진 경우 — 프로젝트 연결 대상에서 빠져야 한다. + await mark_temp_batch_incomplete(connection, batch_id=batch_id) return complete @@ -235,6 +241,55 @@ async def remove_batch( ) +@router.delete("/{batch_id}/files/{file_type}") +async def remove_batch_file( + batch_id: str, + file_type: str, + session: dict[str, Any] = Depends(verify_session), +) -> JSONResponse: + """묶음 안 파일 1건을 지운다 — DB 행과 임시 저장소 실제 파일을 함께 지운다.""" + user_id = int(session["user_id"]) + normalized = file_type.lower().lstrip(".") + pool = get_db_pool() + try: + async with pool.acquire() as connection: + batch = await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + if str(batch["status"]) == "linked": + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "프로젝트에 연결된 보관함은 수정할 수 없습니다.", + }, + ) + item = await get_temp_batch_file(connection, batch_id=batch_id, file_type=normalized) + await delete_temp_batch_file(connection, batch_id=batch_id, file_type=normalized) + required_complete = await _refresh_batch_status(connection, batch_id=batch_id) + await connection.commit() + + batch_root = Path(resolve_temp_batch_path(user_id, batch_id, create=False)) + stored = batch_root / str(item["relative_path"]) + stored.unlink(missing_ok=True) + return JSONResponse( + content={ + "status": "success", + "batch_id": batch_id, + "file_type": normalized, + "required_complete": required_complete, + } + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except OSError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("보관함 파일 삭제 실패: batch_id=%s type=%s", batch_id, file_type) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "파일 삭제 중 오류가 발생했습니다."}, + ) + + @router.post("/{batch_id}/files", response_model=TempFileUploadResponse) async def upload_batch_files( batch_id: str, diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index ca477e19..7d5cb06f 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -82,6 +82,26 @@ export const ui_locales_b1 = { B01_Temp_Field_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"], B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"], B01_Temp_Btn_Upload: ["보관함에 올리기", "Upload to storage"], + B01_Temp_Btn_Pick: ["파일 선택", "Choose files"], + B01_Temp_Btn_Add: ["파일 추가", "Add files"], + B01_Temp_Modal_Create: ["임시 자료 등록", "New stored set"], + B01_Temp_Modal_Add: ["파일 추가", "Add files"], + B01_Temp_Table_Type: ["종류", "Type"], + B01_Temp_Table_Name: ["파일명", "File"], + B01_Temp_Table_Size: ["크기", "Size"], + B01_Temp_Table_Status: ["상태", "Status"], + B01_Temp_Table_Action: ["작업", "Action"], + B01_Temp_File_Status_Stored: ["저장 완료", "Stored"], + B01_Temp_File_Status_Waiting: ["대기", "Waiting"], + B01_Temp_File_Status_Uploading: ["업로드 중", "Uploading"], + B01_Temp_File_Status_Paused: ["이어올리기 대기", "Resume pending"], + B01_Temp_File_Status_Failed: ["실패", "Failed"], + B01_Temp_File_Delete_Confirm: [ + "이 파일을 보관함에서 삭제할까요?", + "Delete this file from temporary storage?", + ], + B01_Temp_File_Delete_Success: ["파일을 삭제했습니다.", "File deleted."], + B01_Temp_File_Delete_Failed: ["파일 삭제에 실패했습니다.", "Failed to delete the file."], B01_Temp_Hint: [ "프로젝트를 만들기 전에 자료를 먼저 올려 둘 수 있습니다. 나중에 프로젝트 파일 입력 화면에서 불러오면 됩니다.", "Upload files before creating a project, then pull them in from the project file input page.",