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:
byeongcheolryu
2025-11-28 20:14:43 +09:00
parent 8fd9cf2d40
commit 6ed5d4ffb3
28 changed files with 5359 additions and 2 deletions

View 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;