Files
sam-react-prod/src/components/items/DynamicItemForm/fields/MultiSelectField.tsx
유병철 020d74f36c 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>
2026-02-12 11:17:57 +09:00

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>
);
}