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,91 @@
/**
* CheckboxField Component
*
* 체크박스/스위치 필드 (checkbox, switch)
*/
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
export function CheckboxField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const isSwitch = field.field_type === 'switch';
const checked = value === true || value === 'true' || value === 1;
const handleChange = (newChecked: boolean) => {
onChange(newChecked);
onBlur();
};
if (isSwitch) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<Switch
id={field.field_key}
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || field.is_readonly}
/>
</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>
);
}
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={field.field_key}
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || field.is_readonly}
/>
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium cursor-pointer',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500",
(disabled || field.is_readonly) && 'cursor-not-allowed opacity-50'
)}
>
{field.field_name}
</Label>
</div>
{field.help_text && !error && (
<p className="text-xs text-muted-foreground ml-6">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500 ml-6">{error}</p>}
</div>
);
}
export default CheckboxField;

View File

@@ -0,0 +1,479 @@
/**
* CustomField Component
*
* 특수 필드 컴포넌트 래퍼 (전개도, BOM 등)
* - custom:drawing-canvas → DrawingCanvas
* - custom:bending-detail-table → BendingDetailTable (전개도 상세 테이블)
* - custom:bom-table → BOMSection
*
* 기존 ItemForm의 특수 컴포넌트를 재사용하면서 동적 폼과 통합
*/
'use client';
import { useState, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FileImage, Plus, Trash2 } from 'lucide-react';
import type { DynamicFieldProps, FormValue } from '../types';
import type { BendingDetail, BOMLine } from '@/types/item';
import { cn } from '@/lib/utils';
// ===== BOM 테이블 컴포넌트 =====
interface BOMTableProps {
value: BOMLine[];
onChange: (lines: BOMLine[]) => void;
disabled?: boolean;
}
function BOMTable({ value, onChange, disabled }: BOMTableProps) {
const bomLines = Array.isArray(value) ? value : [];
const addLine = () => {
const newLine: BOMLine = {
id: `bom-${Date.now()}`,
childItemCode: '',
childItemName: '',
quantity: 1,
unit: 'EA',
};
onChange([...bomLines, newLine]);
};
const updateLine = (index: number, field: keyof BOMLine, fieldValue: string | number) => {
const updated = [...bomLines];
updated[index] = { ...updated[index], [field]: fieldValue };
onChange(updated);
};
const removeLine = (index: number) => {
onChange(bomLines.filter((_, i) => i !== index));
};
return (
<Card>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm"> (BOM)</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={addLine}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
{bomLines.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p> BOM .</p>
<p className="text-sm mt-1"> .</p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
<div className="col-span-3"></div>
<div className="col-span-4"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
</div>
{bomLines.map((line, index) => (
<div key={line.id} className="grid grid-cols-12 gap-2 items-center">
<Input
className="col-span-3 h-8 text-sm"
placeholder="품목코드"
value={line.childItemCode}
onChange={(e) => updateLine(index, 'childItemCode', e.target.value)}
disabled={disabled}
/>
<Input
className="col-span-4 h-8 text-sm"
placeholder="품목명"
value={line.childItemName}
onChange={(e) => updateLine(index, 'childItemName', e.target.value)}
disabled={disabled}
/>
<Input
className="col-span-2 h-8 text-sm text-right"
type="number"
min={1}
value={line.quantity}
onChange={(e) => updateLine(index, 'quantity', parseInt(e.target.value) || 1)}
disabled={disabled}
/>
<Input
className="col-span-2 h-8 text-sm"
value={line.unit}
onChange={(e) => updateLine(index, 'unit', e.target.value)}
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="col-span-1 h-8 w-8 p-0"
onClick={() => removeLine(index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ===== 전개도 상세 테이블 컴포넌트 =====
interface BendingDetailTableProps {
value: BendingDetail[];
onChange: (details: BendingDetail[]) => void;
disabled?: boolean;
}
function BendingDetailTable({ value, onChange, disabled }: BendingDetailTableProps) {
const details = Array.isArray(value) ? value : [];
// 폭 합계 계산
const totalWidth = details.reduce((acc, d) => acc + d.input + d.elongation, 0);
const addRow = () => {
const newRow: BendingDetail = {
id: `bend-${Date.now()}`,
no: details.length + 1,
input: 0,
elongation: -1,
calculated: 0,
sum: 0,
shaded: false,
};
onChange([...details, newRow]);
};
const updateRow = (index: number, field: keyof BendingDetail, fieldValue: number | boolean) => {
const updated = [...details];
updated[index] = { ...updated[index], [field]: fieldValue };
// calculated와 sum 자동 계산
if (field === 'input' || field === 'elongation') {
const input = field === 'input' ? (fieldValue as number) : updated[index].input;
const elongation = field === 'elongation' ? (fieldValue as number) : updated[index].elongation;
updated[index].calculated = input + elongation;
// 누적 합계 재계산
let runningSum = 0;
for (let i = 0; i <= index; i++) {
runningSum += updated[i].input + updated[i].elongation;
updated[i].sum = runningSum;
}
}
onChange(updated);
};
const removeRow = (index: number) => {
const updated = details.filter((_, i) => i !== index);
// 번호 재정렬
updated.forEach((row, i) => {
row.no = i + 1;
});
onChange(updated);
};
return (
<Card>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<FileImage className="h-4 w-4" />
</CardTitle>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
: <strong className="text-foreground">{totalWidth.toFixed(1)} mm</strong>
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{details.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p> .</p>
<p className="text-sm mt-1"> .</p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
<div className="col-span-1">No</div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
</div>
{details.map((row, index) => (
<div key={row.id} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-1 text-sm text-center text-muted-foreground">
{row.no}
</div>
<Input
className="col-span-2 h-8 text-sm text-right"
type="number"
step="0.1"
value={row.input}
onChange={(e) => updateRow(index, 'input', parseFloat(e.target.value) || 0)}
disabled={disabled}
/>
<Input
className="col-span-2 h-8 text-sm text-right"
type="number"
step="0.1"
value={row.elongation}
onChange={(e) => updateRow(index, 'elongation', parseFloat(e.target.value) || 0)}
disabled={disabled}
/>
<div className="col-span-2 text-sm text-right pr-3 text-muted-foreground">
{(row.input + row.elongation).toFixed(1)}
</div>
<div className="col-span-2 text-sm text-right pr-3 font-medium">
{row.sum.toFixed(1)}
</div>
<div className="col-span-2 flex justify-center">
<input
type="checkbox"
checked={row.shaded}
onChange={(e) => updateRow(index, 'shaded', e.target.checked)}
disabled={disabled}
className="h-4 w-4"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="col-span-1 h-8 w-8 p-0"
onClick={() => removeRow(index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ===== 전개도 캔버스 (간단 버전) =====
interface DrawingCanvasProps {
value: string | null;
onChange: (dataUrl: string | null) => void;
disabled?: boolean;
}
function DrawingCanvasSimple({ value, onChange, disabled }: DrawingCanvasProps) {
const [inputMethod, setInputMethod] = useState<'file' | 'drawing'>('file');
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
onChange(event.target?.result as string);
};
reader.readAsDataURL(file);
};
const handleClear = () => {
onChange(null);
};
return (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2">
<FileImage className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 입력 방식 선택 */}
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="drawingMethod"
checked={inputMethod === 'file'}
onChange={() => setInputMethod('file')}
disabled={disabled}
/>
<span className="text-sm"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="drawingMethod"
checked={inputMethod === 'drawing'}
onChange={() => setInputMethod('drawing')}
disabled={disabled}
/>
<span className="text-sm"> ( )</span>
</label>
</div>
{/* 파일 업로드 */}
{inputMethod === 'file' && (
<div>
{value ? (
<div className="relative">
<img
src={value}
alt="전개도"
className="max-w-full h-auto border rounded-lg"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={handleClear}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={disabled}
className="hidden"
id="drawing-file-input"
/>
<label
htmlFor="drawing-file-input"
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<FileImage className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p> </p>
<p className="text-xs mt-1">PNG, JPG, GIF </p>
</label>
</div>
)}
</div>
)}
{/* 직접 그리기 (플레이스홀더) */}
{inputMethod === 'drawing' && (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
<p> .</p>
<p className="text-sm mt-1"> DrawingCanvas </p>
</div>
)}
</CardContent>
</Card>
);
}
// ===== 메인 CustomField 컴포넌트 =====
export function CustomField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const renderCustomComponent = () => {
switch (field.field_type) {
case 'custom:drawing-canvas':
return (
<DrawingCanvasSimple
value={value as string | null}
onChange={(dataUrl) => {
onChange(dataUrl);
onBlur();
}}
disabled={disabled}
/>
);
case 'custom:bending-detail-table':
return (
<BendingDetailTable
value={value as BendingDetail[] || []}
onChange={(details) => {
onChange(details as unknown as FormValue);
onBlur();
}}
disabled={disabled}
/>
);
case 'custom:bom-table':
return (
<BOMTable
value={value as BOMLine[] || []}
onChange={(lines) => {
onChange(lines as unknown as FormValue);
onBlur();
}}
disabled={disabled}
/>
);
default:
return (
<div className="p-4 border border-orange-200 bg-orange-50 rounded-md">
<p className="text-sm text-orange-600">
: {field.field_type}
</p>
</div>
);
}
};
// 커스텀 필드는 자체 레이블이 있으므로 별도 레이블 불필요
return (
<div className="space-y-2">
{renderCustomComponent()}
{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 CustomField;

View File

@@ -0,0 +1,100 @@
/**
* DateField Component
*
* 날짜 선택 필드 (date, date-range)
*/
'use client';
import { useState } from 'react';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
export function DateField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const [open, setOpen] = useState(false);
// 값을 Date 객체로 변환
const dateValue = value ? new Date(value as string) : undefined;
const isValidDate = dateValue && !isNaN(dateValue.getTime());
const handleSelect = (date: Date | undefined) => {
if (date) {
// ISO 문자열로 변환 (YYYY-MM-DD)
onChange(format(date, 'yyyy-MM-dd'));
} else {
onChange(null);
}
setOpen(false);
onBlur();
};
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={field.field_key}
variant="outline"
disabled={disabled || field.is_readonly}
className={cn(
'w-full justify-start text-left font-normal',
!isValidDate && 'text-muted-foreground',
error && 'border-red-500 focus:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{isValidDate
? format(dateValue, 'yyyy년 MM월 dd일', { locale: ko })
: field.placeholder || '날짜를 선택하세요'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={isValidDate ? dateValue : undefined}
onSelect={handleSelect}
locale={ko}
initialFocus
/>
</PopoverContent>
</Popover>
{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 DateField;

View File

@@ -0,0 +1,118 @@
/**
* DropdownField Component
*
* 드롭다운/선택 필드 (dropdown, searchable-dropdown)
*/
'use client';
import { useState, useEffect } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps, DropdownOption } from '../types';
import { cn } from '@/lib/utils';
export function DropdownField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const [options, setOptions] = useState<DropdownOption[]>(
field.dropdown_config?.options || []
);
const [isLoading, setIsLoading] = useState(false);
// API에서 옵션 로드 (options_endpoint가 있는 경우)
useEffect(() => {
if (field.dropdown_config?.options_endpoint) {
setIsLoading(true);
fetch(field.dropdown_config.options_endpoint)
.then((res) => res.json())
.then((data) => {
if (data.success && Array.isArray(data.data)) {
setOptions(data.data);
}
})
.catch((err) => {
console.error('[DropdownField] Failed to load options:', err);
})
.finally(() => {
setIsLoading(false);
});
}
}, [field.dropdown_config?.options_endpoint]);
const displayValue = value === null || value === undefined ? '' : String(value);
const handleValueChange = (newValue: string) => {
onChange(newValue);
onBlur();
};
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<Select
value={displayValue}
onValueChange={handleValueChange}
disabled={disabled || field.is_readonly || isLoading}
>
<SelectTrigger
id={field.field_key}
className={cn(
error && 'border-red-500 focus:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500'
)}
>
<SelectValue
placeholder={
isLoading
? '로딩 중...'
: field.dropdown_config?.placeholder || '선택하세요'
}
/>
</SelectTrigger>
<SelectContent>
{field.dropdown_config?.allow_empty && (
<SelectItem value=""> </SelectItem>
)}
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{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 DropdownField;

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;

View File

@@ -0,0 +1,122 @@
/**
* NumberField Component
*
* 숫자 입력 필드 (number, currency)
*/
'use client';
import { useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
/**
* 숫자 포맷팅 (천단위 콤마)
*/
function formatNumber(value: number | string, isCurrency: boolean): string {
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '';
if (isCurrency) {
return num.toLocaleString('ko-KR');
}
return num.toString();
}
/**
* 포맷팅된 문자열을 숫자로 변환
*/
function parseFormattedNumber(value: string): number {
// 콤마 제거 후 숫자로 변환
const cleaned = value.replace(/,/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? 0 : num;
}
export function NumberField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const isCurrency = field.field_type === 'currency';
const numValue = typeof value === 'number' ? value : parseFormattedNumber(String(value || '0'));
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// 빈 값 허용
if (inputValue === '' || inputValue === '-') {
onChange(0);
return;
}
// 숫자와 콤마, 소수점, 마이너스만 허용
const cleaned = inputValue.replace(/[^\d.,-]/g, '');
const num = parseFormattedNumber(cleaned);
// 유효성 검사
const { min, max } = field.validation_rules || {};
if (min !== undefined && num < min) return;
if (max !== undefined && num > max) return;
onChange(num);
},
[onChange, field.validation_rules]
);
// 표시용 값 (통화면 포맷팅)
const displayValue = formatNumber(numValue, isCurrency);
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<div className="relative">
{isCurrency && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
</span>
)}
<Input
id={field.field_key}
name={field.field_key}
type="text"
inputMode="numeric"
value={displayValue}
onChange={handleChange}
onBlur={onBlur}
placeholder={field.placeholder || '0'}
disabled={disabled || field.is_readonly}
className={cn(
isCurrency && 'pl-8',
error && 'border-red-500 focus-visible:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500',
'text-right'
)}
/>
</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 NumberField;

View File

@@ -0,0 +1,83 @@
/**
* TextField Component
*
* 텍스트 입력 필드 (textbox, textarea)
*/
'use client';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
export function TextField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const isTextarea = field.field_type === 'textarea';
const displayValue = value === null || value === undefined ? '' : String(value);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onChange(e.target.value);
};
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
{isTextarea ? (
<Textarea
id={field.field_key}
name={field.field_key}
value={displayValue}
onChange={handleChange}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled || field.is_readonly}
className={cn(
'min-h-[100px]',
error && 'border-red-500 focus-visible:ring-red-500'
)}
/>
) : (
<Input
id={field.field_key}
name={field.field_key}
type="text"
value={displayValue}
onChange={handleChange}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled || field.is_readonly}
maxLength={field.validation_rules?.maxLength}
className={cn(
error && 'border-red-500 focus-visible:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500'
)}
/>
)}
{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 TextField;

View File

@@ -0,0 +1,11 @@
/**
* 동적 필드 컴포넌트 인덱스
*/
export { TextField } from './TextField';
export { DropdownField } from './DropdownField';
export { NumberField } from './NumberField';
export { DateField } from './DateField';
export { CheckboxField } from './CheckboxField';
export { FileField } from './FileField';
export { CustomField } from './CustomField';