ERP-node/frontend/components/common/DualListBox.tsx

380 lines
12 KiB
TypeScript
Raw Normal View History

2025-10-27 16:40:59 +09:00
"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
* <DualListBox
* availableItems={allUsers}
* selectedItems={groupMembers}
* onSelectionChange={setGroupMembers}
* availableLabel="전체 사용자"
* selectedLabel="그룹 멤버"
* enableSearch
* />
* ```
*/
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<Set<string>>(new Set());
// 우측 체크된 아이템
const [rightChecked, setRightChecked] = useState<Set<string>>(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 (
<div className="flex flex-col">
<span className="text-sm font-medium">{item.label}</span>
{item.description && <span className="text-muted-foreground text-xs">{item.description}</span>}
</div>
);
}, []);
const itemRenderer = renderItem || defaultRenderItem;
return (
<div className={cn("flex gap-4", className)}>
{/* 좌측 리스트 (사용 가능한 항목) */}
<div className="flex flex-1 flex-col gap-2">
<Label className="text-sm font-semibold">{availableLabel}</Label>
{/* 검색 */}
{enableSearch && (
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={leftSearch}
onChange={(e) => setLeftSearch(e.target.value)}
className="h-9 pl-9 text-sm"
disabled={disabled}
/>
</div>
)}
{/* 전체 선택 */}
<div className="flex items-center gap-2">
<Checkbox
id="left-select-all"
checked={filteredAvailable.length > 0 && leftChecked.size === filteredAvailable.length}
onCheckedChange={handleLeftSelectAll}
disabled={disabled || filteredAvailable.length === 0}
/>
<Label htmlFor="left-select-all" className="text-muted-foreground cursor-pointer text-xs">
({filteredAvailable.length})
</Label>
</div>
{/* 아이템 리스트 */}
<div className="bg-background overflow-y-auto rounded-lg border" style={{ height }}>
{filteredAvailable.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{leftSearch ? "검색 결과가 없습니다" : "사용 가능한 항목이 없습니다"}
</p>
</div>
) : (
<div className="space-y-1 p-2">
{filteredAvailable.map((item) => (
<div
key={item.id}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors",
leftChecked.has(item.id) && "bg-muted",
item.disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !item.disabled && !disabled && handleLeftCheck(item.id, !leftChecked.has(item.id))}
>
<Checkbox
checked={leftChecked.has(item.id)}
onCheckedChange={(checked) => handleLeftCheck(item.id, checked as boolean)}
disabled={disabled || item.disabled}
className="mt-0.5"
/>
{itemRenderer(item)}
</div>
))}
</div>
)}
</div>
</div>
{/* 중앙 버튼 (이동) */}
<div
className="flex flex-col items-center justify-center gap-2"
style={{ marginTop: enableSearch ? "80px" : "48px" }}
>
<Button
variant="outline"
size="icon"
onClick={moveAllToRight}
disabled={disabled || available.length === 0}
title="모두 오른쪽으로 이동"
className="h-9 w-9"
>
<ChevronsRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={moveToRight}
disabled={disabled || leftChecked.size === 0}
title="선택된 항목 오른쪽으로 이동"
className="h-9 w-9"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={moveToLeft}
disabled={disabled || rightChecked.size === 0}
title="선택된 항목 왼쪽으로 이동"
className="h-9 w-9"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={moveAllToLeft}
disabled={disabled || selectedItems.length === 0}
title="모두 왼쪽으로 이동"
className="h-9 w-9"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
</div>
{/* 우측 리스트 (선택된 항목) */}
<div className="flex flex-1 flex-col gap-2">
<Label className="text-sm font-semibold">{selectedLabel}</Label>
{/* 검색 */}
{enableSearch && (
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={rightSearch}
onChange={(e) => setRightSearch(e.target.value)}
className="h-9 pl-9 text-sm"
disabled={disabled}
/>
</div>
)}
{/* 전체 선택 */}
<div className="flex items-center gap-2">
<Checkbox
id="right-select-all"
checked={filteredSelected.length > 0 && rightChecked.size === filteredSelected.length}
onCheckedChange={handleRightSelectAll}
disabled={disabled || filteredSelected.length === 0}
/>
<Label htmlFor="right-select-all" className="text-muted-foreground cursor-pointer text-xs">
({filteredSelected.length})
</Label>
</div>
{/* 아이템 리스트 */}
<div className="bg-background overflow-y-auto rounded-lg border" style={{ height }}>
{filteredSelected.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{rightSearch ? "검색 결과가 없습니다" : "선택된 항목이 없습니다"}
</p>
</div>
) : (
<div className="space-y-1 p-2">
{filteredSelected.map((item) => (
<div
key={item.id}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors",
rightChecked.has(item.id) && "bg-muted",
item.disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !item.disabled && !disabled && handleRightCheck(item.id, !rightChecked.has(item.id))}
>
<Checkbox
checked={rightChecked.has(item.id)}
onCheckedChange={(checked) => handleRightCheck(item.id, checked as boolean)}
disabled={disabled || item.disabled}
className="mt-0.5"
/>
{itemRenderer(item)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}