feat: 항공영상 업로드 모달

This commit is contained in:
2026-04-10 12:24:10 +09:00
parent 35f6023f5a
commit 8b33161284
8 changed files with 807 additions and 173 deletions

View File

@@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<AerialItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
useEffect(() => {
const load = async () => {
@@ -37,74 +39,76 @@ export function AerialList() {
}, []);
return (
<Section className="w-full pb-4" variant="list">
<div className="flex flex-col h-full p-4 gap-4">
<h1 className="text-xl font-bold"></h1>
<>
<Section className="w-full pb-4" variant="list">
<div className="flex flex-col h-full p-4 gap-4">
<h1 className="text-xl font-bold"></h1>
<Table isLayoutFixed isFullHeight>
<Table.Caption>
<Table.CaptionLeft>
<Table.Total count={totalCount} />
</Table.CaptionLeft>
<Table.CaptionRight>
<div className="flex gap-2">
<Button color="light" size="small"> </Button>
<Button color="primary" size="small"> </Button>
</div>
</Table.CaptionRight>
</Table.Caption>
<Table isLayoutFixed isFullHeight>
<Table.Caption>
<Table.CaptionLeft>
<Table.Total count={totalCount} />
</Table.CaptionLeft>
<Table.CaptionRight>
<div className="flex gap-2">
<Button color="primary" onClick={() => setIsRegisterModalOpen(true)}> </Button>
</div>
</Table.CaptionRight>
</Table.Caption>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={180} />
<Table.Col width={200} />
<Table.Col width={120} />
<Table.Col width={100} />
<Table.Col width={120} />
<Table.Col width={80} />
</Table.Colgroup>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={180} />
<Table.Col width={200} />
<Table.Col width={120} />
<Table.Col width={100} />
<Table.Col width={120} />
<Table.Col width={80} />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">No</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
<Table.HeaderCell align="right"></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">No</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
<Table.HeaderCell align="right"></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{isLoading ? (
<Table.Loading colSpan={7} />
) : data.length === 0 ? (
<Table.Empty colSpan={7}> .</Table.Empty>
) : (
data.map((item, index) => (
<Table.Row
key={item.mapId}
isSelected={selectedId === item.mapId}
onClick={() => setSelectedId(item.mapId)}
>
<Table.Cell align="center">{index + 1}</Table.Cell>
<Table.Cell>{item.fileName}</Table.Cell>
<Table.Cell>{item.region}</Table.Cell>
<Table.Cell align="center">{item.capturedDttm}</Table.Cell>
<Table.Cell align="center">{item.scale}</Table.Cell>
<Table.Cell align="right">{item.fileSize}</Table.Cell>
<Table.Cell align="center">
{item.status}
</Table.Cell>
</Table.Row>
))
)}
</Table.Body>
</Table.Container>
</Table>
</div>
</Section>
<Table.Body>
{isLoading ? (
<Table.Loading colSpan={7} />
) : data.length === 0 ? (
<Table.Empty colSpan={7}> .</Table.Empty>
) : (
data.map((item, index) => (
<Table.Row
key={item.mapId}
isSelected={selectedId === item.mapId}
onClick={() => setSelectedId(item.mapId)}
>
<Table.Cell align="center">{index + 1}</Table.Cell>
<Table.Cell>{item.fileName}</Table.Cell>
<Table.Cell>{item.region}</Table.Cell>
<Table.Cell align="center">{item.capturedDttm}</Table.Cell>
<Table.Cell align="center">{item.scale}</Table.Cell>
<Table.Cell align="right">{item.fileSize}</Table.Cell>
<Table.Cell align="center">
{item.status}
</Table.Cell>
</Table.Row>
))
)}
</Table.Body>
</Table.Container>
</Table>
</div>
</Section>
{isRegisterModalOpen && <AerialRegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)} />}
</>
);
}

View 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>
);
};