diff --git a/web-app/app/app.css b/web-app/app/app.css index 5ba9722..55af49e 100644 --- a/web-app/app/app.css +++ b/web-app/app/app.css @@ -36,6 +36,10 @@ --font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif, "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) */ --color-primary: var(--color-dabeeo-navy-main); --color-primary-secondary: var(--color-dabeeo-navy-secondary); @@ -50,6 +54,16 @@ --color-dabeeo-navy-tertiary01: #d4dde9; --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 */ --color-dabeeo-gray-44: #444444; --color-dabeeo-gray-99: #999999; @@ -57,6 +71,12 @@ --color-dabeeo-gray-da: #dadada; --color-dabeeo-gray-eb: #ebebeb; --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, diff --git a/web-app/app/features/imagery/api/aerial.ts b/web-app/app/features/imagery/api/aerial.ts index a3121f9..9d0dd0f 100644 --- a/web-app/app/features/imagery/api/aerial.ts +++ b/web-app/app/features/imagery/api/aerial.ts @@ -1,13 +1,10 @@ import axios from 'axios'; import type { ApiResponse, PagedResponse } from '~/shared/types/api'; import type { + AerialData, AerialDetail, AerialItem, AerialListParams, - ChunkUploadParams, - ChunkUploadResponse, - FolderListResponse, - Region, } 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 { - const response = await axios.get>('/api/imagery/regions/provinces'); + const response = await axios.post('/api/imagery/aerial', data); return response.data.data; } catch (error) { - console.error('fetchRegions error:', error); + console.error('registerAerialData error:', error); throw error; } -}; - -/** - * 업로드 폴더 조회 - */ -export const fetchFolderList = async (dirPath: string) => { - try { - const response = await axios.post>('/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>( - '/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>( - `/api/imagery/upload/chunk-upload-complete/${uuid}`, - ); - return response.data.data; - } catch (error) { - console.error('completeChunkUpload error:', error); - throw error; - } -}; +} diff --git a/web-app/app/features/imagery/api/imagery.ts b/web-app/app/features/imagery/api/imagery.ts new file mode 100644 index 0000000..b1359ed --- /dev/null +++ b/web-app/app/features/imagery/api/imagery.ts @@ -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>('/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>('/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>( + '/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>( + `/api/imagery/upload/chunk-upload-complete/${uuid}`, + ); + return response.data.data; + } catch (error) { + console.error('completeChunkUpload error:', error); + throw error; + } +}; diff --git a/web-app/app/features/imagery/components/AerialList.tsx b/web-app/app/features/imagery/components/AerialList.tsx index 07e1d88..7e8225c 100644 --- a/web-app/app/features/imagery/components/AerialList.tsx +++ b/web-app/app/features/imagery/components/AerialList.tsx @@ -3,12 +3,14 @@ import { Button } from '~/shared/components/button/Button'; import { Section } from '~/shared/components/section/Section'; import { Table } from '~/shared/components/table'; import type { AerialItem } from '../types/aerial'; +import { AerialRegisterModal } from './AerialRegisterModal'; export function AerialList() { const [selectedId, setSelectedId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [data, setData] = useState([]); const [totalCount, setTotalCount] = useState(0); + const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false); useEffect(() => { const load = async () => { @@ -37,74 +39,76 @@ export function AerialList() { }, []); return ( -
-
-

항공영상관리

+ <> +
+
+

항공영상관리

- - - - - - -
- - -
-
-
+
+ + + + + +
+ +
+
+
- - - - - - - - - - + + + + + + + + + + - - - No - 파일명 - 지역 - 촬영일시 - 축적 - 용량 - 전처리 - - + + + No + 파일명 + 지역 + 촬영일시 + 축적 + 용량 + 전처리 + + - - {isLoading ? ( - - ) : data.length === 0 ? ( - 등록된 항공영상이 없습니다. - ) : ( - data.map((item, index) => ( - setSelectedId(item.mapId)} - > - {index + 1} - {item.fileName} - {item.region} - {item.capturedDttm} - {item.scale} - {item.fileSize} - - {item.status} - - - )) - )} - - -
-
-
+ + {isLoading ? ( + + ) : data.length === 0 ? ( + 등록된 항공영상이 없습니다. + ) : ( + data.map((item, index) => ( + setSelectedId(item.mapId)} + > + {index + 1} + {item.fileName} + {item.region} + {item.capturedDttm} + {item.scale} + {item.fileSize} + + {item.status} + + + )) + )} + + + +
+
+ {isRegisterModalOpen && setIsRegisterModalOpen(false)} />} + ); } diff --git a/web-app/app/features/imagery/components/AerialRegisterModal.tsx b/web-app/app/features/imagery/components/AerialRegisterModal.tsx new file mode 100644 index 0000000..ecc5d5c --- /dev/null +++ b/web-app/app/features/imagery/components/AerialRegisterModal.tsx @@ -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 ( +
+
+
+ ); +}; + +const FileItem = ({ + file, + folderPath, + onRemove, +}: { + file: UploadingFile; + folderPath: string; + onRemove: (id: string) => void; +}) => { + const fullPath = folderPath ? `${folderPath}/${file.fileName}` : file.fileName; + return ( +
+ {fullPath} + +
+ ); +}; + +export const AerialRegisterModal = ({ isOpen, onClose, onUploadSuccess }: AerialRegisterModalProps) => { + const treeRef = useRef(null); + const fileInputRef = useRef(null); + + const { control, setValue, watch, reset: resetForm } = useForm({ + defaultValues: { + type: 'mapFrame', + region: '', + folderPath: '', + }, + }); + + const selectedRegion = watch('region'); + const selectedFolder = watch('folderPath'); + + const [regions, setRegions] = useState([]); + const [treeItems, setTreeItems] = useState([]); + 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) => { + 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 ( + + {() => ( +
{ e.preventDefault(); handleUpload(); }}> + 업로드 폴더 선택 + +
+ {/* 타입 선택 */} +
+ + ( + { + onChange(v); + setValue('folderPath', ''); + }} + placeholder="지역선택" + className="w-40" + /> + )} + /> +
+ + {/* 폴더 선택 */} +
+ +
+ {isLoadingFolders ? ( +
+ 로딩 중... +
+ ) : treeItems.length > 0 ? ( + ( + { + handleExpandFolder(key); + onChange(String(key)); + }} + onCollapse={(key) => { + handleCollapseFolder(key); + }} + onSelect={(key) => { + if (key) onChange(String(key)); + }} + enableDragAndDrop={false} + persistSelectionOnCollapse + /> + )} + /> + ) : selectedRegion ? ( +
+ 폴더가 없습니다. +
+ ) : ( +
+ 지역을 선택해주세요. +
+ )} +
+
+ +

+ ※ 업로드 대상 폴더를 정확하게 지정하시기 바랍니다. +

+ + {/* 파일명 */} +
+ +
+ {files.length > 0 ? ( + files.map((file) => ( + + )) + ) : ( + 파일이 업로드 되면 자동 입력됩니다. + )} +
+
+ + {/* 파일 선택 */} +
+ +
+
+ + {currentUploadingFile?.fileName || ''} + +
+ + +
+
+ + {/* 진행률 표시 */} + {hasFiles && ( +
+ + {overallProgress}% +
+ )} + + {hasFiles && ( +
+ Loading {formatFileSize(uploadedSize)} / {formatFileSize(totalSize)} +
+ )} +
+
+ + + + +
+ )} +
+ ); +}; diff --git a/web-app/app/features/imagery/hooks/useAerialChunkUpload.ts b/web-app/app/features/imagery/hooks/useAerialChunkUpload.ts new file mode 100644 index 0000000..7c0c310 --- /dev/null +++ b/web-app/app/features/imagery/hooks/useAerialChunkUpload.ts @@ -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([]) + const abortControllersRef = useRef>(new Map()) + + const updateFileState = useCallback((id: string, updates: Partial) => { + 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, + } +} diff --git a/web-app/app/features/imagery/types/aerial.ts b/web-app/app/features/imagery/types/aerial.ts index f6b6bb2..6b5084f 100644 --- a/web-app/app/features/imagery/types/aerial.ts +++ b/web-app/app/features/imagery/types/aerial.ts @@ -25,47 +25,6 @@ export type AerialDetail = { 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 = { dateRangeType: 'capturedDttm' | 'createdDttm'; @@ -74,3 +33,14 @@ export type AerialListParams = { page?: number; size?: number; }; + +// 항공영상 데이터 등록 +export type AerialData = { + fileName: string; + region: string; + scale: string; + capturedDttm: string; + fileSize: string; + status: string; + type: '도곽'; +}; diff --git a/web-app/app/features/imagery/types/imageryRegister.ts b/web-app/app/features/imagery/types/imageryRegister.ts new file mode 100644 index 0000000..7483eda --- /dev/null +++ b/web-app/app/features/imagery/types/imageryRegister.ts @@ -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; +};