211 lines
5.7 KiB
TypeScript
211 lines
5.7 KiB
TypeScript
|
|
/**
|
||
|
|
* 파일 업로드 컴포넌트
|
||
|
|
*
|
||
|
|
* 시방서, 인정서, 전개도 등 파일 업로드 UI
|
||
|
|
*/
|
||
|
|
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useRef } from 'react';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Label } from '@/components/ui/label';
|
||
|
|
import { X, Upload, FileText, CheckCircle2 } from 'lucide-react';
|
||
|
|
|
||
|
|
interface FileUploadProps {
|
||
|
|
label: string;
|
||
|
|
accept?: string;
|
||
|
|
maxSize?: number; // MB
|
||
|
|
currentFile?: {
|
||
|
|
url: string;
|
||
|
|
filename: string;
|
||
|
|
};
|
||
|
|
onFileSelect: (file: File) => void;
|
||
|
|
onFileRemove?: () => void;
|
||
|
|
disabled?: boolean;
|
||
|
|
required?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function FileUpload({
|
||
|
|
label,
|
||
|
|
accept = '*/*',
|
||
|
|
maxSize = 10, // 10MB default
|
||
|
|
currentFile,
|
||
|
|
onFileSelect,
|
||
|
|
onFileRemove,
|
||
|
|
disabled = false,
|
||
|
|
required = false,
|
||
|
|
}: FileUploadProps) {
|
||
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [isDragging, setIsDragging] = useState(false);
|
||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const handleFileChange = (file: File | null) => {
|
||
|
|
if (!file) {
|
||
|
|
setSelectedFile(null);
|
||
|
|
setError(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 파일 크기 검증
|
||
|
|
const fileSizeMB = file.size / (1024 * 1024);
|
||
|
|
if (fileSizeMB > maxSize) {
|
||
|
|
setError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setError(null);
|
||
|
|
setSelectedFile(file);
|
||
|
|
onFileSelect(file);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const file = e.target.files?.[0] || null;
|
||
|
|
handleFileChange(file);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragEnter = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
setIsDragging(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
setIsDragging(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragOver = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDrop = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
setIsDragging(false);
|
||
|
|
|
||
|
|
const file = e.dataTransfer.files?.[0] || null;
|
||
|
|
handleFileChange(file);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleRemove = () => {
|
||
|
|
setSelectedFile(null);
|
||
|
|
setError(null);
|
||
|
|
if (fileInputRef.current) {
|
||
|
|
fileInputRef.current.value = '';
|
||
|
|
}
|
||
|
|
onFileRemove?.();
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleClick = () => {
|
||
|
|
fileInputRef.current?.click();
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor={`file-upload-${label}`}>
|
||
|
|
{label}
|
||
|
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
|
||
|
|
{/* 파일 입력 (숨김) */}
|
||
|
|
<Input
|
||
|
|
ref={fileInputRef}
|
||
|
|
id={`file-upload-${label}`}
|
||
|
|
type="file"
|
||
|
|
accept={accept}
|
||
|
|
onChange={handleInputChange}
|
||
|
|
disabled={disabled}
|
||
|
|
className="hidden"
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 드래그 앤 드롭 영역 */}
|
||
|
|
<div
|
||
|
|
onDragEnter={handleDragEnter}
|
||
|
|
onDragLeave={handleDragLeave}
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
onDrop={handleDrop}
|
||
|
|
onClick={handleClick}
|
||
|
|
className={`
|
||
|
|
border-2 border-dashed rounded-lg p-6
|
||
|
|
flex flex-col items-center justify-center
|
||
|
|
cursor-pointer transition-colors
|
||
|
|
${isDragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary/50'}
|
||
|
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||
|
|
`}
|
||
|
|
>
|
||
|
|
{selectedFile || currentFile ? (
|
||
|
|
<div className="w-full">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<FileText className="w-10 h-10 text-primary" />
|
||
|
|
<div>
|
||
|
|
<p className="font-medium text-sm">
|
||
|
|
{selectedFile?.name || currentFile?.filename}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">
|
||
|
|
{selectedFile
|
||
|
|
? `${(selectedFile.size / 1024).toFixed(1)} KB`
|
||
|
|
: currentFile?.url && (
|
||
|
|
<a
|
||
|
|
href={currentFile.url}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="text-primary hover:underline"
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
파일 보기
|
||
|
|
</a>
|
||
|
|
)}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{selectedFile && (
|
||
|
|
<CheckCircle2 className="w-5 h-5 text-green-500 ml-2" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!disabled && (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleRemove();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<X className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Upload className="w-12 h-12 text-gray-400 mb-3" />
|
||
|
|
<p className="text-sm text-gray-600 mb-1">
|
||
|
|
클릭하거나 파일을 드래그하여 업로드
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-gray-500">
|
||
|
|
최대 {maxSize}MB
|
||
|
|
</p>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 에러 메시지 */}
|
||
|
|
{error && (
|
||
|
|
<p className="text-sm text-red-500">{error}</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 도움말 */}
|
||
|
|
{!error && accept !== '*/*' && (
|
||
|
|
<p className="text-xs text-gray-500">
|
||
|
|
허용 파일 형식: {accept}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|