INIT
This commit is contained in:
235
frontend/src/components/genome/CowCompareModal.tsx
Normal file
235
frontend/src/components/genome/CowCompareModal.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { User, CheckCircle2, BarChart3 } from "lucide-react"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { GenomeTrait as GenomeTraitType } from "@/types/genome.types"
|
||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend, Tooltip as RechartsTooltip } from 'recharts'
|
||||
|
||||
interface CowCompareModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
compareCowsData: { cow: CowDetail; genome: GenomeTraitType[] }[]
|
||||
transformGenomeData: (genomeData: GenomeTraitType[]) => any[]
|
||||
CATEGORIES: string[]
|
||||
TRAIT_COLORS: string[]
|
||||
}
|
||||
|
||||
export function CowCompareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
compareCowsData,
|
||||
transformGenomeData,
|
||||
CATEGORIES,
|
||||
TRAIT_COLORS
|
||||
}: CowCompareModalProps) {
|
||||
if (!isOpen || compareCowsData.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white w-full max-w-6xl max-h-[90vh] rounded-lg overflow-hidden flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary" />
|
||||
개체 유전체 비교
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{compareCowsData.length}개 개체 비교
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onClose} variant="ghost" size="sm">
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 비교 내용 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* 개체 카드들 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{compareCowsData.map((cowData, idx) => {
|
||||
const genomeTraits = transformGenomeData(cowData.genome)
|
||||
const avgBreedVal = genomeTraits.length > 0
|
||||
? genomeTraits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / genomeTraits.length
|
||||
: 0
|
||||
const avgPercentile = genomeTraits.length > 0
|
||||
? genomeTraits.reduce((sum: number, t: any) => sum + t.percentile, 0) / genomeTraits.length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={cowData.cow.pkCowNo} className={idx === 0 ? 'border-2 border-primary' : ''}>
|
||||
<CardHeader className={idx === 0 ? 'bg-primary/5' : ''}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{cowData.cow.cowId || cowData.cow.pkCowNo}</CardTitle>
|
||||
{cowData.cow.cowId && (
|
||||
<CardDescription>{cowData.cow.cowId}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{idx === 0 && (
|
||||
<Badge className="bg-primary text-white">현재 개체</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-center p-3 bg-muted/30 rounded-lg">
|
||||
<div className="text-xs text-muted-foreground mb-1">종합 육종가</div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{avgBreedVal > 0 ? '+' : ''}{avgBreedVal.toFixed(2)}σ
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
상위 {(100 - avgPercentile).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">생년월일</span>
|
||||
<span className="font-semibold">
|
||||
{cowData.cow.cowBirthDt
|
||||
? new Date(cowData.cow.cowBirthDt).toLocaleDateString('ko-KR')
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">나이</span>
|
||||
<span className="font-semibold">
|
||||
{cowData.cow.age ? `${cowData.cow.age}세` : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">평가 형질 수</span>
|
||||
<span className="font-semibold">{genomeTraits.length}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 카테고리별 비교 차트 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>카테고리별 육종가 비교</CardTitle>
|
||||
<CardDescription>각 개체의 카테고리별 평균 표준화육종가</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart>
|
||||
<PolarGrid stroke="#e2e8f0" />
|
||||
<PolarAngleAxis
|
||||
dataKey="category"
|
||||
tick={{ fill: '#64748b', fontSize: 12 }}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[-1, 2]}
|
||||
tick={{ fill: '#64748b', fontSize: 10 }}
|
||||
/>
|
||||
<RechartsTooltip />
|
||||
<Legend />
|
||||
{compareCowsData.map((cowData, idx) => {
|
||||
const genomeTraits = transformGenomeData(cowData.genome)
|
||||
const categoryData = CATEGORIES.map(cat => {
|
||||
const traits = genomeTraits.filter((t: any) => t.category === cat)
|
||||
const avgBreedVal = traits.length > 0
|
||||
? traits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / traits.length
|
||||
: 0
|
||||
return {
|
||||
category: cat,
|
||||
value: avgBreedVal
|
||||
}
|
||||
})
|
||||
|
||||
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
|
||||
return (
|
||||
<Radar
|
||||
key={cowData.cow.pkCowNo}
|
||||
name={idx === 0 ? `${cowLabel} (현재)` : cowLabel}
|
||||
dataKey="value"
|
||||
stroke={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
|
||||
fill={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
|
||||
fillOpacity={idx === 0 ? 0.6 : 0.3}
|
||||
strokeWidth={idx === 0 ? 3 : 2}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 형질별 비교 테이블 */}
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>주요 형질 비교 (Top 10)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b-2">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold sticky left-0 bg-muted/50">형질명</th>
|
||||
{compareCowsData.map((cowData, idx) => {
|
||||
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
|
||||
return (
|
||||
<th key={cowData.cow.pkCowNo} className="px-3 py-2 text-center font-semibold">
|
||||
{idx === 0 ? `${cowLabel}\n(현재)` : cowLabel}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{transformGenomeData(compareCowsData[0].genome).slice(0, 10).map((trait: any) => (
|
||||
<tr key={trait.id} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-medium sticky left-0 bg-white">
|
||||
{trait.name}
|
||||
</td>
|
||||
{compareCowsData.map((cowData) => {
|
||||
const genomeTraits = transformGenomeData(cowData.genome)
|
||||
const matchTrait = genomeTraits.find((t: any) => t.name === trait.name)
|
||||
return (
|
||||
<td key={cowData.cow.pkCowNo} className="px-3 py-2 text-center">
|
||||
{matchTrait ? (
|
||||
<div>
|
||||
<div className={`font-bold ${matchTrait.breedVal > 0 ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
{matchTrait.breedVal > 0 ? '+' : ''}{matchTrait.breedVal.toFixed(2)}σ
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{matchTrait.percentile.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-border bg-muted/30 flex justify-end">
|
||||
<Button onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
frontend/src/components/genome/CowSelectSheet.tsx
Normal file
159
frontend/src/components/genome/CowSelectSheet.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { User, CheckCircle2 } from "lucide-react"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
|
||||
interface CowSelectSheetProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
farmCows: CowDetail[]
|
||||
selectedCowsForCompare: number[]
|
||||
toggleCowForCompare: (cowNo: number) => void
|
||||
onCompare: () => void
|
||||
onClearSelection: () => void
|
||||
}
|
||||
|
||||
export function CowSelectSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
farmCows,
|
||||
selectedCowsForCompare,
|
||||
toggleCowForCompare,
|
||||
onCompare,
|
||||
onClearSelection
|
||||
}: CowSelectSheetProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center md:justify-center">
|
||||
<div className="bg-white w-full md:max-w-3xl md:max-h-[80vh] md:rounded-lg overflow-hidden flex flex-col max-h-[90vh] rounded-t-2xl md:rounded-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
농장 내 개체 비교
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
비교할 개체를 선택하세요 ({selectedCowsForCompare.length}/{farmCows.length})
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onClose} variant="ghost" size="sm">
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 개체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{farmCows.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<User className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>같은 농장에 다른 개체가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{farmCows.map((farmCow) => {
|
||||
const isSelected = selectedCowsForCompare.includes(farmCow.pkCowNo)
|
||||
return (
|
||||
<div
|
||||
key={farmCow.pkCowNo}
|
||||
onClick={() => toggleCowForCompare(farmCow.pkCowNo)}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 체크박스 */}
|
||||
<div className="flex items-center pt-1">
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckCircle2 className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개체 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-foreground">{farmCow.cowId || farmCow.pkCowNo}</h4>
|
||||
{farmCow.cowId && (
|
||||
<p className="text-sm text-muted-foreground">{farmCow.cowId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-3 gap-2 text-xs mt-2">
|
||||
<div>
|
||||
<div className="text-muted-foreground">생년월일</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{farmCow.cowBirthDt
|
||||
? new Date(farmCow.cowBirthDt).toLocaleDateString('ko-KR', {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">나이</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{farmCow.age ? `${farmCow.age}세` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">성별</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{farmCow.cowSex === 'F' ? '암' : farmCow.cowSex === 'M' ? '수' : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-border bg-muted/30 flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedCowsForCompare.length}개 개체 선택됨
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onClearSelection}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={selectedCowsForCompare.length === 0}
|
||||
>
|
||||
선택 해제
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCompare}
|
||||
className="gap-2"
|
||||
disabled={selectedCowsForCompare.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
비교하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
300
frontend/src/components/genome/gene-filter-modal.tsx
Normal file
300
frontend/src/components/genome/gene-filter-modal.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Search, Loader2 } from "lucide-react"
|
||||
import { geneApi } from "@/lib/api/gene.api"
|
||||
|
||||
/**
|
||||
* 마커 데이터 타입 (API에서 받아오는 형식)
|
||||
*/
|
||||
interface MarkerData {
|
||||
pkMarkerNo: number
|
||||
markerNm: string
|
||||
markerDesc: string
|
||||
markerTypeCd: string
|
||||
relatedTrait: string
|
||||
favorableAllele: string
|
||||
useYn: string
|
||||
markerTypeInfo?: {
|
||||
pkTypeCd: string
|
||||
typeNm: string
|
||||
typeDesc: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 필터에서 사용할 간소화된 타입
|
||||
*/
|
||||
interface GeneOption {
|
||||
name: string
|
||||
description: string
|
||||
type: 'QTY' | 'QLT'
|
||||
relatedTrait: string
|
||||
}
|
||||
|
||||
interface GeneFilterModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedGenes: string[]
|
||||
onConfirm: (genes: string[]) => void
|
||||
}
|
||||
|
||||
export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }: GeneFilterModalProps) {
|
||||
const [tempSelectedGenes, setTempSelectedGenes] = useState<string[]>(selectedGenes)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'type'>('name')
|
||||
const [allMarkers, setAllMarkers] = useState<GeneOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// API에서 마커 목록 가져오기
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchMarkers()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const fetchMarkers = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
|
||||
|
||||
// API 데이터를 GeneOption 형식으로 변환
|
||||
const geneOptions: GeneOption[] = markers.map(marker => ({
|
||||
name: marker.markerNm,
|
||||
description: marker.relatedTrait || marker.markerDesc || '',
|
||||
type: marker.markerTypeCd as 'QTY' | 'QLT',
|
||||
relatedTrait: marker.relatedTrait || ''
|
||||
}))
|
||||
|
||||
setAllMarkers(geneOptions)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch markers:', err)
|
||||
setError('유전자 목록을 불러오는데 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 육량형/육질형 필터링
|
||||
const quantityGenes = useMemo(() => {
|
||||
return allMarkers.filter(g => g.type === 'QTY').sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [allMarkers])
|
||||
|
||||
const qualityGenes = useMemo(() => {
|
||||
return allMarkers.filter(g => g.type === 'QLT').sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [allMarkers])
|
||||
|
||||
// 전체 유전자 목록 (정렬)
|
||||
const allGenes = useMemo(() => {
|
||||
return [...allMarkers].sort((a, b) => {
|
||||
if (sortBy === 'type') {
|
||||
if (a.type !== b.type) {
|
||||
return a.type.localeCompare(b.type)
|
||||
}
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}, [allMarkers, sortBy])
|
||||
|
||||
const filteredGenes = useMemo(() => {
|
||||
if (!searchQuery) return allGenes
|
||||
return allGenes.filter(gene =>
|
||||
gene.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
gene.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [allGenes, searchQuery])
|
||||
|
||||
const toggleGene = (geneName: string) => {
|
||||
setTempSelectedGenes(prev =>
|
||||
prev.includes(geneName)
|
||||
? prev.filter(g => g !== geneName)
|
||||
: [...prev, geneName]
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(tempSelectedGenes)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setTempSelectedGenes(selectedGenes)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>유전자 선택</DialogTitle>
|
||||
<DialogDescription>
|
||||
개체를 필터링할 유전자를 선택하세요. 시스템이 자동으로 중요도 순으로 정렬합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2">유전자 목록 불러오는 중...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={fetchMarkers}>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="quick" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="quick">타입별 선택 ({allMarkers.length}개)</TabsTrigger>
|
||||
<TabsTrigger value="all">전체 유전자 검색</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="quick" className="space-y-4">
|
||||
<Tabs defaultValue="quantity" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="quantity">육량형 ({quantityGenes.length}개)</TabsTrigger>
|
||||
<TabsTrigger value="quality">육질형 ({qualityGenes.length}개)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="quantity">
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
||||
<div className="space-y-3">
|
||||
{quantityGenes.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
육량형 유전자가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
quantityGenes.map((gene) => (
|
||||
<div key={gene.name} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`quick-${gene.name}`}
|
||||
checked={tempSelectedGenes.includes(gene.name)}
|
||||
onCheckedChange={() => toggleGene(gene.name)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`quick-${gene.name}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{gene.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quality">
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
||||
<div className="space-y-3">
|
||||
{qualityGenes.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
육질형 유전자가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
qualityGenes.map((gene) => (
|
||||
<div key={gene.name} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`quick-${gene.name}`}
|
||||
checked={tempSelectedGenes.includes(gene.name)}
|
||||
onCheckedChange={() => toggleGene(gene.name)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`quick-${gene.name}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{gene.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="유전자명 또는 설명 검색..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSortBy(sortBy === 'name' ? 'type' : 'name')}
|
||||
>
|
||||
{sortBy === 'type' ? '타입순' : '이름순'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
||||
<div className="space-y-3">
|
||||
{filteredGenes.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
검색 결과가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
filteredGenes.map((gene) => (
|
||||
<div key={gene.name} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`all-${gene.name}`}
|
||||
checked={tempSelectedGenes.includes(gene.name)}
|
||||
onCheckedChange={() => toggleGene(gene.name)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`all-${gene.name}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{gene.name} <span className="text-xs text-muted-foreground">({gene.type === 'QTY' ? '육량형' : '육질형'})</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
선택된 유전자: {tempSelectedGenes.length}개
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>
|
||||
선택 완료
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
206
frontend/src/components/genome/gene-possession-status.tsx
Normal file
206
frontend/src/components/genome/gene-possession-status.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
interface GeneData {
|
||||
geneName: string
|
||||
geneType: '육량' | '육질' // 유전자 분류
|
||||
farmRate: number // 우리 농장 우량형(AA) 보유율
|
||||
regionAvgRate: number // 지역 평균
|
||||
}
|
||||
|
||||
interface GenePossessionStatusProps {
|
||||
farmNo: number | null
|
||||
}
|
||||
|
||||
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
|
||||
const { selectedYear } = useAnalysisYear()
|
||||
const { filters } = useGlobalFilter()
|
||||
const [allGenes, setAllGenes] = useState<GeneData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// 선택된 유전자 확인
|
||||
const selectedGenes = filters.selectedGenes || []
|
||||
const hasFilter = selectedGenes.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
|
||||
// TODO: 백엔드 API 연동 시 실제 데이터 fetch
|
||||
// 현재는 목업 데이터 사용 (전체 유전자 리스트)
|
||||
const mockAllGenes: GeneData[] = [
|
||||
// 육량 관련
|
||||
{ geneName: 'PLAG1', geneType: '육량', farmRate: 85, regionAvgRate: 72 },
|
||||
{ geneName: 'NCAPG', geneType: '육량', farmRate: 82, regionAvgRate: 75 },
|
||||
{ geneName: 'LCORL', geneType: '육량', farmRate: 78, regionAvgRate: 68 },
|
||||
{ geneName: 'LAP3', geneType: '육량', farmRate: 65, regionAvgRate: 58 },
|
||||
|
||||
// 육질 관련
|
||||
{ geneName: 'FABP4', geneType: '육질', farmRate: 88, regionAvgRate: 70 },
|
||||
{ geneName: 'SCD', geneType: '육질', farmRate: 80, regionAvgRate: 72 },
|
||||
{ geneName: 'DGAT1', geneType: '육질', farmRate: 75, regionAvgRate: 65 },
|
||||
{ geneName: 'FASN', geneType: '육질', farmRate: 70, regionAvgRate: 62 },
|
||||
{ geneName: 'CAPN1', geneType: '육질', farmRate: 82, regionAvgRate: 68 },
|
||||
{ geneName: 'CAST', geneType: '육질', farmRate: 77, regionAvgRate: 64 },
|
||||
]
|
||||
|
||||
// 선택된 유전자 중 목업 데이터에 없는 유전자가 있다면 추가
|
||||
if (selectedGenes.length > 0) {
|
||||
selectedGenes.forEach(geneName => {
|
||||
if (!mockAllGenes.find(g => g.geneName === geneName)) {
|
||||
// 선택된 유전자가 목업 데이터에 없으면 기본값으로 추가
|
||||
mockAllGenes.push({
|
||||
geneName: geneName,
|
||||
geneType: geneName.includes('PLAG') || geneName.includes('NCAPG') || geneName.includes('LCORL') || geneName.includes('LAP') ? '육량' : '육질',
|
||||
farmRate: Math.floor(Math.random() * 30) + 60, // 60-90 사이 랜덤값
|
||||
regionAvgRate: Math.floor(Math.random() * 20) + 55, // 55-75 사이 랜덤값
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setAllGenes(mockAllGenes)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [selectedYear, farmNo, selectedGenes])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!farmNo) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-2">농장 정보가 없습니다</p>
|
||||
<p className="text-sm text-muted-foreground">로그인 후 다시 시도해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 필터에 따라 표시할 유전자 선택
|
||||
const allDisplayGenes = hasFilter
|
||||
? allGenes.filter(g => selectedGenes.includes(g.geneName))
|
||||
: allGenes.slice(0, 6) // TOP 6 (보유율 높은 순으로 이미 정렬됨)
|
||||
|
||||
// 접기/펼치기 적용 (4개 기준)
|
||||
// 단, 선택된 유전자가 있을 때는 모두 표시
|
||||
const DISPLAY_LIMIT = 4
|
||||
const displayGenes = hasFilter || isExpanded ? allDisplayGenes : allDisplayGenes.slice(0, DISPLAY_LIMIT)
|
||||
const hasMore = !hasFilter && allDisplayGenes.length > DISPLAY_LIMIT
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필터 배지 표시 */}
|
||||
{hasFilter && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">타겟 유전자:</span>
|
||||
</div>
|
||||
{selectedGenes.map(gene => (
|
||||
<Badge
|
||||
key={gene}
|
||||
variant="secondary"
|
||||
className="text-xs font-medium bg-blue-50 text-blue-700 border-blue-200"
|
||||
>
|
||||
{gene}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 유전자별 바 차트 */}
|
||||
<div className="space-y-2.5">
|
||||
{displayGenes.map((gene, index) => (
|
||||
<div key={gene.geneName} className="space-y-1">
|
||||
{/* 유전자명 + 타입 배지 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 min-w-[60px]">
|
||||
{gene.geneName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs px-2 py-0 ${
|
||||
gene.geneType === '육량'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: 'bg-purple-50 text-purple-700 border-purple-200'
|
||||
}`}
|
||||
>
|
||||
{gene.geneType}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{gene.farmRate}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프로그레스 바 */}
|
||||
<div className="relative h-7 bg-gray-100 rounded-full overflow-hidden">
|
||||
{/* 우리 농장 */}
|
||||
<div
|
||||
className={`absolute h-full transition-all duration-800 ${
|
||||
gene.geneType === '육량' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`}
|
||||
style={{ width: `${gene.farmRate}%` }}
|
||||
/>
|
||||
{/* 지역 평균 표시 (점선) */}
|
||||
<div
|
||||
className="absolute h-full border-l-2 border-dashed border-gray-400"
|
||||
style={{ left: `${gene.regionAvgRate}%` }}
|
||||
title={`지역 평균: ${gene.regionAvgRate}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지역 평균 레이블 */}
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-gray-500">
|
||||
지역 평균: {gene.regionAvgRate}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 더보기/접기 버튼 */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
접기
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
나머지 {allDisplayGenes.length - DISPLAY_LIMIT}개 더보기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/genome/gene-search-modal.tsx
Normal file
245
frontend/src/components/genome/gene-search-modal.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
181
frontend/src/components/genome/genome-distribution-donut.tsx
Normal file
181
frontend/src/components/genome/genome-distribution-donut.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { PieChart as PieChartIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
||||
|
||||
interface DistributionData {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
range: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface GenomeDistributionDonutProps {
|
||||
farmNo: number | null
|
||||
}
|
||||
|
||||
export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) {
|
||||
const [data, setData] = useState<DistributionData[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [
|
||||
{ traitNm: '도체중', weight: 0.25 },
|
||||
{ traitNm: '근내지방도', weight: 0.25 },
|
||||
{ traitNm: '등심단면적', weight: 0.25 },
|
||||
{ traitNm: '등지방두께', weight: 0.25 },
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = response.data || response
|
||||
const items = result.items || []
|
||||
setTotalCount(items.length)
|
||||
|
||||
const distribution = {
|
||||
top: 0, // 0σ 이상
|
||||
middle: 0, // -1.0σ ~ 0σ
|
||||
bottom: 0 // -1.0σ 이하
|
||||
}
|
||||
|
||||
items.forEach((item: any) => {
|
||||
const score = item.sortValue || 0
|
||||
if (score >= 0) distribution.top++
|
||||
else if (score >= -1.0) distribution.middle++
|
||||
else distribution.bottom++
|
||||
})
|
||||
|
||||
setData([
|
||||
{ name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' },
|
||||
{ name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' },
|
||||
{ name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' },
|
||||
].filter(d => d.value > 0))
|
||||
|
||||
} catch (error) {
|
||||
console.error('분포 데이터 로드 실패:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [farmNo])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[280px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center shadow-md shadow-violet-500/20">
|
||||
<PieChartIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">우리 소들의 등급</h3>
|
||||
<p className="text-[10px] text-slate-500">총 {totalCount}두 분포</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">{totalCount}두</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 */}
|
||||
<div className="p-5">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-[180px] h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={54}
|
||||
outerRadius={86}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="white" strokeWidth={2} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0].payload
|
||||
return (
|
||||
<div className="bg-slate-900 px-4 py-3 rounded-xl shadow-xl border border-slate-700">
|
||||
<p className="text-white font-bold mb-2">{item.name}</p>
|
||||
<p className="text-slate-200 text-sm">{item.description}</p>
|
||||
<div className="mt-2 pt-2 border-t border-slate-700">
|
||||
<p className="text-slate-300 text-sm">{item.value}두 ({Math.round(item.value / totalCount * 100)}%)</p>
|
||||
<p className="text-slate-400 text-xs mt-1">{item.range}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* 중앙 텍스트 */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold text-slate-900">{totalCount}</span>
|
||||
<span className="text-xs text-slate-500 mt-1">전체</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="w-full mt-5 pt-4 border-t border-slate-100">
|
||||
<div className="space-y-2.5">
|
||||
{data.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-100 hover:bg-slate-100/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 rounded-full flex-shrink-0 shadow-sm" style={{ backgroundColor: item.color }}></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-900">{item.name}</span>
|
||||
<span className="text-[10px] text-slate-500">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-base font-bold text-slate-900">{item.value}<span className="text-xs text-slate-500 font-normal ml-0.5">두</span></p>
|
||||
<p className="text-[10px] text-slate-500">{Math.round(item.value / totalCount * 100)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-4 text-center leading-relaxed">
|
||||
σ(시그마)는 유전능력 수준을 나타내요<br/>
|
||||
0보다 클수록 우수해요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
250
frontend/src/components/genome/genome-radar-chart.tsx
Normal file
250
frontend/src/components/genome/genome-radar-chart.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client'
|
||||
|
||||
import { Target } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
|
||||
|
||||
interface TraitScore {
|
||||
trait: string
|
||||
diff: number
|
||||
myFarm: number
|
||||
region: number
|
||||
}
|
||||
|
||||
interface GenomeRadarChartProps {
|
||||
farmNo: number | null
|
||||
}
|
||||
|
||||
export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) {
|
||||
const [data, setData] = useState<TraitScore[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const traits = [
|
||||
{ name: '도체중', key: '도체중' },
|
||||
{ name: '근내지방', key: '근내지방도' },
|
||||
{ name: '등심단면적', key: '등심단면적' },
|
||||
{ name: '등지방', key: '등지방두께' },
|
||||
{ name: '12개월체중', key: '12개월령체중' },
|
||||
]
|
||||
|
||||
const results: TraitScore[] = []
|
||||
|
||||
for (const trait of traits) {
|
||||
try {
|
||||
const farmResponse = await apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const globalResponse = await apiClient.post('/cow/ranking/global', {
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const farmResult = farmResponse.data || farmResponse
|
||||
const globalResult = globalResponse.data || globalResponse
|
||||
|
||||
const farmScores = farmResult.items?.map((item: any) => {
|
||||
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
|
||||
return traitDetail?.value ?? item.sortValue ?? 0
|
||||
}) || []
|
||||
const farmAvgScore = farmScores.length > 0
|
||||
? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length
|
||||
: 0
|
||||
|
||||
const globalScores = globalResult.items?.map((item: any) => {
|
||||
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
|
||||
return traitDetail?.value ?? item.sortValue ?? 0
|
||||
}) || []
|
||||
const regionAvgScore = globalScores.length > 0
|
||||
? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length
|
||||
: 0
|
||||
|
||||
const diff = farmAvgScore - regionAvgScore
|
||||
|
||||
results.push({
|
||||
trait: trait.name,
|
||||
diff: parseFloat(diff.toFixed(2)),
|
||||
myFarm: parseFloat(farmAvgScore.toFixed(2)),
|
||||
region: parseFloat(regionAvgScore.toFixed(2))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`형질 ${trait.name} 로드 실패:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setData(results)
|
||||
} catch (error) {
|
||||
console.error('레이더 차트 데이터 로드 실패:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [farmNo])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[280px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const validDiffs = data.filter(d => !isNaN(d.diff))
|
||||
const avgDiff = validDiffs.length > 0
|
||||
? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center shadow-md shadow-sky-500/20">
|
||||
<Target className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">카테고리별 결과</h3>
|
||||
<p className="text-[10px] text-slate-500">주요 형질 평균 비교</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${avgDiff >= 0
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: 'bg-red-50 text-red-600 border-red-200'
|
||||
}`}>
|
||||
평균 {avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 */}
|
||||
<div className="p-5">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-blue-50/30 rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<RadarChart data={data} margin={{ top: 30, right: 40, bottom: 30, left: 40 }}>
|
||||
<defs>
|
||||
<linearGradient id="radarGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#1482B0" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#1482B0" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<PolarGrid
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
<PolarAngleAxis
|
||||
dataKey="trait"
|
||||
tick={{ fontSize: 13, fill: '#334155', fontWeight: 600 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[-1.5, 1.5]}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickCount={4}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Radar
|
||||
name="보은군 평균"
|
||||
dataKey={() => 0}
|
||||
stroke="#94a3b8"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
<Radar
|
||||
name="내농장"
|
||||
dataKey="diff"
|
||||
stroke="#1482B0"
|
||||
fill="url(#radarGradient)"
|
||||
strokeWidth={3}
|
||||
dot={{
|
||||
fill: '#fff',
|
||||
strokeWidth: 3,
|
||||
stroke: '#1482B0',
|
||||
r: 6,
|
||||
strokeOpacity: 1
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0]?.payload
|
||||
const diff = item?.diff ?? 0
|
||||
const myFarm = item?.myFarm ?? 0
|
||||
const region = item?.region ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 px-4 py-3 rounded-xl text-sm shadow-xl border border-slate-700">
|
||||
<p className="text-white font-bold mb-2">{item?.trait}</p>
|
||||
<div className="space-y-1 text-slate-300 text-xs">
|
||||
<p>내농장: <span className="text-[#1482B0] font-semibold">{myFarm > 0 ? '+' : ''}{myFarm}σ</span></p>
|
||||
<p>보은군: <span className="text-slate-400">{region > 0 ? '+' : ''}{region}σ</span></p>
|
||||
</div>
|
||||
<div className={`mt-2 pt-2 border-t border-slate-700 font-bold ${diff >= 0.3 ? 'text-emerald-400' :
|
||||
diff <= -0.3 ? 'text-amber-400' :
|
||||
'text-slate-300'
|
||||
}`}>
|
||||
{diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-0.5 bg-slate-400 opacity-60" style={{ borderTop: '2px dashed #94a3b8' }}></div>
|
||||
<span className="text-xs text-slate-600">보은군 평균</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-white border-3 border-[#1482B0] shadow-sm"></div>
|
||||
<span className="text-xs text-slate-900 font-semibold">내 농장</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 형질별 수치 */}
|
||||
<div className="grid grid-cols-5 gap-2 mt-4 pt-4 border-t border-slate-200">
|
||||
{data.map((item, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<p className="text-[10px] text-slate-500 mb-1 truncate font-medium">{item.trait}</p>
|
||||
<p className={`text-sm font-bold ${item.diff >= 0.3 ? 'text-emerald-600' :
|
||||
item.diff <= -0.3 ? 'text-amber-600' :
|
||||
'text-slate-700'
|
||||
}`}>
|
||||
{item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/components/genome/genome-strengths-weaknesses.tsx
Normal file
208
frontend/src/components/genome/genome-strengths-weaknesses.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
|
||||
interface GenomeData {
|
||||
trait: string
|
||||
score: number
|
||||
type: string
|
||||
}
|
||||
|
||||
interface GenomeStrengthsWeaknessesProps {
|
||||
farmNo?: number | null
|
||||
}
|
||||
|
||||
export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) {
|
||||
const [allMetrics, setAllMetrics] = useState<GenomeData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTraitScores = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const traits = [
|
||||
{ name: '도체중', key: '도체중' },
|
||||
{ name: '근내지방도', key: '근내지방도' },
|
||||
{ name: '등심단면적', key: '등심단면적' },
|
||||
{ name: '등지방두께', key: '등지방두께' },
|
||||
{ name: '12개월령체중', key: '12개월령체중' },
|
||||
{ name: '체고', key: '체고' },
|
||||
]
|
||||
|
||||
const traitScores: GenomeData[] = []
|
||||
|
||||
for (const trait of traits) {
|
||||
try {
|
||||
const rankingRequest = {
|
||||
filterOptions: { farmNo: farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/cow/ranking', rankingRequest)
|
||||
const rankingResult = response.data || response
|
||||
|
||||
const scores = rankingResult.items?.map((item: any) => item.sortValue) || []
|
||||
const avgScore = scores.length > 0
|
||||
? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length
|
||||
: 0
|
||||
|
||||
traitScores.push({
|
||||
trait: trait.name,
|
||||
score: parseFloat(avgScore.toFixed(2)),
|
||||
type: '유전체'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setAllMetrics(traitScores)
|
||||
} catch (error) {
|
||||
console.error('형질 점수 로드 실패:', error)
|
||||
setAllMetrics([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTraitScores()
|
||||
}, [farmNo])
|
||||
|
||||
const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3)
|
||||
const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[140px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 강점 */}
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
|
||||
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
|
||||
<TrendingUp className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900">이 부분이 강해요</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">보은군보다 우수한 형질</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold border border-emerald-200 shadow-sm">TOP 3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{strengths.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{strengths.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-4 bg-gradient-to-r from-emerald-50 to-transparent rounded-xl hover:from-emerald-100 hover:to-emerald-50 transition-all duration-200 border-2 border-emerald-100 group-hover:border-emerald-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
|
||||
idx === 0 ? 'bg-gradient-to-br from-emerald-500 to-emerald-600 text-white shadow-emerald-500/30' :
|
||||
idx === 1 ? 'bg-emerald-200 text-emerald-800 border-2 border-emerald-300' :
|
||||
'bg-emerald-100 text-emerald-700 border-2 border-emerald-200'
|
||||
}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-emerald-200 shadow-sm">
|
||||
<span className="text-lg font-bold text-emerald-600">
|
||||
{item.score > 0 ? '+' : ''}{item.score}
|
||||
</span>
|
||||
<span className="text-xs text-emerald-500 font-semibold">σ</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 약점 */}
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
|
||||
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-amber-50 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
|
||||
<TrendingDown className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900">더 좋아질 수 있어요</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">개선하면 좋을 형질</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold border border-amber-200 shadow-sm">BOTTOM 3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{weaknesses.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{weaknesses.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-4 bg-gradient-to-r from-amber-50 to-transparent rounded-xl hover:from-amber-100 hover:to-amber-50 transition-all duration-200 border-2 border-amber-100 group-hover:border-amber-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
|
||||
idx === 0 ? 'bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-amber-500/30' :
|
||||
idx === 1 ? 'bg-amber-200 text-amber-800 border-2 border-amber-300' :
|
||||
'bg-amber-100 text-amber-700 border-2 border-amber-200'
|
||||
}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-amber-200 shadow-sm">
|
||||
<span className="text-lg font-bold text-amber-600">
|
||||
{item.score > 0 ? '+' : ''}{item.score}
|
||||
</span>
|
||||
<span className="text-xs text-amber-500 font-semibold">σ</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/genome/genome-traits-table.tsx
Normal file
199
frontend/src/components/genome/genome-traits-table.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
|
||||
interface TraitData {
|
||||
trait: string
|
||||
regional: number
|
||||
myFarm: number
|
||||
}
|
||||
|
||||
interface GenomeTraitsTableProps {
|
||||
farmNo?: number | null
|
||||
}
|
||||
|
||||
export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) {
|
||||
const [traitData, setTraitData] = useState<TraitData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTraitData = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const traits = [
|
||||
{ name: '12개월령체중', key: '12개월령체중' },
|
||||
{ name: '도체중', key: '도체중' },
|
||||
{ name: '근내지방도', key: '근내지방도' },
|
||||
{ name: '등심단면적', key: '등심단면적' },
|
||||
{ name: '등지방두께', key: '등지방두께' },
|
||||
]
|
||||
|
||||
const results: TraitData[] = []
|
||||
|
||||
for (const trait of traits) {
|
||||
try {
|
||||
const farmResponse = await apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo: farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const globalResponse = await apiClient.post('/cow/ranking/global', {
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const farmResult = farmResponse.data || farmResponse
|
||||
const globalResult = globalResponse.data || globalResponse
|
||||
|
||||
const farmScores = farmResult.items?.map((item: any) => item.sortValue) || []
|
||||
const farmAvg = farmScores.length > 0
|
||||
? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length
|
||||
: 0
|
||||
|
||||
const globalScores = globalResult.items?.map((item: any) => item.sortValue) || []
|
||||
const regionalAvg = globalScores.length > 0
|
||||
? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length
|
||||
: 0
|
||||
|
||||
results.push({
|
||||
trait: trait.name,
|
||||
myFarm: parseFloat(farmAvg.toFixed(2)),
|
||||
regional: parseFloat(regionalAvg.toFixed(2))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setTraitData(results)
|
||||
} catch (error) {
|
||||
console.error('[형질 테이블] 전체 데이터 로드 실패:', error)
|
||||
setTraitData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTraitData()
|
||||
}, [farmNo])
|
||||
|
||||
const getTraitShortName = (name: string) => {
|
||||
const shortNames: Record<string, string> = {
|
||||
'12개월령체중': '12개월령체중',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'근내지방도': '근내지방도',
|
||||
'도체중': '도체중'
|
||||
}
|
||||
return shortNames[name] || name
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[180px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1482B0] to-[#0d5f82] flex items-center justify-center shadow-md shadow-[#1482B0]/20">
|
||||
<BarChart3 className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">형질별 점수</h3>
|
||||
<p className="text-[10px] text-slate-500">보은군과 비교한 수치에요</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">5개 형질</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="p-5">
|
||||
{traitData.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{traitData.map((item, idx) => {
|
||||
const diff = item.myFarm - item.regional
|
||||
const isPositive = diff >= 0
|
||||
// σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100)
|
||||
const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100))
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
{/* 형질명 + 차이 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-800">
|
||||
{getTraitShortName(item.trait)}
|
||||
</span>
|
||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-lg ${diff >= 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' :
|
||||
diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' :
|
||||
'bg-slate-50 text-slate-700 border border-slate-200'
|
||||
}`}>
|
||||
{diff > 0 ? '+' : ''}{diff.toFixed(1)}σ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 비교 바 */}
|
||||
<div className="relative h-6 bg-slate-100 rounded-lg overflow-hidden shadow-inner">
|
||||
{/* 보은군 바 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-slate-300/80 rounded-lg transition-all duration-500"
|
||||
style={{ width: `${toPercent(item.regional)}%` }}
|
||||
/>
|
||||
{/* 내농장 바 */}
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full rounded-lg transition-all duration-500 shadow-sm ${isPositive
|
||||
? 'bg-gradient-to-r from-[#1482B0] via-[#1482B0] to-[#0d5f82]'
|
||||
: 'bg-gradient-to-r from-slate-400 to-slate-500'
|
||||
}`}
|
||||
style={{ width: `${toPercent(item.myFarm)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#1482B0] shadow-sm"></div>
|
||||
<span className="text-slate-600 font-medium">
|
||||
내농장 <span className="font-bold text-slate-900">{item.myFarm > 0 ? '+' : ''}{item.myFarm}σ</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 shadow-sm"></div>
|
||||
<span className="text-slate-500 font-medium">
|
||||
보은군 {item.regional > 0 ? '+' : ''}{item.regional}σ
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user