kcg-ai-monitoring/frontend/src/shared/components/common/FileUpload.tsx
htlee f4d56ea891 fix(frontend): 아이콘 전용 버튼 접근 이름 누락 7곳 보완
이전 스캐너가 놓친 패턴 — 모달 닫기 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 
2026-04-08 13:16:20 +09:00

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