Files
sam-react-prod/src/components/items/DynamicItemForm/fields/FileField.tsx
유병철 020d74f36c feat(WEB): DynamicItemForm 필드 타입 확장 및 컴포넌트 레지스트리 추가
- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed)
- DynamicTableSection 및 TableCellRenderer 추가
- 필드 프리셋 및 설정 구조 분리
- 컴포넌트 레지스트리 개발 도구 페이지 추가
- UniversalListPage 개선
- 근태관리 코드 정리
- 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:17:57 +09:00

200 lines
6.6 KiB
TypeScript

/**
* File 필드 컴포넌트
* 파일/이미지 첨부 (드래그 앤 드롭 + 파일 선택)
*
* API 연동 전: File 객체를 로컬 상태로 관리, URL.createObjectURL로 미리보기
* API 연동 시: POST /v1/files/upload (multipart)
*/
'use client';
import { useState, useRef, useCallback } from 'react';
import { Upload, X, FileIcon, ImageIcon } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import type { DynamicFieldRendererProps, FileConfig } from '../types';
interface LocalFile {
file: File;
previewUrl?: string;
}
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 isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}
export function FileField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const fileInputRef = useRef<HTMLInputElement>(null);
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const config = (field.properties || {}) as FileConfig;
const accept = config.accept || '*';
const maxSize = config.maxSize || 10485760; // 10MB
const maxFiles = config.maxFiles || 1;
const showPreview = config.preview !== false;
// 파일 추가
const addFiles = useCallback((newFiles: FileList | File[]) => {
const files = Array.from(newFiles);
const validFiles: LocalFile[] = [];
for (const file of files) {
if (localFiles.length + validFiles.length >= maxFiles) break;
if (file.size > maxSize) continue;
const previewUrl = showPreview && isImageFile(file)
? URL.createObjectURL(file)
: undefined;
validFiles.push({ file, previewUrl });
}
if (validFiles.length === 0) return;
const updated = [...localFiles, ...validFiles];
setLocalFiles(updated);
// 단일 파일이면 File 객체, 복수면 배열 이름을 저장
onChange(maxFiles === 1
? updated[0]?.file.name || null
: updated.map(f => f.file.name)
);
}, [localFiles, maxFiles, maxSize, showPreview, onChange]);
// 파일 제거
const removeFile = (index: number) => {
const file = localFiles[index];
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
const updated = localFiles.filter((_, i) => i !== index);
setLocalFiles(updated);
onChange(updated.length === 0 ? null : (
maxFiles === 1 ? updated[0]?.file.name : updated.map(f => f.file.name)
));
};
// 드래그 핸들러
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragOver(true);
};
const handleDragLeave = () => setIsDragOver(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (!disabled && e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
};
const canAddMore = localFiles.length < maxFiles;
// value가 이미 있으면 (서버에서 받은 파일명) 기존 파일 표시
const existingFileName = typeof value === 'string' && value && localFiles.length === 0 ? value : null;
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
{/* 드래그 앤 드롭 영역 */}
{canAddMore && !disabled && (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`mt-1 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer ${
isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50'
} ${error ? 'border-red-500' : ''}`}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatFileSize(maxSize)}
{accept !== '*' && ` (${accept})`}
</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
id={fieldKey}
accept={accept}
multiple={maxFiles > 1}
className="hidden"
onChange={(e) => e.target.files && addFiles(e.target.files)}
/>
{/* 기존 파일 (서버에서 받은) */}
{existingFileName && (
<div className="mt-2 flex items-center gap-2 rounded-md border p-2">
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate flex-1">{existingFileName}</span>
</div>
)}
{/* 선택된 파일 목록 */}
{localFiles.length > 0 && (
<div className="mt-2 space-y-2">
{localFiles.map((lf, index) => (
<div key={index} className="flex items-center gap-2 rounded-md border p-2">
{/* 이미지 미리보기 */}
{lf.previewUrl ? (
<img
src={lf.previewUrl}
alt={lf.file.name}
className="h-10 w-10 rounded object-cover shrink-0"
/>
) : (
isImageFile(lf.file)
? <ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
: <FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{lf.file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(lf.file.size)}</p>
</div>
{!disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => removeFile(index)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}