feat(B01): 임시 보관함 UI를 그룹+파일 리스트 컨테이너로 개편
프로젝트/사용자 관리 컨테이너와 같은 형태로 통일한다. 등록 폼을 화면에 상시
노출하지 않고, 우측 상단 [+] 모달로 받아 그룹(임시 프로젝트명) 아래 파일 표를
그린다.
프론트엔드
- B01_Dashboard_UI_TempModal.ts 신설: 등록/추가 모달(임시 프로젝트명 + 파일 선택,
고른 파일을 모달 안 표로 표시, 하단 [취소][확인] — 기존 대시보드 모달과 동일 규격)
- B01_Dashboard_UI_TempUpload.ts: 섹션 전체(제목·[+]·목록)를 반환하도록 변경.
그룹 카드 + 파일 표(종류/파일명/크기/상태/작업), 그룹별 [파일 추가],
파일별 [삭제], 진행률은 해당 파일 행 안에서 표시
- 청크마다 목록을 다시 조회하지 않고 막대 DOM만 갱신하도록 정리
- 대시보드 배치: 프로젝트 컨테이너 바로 아래(역할 3분기 모두)
백엔드
- DELETE /api/temp-uploads/{batch_id}/files/{file_type}: DB 행과 임시 저장소
실제 파일을 함께 삭제. 필수 파일이 빠지면 상태를 uploading으로 되돌리고
만료일은 최초 완료 시점 기준을 유지
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,3 +50,4 @@ resources/
|
|||||||
|
|
||||||
# 루트 임시 파일 (세션 도구 산출물)
|
# 루트 임시 파일 (세션 도구 산출물)
|
||||||
.tmp_*
|
.tmp_*
|
||||||
|
*.log.err
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* GET /api/temp-uploads 내 보관함 목록
|
* GET /api/temp-uploads 내 보관함 목록
|
||||||
* DELETE /api/temp-uploads/{batch_id} 묶음 삭제
|
* DELETE /api/temp-uploads/{batch_id} 묶음 삭제
|
||||||
* POST /api/temp-uploads/{batch_id}/files 작은 파일 저장
|
* 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}/upload-sessions 청크 세션 생성
|
||||||
* POST /api/temp-uploads/{batch_id}/chunks 청크 저장
|
* POST /api/temp-uploads/{batch_id}/chunks 청크 저장
|
||||||
* POST /api/temp-uploads/{batch_id}/finalize 병합·완료
|
* POST /api/temp-uploads/{batch_id}/finalize 병합·완료
|
||||||
@@ -90,6 +91,14 @@ export async function deleteTempBatch(batchId: string): Promise<void> {
|
|||||||
await requestJson(`/temp-uploads/${encodeURIComponent(batchId)}`, { method: "DELETE" });
|
await requestJson(`/temp-uploads/${encodeURIComponent(batchId)}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 묶음 안 파일 1건 삭제 — 임시 저장소의 실제 파일도 함께 지워진다. */
|
||||||
|
export async function deleteTempBatchFile(batchId: string, fileType: string): Promise<void> {
|
||||||
|
await requestJson(
|
||||||
|
`/temp-uploads/${encodeURIComponent(batchId)}/files/${encodeURIComponent(fileType)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 작은 파일(csv·prj·tfw·tif)은 한 번에 보낸다. */
|
/** 작은 파일(csv·prj·tfw·tif)은 한 번에 보낸다. */
|
||||||
export async function uploadTempBatchFiles(
|
export async function uploadTempBatchFiles(
|
||||||
batchId: string,
|
batchId: string,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_M
|
|||||||
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
|
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
|
||||||
import { projectTable } from "./B01_Dashboard_UI_Projects";
|
import { projectTable } from "./B01_Dashboard_UI_Projects";
|
||||||
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
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";
|
import "./B01_Dashboard_UI_Style.css";
|
||||||
|
|
||||||
export interface DashboardState {
|
export interface DashboardState {
|
||||||
@@ -143,6 +143,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
|
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
|
||||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||||
]),
|
]),
|
||||||
|
buildTempUploadSection(),
|
||||||
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
|
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
|
||||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||||
]),
|
]),
|
||||||
@@ -157,6 +158,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
|
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
|
||||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||||
]),
|
]),
|
||||||
|
buildTempUploadSection(),
|
||||||
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
|
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
|
||||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||||
]),
|
]),
|
||||||
@@ -168,14 +170,14 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
|
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
|
||||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||||
]),
|
]),
|
||||||
|
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
|
||||||
|
// 바로 아래에 둔다(2026-08-08 사용자 지시).
|
||||||
|
buildTempUploadSection(),
|
||||||
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
|
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳이라 프로젝트 목록
|
|
||||||
// 바로 다음에 둔다(2026-08-08 사용자 지시).
|
|
||||||
grid.append(
|
grid.append(
|
||||||
section(L("B01_Temp_Section"), buildTempUploadPanel(), true),
|
|
||||||
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
|
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
|
||||||
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
section(L("B01_Account_Section_Security"), buildSecurityForm()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +1,12 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B01_Dashboard_UI_Style_Temp.css
|
* 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 {
|
.b01-temp__hint {
|
||||||
margin: var(--spacing-8) 0 var(--spacing-16);
|
margin: 0 0 var(--spacing-16);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-muted-text, var(--color-primary-text));
|
color: var(--color-muted-text, var(--color-primary-text));
|
||||||
}
|
}
|
||||||
@@ -44,10 +21,11 @@
|
|||||||
.b01-temp__list {
|
.b01-temp__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-8);
|
gap: var(--spacing-16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-temp__row {
|
/* 그룹 = 임시 프로젝트명 1건. 아래에 파일 표가 붙는다. */
|
||||||
|
.b01-temp__group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-8);
|
gap: var(--spacing-8);
|
||||||
@@ -57,16 +35,34 @@
|
|||||||
background: var(--color-paper);
|
background: var(--color-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 목록에서 현재 쓸 수 있는 자료와 구분한다. */
|
/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 지금 쓸 수 있는 자료와 구분한다. */
|
||||||
.b01-temp__row.is-linked {
|
.b01-temp__group.is-linked {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-temp__row-head {
|
.b01-temp__group-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--spacing-8);
|
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 {
|
.b01-temp__badge {
|
||||||
@@ -77,40 +73,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-temp__row.is-completed .b01-temp__badge {
|
.b01-temp__group.is-completed .b01-temp__badge {
|
||||||
border-color: var(--color-success);
|
border-color: var(--color-success);
|
||||||
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);
|
border-color: var(--color-warning);
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-temp__row-meta {
|
/* 업로드 진행률은 모달이 아니라 해당 파일 행 안에서 보여 준다(2026-08-08 사용자 지시). */
|
||||||
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 사용자 지시). */
|
|
||||||
.b01-temp__progress {
|
.b01-temp__progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 100%;
|
min-width: 140px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +105,22 @@
|
|||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-temp__row-actions {
|
/* ── 등록/추가 모달 ───────────────────────────────────────────────────── */
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
.b01-temp__hidden-input {
|
||||||
gap: var(--spacing-8);
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
.b01-temp__modal-pick {
|
||||||
.b01-temp__form {
|
display: flex;
|
||||||
grid-template-columns: 1fr;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,17 +2,22 @@
|
|||||||
* B01_Dashboard_UI_TempUpload.ts
|
* B01_Dashboard_UI_TempUpload.ts
|
||||||
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
||||||
*
|
*
|
||||||
* 등록 폼은 프로젝트 등록(B02)과 같은 모양으로 두고, 진행 상황은 별도 모달이 아니라
|
* 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시):
|
||||||
* 보관함 **리스트 행**에 표시한다(2026-08-08 사용자 지시). 대용량 라이다는 청크로
|
* - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환
|
||||||
* 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
|
* - 그룹(임시 프로젝트명) 아래에 파일 리스트(표)
|
||||||
|
* - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음)
|
||||||
|
* - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다
|
||||||
|
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||||
|
import { section, table, text } from "@ui/ui_template_general_blocks";
|
||||||
import {
|
import {
|
||||||
createTempBatch,
|
createTempBatch,
|
||||||
createTempUploadSession,
|
createTempUploadSession,
|
||||||
deleteTempBatch,
|
deleteTempBatch,
|
||||||
|
deleteTempBatchFile,
|
||||||
fetchTempBatches,
|
fetchTempBatches,
|
||||||
finalizeTempUpload,
|
finalizeTempUpload,
|
||||||
fetchTempUploadStatus,
|
fetchTempUploadStatus,
|
||||||
@@ -21,6 +26,7 @@ import {
|
|||||||
type TempBatchItem,
|
type TempBatchItem,
|
||||||
} from "./B01_Dashboard_Api_Temp";
|
} from "./B01_Dashboard_Api_Temp";
|
||||||
import { L } from "./B01_Dashboard_UI_Common";
|
import { L } from "./B01_Dashboard_UI_Common";
|
||||||
|
import { formatTempBytes, openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
|
||||||
import "./B01_Dashboard_UI_Style_Temp.css";
|
import "./B01_Dashboard_UI_Style_Temp.css";
|
||||||
|
|
||||||
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
|
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
|
||||||
@@ -33,16 +39,24 @@ interface StoredTempSession {
|
|||||||
totalChunks: number;
|
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 {
|
function sessionKey(batchId: string, file: File): string {
|
||||||
return `temp:session:${batchId}:${file.name}:${file.size}`;
|
return `temp:session:${batchId}:${file.name}:${file.size}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function taskKey(file: File): string {
|
||||||
const gb = bytes / 1024 / 1024 / 1024;
|
return `${file.name}::${file.size}`;
|
||||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
function formatDate(value: string | null): string {
|
||||||
@@ -51,70 +65,123 @@ function formatDate(value: string | null): string {
|
|||||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
|
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");
|
const panel = document.createElement("div");
|
||||||
panel.className = "b01-temp";
|
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");
|
const hint = document.createElement("p");
|
||||||
hint.className = "b01-temp__hint";
|
hint.className = "b01-temp__hint";
|
||||||
hint.textContent = L("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");
|
const listHost = document.createElement("div");
|
||||||
listHost.className = "b01-temp__list";
|
listHost.className = "b01-temp__list";
|
||||||
panel.append(listHost);
|
panel.append(hint, listHost);
|
||||||
|
|
||||||
let retentionDays = 30;
|
/** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */
|
||||||
|
const tasks = new Map<string, UploadTask[]>();
|
||||||
|
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
|
||||||
|
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
|
||||||
|
|
||||||
function renderRow(batch: TempBatchItem): HTMLElement {
|
/* ── 행 구성 ─────────────────────────────────────────────────────────── */
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = `b01-temp__row is-${batch.status}`;
|
function progressCell(key: string, percent: number, label: string): HTMLElement {
|
||||||
row.dataset.batchId = batch.batch_id;
|
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");
|
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");
|
const title = document.createElement("strong");
|
||||||
title.textContent = batch.name;
|
title.textContent = batch.name;
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
@@ -125,13 +192,13 @@ export function buildTempUploadPanel(): HTMLElement {
|
|||||||
: batch.required_complete
|
: batch.required_complete
|
||||||
? L("B01_Temp_Status_Ready")
|
? L("B01_Temp_Status_Ready")
|
||||||
: L("B01_Temp_Status_Uploading");
|
: L("B01_Temp_Status_Uploading");
|
||||||
head.append(title, badge);
|
titleRow.append(title, badge);
|
||||||
|
|
||||||
const meta = document.createElement("div");
|
const meta = document.createElement("div");
|
||||||
meta.className = "b01-temp__row-meta";
|
meta.className = "b01-temp__group-meta";
|
||||||
const parts = [
|
const parts = [
|
||||||
`${batch.files.length}${L("B01_Temp_Meta_FileCount")}`,
|
`${batch.files.length}${L("B01_Temp_Meta_FileCount")}`,
|
||||||
formatBytes(batch.total_size_bytes),
|
formatTempBytes(batch.total_size_bytes),
|
||||||
];
|
];
|
||||||
if (batch.status === "linked") {
|
if (batch.status === "linked") {
|
||||||
parts.push(L("B01_Temp_Meta_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)}`);
|
parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`);
|
||||||
}
|
}
|
||||||
meta.textContent = parts.join(" · ");
|
meta.textContent = parts.join(" · ");
|
||||||
|
info.append(titleRow, meta);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "b01-temp__row-actions";
|
actions.className = "b01-dashboard__actions";
|
||||||
if (batch.status !== "linked") {
|
if (batch.status !== "linked") {
|
||||||
actions.append(
|
actions.append(
|
||||||
|
createButton({
|
||||||
|
label: L("B01_Temp_Btn_Add"),
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () => openAddModal(batch),
|
||||||
|
}),
|
||||||
createButton({
|
createButton({
|
||||||
label: L("Common_Btn_Delete"),
|
label: L("Common_Btn_Delete"),
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
@@ -174,16 +224,29 @@ export function buildTempUploadPanel(): HTMLElement {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
head.append(info, actions);
|
||||||
|
|
||||||
row.append(head, meta, files, actions);
|
group.append(
|
||||||
return row;
|
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<void> {
|
async function refresh(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetchTempBatches();
|
const response = await fetchTempBatches();
|
||||||
retentionDays = response.retention_days;
|
hint.textContent = `${L("B01_Temp_Hint")} (${response.retention_days}${L("B01_Temp_Hint_Days")})`;
|
||||||
hint.textContent = `${L("B01_Temp_Hint")} (${retentionDays}${L("B01_Temp_Hint_Days")})`;
|
progressNodes.clear();
|
||||||
listHost.replaceChildren();
|
listHost.replaceChildren();
|
||||||
if (response.batches.length === 0) {
|
if (response.batches.length === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
@@ -192,16 +255,19 @@ export function buildTempUploadPanel(): HTMLElement {
|
|||||||
listHost.append(empty);
|
listHost.append(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const batch of response.batches) listHost.append(renderRow(batch));
|
for (const batch of response.batches) listHost.append(renderGroup(batch));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
|
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 삭제 ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
async function removeBatch(batch: TempBatchItem): Promise<void> {
|
async function removeBatch(batch: TempBatchItem): Promise<void> {
|
||||||
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
|
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
|
||||||
try {
|
try {
|
||||||
await deleteTempBatch(batch.batch_id);
|
await deleteTempBatch(batch.batch_id);
|
||||||
|
tasks.delete(batch.batch_id);
|
||||||
showToast(L("B01_Temp_Delete_Success"), "success");
|
showToast(L("B01_Temp_Delete_Success"), "success");
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -209,8 +275,29 @@ export function buildTempUploadPanel(): HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 대용량 파일 1건을 청크로 올린다(이어올리기 포함). 진행률은 리스트 행에 반영. */
|
async function removeFile(
|
||||||
async function uploadLargeFile(batchId: string, file: File): Promise<void> {
|
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 chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
||||||
const key = sessionKey(batchId, file);
|
const key = sessionKey(batchId, file);
|
||||||
let stored: StoredTempSession | null = null;
|
let stored: StoredTempSession | null = null;
|
||||||
@@ -251,55 +338,101 @@ export function buildTempUploadPanel(): HTMLElement {
|
|||||||
} satisfies StoredTempSession),
|
} satisfies StoredTempSession),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onProgress((done.size / Math.max(1, totalChunks)) * 100);
|
||||||
for (let index = 0; index < totalChunks; index += 1) {
|
for (let index = 0; index < totalChunks; index += 1) {
|
||||||
if (done.has(index)) continue;
|
if (done.has(index)) continue;
|
||||||
const start = index * chunkSizeBytes;
|
const start = index * chunkSizeBytes;
|
||||||
const end = Math.min(file.size, start + chunkSizeBytes);
|
const end = Math.min(file.size, start + chunkSizeBytes);
|
||||||
await uploadTempChunk(batchId, sessionId, index, file.slice(start, end));
|
await uploadTempChunk(batchId, sessionId, index, file.slice(start, end));
|
||||||
// 리스트 행 진행률은 서버 정본을 다시 읽어 갱신한다(모달 없이 화면에서 확인).
|
done.add(index);
|
||||||
if (index % 2 === 1 || index === totalChunks - 1) await refresh();
|
onProgress((done.size / Math.max(1, totalChunks)) * 100);
|
||||||
}
|
}
|
||||||
await finalizeTempUpload(batchId, sessionId, totalChunks);
|
await finalizeTempUpload(batchId, sessionId, totalChunks);
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startUpload(): Promise<void> {
|
/** 고른 파일들을 한 그룹에 차례로 올린다. 끝난 파일은 서버 목록으로 넘어간다. */
|
||||||
const name = nameInput.value.trim();
|
async function runUpload(batchId: string, files: File[]): Promise<void> {
|
||||||
const selected = Array.from(fileInput.files ?? []);
|
const queued: UploadTask[] = files.map((file) => ({
|
||||||
if (!name) {
|
key: taskKey(file),
|
||||||
showToast(L("B01_Temp_Error_Name"), "error");
|
fileName: file.name,
|
||||||
return;
|
fileType: tempFileType(file.name),
|
||||||
}
|
sizeBytes: file.size,
|
||||||
if (selected.length === 0) {
|
percent: 0,
|
||||||
showToast(L("B01_Temp_Error_Files"), "error");
|
status: "waiting",
|
||||||
return;
|
}));
|
||||||
}
|
// 지난번 실패 행은 새로 올리기 시작하면 치운다.
|
||||||
|
const previous = (tasks.get(batchId) ?? []).filter((item) => item.status !== "failed");
|
||||||
|
tasks.set(batchId, [...previous, ...queued]);
|
||||||
|
await refresh();
|
||||||
|
|
||||||
submit.disabled = true;
|
let failed = 0;
|
||||||
try {
|
for (const [index, file] of files.entries()) {
|
||||||
const batch = await createTempBatch(name);
|
const task = queued[index];
|
||||||
const small = selected.filter((file) => !CHUNK_UPLOAD_EXT.has(extensionOf(file.name)));
|
task.status = "uploading";
|
||||||
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");
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
try {
|
||||||
showToast(error instanceof Error ? error.message : L("B01_Temp_Upload_Failed"), "error");
|
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();
|
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();
|
void refresh();
|
||||||
return panel;
|
|
||||||
|
return section(L("B01_Temp_Section"), panel, true, [
|
||||||
|
createButton({ label: "+", onClick: openCreateModal }),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
async def get_temp_batch_file_types(
|
||||||
connection: aiomysql.Connection,
|
connection: aiomysql.Connection,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
|
|||||||
create_temp_batch,
|
create_temp_batch,
|
||||||
create_temp_upload_session,
|
create_temp_upload_session,
|
||||||
delete_temp_batch,
|
delete_temp_batch,
|
||||||
|
delete_temp_batch_file,
|
||||||
get_temp_batch,
|
get_temp_batch,
|
||||||
|
get_temp_batch_file,
|
||||||
get_temp_batch_file_types,
|
get_temp_batch_file_types,
|
||||||
get_temp_upload_session,
|
get_temp_upload_session,
|
||||||
is_batch_required_complete,
|
is_batch_required_complete,
|
||||||
@@ -44,6 +46,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
|
|||||||
list_temp_batch_sessions,
|
list_temp_batch_sessions,
|
||||||
list_temp_batches,
|
list_temp_batches,
|
||||||
mark_temp_batch_completed,
|
mark_temp_batch_completed,
|
||||||
|
mark_temp_batch_incomplete,
|
||||||
mark_temp_batch_linked,
|
mark_temp_batch_linked,
|
||||||
upsert_temp_batch_file,
|
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:
|
async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool:
|
||||||
"""필수 파일이 다 찼으면 완료로 올린다. 완료 여부를 돌려준다."""
|
"""필수 파일 충족 여부에 맞춰 상태를 맞춘다. 완료 여부를 돌려준다."""
|
||||||
file_types = await get_temp_batch_file_types(connection, batch_id=batch_id)
|
file_types = await get_temp_batch_file_types(connection, batch_id=batch_id)
|
||||||
complete = is_batch_required_complete(file_types)
|
complete = is_batch_required_complete(file_types)
|
||||||
if complete:
|
if complete:
|
||||||
await mark_temp_batch_completed(connection, batch_id=batch_id)
|
await mark_temp_batch_completed(connection, batch_id=batch_id)
|
||||||
|
else:
|
||||||
|
# 파일을 지워 필수 조건이 깨진 경우 — 프로젝트 연결 대상에서 빠져야 한다.
|
||||||
|
await mark_temp_batch_incomplete(connection, batch_id=batch_id)
|
||||||
return complete
|
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)
|
@router.post("/{batch_id}/files", response_model=TempFileUploadResponse)
|
||||||
async def upload_batch_files(
|
async def upload_batch_files(
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
|
|||||||
@@ -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_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"],
|
||||||
B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"],
|
B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"],
|
||||||
B01_Temp_Btn_Upload: ["보관함에 올리기", "Upload to storage"],
|
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: [
|
B01_Temp_Hint: [
|
||||||
"프로젝트를 만들기 전에 자료를 먼저 올려 둘 수 있습니다. 나중에 프로젝트 파일 입력 화면에서 불러오면 됩니다.",
|
"프로젝트를 만들기 전에 자료를 먼저 올려 둘 수 있습니다. 나중에 프로젝트 파일 입력 화면에서 불러오면 됩니다.",
|
||||||
"Upload files before creating a project, then pull them in from the project file input page.",
|
"Upload files before creating a project, then pull them in from the project file input page.",
|
||||||
|
|||||||
Reference in New Issue
Block a user