diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
| commit | e0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch) | |
| tree | 68543a65d88f5afb3a0202925804103daa91bc6f /lib/rfqs/table/BudgetaryRfqSelector.tsx | |
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/rfqs/table/BudgetaryRfqSelector.tsx')
| -rw-r--r-- | lib/rfqs/table/BudgetaryRfqSelector.tsx | 261 |
1 files changed, 261 insertions, 0 deletions
diff --git a/lib/rfqs/table/BudgetaryRfqSelector.tsx b/lib/rfqs/table/BudgetaryRfqSelector.tsx new file mode 100644 index 00000000..cea53c1d --- /dev/null +++ b/lib/rfqs/table/BudgetaryRfqSelector.tsx @@ -0,0 +1,261 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { useDebounce } from "@/hooks/use-debounce" +import { getBudgetaryRfqs, type BudgetaryRfq } from "../service" + +interface BudgetaryRfqSelectorProps { + selectedRfqId?: number; + onRfqSelect: (rfq: BudgetaryRfq | null) => void; + placeholder?: string; +} + +export function BudgetaryRfqSelector({ + selectedRfqId, + onRfqSelect, + placeholder = "Budgetary RFQ 선택..." +}: BudgetaryRfqSelectorProps) { + const [searchTerm, setSearchTerm] = React.useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]); + const [selectedRfq, setSelectedRfq] = React.useState<BudgetaryRfq | null>(null); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(true); + const [totalCount, setTotalCount] = React.useState(0); + + const listRef = React.useRef<HTMLDivElement>(null); + + // 초기 선택된 RFQ가 있을 경우 로드 + React.useEffect(() => { + if (selectedRfqId && open) { + const loadSelectedRfq = async () => { + try { + const result = await getBudgetaryRfqs({ + limit: 1, + // null을 undefined로 변환하여 타입 오류 해결 + projectId: selectedRfq?.projectId ?? undefined + }); + + if ('rfqs' in result && result.rfqs) { + // 옵셔널 체이닝 또는 조건부 검사로 undefined 체크 + const foundRfq = result.rfqs.find(rfq => rfq.id === selectedRfqId); + if (foundRfq) { + setSelectedRfq(foundRfq); + } + } + } catch (error) { + console.error("선택된 RFQ 로드 오류:", error); + } + }; + + if (!selectedRfq || selectedRfq.id !== selectedRfqId) { + loadSelectedRfq(); + } + } + }, [selectedRfqId, open, selectedRfq]); + + // 검색어 변경 시 데이터 리셋 및 재로드 + React.useEffect(() => { + if (open) { + setPage(1); + setHasMore(true); + setBudgetaryRfqs([]); + loadBudgetaryRfqs(1, true); + } + }, [debouncedSearchTerm, open]); + + // 데이터 로드 함수 + const loadBudgetaryRfqs = async (pageToLoad: number, reset = false) => { + if (!open) return; + + setLoading(true); + try { + const limit = 20; // 한 번에 로드할 항목 수 + const result = await getBudgetaryRfqs({ + search: debouncedSearchTerm, + limit, + offset: (pageToLoad - 1) * limit, + }); + + if ('rfqs' in result && result.rfqs) { + if (reset) { + setBudgetaryRfqs(result.rfqs); + } else { + setBudgetaryRfqs(prev => [...prev, ...result.rfqs]); + } + + setTotalCount(result.totalCount); + setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount); + setPage(pageToLoad); + } + } catch (error) { + console.error("Budgetary RFQs 로드 오류:", error); + } finally { + setLoading(false); + } + }; + + // 무한 스크롤 처리 + const handleScroll = () => { + if (listRef.current) { + const { scrollTop, scrollHeight, clientHeight } = listRef.current; + + // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드 + if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) { + loadBudgetaryRfqs(page + 1); + } + } + }; + + // RFQ를 프로젝트별로 그룹화하는 함수 + const groupRfqsByProject = (rfqs: BudgetaryRfq[]) => { + const groups: Record<string, { + projectId: number | null; + projectCode: string | null; + projectName: string | null; + rfqs: BudgetaryRfq[]; + }> = {}; + + // 'No Project' 그룹 기본 생성 + groups['no-project'] = { + projectId: null, + projectCode: null, + projectName: null, + rfqs: [] + }; + + // 프로젝트별로 RFQ 그룹화 + rfqs.forEach(rfq => { + const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project'; + + if (!groups[key] && rfq.projectId) { + groups[key] = { + projectId: rfq.projectId, + projectCode: rfq.projectCode, + projectName: rfq.projectName, + rfqs: [] + }; + } + + groups[key].rfqs.push(rfq); + }); + + // 필터링된 결과가 있는 그룹만 남기기 + return Object.values(groups).filter(group => group.rfqs.length > 0); + }; + + // 그룹화된 RFQ 목록 + const groupedRfqs = React.useMemo(() => { + return groupRfqsByProject(budgetaryRfqs); + }, [budgetaryRfqs]); + + // RFQ 선택 처리 + const handleRfqSelect = (rfq: BudgetaryRfq | null) => { + setSelectedRfq(rfq); + onRfqSelect(rfq); + setOpen(false); + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + > + {selectedRfq + ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}` + : placeholder} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="Budgetary RFQ 코드/설명/프로젝트 검색..." + value={searchTerm} + onValueChange={setSearchTerm} + /> + <CommandList + className="max-h-[300px]" + ref={listRef} + onScroll={handleScroll} + > + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + + <CommandGroup> + <CommandItem + value="none" + onSelect={() => handleRfqSelect(null)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + !selectedRfq + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">선택 안함</span> + </CommandItem> + </CommandGroup> + + {groupedRfqs.map((group, index) => ( + <CommandGroup + key={`group-${group.projectId || index}`} + heading={ + group.projectId + ? `${group.projectCode || ""} - ${group.projectName || ""}` + : "프로젝트 없음" + } + > + {group.rfqs.map((rfq) => ( + <CommandItem + key={rfq.id} + value={`${rfq.rfqCode || ""} ${rfq.description || ""}`} + onSelect={() => handleRfqSelect(rfq)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedRfq?.id === rfq.id + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{rfq.rfqCode || ""}</span> + <span className="ml-2 text-gray-500 truncate"> + - {rfq.description || ""} + </span> + </CommandItem> + ))} + </CommandGroup> + ))} + + {loading && ( + <div className="py-2 text-center"> + <Loader className="h-4 w-4 animate-spin mx-auto" /> + </div> + )} + + {!loading && !hasMore && budgetaryRfqs.length > 0 && ( + <div className="py-2 text-center text-sm text-muted-foreground"> + 총 {totalCount}개 중 {budgetaryRfqs.length}개 표시됨 + </div> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file |
