246 lines
9.6 KiB
TypeScript
246 lines
9.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { Search, X, Filter, Sparkles } from "lucide-react"
|
|
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
|
|
interface GeneSearchDrawerProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
selectedGenes: string[]
|
|
onGenesChange: (genes: string[]) => void
|
|
}
|
|
|
|
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
|
|
const [allGenes, setAllGenes] = useState<MarkerModel[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
|
|
|
|
// 모달 열릴 때 전체 유전자 로드
|
|
useEffect(() => {
|
|
if (open) {
|
|
loadAllGenes()
|
|
}
|
|
}, [open])
|
|
|
|
const loadAllGenes = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const genes = await geneApi.getAllMarkers()
|
|
setAllGenes(genes)
|
|
} catch {
|
|
// 유전자 로드 실패 시 빈 배열 유지
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// 검색 및 필터링
|
|
const filteredGenes = allGenes.filter((gene) => {
|
|
// 타입 필터
|
|
if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) {
|
|
return false
|
|
}
|
|
|
|
// 검색어 필터
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase()
|
|
return (
|
|
gene.markerNm.toLowerCase().includes(query) ||
|
|
gene.markerDesc?.toLowerCase().includes(query) ||
|
|
gene.relatedTrait?.toLowerCase().includes(query)
|
|
)
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
const toggleGene = (markerNm: string) => {
|
|
if (selectedGenes.includes(markerNm)) {
|
|
onGenesChange(selectedGenes.filter(g => g !== markerNm))
|
|
} else {
|
|
onGenesChange([...selectedGenes, markerNm])
|
|
}
|
|
}
|
|
|
|
const selectAllFiltered = () => {
|
|
const newGenes = [...selectedGenes]
|
|
filteredGenes.forEach(gene => {
|
|
if (!newGenes.includes(gene.markerNm)) {
|
|
newGenes.push(gene.markerNm)
|
|
}
|
|
})
|
|
onGenesChange(newGenes)
|
|
}
|
|
|
|
const clearAll = () => {
|
|
onGenesChange([])
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col p-0 gap-0">
|
|
{/* 헤더 */}
|
|
<DialogHeader className="px-5 pt-5 pb-3 border-b flex-shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 bg-primary/10 rounded-lg">
|
|
<Sparkles className="h-4 w-4 text-primary" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-lg font-bold">유전자 검색 및 선택</DialogTitle>
|
|
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
|
전체 <span className="font-semibold text-foreground">{allGenes.length.toLocaleString()}</span>개 / 선택 <span className="font-semibold text-primary">{selectedGenes.length}</span>개
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{/* 검색 및 필터 */}
|
|
<div className="px-4 py-3 space-y-3 flex-shrink-0 bg-muted/20">
|
|
{/* 검색바 */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="유전자명, 설명, 관련 형질로 검색..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9 pr-9 h-10 text-sm bg-background"
|
|
autoFocus
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery("")}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 hover:bg-muted rounded-full p-1 transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 필터 탭 및 액션 버튼 */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<Tabs value={filterType} onValueChange={(v) => setFilterType(v as any)} className="flex-1">
|
|
<TabsList className="w-full grid grid-cols-3 h-9">
|
|
<TabsTrigger value="ALL" className="text-xs">
|
|
전체 <span className="ml-1 font-semibold">({allGenes.length})</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="QTY" className="text-xs">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb]"></div>
|
|
육량 <span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QTY').length})</span>
|
|
</div>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="QLT" className="text-xs">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-slate-400"></div>
|
|
육질 <span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QLT').length})</span>
|
|
</div>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={selectAllFiltered}
|
|
disabled={filteredGenes.length === 0}
|
|
className="h-8 text-xs px-3"
|
|
>
|
|
전체선택
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={clearAll}
|
|
disabled={selectedGenes.length === 0}
|
|
className="h-8 text-xs px-3"
|
|
>
|
|
전체해제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 유전자 목록 */}
|
|
<div className="flex-1 overflow-hidden">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-10 w-10 border-2 border-slate-200 border-t-[#2563eb] mx-auto mb-4"></div>
|
|
<p className="text-muted-foreground font-medium">유전자 데이터 로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
) : filteredGenes.length > 0 ? (
|
|
<ScrollArea className="h-full px-4">
|
|
<div className="flex flex-wrap gap-1.5 py-3">
|
|
{filteredGenes.map((gene) => {
|
|
const isSelected = selectedGenes.includes(gene.markerNm)
|
|
const isQuantity = gene.markerTypeCd === 'QTY'
|
|
|
|
return (
|
|
<Badge
|
|
key={gene.markerNm}
|
|
variant={isSelected ? "default" : "outline"}
|
|
className={`cursor-pointer transition-colors text-xs h-7 px-2.5 ${
|
|
isSelected
|
|
? isQuantity
|
|
? 'bg-[#2563eb] text-white hover:bg-[#2563eb]/90 border-[#2563eb]'
|
|
: 'bg-slate-400 text-white hover:bg-slate-500 border-slate-400'
|
|
: isQuantity
|
|
? 'border-[#2563eb]/40 text-[#2563eb] hover:bg-[#2563eb]/5 hover:border-[#2563eb]'
|
|
: 'border-slate-300 text-slate-600 hover:bg-slate-50 hover:border-slate-400'
|
|
}`}
|
|
onClick={() => toggleGene(gene.markerNm)}
|
|
title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`}
|
|
>
|
|
{gene.markerNm}
|
|
</Badge>
|
|
)
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-center text-muted-foreground">
|
|
<Filter className="h-16 w-16 mx-auto mb-4 opacity-40" />
|
|
<p className="text-lg font-semibold">검색 결과가 없습니다</p>
|
|
<p className="text-sm mt-2">다른 검색어나 필터를 시도해보세요</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="px-4 py-3 border-t flex justify-between items-center flex-shrink-0 bg-muted/20">
|
|
<div className="text-sm">
|
|
{searchQuery && (
|
|
<span className="text-muted-foreground mr-3">
|
|
검색: <span className="font-semibold text-foreground">{filteredGenes.length.toLocaleString()}</span>개
|
|
</span>
|
|
)}
|
|
<span className="text-muted-foreground">
|
|
선택: <span className="font-bold text-primary text-base">{selectedGenes.length}</span>개
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 px-4">
|
|
취소
|
|
</Button>
|
|
<Button onClick={() => onOpenChange(false)} className="h-9 px-4">
|
|
완료
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|