파일 업로드 구조 개선

This commit is contained in:
dohyeons 2025-11-05 15:39:02 +09:00
parent 63b6e89435
commit 8489ff03c2
3 changed files with 215 additions and 83 deletions

View File

@ -767,8 +767,9 @@ export const previewFile = async (
mimeType = "application/octet-stream";
}
// CORS 헤더 설정 (더 포괄적으로)
res.setHeader("Access-Control-Allow-Origin", "*");
// CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요)
const origin = req.headers.origin || "http://localhost:9771";
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"

View File

@ -26,80 +26,108 @@ import {
// 위젯 동적 임포트
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합)
const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 지도 위젯 (REST API 지원)
const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스)
const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 차트 위젯 (다중 데이터 소스)
const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const ListTestWidget = dynamic(
() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
{
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
},
);
const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석
@ -128,22 +156,30 @@ const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 시계 위젯 임포트
@ -160,25 +196,33 @@ import { Button } from "@/components/ui/button";
// 야드 관리 3D 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 작업 이력 위젯
const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 커스텀 통계 카드 위젯
const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 사용자 커스텀 카드 위젯
const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
interface CanvasElementProps {
@ -758,7 +802,7 @@ export function CanvasElement({
<div
ref={elementRef}
data-element-id={element.id}
className={`absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 bg-background shadow-lg ${isSelected ? "border-primary ring-2 ring-primary/20" : "border-border"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
className={`bg-background absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 shadow-lg ${isSelected ? "border-primary ring-primary/20 ring-2" : "border-border"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
style={{
left: displayPosition.x,
top: displayPosition.y,
@ -809,7 +853,7 @@ export function CanvasElement({
)}
{/* 제목 */}
{!element.type || element.type !== "chart" ? (
<span className="text-xs font-bold text-foreground">{element.customTitle || element.title}</span>
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
) : null}
</div>
<div className="flex gap-1">
@ -817,7 +861,7 @@ export function CanvasElement({
<Button
variant="ghost"
size="icon"
className="element-close hover:bg-destructive h-5 w-5 text-muted-foreground hover:text-white"
className="element-close hover:bg-destructive text-muted-foreground h-5 w-5 hover:text-white"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
title="삭제"
@ -831,9 +875,9 @@ export function CanvasElement({
<div className="relative h-[calc(100%-32px)] px-2 pb-2">
{element.type === "chart" ? (
// 차트 렌더링
<div className="h-full w-full bg-background">
<div className="bg-background h-full w-full">
{isLoadingData ? (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
<div className="text-sm"> ...</div>
@ -921,7 +965,12 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "status-summary" ? (
// 커스텀 상태 카드 - 범용 위젯
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-background to-primary/10" />
<StatusSummaryWidget
element={element}
title="상태 요약"
icon="📊"
bgGradient="from-background to-primary/10"
/>
</div>
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
@ -1106,7 +1155,7 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
return (
<div
className={`resize-handle absolute h-3 w-3 border border-white bg-success ${getPositionClass()} `}
className={`resize-handle bg-success absolute h-3 w-3 border border-white ${getPositionClass()} `}
onMouseDown={(e) => onMouseDown(e, position)}
/>
);

View File

@ -52,6 +52,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
const [uploading, setUploading] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null); // 선택된 파일 (좌측 미리보기용)
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); // 이미지 미리보기 URL
const fileInputRef = useRef<HTMLInputElement>(null);
// 파일 아이콘 가져오기
@ -141,10 +143,49 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
setViewerFile(null);
};
// 파일 클릭 시 미리보기 로드
const handleFileClick = async (file: FileInfo) => {
setSelectedFile(file);
// 이미지 파일인 경우 미리보기 로드
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
if (imageExtensions.includes(file.fileExt.toLowerCase())) {
try {
// 이전 Blob URL 해제
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: 'blob'
});
const blob = new Blob([response.data]);
const blobUrl = URL.createObjectURL(blob);
setPreviewImageUrl(blobUrl);
} catch (error) {
console.error("이미지 로드 실패:", error);
setPreviewImageUrl(null);
}
} else {
setPreviewImageUrl(null);
}
};
// 컴포넌트 언마운트 시 Blob URL 해제
React.useEffect(() => {
return () => {
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
};
}, [previewImageUrl]);
return (
<>
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden [&>button]:hidden">
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<DialogTitle className="text-lg font-semibold">
({uploadedFiles.length})
@ -160,17 +201,21 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</Button>
</DialogHeader>
<div className="flex flex-col space-y-4 h-[70vh]">
{/* 파일 업로드 영역 */}
<div className="flex flex-col space-y-3 h-[75vh]">
{/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploading ? 'opacity-75' : ''}
`}
onClick={handleFileSelect}
onClick={() => {
if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click();
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@ -186,47 +231,71 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
/>
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
<span className="text-blue-600 font-medium"> ...</span>
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-600 font-medium"> ...</span>
</div>
) : (
<div className="flex flex-col items-center">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-900 mb-2">
<div className="flex items-center justify-center gap-3">
<Upload className="h-6 w-6 text-gray-400" />
<p className="text-sm font-medium text-gray-700">
</p>
<p className="text-sm text-gray-500">
{config.accept && `지원 형식: ${config.accept}`}
{config.maxSize && ` • 최대 ${formatFileSize(config.maxSize)}`}
{config.multiple && ' • 여러 파일 선택 가능'}
</p>
</div>
)}
</div>
)}
{/* 파일 목록 */}
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-700">
</h3>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
{/* 좌우 분할 레이아웃 */}
<div className="flex-1 flex gap-4 min-h-0">
{/* 좌측: 이미지 미리보기 */}
<div className="w-1/2 border border-gray-200 rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
{selectedFile && previewImageUrl ? (
<img
src={previewImageUrl}
alt={selectedFile.realFileName}
className="max-w-full max-h-full object-contain"
/>
) : selectedFile ? (
<div className="flex flex-col items-center text-gray-400">
{getFileIcon(selectedFile.fileExt)}
<p className="mt-2 text-sm"> </p>
</div>
) : (
<div className="flex flex-col items-center text-gray-400">
<ImageIcon className="w-16 h-16 mb-2" />
<p className="text-sm"> </p>
</div>
)}
</div>
{/* 우측: 파일 목록 */}
<div className="w-1/2 border border-gray-200 rounded-lg overflow-hidden flex flex-col">
<div className="p-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">
</h3>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
</div>
{uploadedFiles.length > 0 ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="flex-1 overflow-y-auto p-3">
{uploadedFiles.length > 0 ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className={`
flex items-center space-x-3 p-2 rounded-lg transition-colors cursor-pointer
${selectedFile?.objid === file.objid ? 'bg-blue-50 border border-blue-200' : 'bg-gray-50 hover:bg-gray-100'}
`}
onClick={() => handleFileClick(file)}
>
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
@ -250,40 +319,52 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
<Button
variant={file.isRepresentative ? "default" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => onSetRepresentative(file)}
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onSetRepresentative(file);
}}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
>
<Star className={`w-4 h-4 ${file.isRepresentative ? "fill-white" : ""}`} />
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleFileViewInternal(file)}
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleFileViewInternal(file);
}}
title="미리보기"
>
<Eye className="w-4 h-4" />
<Eye className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onFileDownload(file)}
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="w-4 h-4" />
<Download className="w-3 h-3" />
</Button>
{!isDesignMode && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
onClick={() => onFileDelete(file)}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
onFileDelete(file);
}}
title="삭제"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
@ -291,17 +372,18 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<File className="w-16 h-16 mb-4 text-gray-300" />
<p className="text-lg font-medium text-gray-600"> </p>
<p className="text-sm text-gray-500 mt-2">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 드래그하거나 클릭하여 업로드하세요'}
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<File className="w-12 h-12 mb-3 text-gray-300" />
<p className="text-sm font-medium text-gray-600"> </p>
<p className="text-xs text-gray-500 mt-1">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>