diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-22 18:59:13 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-22 18:59:13 +0900 |
| commit | ba35e67845f935c8ce0151c9ef1fefa0b0510faf (patch) | |
| tree | d05eb27fab2acc54a839b2590c89e860d58fb747 /components/common/vendor/vendor-selector-dialog-multi.tsx | |
| parent | e4bd037d158513e45373ad9e1ef13f71af12162a (diff) | |
(김준회) AVL 피드백 반영 (이진용 프로 건)
Diffstat (limited to 'components/common/vendor/vendor-selector-dialog-multi.tsx')
| -rw-r--r-- | components/common/vendor/vendor-selector-dialog-multi.tsx | 320 |
1 files changed, 320 insertions, 0 deletions
diff --git a/components/common/vendor/vendor-selector-dialog-multi.tsx b/components/common/vendor/vendor-selector-dialog-multi.tsx new file mode 100644 index 00000000..32c8fa54 --- /dev/null +++ b/components/common/vendor/vendor-selector-dialog-multi.tsx @@ -0,0 +1,320 @@ +"use client" + +import React, { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { VendorSelector } from "./vendor-selector" +import { VendorSearchItem } from "./vendor-service" +import { X } from "lucide-react" + +/** + * 벤더 다중 선택 Dialog 컴포넌트 + * + * @description + * - VendorSelector를 Dialog로 래핑한 다중 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 여러 벤더를 선택한 후 확인 버튼으로 완료 + * - 최대 선택 개수 제한 가능 + * + * @VendorSearchItem_Structure + * 상태에서 관리되는 벤더 객체의 형태: + * ```typescript + * interface VendorSearchItem { + * id: number; // 벤더 ID + * vendorName: string; // 벤더명 + * vendorCode: string | null; // 벤더코드 (없을 수 있음) + * status: string; // 벤더 상태 (ACTIVE, PENDING_REVIEW 등) + * displayText: string; // 표시용 텍스트 (vendorName + vendorCode) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedVendors: 현재 선택된 벤더들 (배열) + * - tempSelectedVendors: Dialog 내에서 임시로 선택된 벤더들 (확인 버튼 클릭 전까지) + * + * @callback + * - onVendorsSelect: 벤더 선택 완료 시 호출되는 콜백 + * - 매개변수: VendorSearchItem[] + * - 선택된 벤더들의 배열 (빈 배열일 수도 있음) + * + * @usage + * ```tsx + * <VendorSelectorDialogMulti + * triggerLabel="벤더 선택 (다중)" + * selectedVendors={selectedVendors} + * onVendorsSelect={(vendors) => { + * setSelectedVendors(vendors); + * console.log('선택된 벤더들:', vendors); + * }} + * maxSelections={5} + * placeholder="벤더를 검색하세요..." + * /> + * ``` + */ + +interface VendorSelectorDialogMultiProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string + /** 현재 선택된 벤더들 */ + selectedVendors?: VendorSearchItem[] + /** 벤더 선택 완료 시 호출되는 콜백 */ + onVendorsSelect?: (vendors: VendorSearchItem[]) => void + /** 최대 선택 가능한 벤더 개수 */ + maxSelections?: number + /** 검색 입력창 placeholder */ + placeholder?: string + /** Dialog 제목 */ + title?: string + /** Dialog 설명 */ + description?: string + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" + /** 제외할 벤더 ID들 */ + excludeVendorIds?: Set<number> + /** 초기 데이터 표시 여부 */ + showInitialData?: boolean + /** 트리거 버튼에서 선택된 벤더들을 표시할지 여부 */ + showSelectedInTrigger?: boolean + /** 벤더 상태 필터 */ + statusFilter?: string +} + +export function VendorSelectorDialogMulti({ + triggerLabel = "벤더 선택", + selectedVendors = [], + onVendorsSelect, + maxSelections, + placeholder = "벤더를 검색하세요...", + title = "벤더 선택 (다중)", + description, + disabled = false, + triggerVariant = "outline", + excludeVendorIds, + showInitialData = true, + showSelectedInTrigger = true, + statusFilter +}: VendorSelectorDialogMultiProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false) + + // Dialog 내에서 임시로 선택된 벤더들 (확인 버튼 클릭 전까지) + const [tempSelectedVendors, setTempSelectedVendors] = useState<VendorSearchItem[]>([]) + + // Dialog 설명 동적 생성 + const dialogDescription = description || + (maxSelections + ? `원하는 벤더를 검색하고 선택해주세요. (최대 ${maxSelections}개)` + : "원하는 벤더를 검색하고 선택해주세요." + ) + + // Dialog 열림 시 현재 선택된 벤더들로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen) { + setTempSelectedVendors([...selectedVendors]) + } + }, [selectedVendors]) + + // 벤더 선택 처리 (Dialog 내에서) + const handleVendorsChange = useCallback((vendors: VendorSearchItem[]) => { + setTempSelectedVendors(vendors) + }, []) + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onVendorsSelect?.(tempSelectedVendors) + setOpen(false) + }, [tempSelectedVendors, onVendorsSelect]) + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedVendors([...selectedVendors]) + setOpen(false) + }, [selectedVendors]) + + // 전체 선택 해제 + const handleClearAll = useCallback(() => { + setTempSelectedVendors([]) + }, []) + + // 개별 벤더 제거 (트리거 버튼에서) + const handleRemoveVendor = useCallback((vendorToRemove: VendorSearchItem, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const newVendors = selectedVendors.filter( + (vendor) => vendor.id !== vendorToRemove.id + ) + onVendorsSelect?.(newVendors) + }, [selectedVendors, onVendorsSelect]) + + // 벤더 상태별 색상 + const getStatusColor = (status: string) => { + switch (status) { + case 'ACTIVE': return 'bg-green-100 text-green-800' + case 'APPROVED': return 'bg-blue-100 text-blue-800' + case 'PENDING_REVIEW': return 'bg-yellow-100 text-yellow-800' + case 'INACTIVE': return 'bg-gray-100 text-gray-800' + default: return 'bg-gray-100 text-gray-800' + } + } + + // 트리거 버튼 렌더링 + const renderTriggerContent = () => { + if (selectedVendors.length === 0) { + return triggerLabel + } + + if (!showSelectedInTrigger) { + return `${triggerLabel} (${selectedVendors.length}개)` + } + + return ( + <div className="flex flex-wrap gap-1 items-center max-w-full"> + <span className="shrink-0">{triggerLabel}:</span> + {selectedVendors.slice(0, 2).map((vendor) => ( + <Badge + key={vendor.id} + variant="secondary" + className="gap-1 pr-1 max-w-[120px]" + > + <span className="truncate text-xs"> + {vendor.vendorName} + </span> + <Badge className={`${getStatusColor(vendor.status)} ml-1 text-xs`}> + {vendor.status} + </Badge> + {!disabled && ( + <button + type="button" + className="ml-1 h-3 w-3 rounded-sm hover:bg-red-100 flex items-center justify-center" + onClick={(e) => handleRemoveVendor(vendor, e)} + > + <X className="h-3 w-3 hover:text-red-500" /> + </button> + )} + </Badge> + ))} + {selectedVendors.length > 2 && ( + <Badge variant="outline" className="text-xs"> + +{selectedVendors.length - 2}개 + </Badge> + )} + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button + variant={triggerVariant} + disabled={disabled} + className="min-h-[2.5rem] h-auto justify-start" + > + {renderTriggerContent()} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{dialogDescription}</DialogDescription> + </DialogHeader> + + <div className="py-4"> + <VendorSelector + selectedVendors={tempSelectedVendors} + onVendorsChange={handleVendorsChange} + singleSelect={false} + maxSelections={maxSelections} + placeholder={placeholder} + noValuePlaceHolder="벤더를 선택해주세요" + closeOnSelect={false} + excludeVendorIds={excludeVendorIds} + showInitialData={showInitialData} + statusFilter={statusFilter} + /> + + {/* 선택된 벤더 개수 표시 */} + <div className="mt-2 text-sm text-muted-foreground"> + {maxSelections ? ( + `선택됨: ${tempSelectedVendors.length}/${maxSelections}개` + ) : ( + `선택됨: ${tempSelectedVendors.length}개` + )} + </div> + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedVendors.length > 0 && ( + <Button variant="ghost" onClick={handleClearAll}> + 전체 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 ({tempSelectedVendors.length}개) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +/** + * 사용 예시: + * + * ```tsx + * const [selectedVendors, setSelectedVendors] = useState<VendorSearchItem[]>([]); + * + * return ( + * <VendorSelectorDialogMulti + * triggerLabel="벤더 선택" + * selectedVendors={selectedVendors} + * onVendorsSelect={(vendors) => { + * setSelectedVendors(vendors); + * console.log('선택된 벤더들:', vendors.map(v => ({ + * id: v.id, + * name: v.vendorName, + * code: v.vendorCode, + * status: v.status + * }))); + * }} + * maxSelections={5} + * title="협력업체 선택" + * description="프로젝트에 참여할 협력업체들을 선택해주세요." + * showSelectedInTrigger={true} + * statusFilter="ACTIVE" + * /> + * ); + * ``` + * + * @advanced_usage + * ```tsx + * // 제외할 벤더가 있는 경우 + * const excludedVendorIds = new Set([1, 2, 3]); + * + * <VendorSelectorDialogMulti + * selectedVendors={selectedVendors} + * onVendorsSelect={setSelectedVendors} + * excludeVendorIds={excludedVendorIds} + * maxSelections={3} + * triggerVariant="default" + * showSelectedInTrigger={false} + * statusFilter="APPROVED" + * /> + * ``` + */ |
