- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed) - DynamicTableSection 및 TableCellRenderer 추가 - 필드 프리셋 및 설정 구조 분리 - 컴포넌트 레지스트리 개발 도구 페이지 추가 - UniversalListPage 개선 - 근태관리 코드 정리 - 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|