"use client"; import React, { useState, useMemo, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { ChevronRight, ChevronLeft, ChevronsRight, ChevronsLeft, Search } from "lucide-react"; import { cn } from "@/lib/utils"; /** * DualListBox 아이템 인터페이스 */ export interface DualListBoxItem { id: string; label: string; description?: string; disabled?: boolean; [key: string]: any; } /** * DualListBox 컴포넌트 Props */ interface DualListBoxProps { // 데이터 availableItems: DualListBoxItem[]; selectedItems: DualListBoxItem[]; // 이벤트 onSelectionChange: (selectedItems: DualListBoxItem[]) => void; // 라벨 availableLabel?: string; selectedLabel?: string; // 검색 활성화 enableSearch?: boolean; // 높이 설정 height?: string; // 비활성화 disabled?: boolean; // 커스텀 렌더링 renderItem?: (item: DualListBoxItem) => React.ReactNode; // 스타일 className?: string; } /** * Dual List Box 컴포넌트 (좌우 이동 방식) * * 사용 예시: * ```tsx * * ``` */ export function DualListBox({ availableItems = [], selectedItems = [], onSelectionChange, availableLabel = "사용 가능한 항목", selectedLabel = "선택된 항목", enableSearch = true, height = "400px", disabled = false, renderItem, className, }: DualListBoxProps) { // 선택된 아이템 ID 목록 (안전하게 처리) const selectedItemIds = useMemo(() => new Set((selectedItems || []).map((item) => item.id)), [selectedItems]); // 사용 가능한 아이템 (선택되지 않은 것들만) const available = useMemo( () => (availableItems || []).filter((item) => !selectedItemIds.has(item.id)), [availableItems, selectedItemIds], ); // 좌측 체크된 아이템 const [leftChecked, setLeftChecked] = useState>(new Set()); // 우측 체크된 아이템 const [rightChecked, setRightChecked] = useState>(new Set()); // 좌측 검색어 const [leftSearch, setLeftSearch] = useState(""); // 우측 검색어 const [rightSearch, setRightSearch] = useState(""); // 검색 필터링 const filterItems = useCallback((items: DualListBoxItem[], searchTerm: string) => { if (!searchTerm.trim()) return items; const term = searchTerm.toLowerCase(); return items.filter( (item) => item.label.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term), ); }, []); const filteredAvailable = useMemo(() => filterItems(available, leftSearch), [available, leftSearch, filterItems]); const filteredSelected = useMemo( () => filterItems(selectedItems, rightSearch), [selectedItems, rightSearch, filterItems], ); // 좌측 체크박스 토글 const handleLeftCheck = useCallback((itemId: string, checked: boolean) => { setLeftChecked((prev) => { const newSet = new Set(prev); if (checked) { newSet.add(itemId); } else { newSet.delete(itemId); } return newSet; }); }, []); // 우측 체크박스 토글 const handleRightCheck = useCallback((itemId: string, checked: boolean) => { setRightChecked((prev) => { const newSet = new Set(prev); if (checked) { newSet.add(itemId); } else { newSet.delete(itemId); } return newSet; }); }, []); // 좌측 전체 선택/해제 const handleLeftSelectAll = useCallback(() => { if (leftChecked.size === filteredAvailable.length) { setLeftChecked(new Set()); } else { setLeftChecked(new Set(filteredAvailable.map((item) => item.id))); } }, [leftChecked.size, filteredAvailable]); // 우측 전체 선택/해제 const handleRightSelectAll = useCallback(() => { if (rightChecked.size === filteredSelected.length) { setRightChecked(new Set()); } else { setRightChecked(new Set(filteredSelected.map((item) => item.id))); } }, [rightChecked.size, filteredSelected]); // 선택된 항목 오른쪽으로 이동 const moveToRight = useCallback(() => { const itemsToMove = available.filter((item) => leftChecked.has(item.id)); onSelectionChange([...selectedItems, ...itemsToMove]); setLeftChecked(new Set()); }, [available, leftChecked, selectedItems, onSelectionChange]); // 선택된 항목 왼쪽으로 이동 const moveToLeft = useCallback(() => { const newSelected = selectedItems.filter((item) => !rightChecked.has(item.id)); onSelectionChange(newSelected); setRightChecked(new Set()); }, [selectedItems, rightChecked, onSelectionChange]); // 모든 항목 오른쪽으로 이동 const moveAllToRight = useCallback(() => { onSelectionChange([...selectedItems, ...available]); setLeftChecked(new Set()); }, [available, selectedItems, onSelectionChange]); // 모든 항목 왼쪽으로 이동 const moveAllToLeft = useCallback(() => { onSelectionChange([]); setRightChecked(new Set()); }, [onSelectionChange]); // 기본 아이템 렌더링 const defaultRenderItem = useCallback((item: DualListBoxItem) => { return ( {item.label} {item.description && {item.description}} ); }, []); const itemRenderer = renderItem || defaultRenderItem; return ( {/* 좌측 리스트 (사용 가능한 항목) */} {availableLabel} {/* 검색 */} {enableSearch && ( setLeftSearch(e.target.value)} className="h-9 pl-9 text-sm" disabled={disabled} /> )} {/* 전체 선택 */} 0 && leftChecked.size === filteredAvailable.length} onCheckedChange={handleLeftSelectAll} disabled={disabled || filteredAvailable.length === 0} /> 전체 선택 ({filteredAvailable.length}개) {/* 아이템 리스트 */} {filteredAvailable.length === 0 ? ( {leftSearch ? "검색 결과가 없습니다" : "사용 가능한 항목이 없습니다"} ) : ( {filteredAvailable.map((item) => ( !item.disabled && !disabled && handleLeftCheck(item.id, !leftChecked.has(item.id))} > handleLeftCheck(item.id, checked as boolean)} disabled={disabled || item.disabled} className="mt-0.5" /> {itemRenderer(item)} ))} )} {/* 중앙 버튼 (이동) */} {/* 우측 리스트 (선택된 항목) */} {selectedLabel} {/* 검색 */} {enableSearch && ( setRightSearch(e.target.value)} className="h-9 pl-9 text-sm" disabled={disabled} /> )} {/* 전체 선택 */} 0 && rightChecked.size === filteredSelected.length} onCheckedChange={handleRightSelectAll} disabled={disabled || filteredSelected.length === 0} /> 전체 선택 ({filteredSelected.length}개) {/* 아이템 리스트 */} {filteredSelected.length === 0 ? ( {rightSearch ? "검색 결과가 없습니다" : "선택된 항목이 없습니다"} ) : ( {filteredSelected.map((item) => ( !item.disabled && !disabled && handleRightCheck(item.id, !rightChecked.has(item.id))} > handleRightCheck(item.id, checked as boolean)} disabled={disabled || item.disabled} className="mt-0.5" /> {itemRenderer(item)} ))} )} ); }
{leftSearch ? "검색 결과가 없습니다" : "사용 가능한 항목이 없습니다"}
{rightSearch ? "검색 결과가 없습니다" : "선택된 항목이 없습니다"}