feat: 품목관리 동적 렌더링 시스템 구현
- DynamicItemForm 컴포넌트 구조 생성 - DynamicField: 필드 타입별 렌더링 - DynamicSection: 섹션 단위 렌더링 - DynamicFormRenderer: 페이지 전체 렌더링 - 필드 타입별 컴포넌트 (TextField, NumberField, DropdownField, CheckboxField, DateField, FileField, CustomField) - 커스텀 훅 (useDynamicFormState, useFormStructure, useConditionalFields) - DataTable 공통 컴포넌트 (테이블, 페이지네이션, 검색, 탭필터, 통계카드) - ItemFormWrapper: Feature Flag 기반 폼 선택 - 타입 정의 및 문서화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
203
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
203
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* FileField Component
|
||||
*
|
||||
* 파일 업로드 필드
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Upload, X, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* 파일 크기 포맷팅
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function FileField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const fileConfig = field.file_config || {};
|
||||
const accept = fileConfig.accept || '*';
|
||||
const maxSize = fileConfig.max_size || 10 * 1024 * 1024; // 기본 10MB
|
||||
const multiple = fileConfig.multiple || false;
|
||||
|
||||
// 현재 파일(들)
|
||||
const files: File[] = Array.isArray(value)
|
||||
? value
|
||||
: value instanceof File
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
const handleFileSelect = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
|
||||
// 파일 크기 검사
|
||||
if (file.size > maxSize) {
|
||||
alert(`"${file.name}" 파일이 너무 큽니다. 최대 ${formatFileSize(maxSize)}까지 업로드 가능합니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
if (multiple) {
|
||||
onChange([...files, ...validFiles]);
|
||||
} else {
|
||||
onChange(validFiles[0]);
|
||||
}
|
||||
onBlur();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFileSelect(e.target.files);
|
||||
// 같은 파일 재선택 허용을 위해 리셋
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (disabled || field.is_readonly) return;
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (disabled || field.is_readonly) return;
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
if (multiple) {
|
||||
const newFiles = files.filter((_, i) => i !== index);
|
||||
onChange(newFiles.length > 0 ? newFiles : null);
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
onBlur();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || field.is_readonly) return;
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors',
|
||||
dragOver && 'border-primary bg-primary/5',
|
||||
error && 'border-red-500',
|
||||
(disabled || field.is_readonly) && 'opacity-50 cursor-not-allowed bg-gray-50',
|
||||
!dragOver && !error && 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleInputChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
클릭하거나 파일을 드래그하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{accept !== '*' ? `허용 형식: ${accept}` : '모든 형식 허용'} |
|
||||
최대 {formatFileSize(maxSize)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 선택된 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled && !field.is_readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFile(index);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileField;
|
||||
Reference in New Issue
Block a user