이전 스캐너가 놓친 패턴 — 모달 닫기 X 버튼과 토글 스위치 등:
- NoticeManagement: 모달 헤더 X → '닫기'
- ReportManagement: 업로드 패널 X → '업로드 패널 닫기'
- AIModelManagement: 규칙 토글 → role=switch + aria-checked + aria-label
API 예시 복사 → '예시 URL 복사'
- FileUpload: 파일 제거 X → '{파일명} 제거'
- NotificationBanner: 알림 닫기 X → '알림 닫기'
- SearchInput: 입력 aria-label (placeholder), 지우기 버튼 → '검색어 지우기'
검증:
- 개선된 스캐너로 remaining=0 확인 (JSX tag 중첩 파싱)
- tsc ✅
123 lines
4.4 KiB
TypeScript
123 lines
4.4 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import { Upload, File, X, CheckCircle, AlertCircle } from 'lucide-react';
|
|
|
|
/*
|
|
* SFR-02 공통컴포넌트: 파일 업로드
|
|
*/
|
|
|
|
interface FileUploadProps {
|
|
/** 허용 확장자 (예: '.xlsx,.csv,.pdf') */
|
|
accept?: string;
|
|
/** 다중 파일 허용 */
|
|
multiple?: boolean;
|
|
/** 최대 파일 크기 (MB) */
|
|
maxSizeMB?: number;
|
|
/** 파일 선택 콜백 */
|
|
onFilesSelected?: (files: File[]) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function FileUpload({
|
|
accept = '.xlsx,.csv,.pdf,.hwp,.docx',
|
|
multiple = false,
|
|
maxSizeMB = 50,
|
|
onFilesSelected,
|
|
className = '',
|
|
}: FileUploadProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [files, setFiles] = useState<{ file: File; status: 'ready' | 'done' | 'error'; msg?: string }[]>([]);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
const processFiles = (fileList: FileList) => {
|
|
const newFiles = Array.from(fileList).map((file) => {
|
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
return { file, status: 'error' as const, msg: `파일 크기 초과 (최대 ${maxSizeMB}MB)` };
|
|
}
|
|
return { file, status: 'ready' as const };
|
|
});
|
|
setFiles((prev) => (multiple ? [...prev, ...newFiles] : newFiles));
|
|
onFilesSelected?.(newFiles.filter((f) => f.status === 'ready').map((f) => f.file));
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files);
|
|
};
|
|
|
|
const formatSize = (bytes: number) => {
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
<div
|
|
onClick={() => inputRef.current?.click()}
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
|
dragOver
|
|
? 'border-blue-500/50 bg-blue-500/5'
|
|
: 'border-slate-700/50 hover:border-slate-600/50 hover:bg-surface-overlay'
|
|
}`}
|
|
>
|
|
<Upload className="w-8 h-8 text-hint mx-auto mb-2" />
|
|
<p className="text-[11px] text-muted-foreground">
|
|
클릭하여 파일을 선택하거나 드래그하여 업로드
|
|
</p>
|
|
<p className="text-[9px] text-hint mt-1">
|
|
{accept.replace(/\./g, '').toUpperCase().replace(/,/g, ' · ')} | 최대 {maxSizeMB}MB
|
|
{multiple && ' | 다중 파일 가능'}
|
|
</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={accept}
|
|
multiple={multiple}
|
|
onChange={(e) => e.target.files && processFiles(e.target.files)}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
|
|
{/* 파일 목록 */}
|
|
{files.length > 0 && (
|
|
<div className="mt-2 space-y-1">
|
|
{files.map((f, i) => (
|
|
<div
|
|
key={i}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-[11px] ${
|
|
f.status === 'error'
|
|
? 'bg-red-500/10 border border-red-500/20'
|
|
: f.status === 'done'
|
|
? 'bg-green-500/10 border border-green-500/20'
|
|
: 'bg-surface-overlay border border-border'
|
|
}`}
|
|
>
|
|
{f.status === 'error' ? (
|
|
<AlertCircle className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
|
) : f.status === 'done' ? (
|
|
<CheckCircle className="w-3.5 h-3.5 text-green-400 shrink-0" />
|
|
) : (
|
|
<File className="w-3.5 h-3.5 text-hint shrink-0" />
|
|
)}
|
|
<span className="text-label truncate flex-1">{f.file.name}</span>
|
|
<span className="text-hint text-[10px] shrink-0">{formatSize(f.file.size)}</span>
|
|
{f.msg && <span className="text-red-400 text-[10px] shrink-0">{f.msg}</span>}
|
|
<button type="button" aria-label={`${f.file.name} 제거`} onClick={() => removeFile(i)} className="text-hint hover:text-muted-foreground shrink-0">
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|