유전자 DB 연동 및 필터검색 수정

This commit is contained in:
2025-12-17 17:10:57 +09:00
parent c0d7408bcf
commit 7ba2272dc2
3 changed files with 226 additions and 78 deletions

View File

@@ -24,7 +24,6 @@ export class GeneService {
cowId,
delDt: IsNull(),
},
relations: ['genomeRequest'],
order: {
chromosome: 'ASC',
position: 'ASC',

View File

@@ -150,6 +150,8 @@ export default function CowOverviewPage() {
const [cow, setCow] = useState<CowDetail | null>(null)
const [genomeData, setGenomeData] = useState<GenomeTrait[]>([])
const [geneData, setGeneData] = useState<GeneDetail[]>([])
const [geneDataLoaded, setGeneDataLoaded] = useState(false) // 유전자 데이터 로드 여부
const [geneDataLoading, setGeneDataLoading] = useState(false) // 유전자 데이터 로딩 중
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<string>('genome')
@@ -193,6 +195,8 @@ export default function CowOverviewPage() {
const [showFarm] = useState(true)
const [showAllTraits] = useState(false)
const [selectedTraits, setSelectedTraits] = useState<number[]>([])
const [geneCurrentPage, setGeneCurrentPage] = useState(1)
const GENES_PER_PAGE = 50
// 농가/보은군 비교 하이라이트 모드
const [highlightMode, setHighlightMode] = useState<'farm' | 'region' | null>(null)
@@ -215,12 +219,50 @@ export default function CowOverviewPage() {
}, [filters.isActive, firstPinnedTrait, chartFilterTrait])
// 유전자 탭 필터 상태
const [geneSearchKeyword, setGeneSearchKeyword] = useState('')
const [geneSearchInput, setGeneSearchInput] = useState('') // 실시간 입력값
const [geneSearchKeyword, setGeneSearchKeyword] = useState('') // 디바운스된 검색값
const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all')
// 검색어 디바운스 (300ms) 실시간 필터링 너무 느림
// 타이핑이 멈추고 0.3초 후에 검색이 실행
useEffect(() => {
const timer = setTimeout(() => {
setGeneSearchKeyword(geneSearchInput)
setGeneCurrentPage(1)
}, 300)
return () => clearTimeout(timer)
}, [geneSearchInput])
const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all')
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'genotype'>('snpName')
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
// 유전자 데이터 지연 로드 함수
const loadGeneData = async () => {
if (geneDataLoaded || geneDataLoading) return // 이미 로드했거나 로딩 중이면 스킵
setGeneDataLoading(true)
try {
const geneDataResult = await geneApi.findByCowId(cowNo)
const geneList = geneDataResult || []
setGeneData(geneList)
setGeneDataLoaded(true)
} catch (geneErr) {
console.error('유전자 데이터 조회 실패:', geneErr)
setGeneData([])
setGeneDataLoaded(true)
} finally {
setGeneDataLoading(false)
}
}
// 탭 변경 핸들러
const handleTabChange = (value: string) => {
setActiveTab(value)
if (value === 'gene' && !geneDataLoaded) {
loadGeneData()
}
}
// 농가/보은군 배지 클릭 시 차트로 스크롤 + 하이라이트
const handleComparisonClick = (mode: 'farm' | 'region') => {
// 토글: 같은 모드 클릭 시 해제
@@ -273,19 +315,8 @@ export default function CowOverviewPage() {
setGenomeRequest(null)
}
// 유전자(SNP) 데이터 가져오기
try {
const geneDataResult = await geneApi.findByCowId(cowNo)
const geneList = geneDataResult || []
setGeneData(geneList)
// 데이터 없어도 UI는 보여줌
setHasGeneData(true)
} catch (geneErr) {
console.error('유전자 데이터 조회 실패:', geneErr)
setGeneData([])
// 데이터 없어도 UI는 보여줌
setHasGeneData(true)
}
// 유전자(SNP) 데이터는 탭 클릭 시 로드 (지연 로딩)
setHasGeneData(true) // 탭은 보여주되, 데이터는 나중에 로드
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
// TODO: 번식능력 API 연동
@@ -512,7 +543,7 @@ export default function CowOverviewPage() {
</div>
{/* 탭 네비게이션 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full grid grid-cols-3 bg-transparent p-0 h-auto gap-0 rounded-none border-b border-border">
<TabsTrigger
value="genome"
@@ -1172,7 +1203,14 @@ export default function CowOverviewPage() {
{/* 유전자 분석 탭 */}
<TabsContent value="gene" className="mt-6 space-y-6">
{hasGeneData ? (
{geneDataLoading ? (
<div className="flex items-center justify-center h-64 md:h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
) : hasGeneData ? (
<>
{/* 개체 정보 섹션 (유전체 탭과 동일) */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
@@ -1422,10 +1460,10 @@ export default function CowOverviewPage() {
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="SNP, 염색체 검색..."
placeholder="SNP 이름, 염색체 검색..."
className="pl-9 h-11 max-sm:h-10 text-base max-sm:text-sm border-slate-200 bg-white focus:border-blue-400 focus:ring-blue-100"
value={geneSearchKeyword}
onChange={(e) => setGeneSearchKeyword(e.target.value)}
value={geneSearchInput}
onChange={(e) => setGeneSearchInput(e.target.value)}
/>
</div>
@@ -1469,23 +1507,26 @@ export default function CowOverviewPage() {
<div className="flex items-center gap-2 sm:ml-auto">
<Select
value={geneSortBy}
onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'genotype') => setGeneSortBy(value)}
onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks') => setGeneSortBy(value)}
>
<SelectTrigger className="w-[110px] h-9 text-sm border-slate-200 bg-white">
<SelectTrigger className="w-[160px] h-9 text-sm border-slate-200 bg-white">
<SelectValue placeholder="정렬 기준" />
</SelectTrigger>
<SelectContent>
<SelectItem value="snpName">SNP</SelectItem>
<SelectItem value="chromosome"></SelectItem>
<SelectItem value="position"></SelectItem>
<SelectItem value="genotype"></SelectItem>
<SelectItem value="snpName">SNP </SelectItem>
<SelectItem value="chromosome"> </SelectItem>
<SelectItem value="position">Position</SelectItem>
<SelectItem value="snpType">SNP </SelectItem>
<SelectItem value="allele1"> </SelectItem>
<SelectItem value="allele2"> </SelectItem>
<SelectItem value="remarks"></SelectItem>
</SelectContent>
</Select>
<Select
value={geneSortOrder}
onValueChange={(value: 'asc' | 'desc') => setGeneSortOrder(value)}
>
<SelectTrigger className="w-[100px] h-9 text-sm border-slate-200 bg-white">
<SelectTrigger className="w-[110px] h-9 text-sm border-slate-200 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1500,13 +1541,23 @@ export default function CowOverviewPage() {
{/* 유전자 테이블/카드 */}
{(() => {
const filteredData = geneData.filter(gene => {
// 검색 필터
// 검색 필터 (테이블의 모든 필드 검색)
if (geneSearchKeyword) {
const keyword = geneSearchKeyword.toLowerCase()
const snpName = (gene.snpName || '').toLowerCase()
const chromosome = (gene.chromosome || '').toLowerCase()
const position = (gene.position || '').toLowerCase()
if (!snpName.includes(keyword) && !chromosome.includes(keyword) && !position.includes(keyword)) {
const snpType = (gene.snpType || '').toLowerCase()
const allele1 = (gene.allele1 || '').toLowerCase()
const allele2 = (gene.allele2 || '').toLowerCase()
const remarks = (gene.remarks || '').toLowerCase()
if (!snpName.includes(keyword) &&
!chromosome.includes(keyword) &&
!position.includes(keyword) &&
!snpType.includes(keyword) &&
!allele1.includes(keyword) &&
!allele2.includes(keyword) &&
!remarks.includes(keyword)) {
return false
}
}
@@ -1537,9 +1588,21 @@ export default function CowOverviewPage() {
aVal = parseInt(a.position || '0') || 0
bVal = parseInt(b.position || '0') || 0
break
case 'genotype':
aVal = `${a.allele1 || ''}${a.allele2 || ''}`
bVal = `${b.allele1 || ''}${b.allele2 || ''}`
case 'snpType':
aVal = a.snpType || ''
bVal = b.snpType || ''
break
case 'allele1':
aVal = a.allele1 || ''
bVal = b.allele1 || ''
break
case 'allele2':
aVal = a.allele2 || ''
bVal = b.allele2 || ''
break
case 'remarks':
aVal = a.remarks || ''
bVal = b.remarks || ''
break
}
@@ -1554,26 +1617,124 @@ export default function CowOverviewPage() {
: strB.localeCompare(strA)
})
// 페이지네이션 계산
const totalPages = Math.ceil(sortedData.length / GENES_PER_PAGE)
const startIndex = (geneCurrentPage - 1) * GENES_PER_PAGE
const endIndex = startIndex + GENES_PER_PAGE
const displayData = sortedData.length > 0
? sortedData.slice(0, 50)
? sortedData.slice(startIndex, endIndex)
: Array(10).fill(null)
// 페이지네이션 UI 컴포넌트
const PaginationUI = () => {
if (sortedData.length <= GENES_PER_PAGE) return null
// 표시할 페이지 번호들 계산 (현재 페이지 기준 앞뒤 2개씩)
const getPageNumbers = () => {
const pages: (number | string)[] = []
const showPages = 5
let start = Math.max(1, geneCurrentPage - 2)
let end = Math.min(totalPages, start + showPages - 1)
if (end - start < showPages - 1) {
start = Math.max(1, end - showPages + 1)
}
if (start > 1) {
pages.push(1)
if (start > 2) pages.push('...')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < totalPages) {
if (end < totalPages - 1) pages.push('...')
pages.push(totalPages)
}
return pages
}
return (
<div className="px-4 py-3 bg-muted/30 border-t flex flex-col sm:flex-row items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{sortedData.length.toLocaleString()} {startIndex + 1}-{Math.min(endIndex, sortedData.length)}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(1)}
disabled={geneCurrentPage === 1}
className="px-2"
>
«
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(p => Math.max(1, p - 1))}
disabled={geneCurrentPage === 1}
className="px-2"
>
</Button>
{getPageNumbers().map((page, idx) => (
typeof page === 'number' ? (
<Button
key={idx}
variant={geneCurrentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setGeneCurrentPage(page)}
className="px-3 min-w-[36px]"
>
{page}
</Button>
) : (
<span key={idx} className="px-1 text-muted-foreground">...</span>
)
))}
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={geneCurrentPage === totalPages}
className="px-2"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(totalPages)}
disabled={geneCurrentPage === totalPages}
className="px-2"
>
»
</Button>
</div>
</div>
)
}
return (
<>
{/* 데스크톱: 테이블 */}
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<div>
<table className="w-full">
<table className="w-full table-fixed">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">SNP </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"> </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">Position</th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">SNP </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"> </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"> </th>
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground"></th>
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[22%]">SNP </th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[10%]"> </th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">Position</th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">SNP </th>
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[20%]"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -1581,36 +1742,32 @@ export default function CowOverviewPage() {
if (!gene) {
return (
<tr key={idx} className="hover:bg-muted/30">
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td>
</tr>
)
}
return (
<tr key={idx} className="hover:bg-muted/30">
<td className="px-5 py-4 text-center text-base font-medium text-foreground">{gene.snpName || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.chromosome || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.position || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.snpType || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.allele1 || '-'}</td>
<td className="px-5 py-4 text-center text-base text-foreground">{gene.allele2 || '-'}</td>
<td className="px-5 py-4 text-center text-base text-muted-foreground">{gene.remarks || '-'}</td>
<td className="px-4 py-3 text-center text-base font-medium text-foreground">{gene.snpName || '-'}</td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.chromosome || '-'}</td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.position || '-'}</td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.snpType || '-'}</td>
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele1 || '-'}</td>
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele2 || '-'}</td>
<td className="px-4 py-3 text-center text-base text-muted-foreground">{gene.remarks || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{geneData.length > 50 && (
<div className="px-4 py-3 bg-muted/30 text-center text-sm text-muted-foreground border-t">
{geneData.length} 50
</div>
)}
<PaginationUI />
</CardContent>
</Card>
@@ -1652,11 +1809,9 @@ export default function CowOverviewPage() {
</Card>
)
})}
{geneData.length > 50 && (
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
{geneData.length} 50
</div>
)}
</div>
<div className="lg:hidden">
<PaginationUI />
</div>
</>
)

View File

@@ -45,8 +45,7 @@ export const geneApi = {
* GET /gene/:cowId
*/
findByCowId: async (cowId: string): Promise<GeneDetail[]> => {
const response = await apiClient.get<GeneDetail[]>(`/gene/${cowId}`);
return response.data;
return await apiClient.get(`/gene/${cowId}`);
},
/**
@@ -54,8 +53,7 @@ export const geneApi = {
* GET /gene/summary/:cowId
*/
getGeneSummary: async (cowId: string): Promise<GeneSummary> => {
const response = await apiClient.get<GeneSummary>(`/gene/summary/${cowId}`);
return response.data;
return await apiClient.get(`/gene/summary/${cowId}`);
},
/**
@@ -63,8 +61,7 @@ export const geneApi = {
* GET /gene/request/:requestNo
*/
findByRequestNo: async (requestNo: number): Promise<GeneDetail[]> => {
const response = await apiClient.get<GeneDetail[]>(`/gene/request/${requestNo}`);
return response.data;
return await apiClient.get(`/gene/request/${requestNo}`);
},
/**
@@ -72,8 +69,7 @@ export const geneApi = {
* GET /gene/detail/:geneDetailNo
*/
findOne: async (geneDetailNo: number): Promise<GeneDetail> => {
const response = await apiClient.get<GeneDetail>(`/gene/detail/${geneDetailNo}`);
return response.data;
return await apiClient.get(`/gene/detail/${geneDetailNo}`);
},
/**
@@ -81,8 +77,7 @@ export const geneApi = {
* POST /gene
*/
create: async (data: Partial<GeneDetail>): Promise<GeneDetail> => {
const response = await apiClient.post<GeneDetail>('/gene', data);
return response.data;
return await apiClient.post('/gene', data);
},
/**
@@ -90,7 +85,6 @@ export const geneApi = {
* POST /gene/bulk
*/
createBulk: async (dataList: Partial<GeneDetail>[]): Promise<GeneDetail[]> => {
const response = await apiClient.post<GeneDetail[]>('/gene/bulk', dataList);
return response.data;
return await apiClient.post('/gene/bulk', dataList);
},
};