feat: select-basic 컴포넌트에 다중선택 기능 추가

기능:
- 설정 패널에 '다중 선택' 체크박스 추가
- multiple 옵션 활성화 시 다중선택 UI 렌더링
- 선택된 항목을 태그 형식으로 표시
- 각 태그에 X 버튼으로 개별 제거 가능
- 드롭다운에 체크박스 표시
- 콤마(,) 구분자로 값 저장/파싱

수정사항:
- SelectBasicConfigPanel: 다중 선택 체크박스 추가
- SelectBasicConfigPanel: config 병합 방식으로 변경 (다른 속성 보호)
- SelectBasicComponent: 초기값 콤마 구분자로 파싱
- SelectBasicComponent: 외부 value 변경 시 다중선택 배열 동기화
- SelectBasicComponent: 다중선택 UI 렌더링 로직 추가

사용법:
1. 설정 패널에서 '다중 선택' 체크
2. 드롭다운에서 여러 항목 선택
3. 선택된 항목이 태그로 표시되며 X로 제거 가능
4. 저장 시 '값1,값2,값3' 형식으로 저장
This commit is contained in:
kjs 2025-11-20 18:17:08 +09:00
parent 3219015a39
commit c57e0218fe
2 changed files with 114 additions and 4 deletions

View File

@ -62,8 +62,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || ""); const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
const [selectedLabel, setSelectedLabel] = useState(""); const [selectedLabel, setSelectedLabel] = useState("");
// multiselect의 경우 배열로 관리 // multiselect의 경우 배열로 관리 (콤마 구분자로 파싱)
const [selectedValues, setSelectedValues] = useState<string[]>([]); const [selectedValues, setSelectedValues] = useState<string[]>(() => {
const initialValue = externalValue || config?.value || "";
if (config?.multiple && typeof initialValue === "string" && initialValue) {
return initialValue.split(",").map(v => v.trim()).filter(v => v);
}
return [];
});
// autocomplete의 경우 검색어 관리 // autocomplete의 경우 검색어 관리
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -116,8 +122,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리) // 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
if (newValue !== selectedValue) { if (newValue !== selectedValue) {
setSelectedValue(newValue); setSelectedValue(newValue);
// 다중선택 모드인 경우 selectedValues도 업데이트
if (config?.multiple && typeof newValue === "string" && newValue) {
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
setSelectedValues(values);
}
} }
}, [externalValue, config?.value]); }, [externalValue, config?.value, config?.multiple]);
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거 // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime) // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
@ -500,6 +512,93 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
} }
// select (기본 선택박스) // select (기본 선택박스)
// 다중선택 모드인 경우
if (config?.multiple) {
return (
<div className="w-full">
<div
className={cn(
"box-border flex h-full min-h-[40px] w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
onClick={() => !isDesignMode && setIsOpen(true)}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
{selectedValues.map((val, idx) => {
const opt = allOptions.find((o) => o.value === val);
return (
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
{opt?.label || val}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newVals = selectedValues.filter((v) => v !== val);
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
);
})}
{selectedValues.length === 0 && (
<span className="text-gray-500">{placeholder}</span>
)}
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => {
const isSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
const newVals = isSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="h-4 w-4"
/>
<span>{option.label || option.value}</span>
</div>
</div>
);
})
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
}
// 단일선택 모드
return ( return (
<div className="w-full"> <div className="w-full">
<div <div

View File

@ -21,7 +21,9 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onChange, onChange,
}) => { }) => {
const handleChange = (key: keyof SelectBasicConfig, value: any) => { const handleChange = (key: keyof SelectBasicConfig, value: any) => {
onChange({ [key]: value }); // 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
const newConfig = { ...config, [key]: value };
onChange(newConfig);
}; };
return ( return (
@ -67,6 +69,15 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onCheckedChange={(checked) => handleChange("readonly", checked)} onCheckedChange={(checked) => handleChange("readonly", checked)}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="multiple"> </Label>
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => handleChange("multiple", checked)}
/>
</div>
</div> </div>
); );
}; };