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>
This commit is contained in:
유병철
2026-02-12 11:17:57 +09:00
parent 4decb99856
commit 020d74f36c
39 changed files with 12368 additions and 116 deletions

View File

@@ -0,0 +1,135 @@
/**
* 계산 필드 (읽기전용)
* 다른 필드 값 기반 자동 계산, formula 평가
*
* properties: { formula, dependsOn, format, precision }
* 저장값: 계산 결과 number (자동 업데이트)
*/
'use client';
import { useEffect, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import type { DynamicFieldRendererProps, ComputedConfig, DynamicFormData } from '../types';
/**
* 안전한 수식 평가기
* 지원 연산: +, -, *, /, (, )
* 변수: field_key 이름으로 참조
*/
function evaluateFormula(
formula: string,
variables: Record<string, number>
): number | null {
try {
// 변수를 숫자로 치환 (긴 키부터 치환하여 부분 매칭 방지)
let expression = formula;
const sortedKeys = Object.keys(variables).sort((a, b) => b.length - a.length);
for (const key of sortedKeys) {
expression = expression.replace(
new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
String(variables[key])
);
}
// 숫자, 연산자, 괄호, 공백만 허용 (안전성 검사)
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
return null;
}
// Function 생성자로 안전하게 평가
const result = new Function(`"use strict"; return (${expression})`)();
if (typeof result !== 'number' || !isFinite(result)) {
return null;
}
return result;
} catch {
return null;
}
}
function formatResult(
value: number,
format?: string,
precision?: number
): string {
const p = precision ?? 2;
switch (format) {
case 'currency': {
const formatted = value.toFixed(p);
const [intPart, decPart] = formatted.split('.');
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return decPart ? `\u20A9${withCommas}.${decPart}` : `\u20A9${withCommas}`;
}
case 'percent':
return `${value.toFixed(p)}%`;
case 'number':
default:
return value.toFixed(p);
}
}
export function ComputedField({
field,
value,
onChange,
error,
disabled: _disabled,
formData,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as ComputedConfig;
const { formula, dependsOn, format, precision } = config;
const prevResultRef = useRef<number | null>(null);
// dependsOn 필드 값이 변경될 때마다 재계산
useEffect(() => {
if (!formula || !dependsOn || !formData) return;
// 의존 필드 값 수집
const variables: Record<string, number> = {};
for (const dep of dependsOn) {
const depValue = formData[dep];
variables[dep] = depValue !== null && depValue !== undefined ? Number(depValue) || 0 : 0;
}
const result = evaluateFormula(formula, variables);
// 이전 결과와 다를 때만 업데이트 (무한 루프 방지)
if (result !== null && result !== prevResultRef.current) {
prevResultRef.current = result;
onChange(result);
}
}, [formula, dependsOn, formData, onChange]);
const displayValue = value !== null && value !== undefined
? formatResult(Number(value), format, precision)
: '-';
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<Input
id={fieldKey}
value={displayValue}
readOnly
disabled
className="bg-muted text-muted-foreground cursor-default"
/>
{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>
);
}

View File

@@ -0,0 +1,126 @@
/**
* 통화 금액 입력 필드
* 천단위 콤마 포맷, 통화 기호 prefix 지원
*
* properties: { currency, precision, showSymbol, allowNegative }
* 저장값: number (포맷 없이)
*/
'use client';
import { useState, useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { DynamicFieldRendererProps, CurrencyConfig } from '../types';
const CURRENCY_SYMBOLS: Record<string, string> = {
KRW: '\u20A9',
USD: '$',
EUR: '\u20AC',
JPY: '\u00A5',
CNY: '\u00A5',
GBP: '\u00A3',
};
function formatCurrency(num: number, precision: number): string {
const fixed = num.toFixed(precision);
const [intPart, decPart] = fixed.split('.');
const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return decPart !== undefined ? `${formatted}.${decPart}` : formatted;
}
function parseCurrency(str: string): number {
const cleaned = str.replace(/[^0-9.\-]/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? 0 : num;
}
export function CurrencyField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as CurrencyConfig;
const currency = config.currency || 'KRW';
const precision = config.precision ?? 0;
const showSymbol = config.showSymbol !== false;
const allowNegative = config.allowNegative === true;
const symbol = CURRENCY_SYMBOLS[currency] || currency;
const numericValue = value !== null && value !== undefined ? Number(value) : null;
const [isFocused, setIsFocused] = useState(false);
const [inputValue, setInputValue] = useState(
numericValue !== null ? String(numericValue) : ''
);
const handleFocus = useCallback(() => {
setIsFocused(true);
setInputValue(numericValue !== null ? String(numericValue) : '');
}, [numericValue]);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (inputValue === '' || inputValue === '-') {
onChange(null);
return;
}
const parsed = parseCurrency(inputValue);
const final = allowNegative ? parsed : Math.abs(parsed);
onChange(final);
}, [inputValue, onChange, allowNegative]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
// 숫자, 점, 마이너스만 허용
const pattern = allowNegative ? /[^0-9.\-]/g : /[^0-9.]/g;
const cleaned = raw.replace(pattern, '');
setInputValue(cleaned);
}, [allowNegative]);
const displayValue = isFocused
? inputValue
: numericValue !== null
? formatCurrency(numericValue, precision)
: '';
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="relative">
{showSymbol && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground pointer-events-none">
{symbol}
</span>
)}
<Input
id={fieldKey}
type="text"
inputMode="decimal"
placeholder={field.placeholder || '0'}
value={displayValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
className={`${showSymbol ? 'pl-8' : ''} text-right ${error ? 'border-red-500' : ''}`}
/>
</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>
);
}

View File

@@ -1,6 +1,9 @@
/**
* 동적 필드 렌더러
* field_type에 따라 적절한 필드 컴포넌트를 선택하여 렌더링
*
* 기본 6종 + 확장 8종 = 14종 지원
* 알 수 없는 타입은 TextField로 폴백
*/
'use client';
@@ -11,12 +14,24 @@ import { DropdownField } from './DropdownField';
import { CheckboxField } from './CheckboxField';
import { DateField } from './DateField';
import { TextareaField } from './TextareaField';
// Phase 1: 핵심 컴포넌트
import { ReferenceField } from './ReferenceField';
import { MultiSelectField } from './MultiSelectField';
import { FileField } from './FileField';
// Phase 2: 편의 컴포넌트
import { CurrencyField } from './CurrencyField';
import { UnitValueField } from './UnitValueField';
import { RadioField } from './RadioField';
// Phase 3: 고급 컴포넌트
import { ToggleField } from './ToggleField';
import { ComputedField } from './ComputedField';
import type { DynamicFieldRendererProps } from '../types';
export function DynamicFieldRenderer(props: DynamicFieldRendererProps) {
const { field } = props;
switch (field.field_type) {
// 기본 6종
case 'textbox':
return <TextField {...props} />;
@@ -35,6 +50,33 @@ export function DynamicFieldRenderer(props: DynamicFieldRendererProps) {
case 'textarea':
return <TextareaField {...props} />;
// Phase 1: 핵심 타입
case 'reference':
return <ReferenceField {...props} />;
case 'multi-select':
return <MultiSelectField {...props} />;
case 'file':
return <FileField {...props} />;
// Phase 2: 편의 타입
case 'currency':
return <CurrencyField {...props} />;
case 'unit-value':
return <UnitValueField {...props} />;
case 'radio':
return <RadioField {...props} />;
// Phase 3: 고급 타입
case 'toggle':
return <ToggleField {...props} />;
case 'computed':
return <ComputedField {...props} />;
default:
// 알 수 없는 타입은 텍스트 필드로 폴백
console.warn(`Unknown field type: ${field.field_type}, falling back to TextField`);

View File

@@ -0,0 +1,199 @@
/**
* 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>
);
}

View File

@@ -0,0 +1,191 @@
/**
* MultiSelect 필드 컴포넌트
* 여러 항목을 동시에 선택 (태그 칩 형태로 표시)
*
* 저장값: string[] (예: ["CUT", "BEND", "WELD"])
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { X, ChevronDown } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import type { DynamicFieldRendererProps, MultiSelectConfig } from '../types';
// 옵션 정규화 (DropdownField와 동일 로직)
function normalizeOptions(rawOptions: unknown): Array<{ label: string; value: string }> {
if (!rawOptions) return [];
if (typeof rawOptions === 'string') {
return rawOptions.split(',').map(o => {
const trimmed = o.trim();
return { label: trimmed, value: trimmed };
});
}
if (Array.isArray(rawOptions)) {
return rawOptions.map(item => {
if (typeof item === 'object' && item !== null && 'value' in item) {
return {
label: String((item as Record<string, unknown>).label || (item as Record<string, unknown>).value),
value: String((item as Record<string, unknown>).value),
};
}
const str = String(item);
return { label: str, value: str };
});
}
return [];
}
export function MultiSelectField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const config = (field.properties || {}) as MultiSelectConfig;
const maxSelections = config.maxSelections;
const allowCustom = config.allowCustom ?? false;
// 현재 선택된 값 배열
const selectedValues: string[] = Array.isArray(value) ? (value as string[]) : [];
// 옵션 목록
const options = normalizeOptions(field.options);
const filteredOptions = options.filter(
opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()) &&
!selectedValues.includes(opt.value)
);
// 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 항목 추가
const addValue = (val: string) => {
if (maxSelections && selectedValues.length >= maxSelections) return;
const newValues = [...selectedValues, val];
onChange(newValues);
setSearchTerm('');
};
// 항목 제거
const removeValue = (val: string) => {
onChange(selectedValues.filter(v => v !== val));
};
// 커스텀 입력 추가 (Enter)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && searchTerm.trim()) {
e.preventDefault();
if (allowCustom && !selectedValues.includes(searchTerm.trim())) {
addValue(searchTerm.trim());
} else {
// 기존 옵션에서 일치하는 것 선택
const match = filteredOptions[0];
if (match) addValue(match.value);
}
}
if (e.key === 'Backspace' && !searchTerm && selectedValues.length > 0) {
removeValue(selectedValues[selectedValues.length - 1]);
}
};
// 옵션 라벨 조회
const getLabel = (val: string) => {
const opt = options.find(o => o.value === val);
return opt ? opt.label : val;
};
const isAtLimit = maxSelections ? selectedValues.length >= maxSelections : false;
return (
<div ref={containerRef}>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
{maxSelections && (
<span className="text-muted-foreground font-normal ml-1">
({selectedValues.length}/{maxSelections})
</span>
)}
</Label>
<div
className={`min-h-10 flex flex-wrap items-center gap-1 rounded-md border bg-background px-3 py-2 text-sm cursor-text ${
error ? 'border-red-500' : 'border-input'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !disabled && setIsOpen(true)}
>
{/* 선택된 칩들 */}
{selectedValues.map(val => (
<Badge key={val} variant="secondary" className="gap-1 pr-1">
{getLabel(val)}
{!disabled && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeValue(val); }}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
))}
{/* 검색 입력 */}
{!disabled && !isAtLimit && (
<Input
id={fieldKey}
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={selectedValues.length === 0 ? (field.placeholder || '선택하세요') : ''}
className="border-0 shadow-none p-0 h-6 min-w-[60px] flex-1 focus-visible:ring-0"
/>
)}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground ml-auto" />
</div>
{/* 드롭다운 */}
{isOpen && filteredOptions.length > 0 && (
<div className="relative">
<div className="absolute z-50 w-full mt-1 max-h-48 overflow-auto rounded-md border bg-popover shadow-md">
{filteredOptions.map(opt => (
<button
key={opt.value}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => { addValue(opt.value); setIsOpen(false); }}
>
{opt.label}
</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>
);
}

View File

@@ -0,0 +1,91 @@
/**
* 라디오 버튼 그룹 필드
* 수평/수직 레이아웃 지원
*
* properties: { layout: "horizontal" | "vertical" }
* options: [{label, value}]
*/
'use client';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import type { DynamicFieldRendererProps } from '../types';
// 옵션을 {label, value} 형태로 정규화
function normalizeOptions(rawOptions: unknown): Array<{ label: string; value: string }> {
if (!rawOptions) return [];
if (typeof rawOptions === 'string') {
return rawOptions.split(',').map(o => {
const trimmed = o.trim();
return { label: trimmed, value: trimmed };
});
}
if (Array.isArray(rawOptions)) {
return rawOptions.map(item => {
if (typeof item === 'object' && item !== null && 'value' in item) {
return {
label: String(item.label || item.value),
value: String(item.value),
};
}
const str = String(item);
return { label: str, value: str };
});
}
return [];
}
export function RadioField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const layout = (field.properties?.layout as string) || 'vertical';
const options = normalizeOptions(field.options);
const stringValue = value !== null && value !== undefined ? String(value) : '';
return (
<div>
<Label>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<RadioGroup
value={stringValue}
onValueChange={onChange}
disabled={disabled}
className={`mt-2 ${layout === 'horizontal' ? 'flex flex-wrap gap-4' : 'grid gap-2'}`}
>
{options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem
value={option.value}
id={`${fieldKey}-${option.value}`}
/>
<Label
htmlFor={`${fieldKey}-${option.value}`}
className="font-normal cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
{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>
);
}

View File

@@ -0,0 +1,167 @@
/**
* Reference 필드 컴포넌트
* 다른 테이블의 데이터를 검색하여 선택 (거래처, 품목, 고객 등)
*
* API 연동 시: REFERENCE_SOURCES[source]에서 apiUrl 조회 → 검색 API 호출
* API 연동 전: props로 전달된 options 사용 또는 빈 상태에서 UI 확인
*/
'use client';
import { useState, useCallback } from 'react';
import { Search, X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { REFERENCE_SOURCES } from '../config/reference-sources';
import type { DynamicFieldRendererProps, ReferenceConfig } from '../types';
interface ReferenceItem {
[key: string]: unknown;
}
export function ReferenceField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const [isModalOpen, setIsModalOpen] = useState(false);
const [displayValue, setDisplayValue] = useState<string>('');
// properties에서 config 추출
const config = (field.properties || {}) as ReferenceConfig;
const source = config.source || '';
const displayField = config.displayField || 'name';
const valueField = config.valueField || 'id';
const displayFormat = config.displayFormat;
// 소스 프리셋 조회
const preset = source ? REFERENCE_SOURCES[source] : null;
const apiUrl = config.searchApiUrl || preset?.apiUrl || '';
const columns = config.columns || preset?.columns || [
{ key: 'name', label: '이름' },
];
// 표시값 포맷팅
const formatDisplay = useCallback((item: ReferenceItem): string => {
if (displayFormat) {
return displayFormat.replace(/\{(\w+)\}/g, (_, key) =>
String(item[key] || '')
);
}
return String(item[displayField] || '');
}, [displayFormat, displayField]);
// 검색 API 호출
const fetchData = useCallback(async (query: string): Promise<ReferenceItem[]> => {
if (!apiUrl) return [];
try {
const separator = apiUrl.includes('?') ? '&' : '?';
const url = `${apiUrl}${separator}search=${encodeURIComponent(query)}&size=20`;
const response = await fetch(url);
const result = await response.json();
// 다양한 응답 구조 대응
if (Array.isArray(result)) return result;
if (result.data?.data) return result.data.data;
if (Array.isArray(result.data)) return result.data;
return [];
} catch {
return [];
}
}, [apiUrl]);
// 선택 핸들러
const handleSelect = (item: ReferenceItem) => {
const selectedValue = item[valueField];
onChange(selectedValue as string | number);
setDisplayValue(formatDisplay(item));
setIsModalOpen(false);
};
// 선택 해제
const handleClear = () => {
onChange(null);
setDisplayValue('');
};
// 현재 표시값
const currentDisplay = displayValue || (value ? String(value) : '');
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id={fieldKey}
value={currentDisplay}
placeholder={field.placeholder || `${field.field_name}을(를) 선택하세요`}
readOnly
disabled={disabled}
className={`pr-8 ${error ? 'border-red-500' : ''}`}
/>
{currentDisplay && !disabled && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled || !apiUrl}
onClick={() => setIsModalOpen(true)}
>
<Search className="h-4 w-4" />
</Button>
</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>
)}
{/* 검색 모달 */}
<SearchableSelectionModal<ReferenceItem>
mode="single"
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={`${field.field_name} 검색`}
searchPlaceholder="검색어를 입력하세요"
fetchData={fetchData}
keyExtractor={(item) => String(item[valueField])}
renderItem={(item) => (
<div className="flex items-center gap-4 px-3 py-2">
{columns.map((col) => (
<span
key={col.key}
className="text-sm"
style={col.width ? { width: col.width, flexShrink: 0 } : { flex: 1 }}
>
{String(item[col.key] || '')}
</span>
))}
</div>
)}
onSelect={handleSelect}
searchMode="debounce"
loadOnOpen
/>
</div>
);
}

View File

@@ -0,0 +1,61 @@
/**
* On/Off 토글 스위치 필드
*
* properties: { onLabel, offLabel, onValue, offValue }
* 저장값: onValue/offValue (기본: "true"/"false")
*/
'use client';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { DynamicFieldRendererProps, ToggleConfig } from '../types';
export function ToggleField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as ToggleConfig;
const onLabel = config.onLabel || 'ON';
const offLabel = config.offLabel || 'OFF';
const onValue = config.onValue || 'true';
const offValue = config.offValue || 'false';
const isChecked = value === onValue || value === true || value === 'true';
const handleChange = (checked: boolean) => {
onChange(checked ? onValue : offValue);
};
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="flex items-center gap-3 mt-2">
<Switch
id={fieldKey}
checked={isChecked}
onCheckedChange={handleChange}
disabled={disabled}
/>
<span className="text-sm text-muted-foreground">
{isChecked ? onLabel : offLabel}
</span>
</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>
);
}

View File

@@ -0,0 +1,128 @@
/**
* 값+단위 조합 입력 필드
* Input(숫자) + Select(단위) 가로 배치
*
* properties: { units, defaultUnit, precision }
* 저장값: { value: number, unit: string }
*/
'use client';
import { useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { DynamicFieldRendererProps, UnitValueConfig } from '../types';
interface UnitValueData {
value: number | null;
unit: string;
}
function parseUnitValue(raw: unknown, defaultUnit: string): UnitValueData {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>;
return {
value: obj.value !== null && obj.value !== undefined ? Number(obj.value) : null,
unit: typeof obj.unit === 'string' ? obj.unit : defaultUnit,
};
}
// 숫자만 들어온 경우
if (typeof raw === 'number') {
return { value: raw, unit: defaultUnit };
}
return { value: null, unit: defaultUnit };
}
export function UnitValueField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as UnitValueConfig;
const units = config.units || [];
const defaultUnit = config.defaultUnit || (units.length > 0 ? units[0].value : '');
const precision = config.precision;
const data = parseUnitValue(value, defaultUnit);
const handleValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === '' || raw === '-') {
onChange({ value: null, unit: data.unit });
return;
}
const num = parseFloat(raw);
if (!isNaN(num)) {
const final = precision !== undefined
? Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision)
: num;
onChange({ value: final, unit: data.unit });
}
}, [data.unit, precision, onChange]);
const handleUnitChange = useCallback((newUnit: string) => {
onChange({ value: data.value, unit: newUnit });
}, [data.value, onChange]);
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="flex gap-2">
<Input
id={fieldKey}
type="number"
placeholder={field.placeholder || '값 입력'}
value={data.value !== null ? String(data.value) : ''}
onChange={handleValueChange}
disabled={disabled}
step={precision !== undefined ? Math.pow(10, -precision) : 'any'}
className={`flex-1 ${error ? 'border-red-500' : ''}`}
/>
{units.length > 0 ? (
<Select
key={`${fieldKey}-unit-${data.unit}`}
value={data.unit}
onValueChange={handleUnitChange}
disabled={disabled}
>
<SelectTrigger className="w-24 shrink-0">
<SelectValue placeholder="단위" />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.value} value={u.value}>
{u.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="flex items-center text-sm text-muted-foreground px-2">
{data.unit || '-'}
</span>
)}
</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>
);
}

View File

@@ -4,4 +4,15 @@ export { DropdownField } from './DropdownField';
export { CheckboxField } from './CheckboxField';
export { DateField } from './DateField';
export { TextareaField } from './TextareaField';
// Phase 1: 핵심 컴포넌트
export { ReferenceField } from './ReferenceField';
export { MultiSelectField } from './MultiSelectField';
export { FileField } from './FileField';
// Phase 2: 편의 컴포넌트
export { CurrencyField } from './CurrencyField';
export { UnitValueField } from './UnitValueField';
export { RadioField } from './RadioField';
// Phase 3: 고급 컴포넌트
export { ToggleField } from './ToggleField';
export { ComputedField } from './ComputedField';
export { DynamicFieldRenderer } from './DynamicFieldRenderer';