feat: 항공영상 업로드 모달
This commit is contained in:
@@ -36,6 +36,10 @@
|
|||||||
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
|
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
|
||||||
|
--color-dabeeo-blue: #2768ff;
|
||||||
|
--color-dabeeo-red: #ff0000;
|
||||||
|
--color-dabeeo-yellow: #fffce8;
|
||||||
|
|
||||||
/* Primary (Navy) */
|
/* Primary (Navy) */
|
||||||
--color-primary: var(--color-dabeeo-navy-main);
|
--color-primary: var(--color-dabeeo-navy-main);
|
||||||
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
|
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
|
||||||
@@ -50,6 +54,16 @@
|
|||||||
--color-dabeeo-navy-tertiary01: #d4dde9;
|
--color-dabeeo-navy-tertiary01: #d4dde9;
|
||||||
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
||||||
|
|
||||||
|
/* Orange */
|
||||||
|
--color-dabeeo-orange-main: #ff7937;
|
||||||
|
--color-dabeeo-orange-secondary: #c14b11;
|
||||||
|
--color-dabeeo-orange-tertiary01: #fff2eb;
|
||||||
|
--color-dabeeo-orange-tertiary02: #ffe4d7;
|
||||||
|
|
||||||
|
/* Yellow */
|
||||||
|
--color-dabeeo-yellow-main: #ffb724;
|
||||||
|
--color-dabeeo-yellow-secondary: #fffce8;
|
||||||
|
|
||||||
/* Gray */
|
/* Gray */
|
||||||
--color-dabeeo-gray-44: #444444;
|
--color-dabeeo-gray-44: #444444;
|
||||||
--color-dabeeo-gray-99: #999999;
|
--color-dabeeo-gray-99: #999999;
|
||||||
@@ -57,6 +71,12 @@
|
|||||||
--color-dabeeo-gray-da: #dadada;
|
--color-dabeeo-gray-da: #dadada;
|
||||||
--color-dabeeo-gray-eb: #ebebeb;
|
--color-dabeeo-gray-eb: #ebebeb;
|
||||||
--color-dabeeo-gray-f9: #f9f9f9;
|
--color-dabeeo-gray-f9: #f9f9f9;
|
||||||
|
|
||||||
|
--color-dabeeo-black-22: #222222;
|
||||||
|
--color-dabeeo-black-34: #343434;
|
||||||
|
--color-dabeeo-black-47: #475954;
|
||||||
|
--color-dabeeo-black-2a: #2a2e35;
|
||||||
|
--color-dabeeo-black-4d: #4d5562;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { ApiResponse, PagedResponse } from '~/shared/types/api';
|
import type { ApiResponse, PagedResponse } from '~/shared/types/api';
|
||||||
import type {
|
import type {
|
||||||
|
AerialData,
|
||||||
AerialDetail,
|
AerialDetail,
|
||||||
AerialItem,
|
AerialItem,
|
||||||
AerialListParams,
|
AerialListParams,
|
||||||
ChunkUploadParams,
|
|
||||||
ChunkUploadResponse,
|
|
||||||
FolderListResponse,
|
|
||||||
Region,
|
|
||||||
} from '../types/aerial';
|
} from '../types/aerial';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,72 +74,14 @@ export const fetchAerialImage = async (uuid: string, imageType: 'before' | 'afte
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 업로드 지역 조회 (시/도)
|
* 항공영상 데이터 등록
|
||||||
*/
|
*/
|
||||||
export const fetchRegions = async () => {
|
export const registerAerialData = async (data: AerialData) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<ApiResponse<Region[]>>('/api/imagery/regions/provinces');
|
const response = await axios.post('/api/imagery/aerial', data);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('fetchRegions error:', error);
|
console.error('registerAerialData error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 업로드 폴더 조회
|
|
||||||
*/
|
|
||||||
export const fetchFolderList = async (dirPath: string) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.post<ApiResponse<FolderListResponse>>('/api/imagery/folder-list', {
|
|
||||||
dirPath,
|
|
||||||
});
|
|
||||||
return response.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('fetchFolderList error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대용량 파일 분할 전송 (청크 업로드)
|
|
||||||
*/
|
|
||||||
export const uploadFileChunk = async (params: ChunkUploadParams) => {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('fileName', params.fileName);
|
|
||||||
formData.append('fileSize', String(params.fileSize));
|
|
||||||
formData.append('chunkIndex', String(params.chunkIndex));
|
|
||||||
formData.append('chunkTotalIndex', String(params.chunkTotalIndex));
|
|
||||||
formData.append('chunkFile', params.chunkFile);
|
|
||||||
|
|
||||||
const response = await axios.post<ApiResponse<ChunkUploadResponse>>(
|
|
||||||
'/api/imagery/upload/file-chunk-upload',
|
|
||||||
formData,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('uploadFileChunk error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 업로드 완료된 파일 병합
|
|
||||||
*/
|
|
||||||
export const completeChunkUpload = async (uuid: string) => {
|
|
||||||
try {
|
|
||||||
const response = await axios.put<ApiResponse<ChunkUploadResponse>>(
|
|
||||||
`/api/imagery/upload/chunk-upload-complete/${uuid}`,
|
|
||||||
);
|
|
||||||
return response.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('completeChunkUpload error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
74
web-app/app/features/imagery/api/imagery.ts
Normal file
74
web-app/app/features/imagery/api/imagery.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { ApiResponse } from '~/shared/types/api';
|
||||||
|
import type { ChunkUploadParams, ChunkUploadResponse, FolderListResponse, Region } from '../types/imageryRegister';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 지역 조회 (시/도)
|
||||||
|
*/
|
||||||
|
export const fetchRegions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ApiResponse<Region[]>>('/api/imagery/regions/provinces');
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('fetchRegions error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 폴더 조회
|
||||||
|
*/
|
||||||
|
export const fetchFolderList = async (dirPath: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post<ApiResponse<FolderListResponse>>('/api/imagery/folder-list', {
|
||||||
|
dirPath,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('fetchFolderList error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대용량 파일 분할 전송 (청크 업로드)
|
||||||
|
*/
|
||||||
|
export const uploadFileChunk = async (params: ChunkUploadParams) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileName', params.fileName);
|
||||||
|
formData.append('fileSize', String(params.fileSize));
|
||||||
|
formData.append('chunkIndex', String(params.chunkIndex));
|
||||||
|
formData.append('chunkTotalIndex', String(params.chunkTotalIndex));
|
||||||
|
formData.append('chunkFile', params.chunkFile);
|
||||||
|
|
||||||
|
const response = await axios.post<ApiResponse<ChunkUploadResponse>>(
|
||||||
|
'/api/imagery/upload/file-chunk-upload',
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('uploadFileChunk error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 완료된 파일 병합
|
||||||
|
*/
|
||||||
|
export const completeChunkUpload = async (uuid: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put<ApiResponse<ChunkUploadResponse>>(
|
||||||
|
`/api/imagery/upload/chunk-upload-complete/${uuid}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('completeChunkUpload error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,12 +3,14 @@ import { Button } from '~/shared/components/button/Button';
|
|||||||
import { Section } from '~/shared/components/section/Section';
|
import { Section } from '~/shared/components/section/Section';
|
||||||
import { Table } from '~/shared/components/table';
|
import { Table } from '~/shared/components/table';
|
||||||
import type { AerialItem } from '../types/aerial';
|
import type { AerialItem } from '../types/aerial';
|
||||||
|
import { AerialRegisterModal } from './AerialRegisterModal';
|
||||||
|
|
||||||
export function AerialList() {
|
export function AerialList() {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [data, setData] = useState<AerialItem[]>([]);
|
const [data, setData] = useState<AerialItem[]>([]);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -37,6 +39,7 @@ export function AerialList() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Section className="w-full pb-4" variant="list">
|
<Section className="w-full pb-4" variant="list">
|
||||||
<div className="flex flex-col h-full p-4 gap-4">
|
<div className="flex flex-col h-full p-4 gap-4">
|
||||||
<h1 className="text-xl font-bold">항공영상관리</h1>
|
<h1 className="text-xl font-bold">항공영상관리</h1>
|
||||||
@@ -48,8 +51,7 @@ export function AerialList() {
|
|||||||
</Table.CaptionLeft>
|
</Table.CaptionLeft>
|
||||||
<Table.CaptionRight>
|
<Table.CaptionRight>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button color="light" size="small">엑셀 다운로드</Button>
|
<Button color="primary" onClick={() => setIsRegisterModalOpen(true)}>항공영상 등록</Button>
|
||||||
<Button color="primary" size="small">영상 등록</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Table.CaptionRight>
|
</Table.CaptionRight>
|
||||||
</Table.Caption>
|
</Table.Caption>
|
||||||
@@ -106,5 +108,7 @@ export function AerialList() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
{isRegisterModalOpen && <AerialRegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Button } from '~/shared/components/button/Button';
|
||||||
|
import { Modal, ModalRegion } from '~/shared/components/modal';
|
||||||
|
import { Select, type SelectOption } from '~/shared/components/select/Select';
|
||||||
|
import { Tree, type TreeItemType, type TreeRef } from '~/shared/components/tree/Tree';
|
||||||
|
|
||||||
|
import { fetchFolderList, fetchRegions } from '../api/imagery';
|
||||||
|
import { useAerialChunkUpload, type UploadingFile } from '../hooks/useAerialChunkUpload';
|
||||||
|
|
||||||
|
type AerialRegisterModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUploadSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
type: string;
|
||||||
|
region: string;
|
||||||
|
folderPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: 'mapFrame', label: '도곽' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressBar = ({ progress }: { progress: number }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 h-2 bg-dabeeo-gray-eb overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileItem = ({
|
||||||
|
file,
|
||||||
|
folderPath,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
file: UploadingFile;
|
||||||
|
folderPath: string;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) => {
|
||||||
|
const fullPath = folderPath ? `${folderPath}/${file.fileName}` : file.fileName;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<span className="text-sm text-dabeeo-black-34 truncate flex-1">{fullPath}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
className="text-dabeeo-gray-99 hover:text-dabeeo-black-34 cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12.5 4L4 12.5M4 4L12.5 12.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AerialRegisterModal = ({ isOpen, onClose, onUploadSuccess }: AerialRegisterModalProps) => {
|
||||||
|
const treeRef = useRef<TreeRef | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { control, setValue, watch, reset: resetForm } = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
type: 'mapFrame',
|
||||||
|
region: '',
|
||||||
|
folderPath: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRegion = watch('region');
|
||||||
|
const selectedFolder = watch('folderPath');
|
||||||
|
|
||||||
|
const [regions, setRegions] = useState<SelectOption[]>([]);
|
||||||
|
const [treeItems, setTreeItems] = useState<TreeItemType[]>([]);
|
||||||
|
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
addFiles,
|
||||||
|
startUpload,
|
||||||
|
removeFile,
|
||||||
|
reset: resetUpload,
|
||||||
|
isUploading,
|
||||||
|
isAllCompleted,
|
||||||
|
hasFiles,
|
||||||
|
totalSize,
|
||||||
|
uploadedSize,
|
||||||
|
overallProgress,
|
||||||
|
} = useAerialChunkUpload();
|
||||||
|
|
||||||
|
const memorizedSelectedKeys = useMemo(
|
||||||
|
() => new Set(selectedFolder ? [selectedFolder] : []),
|
||||||
|
[selectedFolder]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 지역 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
const loadRegions = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchRegions();
|
||||||
|
setRegions(data.map((r) => ({ value: r.code, label: r.name })));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load regions:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
loadRegions();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 지역 선택 시 폴더 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRegion) {
|
||||||
|
setTreeItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFolders = async () => {
|
||||||
|
setIsLoadingFolders(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchFolderList(selectedRegion);
|
||||||
|
const items: TreeItemType[] = data.folders.map((folder) => ({
|
||||||
|
id: folder.fullPath,
|
||||||
|
title: folder.folderNm,
|
||||||
|
type: 'directory',
|
||||||
|
}));
|
||||||
|
setTreeItems(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load folders:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFolders(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadFolders();
|
||||||
|
}, [selectedRegion]);
|
||||||
|
|
||||||
|
const handleExpandFolder = async (key: string | number) => {
|
||||||
|
const folderPath = String(key);
|
||||||
|
try {
|
||||||
|
const data = await fetchFolderList(folderPath);
|
||||||
|
data.folders.forEach((folder) => {
|
||||||
|
const newItem: TreeItemType = {
|
||||||
|
id: folder.fullPath,
|
||||||
|
title: folder.folderNm,
|
||||||
|
type: 'directory',
|
||||||
|
};
|
||||||
|
treeRef.current?.addItem(newItem, folderPath);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load subfolders:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCollapseFolder = (key: string | number) => {
|
||||||
|
treeRef.current?.clearItemsByParentKey(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = e.target.files;
|
||||||
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const fileArray = Array.from(selectedFiles);
|
||||||
|
const addedFiles = addFiles(fileArray);
|
||||||
|
|
||||||
|
// 파일 선택 즉시 업로드 시작
|
||||||
|
startUpload(addedFiles);
|
||||||
|
|
||||||
|
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
if (!selectedFolder) {
|
||||||
|
ModalRegion.alert({ content: '업로드할 폴더를 선택해주세요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasFiles) {
|
||||||
|
ModalRegion.alert({ content: '업로드할 파일을 선택해주세요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUploading) {
|
||||||
|
ModalRegion.alert({ content: '파일 업로드가 진행 중입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAllCompleted) {
|
||||||
|
ModalRegion.alert({
|
||||||
|
content: '파일 업로드가 완료되었습니다.',
|
||||||
|
onCancel: () => {
|
||||||
|
onUploadSuccess?.();
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetUpload();
|
||||||
|
resetForm();
|
||||||
|
setTreeItems([]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isUploading || hasFiles) {
|
||||||
|
ModalRegion.confirm({
|
||||||
|
content: '업로드를 취소하시겠습니까?\n진행 중인 업로드가 중단됩니다.',
|
||||||
|
confirmText: '예',
|
||||||
|
cancelText: '아니오',
|
||||||
|
onConfirm: (close) => {
|
||||||
|
close();
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentUploadingFile = files.find((f) => f.status === 'uploading');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={handleCancel} isKeyboardDismissDisabled>
|
||||||
|
{() => (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleUpload(); }}>
|
||||||
|
<Modal.Header hasCloseButton={false}>업로드 폴더 선택</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="flex flex-col gap-4 py-2 w-140">
|
||||||
|
{/* 타입 선택 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||||
|
타입 선택
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="type"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Select
|
||||||
|
items={TYPE_OPTIONS}
|
||||||
|
selectedKey={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지역 선택 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||||
|
지역 선택
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="region"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Select
|
||||||
|
items={regions}
|
||||||
|
selectedKey={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
setValue('folderPath', '');
|
||||||
|
}}
|
||||||
|
placeholder="지역선택"
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폴더 선택 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||||
|
폴더 선택
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 border border-dabeeo-gray-be max-h-60 min-h-40 overflow-y-auto">
|
||||||
|
{isLoadingFolders ? (
|
||||||
|
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : treeItems.length > 0 ? (
|
||||||
|
<Controller
|
||||||
|
name="folderPath"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<Tree
|
||||||
|
ref={treeRef}
|
||||||
|
items={treeItems}
|
||||||
|
selectedKeys={memorizedSelectedKeys}
|
||||||
|
onExpand={(key, item) => {
|
||||||
|
handleExpandFolder(key);
|
||||||
|
onChange(String(key));
|
||||||
|
}}
|
||||||
|
onCollapse={(key) => {
|
||||||
|
handleCollapseFolder(key);
|
||||||
|
}}
|
||||||
|
onSelect={(key) => {
|
||||||
|
if (key) onChange(String(key));
|
||||||
|
}}
|
||||||
|
enableDragAndDrop={false}
|
||||||
|
persistSelectionOnCollapse
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : selectedRegion ? (
|
||||||
|
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||||
|
폴더가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||||
|
지역을 선택해주세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-dabeeo-orange-main ml-24">
|
||||||
|
※ 업로드 대상 폴더를 정확하게 지정하시기 바랍니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 파일명 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||||
|
파일명 <span className="text-dabeeo-red">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 border border-dabeeo-gray-be p-2 min-h-10 max-h-24 overflow-y-auto">
|
||||||
|
{files.length > 0 ? (
|
||||||
|
files.map((file) => (
|
||||||
|
<FileItem key={file.id} file={file} folderPath={selectedFolder} onRemove={removeFile} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-dabeeo-gray-99">파일이 업로드 되면 자동 입력됩니다.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 선택 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||||
|
파일 <span className="text-dabeeo-red">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-9 border border-dabeeo-gray-be px-3 flex items-center">
|
||||||
|
<span className="text-sm text-dabeeo-gray-99 truncate">
|
||||||
|
{currentUploadingFile?.fileName || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="aerial-file-input"
|
||||||
|
className="h-9 px-4 bg-dabeeo-black-34 text-white text-sm font-medium flex items-center justify-center cursor-pointer hover:bg-dabeeo-black-47"
|
||||||
|
>
|
||||||
|
파일선택
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="aerial-file-input"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".tif,.tiff"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 표시 */}
|
||||||
|
{hasFiles && (
|
||||||
|
<div className="flex items-center gap-4 ml-24">
|
||||||
|
<ProgressBar progress={overallProgress} />
|
||||||
|
<span className="text-xs text-dabeeo-black-34 w-10 text-right">{overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFiles && (
|
||||||
|
<div className="text-xs text-dabeeo-gray-99 ml-24">
|
||||||
|
Loading {formatFileSize(uploadedSize)} / {formatFileSize(totalSize)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button type="button" color="gray" size="large" onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
isDisabled={!hasFiles || isUploading}
|
||||||
|
isPending={isUploading}
|
||||||
|
>
|
||||||
|
업로드
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { completeChunkUpload, uploadFileChunk } from '../api/imagery';
|
||||||
|
|
||||||
|
export type FileUploadStatus = 'idle' | 'uploading' | 'completed' | 'error'
|
||||||
|
|
||||||
|
export type UploadingFile = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
fileName: string;
|
||||||
|
progress: number;
|
||||||
|
status: FileUploadStatus;
|
||||||
|
totalSize: number;
|
||||||
|
uploadedSize: number;
|
||||||
|
uuid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
const MAX_CONCURRENT_UPLOADS = 3
|
||||||
|
|
||||||
|
export const useAerialChunkUpload = () => {
|
||||||
|
const [files, setFiles] = useState<UploadingFile[]>([])
|
||||||
|
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
||||||
|
|
||||||
|
const updateFileState = useCallback((id: string, updates: Partial<UploadingFile>) => {
|
||||||
|
setFiles((prev) => prev.map((f) => f.id === id ? { ...f, ...updates } : f))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const uploadSingleFile = useCallback(async (uploadFile: UploadingFile) => {
|
||||||
|
const { id, file } = uploadFile
|
||||||
|
const abortController = new AbortController()
|
||||||
|
abortControllersRef.current.set(id, abortController)
|
||||||
|
|
||||||
|
const { signal } = abortController
|
||||||
|
const fileSize = file.size
|
||||||
|
const fileName = file.name
|
||||||
|
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
|
||||||
|
|
||||||
|
updateFileState(id, { status: 'uploading', progress: 0 })
|
||||||
|
|
||||||
|
let uuid = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = chunkIndex * CHUNK_SIZE
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, fileSize)
|
||||||
|
const chunkFile = file.slice(start, end)
|
||||||
|
|
||||||
|
const response = await uploadFileChunk({
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
chunkIndex,
|
||||||
|
chunkTotalIndex: totalChunks - 1,
|
||||||
|
chunkFile,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 청크 응답에서 uuid 저장
|
||||||
|
if (chunkIndex === 0 && response.uuid) {
|
||||||
|
uuid = response.uuid
|
||||||
|
updateFileState(id, { uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100)
|
||||||
|
const uploadedSize = end
|
||||||
|
|
||||||
|
updateFileState(id, {
|
||||||
|
progress,
|
||||||
|
uploadedSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 청크 업로드 완료 후 completeChunkUpload 호출
|
||||||
|
if (uuid) {
|
||||||
|
await completeChunkUpload(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFileState(id, { status: 'completed', progress: 100 })
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
console.error('Upload error:', error)
|
||||||
|
updateFileState(id, { status: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortControllersRef.current.delete(id)
|
||||||
|
}
|
||||||
|
}, [updateFileState])
|
||||||
|
|
||||||
|
const addFiles = useCallback((newFiles: File[]) => {
|
||||||
|
const uploadFiles: UploadingFile[] = newFiles.map((file) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
fileName: file.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'idle' as FileUploadStatus,
|
||||||
|
totalSize: file.size,
|
||||||
|
uploadedSize: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setFiles((prev) => [...prev, ...uploadFiles])
|
||||||
|
return uploadFiles
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startUpload = useCallback(async (filesToUpload?: UploadingFile[]) => {
|
||||||
|
const targetFiles = filesToUpload || files.filter((f) => f.status === 'idle')
|
||||||
|
|
||||||
|
// 병렬 업로드 (최대 MAX_CONCURRENT_UPLOADS개씩)
|
||||||
|
for (let i = 0; i < targetFiles.length; i += MAX_CONCURRENT_UPLOADS) {
|
||||||
|
const batch = targetFiles.slice(i, i + MAX_CONCURRENT_UPLOADS)
|
||||||
|
await Promise.all(batch.map((file) => uploadSingleFile(file)))
|
||||||
|
}
|
||||||
|
}, [files, uploadSingleFile])
|
||||||
|
|
||||||
|
const removeFile = useCallback((id: string) => {
|
||||||
|
// 업로드 중이면 취소
|
||||||
|
const controller = abortControllersRef.current.get(id)
|
||||||
|
if (controller) {
|
||||||
|
controller.abort()
|
||||||
|
abortControllersRef.current.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prev) => prev.filter((f) => f.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cancelAllUploads = useCallback(() => {
|
||||||
|
abortControllersRef.current.forEach((controller) => controller.abort())
|
||||||
|
abortControllersRef.current.clear()
|
||||||
|
setFiles([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
cancelAllUploads()
|
||||||
|
}, [cancelAllUploads])
|
||||||
|
|
||||||
|
const isUploading = files.some((f) => f.status === 'uploading')
|
||||||
|
const isAllCompleted = files.length > 0 && files.every((f) => f.status === 'completed')
|
||||||
|
const hasFiles = files.length > 0
|
||||||
|
|
||||||
|
const totalSize = files.reduce((acc, f) => acc + f.totalSize, 0)
|
||||||
|
const uploadedSize = files.reduce((acc, f) => acc + f.uploadedSize, 0)
|
||||||
|
const overallProgress = totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
addFiles,
|
||||||
|
startUpload,
|
||||||
|
removeFile,
|
||||||
|
cancelAllUploads,
|
||||||
|
reset,
|
||||||
|
isUploading,
|
||||||
|
isAllCompleted,
|
||||||
|
hasFiles,
|
||||||
|
totalSize,
|
||||||
|
uploadedSize,
|
||||||
|
overallProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,47 +25,6 @@ export type AerialDetail = {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 지역 (시/도)
|
|
||||||
export type Region = {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폴더
|
|
||||||
export type Folder = {
|
|
||||||
folderNm: string;
|
|
||||||
parentFolderNm: string;
|
|
||||||
parentPath: string;
|
|
||||||
fullPath: string;
|
|
||||||
depth: number;
|
|
||||||
childCnt: number;
|
|
||||||
lastModified: string;
|
|
||||||
isValid: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FolderListResponse = {
|
|
||||||
dirPath: string;
|
|
||||||
folders: Folder[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 청크 업로드
|
|
||||||
export type ChunkUploadParams = {
|
|
||||||
fileName: string;
|
|
||||||
fileSize: number;
|
|
||||||
chunkIndex: number;
|
|
||||||
chunkTotalIndex: number;
|
|
||||||
chunkFile: Blob;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChunkUploadResponse = {
|
|
||||||
uuid: string;
|
|
||||||
filePath: string;
|
|
||||||
fileName: string;
|
|
||||||
chunkIndex: number;
|
|
||||||
chunkTotalIndex: number;
|
|
||||||
uploadId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 목록 조회 파라미터
|
// 목록 조회 파라미터
|
||||||
export type AerialListParams = {
|
export type AerialListParams = {
|
||||||
dateRangeType: 'capturedDttm' | 'createdDttm';
|
dateRangeType: 'capturedDttm' | 'createdDttm';
|
||||||
@@ -74,3 +33,14 @@ export type AerialListParams = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 항공영상 데이터 등록
|
||||||
|
export type AerialData = {
|
||||||
|
fileName: string;
|
||||||
|
region: string;
|
||||||
|
scale: string;
|
||||||
|
capturedDttm: string;
|
||||||
|
fileSize: string;
|
||||||
|
status: string;
|
||||||
|
type: '도곽';
|
||||||
|
};
|
||||||
|
|||||||
40
web-app/app/features/imagery/types/imageryRegister.ts
Normal file
40
web-app/app/features/imagery/types/imageryRegister.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// 지역 (시/도)
|
||||||
|
export type Region = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폴더
|
||||||
|
export type Folder = {
|
||||||
|
folderNm: string;
|
||||||
|
parentFolderNm: string;
|
||||||
|
parentPath: string;
|
||||||
|
fullPath: string;
|
||||||
|
depth: number;
|
||||||
|
childCnt: number;
|
||||||
|
lastModified: string;
|
||||||
|
isValid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FolderListResponse = {
|
||||||
|
dirPath: string;
|
||||||
|
folders: Folder[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 청크 업로드
|
||||||
|
export type ChunkUploadParams = {
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkTotalIndex: number;
|
||||||
|
chunkFile: Blob;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChunkUploadResponse = {
|
||||||
|
uuid: string;
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkTotalIndex: number;
|
||||||
|
uploadId?: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user