/** * 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).label || (item as Record).value), value: String((item as Record).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(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 (
!disabled && setIsOpen(true)} > {/* 선택된 칩들 */} {selectedValues.map(val => ( {getLabel(val)} {!disabled && ( )} ))} {/* 검색 입력 */} {!disabled && !isAtLimit && ( { 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" /> )}
{/* 드롭다운 */} {isOpen && filteredOptions.length > 0 && (
{filteredOptions.map(opt => ( ))}
)} {error && (

{error}

)} {!error && field.description && (

* {field.description}

)}
); }