refactor: 모달 Content 컴포넌트 분리 및 파일 입력 UI 공통화

- 모달 컴포넌트에서 Content 분리하여 재사용성 향상
  - EstimateDocumentContent, DirectConstructionContent 등
  - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent
- 파일 입력 공통 UI 컴포넌트 추가
  - file-dropzone, file-input, file-list, image-upload
- 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-22 15:07:17 +09:00
parent 6fa69d81f4
commit 9464a368ba
48 changed files with 3900 additions and 4063 deletions

View File

@@ -0,0 +1,226 @@
'use client';
/**
* FileDropzone - 드래그 앤 드롭 파일 업로드 영역
*
* 도면, 첨부파일 등 다중 파일 업로드에 사용
*
* 사용 예시:
* <FileDropzone
* accept=".pdf,.dwg,.jpg"
* multiple
* maxSize={10}
* onFilesSelect={(files) => setFiles(prev => [...prev, ...files])}
* />
*/
import { useState, useRef, useCallback } from 'react';
import { Upload, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface FileDropzoneProps {
/** 파일 선택 시 콜백 */
onFilesSelect: (files: File[]) => void;
/** 허용 파일 타입 */
accept?: string;
/** 다중 파일 선택 허용 */
multiple?: boolean;
/** 최대 파일 크기 (MB) */
maxSize?: number;
/** 최대 파일 개수 */
maxFiles?: number;
/** 비활성화 */
disabled?: boolean;
/** 추가 클래스 */
className?: string;
/** 안내 텍스트 (메인) */
title?: string;
/** 안내 텍스트 (서브) */
description?: string;
/** 에러 상태 */
error?: boolean;
/** 에러 메시지 */
errorMessage?: string;
/** 컴팩트 모드 */
compact?: boolean;
}
export function FileDropzone({
onFilesSelect,
accept = '*/*',
multiple = false,
maxSize = 10,
maxFiles,
disabled = false,
className,
title,
description,
error = false,
errorMessage,
compact = false,
}: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const displayError = errorMessage || validationError;
const handleClick = useCallback(() => {
if (!disabled) {
inputRef.current?.click();
}
}, [disabled]);
const validateFiles = useCallback((files: File[]): File[] => {
const validFiles: File[] = [];
const errors: string[] = [];
for (const file of files) {
// 파일 크기 검증
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSize) {
errors.push(`${file.name}: ${maxSize}MB 초과`);
continue;
}
validFiles.push(file);
}
// 최대 파일 개수 검증
if (maxFiles && validFiles.length > maxFiles) {
setValidationError(`최대 ${maxFiles}개 파일만 선택할 수 있습니다`);
return validFiles.slice(0, maxFiles);
}
if (errors.length > 0) {
setValidationError(errors.join(', '));
} else {
setValidationError(null);
}
return validFiles;
}, [maxSize, maxFiles]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const fileArray = Array.from(files);
const validFiles = validateFiles(fileArray);
if (validFiles.length > 0) {
onFilesSelect(validFiles);
}
}
// input 초기화
if (inputRef.current) {
inputRef.current.value = '';
}
}, [validateFiles, onFilesSelect]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files && files.length > 0) {
const fileArray = multiple ? Array.from(files) : [files[0]];
const validFiles = validateFiles(fileArray);
if (validFiles.length > 0) {
onFilesSelect(validFiles);
}
}
}, [disabled, multiple, validateFiles, onFilesSelect]);
// 기본 텍스트
const defaultTitle = isDragging
? '파일을 여기에 놓으세요'
: '클릭하거나 파일을 드래그하세요';
const defaultDescription = `최대 ${maxSize}MB${accept !== '*/*' ? ` (${accept})` : ''}`;
return (
<div className={cn('space-y-2', className)}>
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-lg transition-colors',
compact ? 'p-4' : 'p-8',
error || displayError
? 'border-red-500 bg-red-50'
: isDragging
? 'border-primary bg-primary/5'
: 'border-gray-300 hover:border-primary/50',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
)}
>
{/* 숨겨진 파일 input */}
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleChange}
disabled={disabled}
className="hidden"
/>
<div className="flex flex-col items-center justify-center text-center">
<Upload
className={cn(
'mb-2 transition-colors',
compact ? 'w-6 h-6' : 'w-10 h-10',
isDragging ? 'text-primary' : 'text-gray-400',
)}
/>
<p className={cn(
'font-medium',
compact ? 'text-sm' : 'text-base',
isDragging ? 'text-primary' : 'text-gray-700',
)}>
{title || defaultTitle}
</p>
<p className={cn(
'text-muted-foreground mt-1',
compact ? 'text-xs' : 'text-sm',
)}>
{description || defaultDescription}
</p>
</div>
</div>
{/* 에러 메시지 */}
{displayError && (
<div className="flex items-center gap-1 text-sm text-red-500">
<AlertCircle className="w-3 h-3 shrink-0" />
<span>{displayError}</span>
</div>
)}
</div>
);
}
export default FileDropzone;

View File

@@ -0,0 +1,203 @@
'use client';
/**
* FileInput - 기본 파일 선택 컴포넌트
*
* 사용 예시:
* <FileInput
* accept=".pdf,.doc"
* maxSize={10}
* onFileSelect={(file) => setFile(file)}
* onFileRemove={() => setFile(null)}
* />
*/
import { useState, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Upload, X, FileText, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface FileInputProps {
/** 파일 선택 시 콜백 */
onFileSelect: (file: File) => void;
/** 파일 제거 시 콜백 */
onFileRemove?: () => void;
/** 허용 파일 타입 (예: ".pdf,.doc" 또는 "image/*") */
accept?: string;
/** 최대 파일 크기 (MB) */
maxSize?: number;
/** 비활성화 */
disabled?: boolean;
/** 버튼 텍스트 */
buttonText?: string;
/** 플레이스홀더 텍스트 */
placeholder?: string;
/** 현재 선택된 파일 (외부 제어용) */
value?: File | null;
/** 기존 파일 정보 (수정 모드용) */
existingFile?: {
name: string;
url?: string;
};
/** 추가 클래스 */
className?: string;
/** 에러 상태 */
error?: boolean;
/** 에러 메시지 */
errorMessage?: string;
}
export function FileInput({
onFileSelect,
onFileRemove,
accept = '*/*',
maxSize = 10,
disabled = false,
buttonText = '파일 선택',
placeholder = '선택된 파일 없음',
value,
existingFile,
className,
error = false,
errorMessage,
}: FileInputProps) {
const [internalFile, setInternalFile] = useState<File | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 외부 제어 또는 내부 상태 사용
const selectedFile = value !== undefined ? value : internalFile;
const displayError = errorMessage || validationError;
const handleClick = useCallback(() => {
if (!disabled) {
inputRef.current?.click();
}
}, [disabled]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 파일 크기 검증
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSize) {
setValidationError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
return;
}
setValidationError(null);
setInternalFile(file);
onFileSelect(file);
// input 초기화 (같은 파일 재선택 가능하도록)
if (inputRef.current) {
inputRef.current.value = '';
}
}, [maxSize, onFileSelect]);
const handleRemove = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setInternalFile(null);
setValidationError(null);
if (inputRef.current) {
inputRef.current.value = '';
}
onFileRemove?.();
}, [onFileRemove]);
// 표시할 파일명 결정
const displayFileName = selectedFile?.name || existingFile?.name;
const hasFile = !!selectedFile || !!existingFile;
return (
<div className={cn('space-y-1', className)}>
<div
className={cn(
'flex items-center gap-2 p-2 border rounded-md bg-background',
error || displayError ? 'border-red-500' : 'border-input',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-primary/50',
)}
onClick={handleClick}
>
{/* 숨겨진 파일 input */}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
disabled={disabled}
className="hidden"
/>
{/* 파일 선택 버튼 */}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
className="shrink-0"
>
<Upload className="w-4 h-4 mr-1" />
{buttonText}
</Button>
{/* 파일명 표시 영역 */}
<div className="flex-1 flex items-center gap-2 min-w-0">
{hasFile ? (
<>
<FileText className="w-4 h-4 text-primary shrink-0" />
<span className="text-sm truncate">
{displayFileName}
</span>
{selectedFile && (
<span className="text-xs text-muted-foreground shrink-0">
({(selectedFile.size / 1024).toFixed(1)} KB)
</span>
)}
</>
) : (
<span className="text-sm text-muted-foreground">
{placeholder}
</span>
)}
</div>
{/* 파일 제거 버튼 */}
{hasFile && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRemove}
className="shrink-0 h-7 w-7 p-0 hover:bg-destructive/10"
>
<X className="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
)}
</div>
{/* 에러 메시지 */}
{displayError && (
<div className="flex items-center gap-1 text-sm text-red-500">
<AlertCircle className="w-3 h-3" />
<span>{displayError}</span>
</div>
)}
{/* 허용 파일 형식 안내 */}
{!displayError && accept !== '*/*' && (
<p className="text-xs text-muted-foreground">
: {accept}
</p>
)}
</div>
);
}
export default FileInput;

View File

@@ -0,0 +1,273 @@
'use client';
/**
* FileList - 업로드된 파일 목록 표시 컴포넌트
*
* 첨부파일 목록 표시, 다운로드, 삭제 기능
*
* 사용 예시:
* <FileList
* files={uploadedFiles}
* onRemove={(index) => removeFile(index)}
* onDownload={(file) => downloadFile(file)}
* />
*/
import { useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { FileText, Download, Trash2, ExternalLink, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/** 새로 업로드된 파일 */
export interface NewFile {
file: File;
/** 업로드 진행률 (0-100) */
progress?: number;
/** 업로드 중 여부 */
uploading?: boolean;
/** 에러 메시지 */
error?: string;
}
/** 기존 파일 (서버에서 가져온) */
export interface ExistingFile {
id: string | number;
name: string;
url?: string;
size?: number;
/** 삭제 중 여부 */
deleting?: boolean;
}
export interface FileListProps {
/** 새로 업로드된 파일 목록 */
files?: NewFile[];
/** 기존 파일 목록 */
existingFiles?: ExistingFile[];
/** 새 파일 제거 콜백 */
onRemove?: (index: number) => void;
/** 기존 파일 제거 콜백 */
onRemoveExisting?: (id: string | number) => void;
/** 기존 파일 다운로드 콜백 */
onDownload?: (file: ExistingFile) => void;
/** 읽기 전용 */
readOnly?: boolean;
/** 추가 클래스 */
className?: string;
/** 파일 없을 때 메시지 */
emptyMessage?: string;
/** 컴팩트 모드 */
compact?: boolean;
}
// 파일 크기 포맷
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// 파일 확장자에 따른 아이콘 색상
function getFileColor(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase();
switch (ext) {
case 'pdf':
return 'text-red-500';
case 'doc':
case 'docx':
return 'text-blue-500';
case 'xls':
case 'xlsx':
return 'text-green-500';
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
return 'text-purple-500';
case 'dwg':
case 'dxf':
return 'text-orange-500';
default:
return 'text-gray-500';
}
}
export function FileList({
files = [],
existingFiles = [],
onRemove,
onRemoveExisting,
onDownload,
readOnly = false,
className,
emptyMessage = '파일이 없습니다',
compact = false,
}: FileListProps) {
const handleDownload = useCallback((file: ExistingFile) => {
if (onDownload) {
onDownload(file);
} else if (file.url) {
// 기본 다운로드 동작
const link = document.createElement('a');
link.href = file.url;
link.download = file.name;
link.click();
}
}, [onDownload]);
const totalFiles = files.length + existingFiles.length;
if (totalFiles === 0) {
return (
<div className={cn('text-sm text-muted-foreground text-center py-4', className)}>
{emptyMessage}
</div>
);
}
return (
<div className={cn('space-y-2', className)}>
{/* 기존 파일 목록 */}
{existingFiles.map((file) => (
<div
key={file.id}
className={cn(
'flex items-center justify-between gap-2 rounded-md border bg-muted/30',
compact ? 'p-2' : 'p-3',
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<FileText className={cn('shrink-0', getFileColor(file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
<div className="min-w-0 flex-1">
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
{file.name}
</p>
{file.size && (
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* 다운로드 버튼 */}
{(file.url || onDownload) && (
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
onClick={() => handleDownload(file)}
className={cn('text-blue-600 hover:text-blue-700 hover:bg-blue-50', compact && 'h-7 w-7 p-0')}
title="다운로드"
>
<Download className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
</Button>
)}
{/* 새 탭에서 열기 */}
{file.url && (
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
onClick={() => window.open(file.url, '_blank')}
className={cn('text-gray-600 hover:text-gray-700', compact && 'h-7 w-7 p-0')}
title="새 탭에서 열기"
>
<ExternalLink className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
</Button>
)}
{/* 삭제 버튼 */}
{!readOnly && onRemoveExisting && (
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
onClick={() => onRemoveExisting(file.id)}
disabled={file.deleting}
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
title="삭제"
>
{file.deleting ? (
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
) : (
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
)}
</Button>
)}
</div>
</div>
))}
{/* 새로 업로드된 파일 목록 */}
{files.map((item, index) => (
<div
key={`new-${index}`}
className={cn(
'flex items-center justify-between gap-2 rounded-md border',
item.error ? 'border-red-300 bg-red-50' : 'border-blue-200 bg-blue-50',
compact ? 'p-2' : 'p-3',
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<FileText className={cn('shrink-0', getFileColor(item.file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
{item.file.name}
</p>
{!item.error && (
<span className={cn('text-blue-600 shrink-0', compact ? 'text-[10px]' : 'text-xs')}>
( )
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatFileSize(item.file.size)}
</p>
{/* 에러 메시지 */}
{item.error && (
<p className="text-xs text-red-500 mt-1">{item.error}</p>
)}
{/* 업로드 진행률 */}
{item.uploading && item.progress !== undefined && (
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all"
style={{ width: `${item.progress}%` }}
/>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* 삭제/취소 버튼 */}
{!readOnly && onRemove && (
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
onClick={() => onRemove(index)}
disabled={item.uploading}
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
title={item.uploading ? '업로드 취소' : '삭제'}
>
{item.uploading ? (
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
) : (
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
)}
</Button>
)}
</div>
</div>
))}
</div>
);
}
export default FileList;

View File

@@ -0,0 +1,296 @@
'use client';
/**
* ImageUpload - 이미지 업로드 컴포넌트 (미리보기 포함)
*
* 프로필 이미지, 로고, 썸네일 등에 사용
*
* 사용 예시:
* <ImageUpload
* value={profileImageUrl}
* onChange={(file) => handleUpload(file)}
* onRemove={() => setProfileImageUrl(null)}
* aspectRatio="square"
* maxSize={5}
* />
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Camera, X, Upload, AlertCircle, Image as ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ImageUploadProps {
/** 현재 이미지 URL (기존 이미지 또는 미리보기) */
value?: string | null;
/** 이미지 선택 시 콜백 (File 객체 전달) */
onChange: (file: File) => void;
/** 이미지 제거 시 콜백 */
onRemove?: () => void;
/** 비활성화 */
disabled?: boolean;
/** 최대 파일 크기 (MB) */
maxSize?: number;
/** 허용 이미지 타입 */
accept?: string;
/** 가로세로 비율 */
aspectRatio?: 'square' | 'video' | 'wide' | 'portrait';
/** 컴포넌트 크기 */
size?: 'sm' | 'md' | 'lg';
/** 추가 클래스 */
className?: string;
/** 안내 텍스트 */
hint?: string;
/** 에러 상태 */
error?: boolean;
/** 에러 메시지 */
errorMessage?: string;
/** 원형 (프로필용) */
rounded?: boolean;
}
const sizeClasses = {
sm: 'w-24 h-24',
md: 'w-32 h-32',
lg: 'w-40 h-40',
};
const aspectRatioClasses = {
square: 'aspect-square',
video: 'aspect-video',
wide: 'aspect-[2/1]',
portrait: 'aspect-[3/4]',
};
export function ImageUpload({
value,
onChange,
onRemove,
disabled = false,
maxSize = 10,
accept = 'image/png,image/jpeg,image/gif,image/webp',
aspectRatio = 'square',
size = 'md',
className,
hint,
error = false,
errorMessage,
rounded = false,
}: ImageUploadProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// 외부 value 또는 내부 preview 사용
const displayUrl = value || previewUrl;
const displayError = errorMessage || validationError;
// value가 변경되면 previewUrl 초기화
useEffect(() => {
if (value) {
setPreviewUrl(null);
}
}, [value]);
const handleClick = useCallback(() => {
if (!disabled) {
inputRef.current?.click();
}
}, [disabled]);
const validateAndProcessFile = useCallback((file: File) => {
// 이미지 타입 검증
if (!file.type.startsWith('image/')) {
setValidationError('이미지 파일만 업로드할 수 있습니다');
return false;
}
// 파일 크기 검증
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSize) {
setValidationError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
return false;
}
setValidationError(null);
// 미리보기 URL 생성
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
onChange(file);
return true;
}, [maxSize, onChange]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
validateAndProcessFile(file);
}
// input 초기화
if (inputRef.current) {
inputRef.current.value = '';
}
}, [validateAndProcessFile]);
const handleRemove = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl(null);
setValidationError(null);
if (inputRef.current) {
inputRef.current.value = '';
}
onRemove?.();
}, [previewUrl, onRemove]);
// 드래그 앤 드롭 핸들러
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const file = e.dataTransfer.files?.[0];
if (file) {
validateAndProcessFile(file);
}
}, [disabled, validateAndProcessFile]);
// 컴포넌트 언마운트 시 미리보기 URL 정리
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
return (
<div className={cn('space-y-2', className)}>
{/* 업로드 영역 */}
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={cn(
'relative border-2 border-dashed flex flex-col items-center justify-center overflow-hidden transition-colors',
rounded ? 'rounded-full' : 'rounded-lg',
sizeClasses[size],
aspectRatio !== 'square' && !rounded && aspectRatioClasses[aspectRatio],
error || displayError
? 'border-red-500 bg-red-50'
: isDragging
? 'border-primary bg-primary/5'
: 'border-gray-300 bg-gray-50 hover:border-primary/50',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
)}
>
{/* 숨겨진 파일 input */}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
disabled={disabled}
className="hidden"
/>
{displayUrl ? (
// 이미지 미리보기
<>
<img
src={displayUrl}
alt="미리보기"
className={cn(
'w-full h-full object-cover',
rounded && 'rounded-full',
)}
/>
{/* 호버 오버레이 */}
{!disabled && (
<div className={cn(
'absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2',
rounded && 'rounded-full',
)}>
<Button
type="button"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
>
<Camera className="w-4 h-4" />
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
>
<X className="w-4 h-4" />
</Button>
</div>
)}
</>
) : (
// 업로드 안내
<div className="flex flex-col items-center justify-center text-center p-2">
{isDragging ? (
<Upload className="w-8 h-8 text-primary mb-1" />
) : (
<ImageIcon className="w-8 h-8 text-gray-400 mb-1" />
)}
<span className="text-xs text-gray-500">
{isDragging ? '놓으세요' : '클릭 또는 드래그'}
</span>
</div>
)}
</div>
{/* 에러 메시지 */}
{displayError && (
<div className="flex items-center gap-1 text-sm text-red-500">
<AlertCircle className="w-3 h-3" />
<span>{displayError}</span>
</div>
)}
{/* 안내 텍스트 */}
{!displayError && hint && (
<p className="text-xs text-muted-foreground text-center">
{hint}
</p>
)}
</div>
);
}
export default ImageUpload;