1827 lines
105 KiB
TypeScript
1827 lines
105 KiB
TypeScript
'use client'
|
|
|
|
import { AuthGuard } from "@/components/auth/auth-guard"
|
|
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
|
import { AppSidebar } from "@/components/layout/app-sidebar"
|
|
import { SiteHeader } from "@/components/layout/site-header"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent } from "@/components/ui/card"
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import { useFilterStore } from "@/store/filter-store"
|
|
import { useMediaQuery } from "@/hooks/use-media-query"
|
|
import { useToast } from "@/hooks/use-toast"
|
|
import { cowApi } from "@/lib/api/cow.api"
|
|
import { geneApi, GeneDetail } from "@/lib/api/gene.api"
|
|
import { genomeApi, ComparisonAveragesDto, GenomeRequestDto, TraitComparisonAveragesDto } from "@/lib/api/genome.api"
|
|
import { mptApi } from "@/lib/api/mpt.api"
|
|
import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config"
|
|
import { ALL_TRAITS } from "@/constants/traits"
|
|
import { CowDetail } from "@/types/cow.types"
|
|
import { GenomeTrait } from "@/types/genome.types"
|
|
import {
|
|
Activity,
|
|
ArrowLeft,
|
|
BarChart3,
|
|
CheckCircle2,
|
|
ChevronUp,
|
|
Dna,
|
|
Search,
|
|
X,
|
|
XCircle
|
|
} from 'lucide-react'
|
|
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
|
|
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
|
|
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
|
|
import { TraitDistributionCharts } from "./genome/_components/trait-distribution-charts"
|
|
import { MptTable } from "./reproduction/_components/mpt-table"
|
|
|
|
// 유전체 차트 3개의 정규분포 곡선 생성
|
|
function generateMultipleDistributions(
|
|
nationwideMean: number, nationwideStd: number,
|
|
regionMean: number, regionStd: number,
|
|
farmMean: number, farmStd: number
|
|
) {
|
|
const data = []
|
|
for (let i = -30; i <= 30; i++) {
|
|
const x = i / 10
|
|
const xVal = parseFloat(x.toFixed(1))
|
|
const yNationwide = (1 / (nationwideStd * Math.sqrt(2 * Math.PI))) *
|
|
Math.exp(-0.5 * Math.pow((x - nationwideMean) / nationwideStd, 2))
|
|
const yRegion = (1 / (regionStd * Math.sqrt(2 * Math.PI))) *
|
|
Math.exp(-0.5 * Math.pow((x - regionMean) / regionStd, 2))
|
|
const yFarm = (1 / (farmStd * Math.sqrt(2 * Math.PI))) *
|
|
Math.exp(-0.5 * Math.pow((x - farmMean) / farmStd, 2))
|
|
data.push({
|
|
x: xVal,
|
|
nationwide: yNationwide * 100,
|
|
region: yRegion * 100,
|
|
farm: yFarm * 100
|
|
})
|
|
}
|
|
return data
|
|
}
|
|
|
|
export default function CowOverviewPage() {
|
|
// ========================================
|
|
// 기본 훅
|
|
// ========================================
|
|
const params = useParams()
|
|
const searchParams = useSearchParams()
|
|
const router = useRouter()
|
|
const cowNo = params.cowNo as string
|
|
const from = searchParams.get('from')
|
|
const { toast } = useToast()
|
|
const { filters } = useFilterStore()
|
|
const isMobile = useMediaQuery("(max-width: 640px)")
|
|
|
|
// ========================================
|
|
// 상태 정의
|
|
// ========================================
|
|
// 1. 개체/유전체 데이터
|
|
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>(() => {
|
|
// 목록에서 진입 시 초기화
|
|
if (from === 'list') return 'genome'
|
|
// 그 외에는 localStorage에서 복원
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem(`cowDetailActiveTab_${cowNo}`)
|
|
return saved || 'genome'
|
|
}
|
|
return 'genome'
|
|
})
|
|
|
|
// 2. 검사 상태
|
|
const [hasGenomeData, setHasGenomeData] = useState(false)
|
|
const [hasGeneData, setHasGeneData] = useState(false)
|
|
const [hasReproductionData, setHasReproductionData] = useState(false)
|
|
const [genomeRequest, setGenomeRequest] = useState<GenomeRequestDto | null>(null)
|
|
|
|
// 3. 선발지수
|
|
const [selectionIndex, setSelectionIndex] = useState<{
|
|
score: number | null;
|
|
percentile: number | null;
|
|
farmRank: number | null;
|
|
farmTotal: number;
|
|
regionRank: number | null;
|
|
regionTotal: number;
|
|
regionName: string | null;
|
|
farmerName: string | null;
|
|
farmAvgScore: number | null;
|
|
regionAvgScore: number | null;
|
|
histogram: { bin: number; count: number; farmCount: number }[];
|
|
} | null>(null)
|
|
|
|
// 4. 분포/비교 데이터
|
|
const [comparisonAverages, setComparisonAverages] = useState<ComparisonAveragesDto | null>(null)
|
|
const [traitComparisonAverages, setTraitComparisonAverages] = useState<TraitComparisonAveragesDto | null>(null)
|
|
const [distributionData, setDistributionData] = useState<{ range: string; count: number; farmCount: number; min: number; max: number }[]>([])
|
|
const [totalCowCount, setTotalCowCount] = useState(0)
|
|
const [, setFarmCowCount] = useState(0)
|
|
const [farmAvgScore, setFarmAvgScore] = useState(0)
|
|
const [regionAvgScore, setRegionAvgScore] = useState(0)
|
|
const [traitComparisons, setTraitComparisons] = useState<TraitComparison[]>([])
|
|
|
|
// 5. UI 상태
|
|
const [showScrollTop, setShowScrollTop] = useState(false)
|
|
const [showAllAppliedTraits, setShowAllAppliedTraits] = useState(false)
|
|
const [isChartModalOpen, setIsChartModalOpen] = useState(false)
|
|
const [showNationwide] = useState(true)
|
|
const [showRegion] = useState(true)
|
|
const [showFarm] = useState(true)
|
|
const [showAllTraits] = useState(false)
|
|
const [selectedTraits, setSelectedTraits] = useState<number[]>([])
|
|
const [highlightMode, setHighlightMode] = useState<'farm' | 'region' | null>(null)
|
|
const distributionChartRef = useRef<HTMLDivElement>(null)
|
|
|
|
// 6. 차트 필터
|
|
const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중'
|
|
const [chartFilterTrait, setChartFilterTrait] = useState<string>(() => {
|
|
return filters.isActive ? 'overall' : firstPinnedTrait
|
|
})
|
|
|
|
// 7. 유전자 탭 필터/정렬
|
|
const [geneSearchInput, setGeneSearchInput] = useState(() => {
|
|
if (typeof window !== 'undefined' && from !== 'list') {
|
|
const saved = localStorage.getItem('geneSearchInput')
|
|
return saved || ''
|
|
}
|
|
return ''
|
|
})
|
|
const [geneSearchKeyword, setGeneSearchKeyword] = useState(() => {
|
|
if (typeof window !== 'undefined' && from !== 'list') {
|
|
const saved = localStorage.getItem('geneSearchKeyword')
|
|
return saved || ''
|
|
}
|
|
return ''
|
|
})
|
|
const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all')
|
|
const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all')
|
|
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
|
|
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
|
|
|
|
// 무한 스크롤 페이지네이션
|
|
const [geneCurrentLoadedPage, setGeneCurrentLoadedPage] = useState(() => {
|
|
if (typeof window !== 'undefined' && from !== 'list') {
|
|
const saved = localStorage.getItem('geneCurrentLoadedPage')
|
|
return saved ? parseInt(saved, 10) : 1
|
|
}
|
|
return 1
|
|
})
|
|
const [genesPerPage, setGenesPerPage] = useState(() => {
|
|
if (typeof window !== 'undefined' && from !== 'list') {
|
|
const saved = localStorage.getItem('genesPerPage')
|
|
return saved ? parseInt(saved, 10) : 50
|
|
}
|
|
return 50
|
|
})
|
|
const [isLoadingMoreGenes, setIsLoadingMoreGenes] = useState(false)
|
|
|
|
// ========================================
|
|
// useEffect - localStorage 저장 (유전자 탭)
|
|
// ========================================
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('geneSearchInput', geneSearchInput)
|
|
}
|
|
}, [geneSearchInput])
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('geneSearchKeyword', geneSearchKeyword)
|
|
}
|
|
}, [geneSearchKeyword])
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('genesPerPage', genesPerPage.toString())
|
|
}
|
|
}, [genesPerPage])
|
|
|
|
// 검색어 또는 genesPerPage 변경 시 1페이지로 리셋
|
|
useEffect(() => {
|
|
setGeneCurrentLoadedPage(1)
|
|
}, [geneSearchKeyword, genesPerPage])
|
|
|
|
// activeTab 변경 시 localStorage 저장 (목록에서 진입 시 제외)
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined' && from !== 'list') {
|
|
localStorage.setItem(`cowDetailActiveTab_${cowNo}`, activeTab)
|
|
}
|
|
}, [activeTab, cowNo, from])
|
|
|
|
// ========================================
|
|
// useEffect - UI 이벤트
|
|
// ========================================
|
|
// 필터 활성 상태 변경 시 차트 기본값 업데이트
|
|
useEffect(() => {
|
|
if (!filters.isActive && chartFilterTrait === 'overall') {
|
|
setChartFilterTrait(firstPinnedTrait)
|
|
}
|
|
}, [filters.isActive, firstPinnedTrait, chartFilterTrait])
|
|
|
|
// 스크롤 투 탑 버튼 표시
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
setShowScrollTop(window.scrollY > 400)
|
|
}
|
|
window.addEventListener('scroll', handleScroll)
|
|
return () => window.removeEventListener('scroll', handleScroll)
|
|
}, [])
|
|
|
|
// 검색어 디바운스 (300ms)
|
|
// 유전자 데이터가 너무 많아서 검색창에 입력할 때마다 모든 데이터를 필터링하지 않고
|
|
// 검색어가 변경된 후 300ms 후에 필터링을 적용
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setGeneSearchKeyword(geneSearchInput)
|
|
setGeneCurrentLoadedPage(1)
|
|
}, 300)
|
|
return () => clearTimeout(timer)
|
|
}, [geneSearchInput])
|
|
|
|
// 유전자 테이블 무한 스크롤: geneCurrentLoadedPage가 변경되면 localStorage에 저장
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('geneCurrentLoadedPage', geneCurrentLoadedPage.toString())
|
|
}
|
|
}, [geneCurrentLoadedPage])
|
|
|
|
// ========================================
|
|
// 헬퍼 함수
|
|
// ========================================
|
|
const scrollToTop = () => {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
}
|
|
|
|
// 부 KPN 배지 렌더링 (분석불가/일치/불일치)
|
|
// 항상 분석 불가 상태를 체크 후에 데이터를 보여줘야함
|
|
const renderSireBadge = (chipSireName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => {
|
|
const sizeClasses = size === 'lg'
|
|
? 'gap-1.5 text-sm px-3 py-1.5'
|
|
: 'gap-1 text-xs px-2 py-1'
|
|
const iconSize = size === 'lg' ? 'w-4 h-4' : 'w-3 h-3'
|
|
|
|
// 분석불가 개체 먼저 체크 (EXCLUDED_COW_IDS 또는 DB에서 '분석불가'/'정보없음'으로 저장된 경우)
|
|
if (isExcludedCow(cow?.cowId) || chipSireName === '분석불가' || chipSireName === '정보없음') {
|
|
return (
|
|
<span className={`flex items-center ${sizeClasses} bg-slate-400 text-white font-semibold rounded-full shrink-0`}>
|
|
<XCircle className={iconSize} />
|
|
<span>분석불가</span>
|
|
</span>
|
|
)
|
|
} else if (chipSireName === '일치') {
|
|
return (
|
|
<span className={`flex items-center ${sizeClasses} bg-primary text-primary-foreground font-semibold rounded-full shrink-0`}>
|
|
<CheckCircle2 className={iconSize} />
|
|
<span>일치</span>
|
|
</span>
|
|
)
|
|
} else if (chipSireName && chipSireName !== '일치') {
|
|
return (
|
|
<span className={`flex items-center ${sizeClasses} bg-red-500 text-white font-semibold rounded-full shrink-0`}>
|
|
<XCircle className={iconSize} />
|
|
<span>불일치</span>
|
|
</span>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
// 모 개체 배지 렌더링 (일치/불일치/이력제부재)
|
|
// 모 불일치일 경우도 유전체 분석결과가 안나옴 체크 후 데이터를 보여줘야함
|
|
const renderDamBadge = (chipDamName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => {
|
|
// 분석불가 개체는 어미 배지 표시 안 함
|
|
if (isExcludedCow(cow?.cowId)) return null
|
|
|
|
const sizeClasses = size === 'lg'
|
|
? 'gap-1.5 text-sm px-3 py-1.5'
|
|
: 'gap-1 text-xs px-2 py-1'
|
|
const iconSize = size === 'lg' ? 'w-4 h-4' : 'w-3 h-3'
|
|
|
|
if (chipDamName === '일치') {
|
|
return (
|
|
<span className={`flex items-center ${sizeClasses} bg-primary text-primary-foreground font-semibold rounded-full shrink-0`}>
|
|
<CheckCircle2 className={iconSize} />
|
|
<span>일치</span>
|
|
</span>
|
|
)
|
|
} else if (chipDamName === '불일치') {
|
|
return (
|
|
<span className={`flex items-center ${sizeClasses} bg-red-500 text-white font-semibold rounded-full shrink-0`}>
|
|
<XCircle className={iconSize} />
|
|
<span>불일치</span>
|
|
</span>
|
|
)
|
|
} else if (chipDamName === '이력제부재') {
|
|
return (
|
|
<span className={`flex items-center ${sizeClasses} bg-amber-500 text-white font-semibold rounded-full shrink-0`}>
|
|
<XCircle className={iconSize} />
|
|
<span>이력제부재</span>
|
|
</span>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
// 유전자 데이터 지연 로드 함수
|
|
const loadGeneData = async () => {
|
|
if (geneDataLoaded || geneDataLoading) return // 이미 로드했거나 로딩 중이면 스킵
|
|
|
|
setGeneDataLoading(true)
|
|
try {
|
|
const geneDataResult = await geneApi.findByCowId(cowNo)
|
|
const geneList = geneDataResult || []
|
|
setGeneData(geneList)
|
|
setGeneDataLoaded(true)
|
|
setHasGeneData(geneList.length > 0)
|
|
} catch (geneErr) {
|
|
console.error('유전자 데이터 조회 실패:', geneErr)
|
|
setGeneData([])
|
|
setGeneDataLoaded(true)
|
|
setHasGeneData(false)
|
|
} finally {
|
|
setGeneDataLoading(false)
|
|
}
|
|
}
|
|
|
|
// 탭 변경 핸들러
|
|
// 유전자 탭이 활성화되면 유전자 데이터 로드
|
|
const handleTabChange = (value: string) => {
|
|
setActiveTab(value)
|
|
if (value === 'gene' && !geneDataLoaded) {
|
|
loadGeneData()
|
|
}
|
|
}
|
|
|
|
const handleBack = () => {
|
|
if (from === 'ranking') {
|
|
router.push('/ranking')
|
|
} else if (from === 'list') {
|
|
router.push('/list')
|
|
} else {
|
|
router.push('/cow')
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 개체 상세 데이터 조회
|
|
// ========================================
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true)
|
|
|
|
// 1. 개체 정보 조회
|
|
const cowData = await cowApi.findOne(cowNo)
|
|
const cowDetail: CowDetail = {
|
|
...cowData,
|
|
age: cowData.cowBirthDt
|
|
? Math.floor((new Date().getTime() - new Date(cowData.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 365))
|
|
: undefined,
|
|
}
|
|
setCow(cowDetail)
|
|
if (cowData.dataStatus) {
|
|
setHasGeneData(cowData.dataStatus.hasGeneData)
|
|
}
|
|
|
|
// 2. 유전체 데이터 조회
|
|
const genomeDataResult = await genomeApi.findByCowNo(cowNo)
|
|
setGenomeData(genomeDataResult)
|
|
const genomeExists = genomeDataResult.length > 0 && !!genomeDataResult[0].genomeCows && genomeDataResult[0].genomeCows.length > 0
|
|
setHasGenomeData(genomeExists)
|
|
|
|
// 3. 분석 의뢰 정보 조회 (친자감별 결과)
|
|
try {
|
|
const requestData = await genomeApi.getRequest(cowNo)
|
|
setGenomeRequest(requestData)
|
|
} catch (reqErr) {
|
|
console.error('분석 의뢰 정보 조회 실패:', reqErr)
|
|
setGenomeRequest(null)
|
|
}
|
|
|
|
// 4. 번식능력(MPT) 데이터 조회
|
|
try {
|
|
const mptData = await mptApi.findByCowId(cowNo)
|
|
setHasReproductionData(mptData && mptData.length > 0)
|
|
} catch {
|
|
setHasReproductionData(false)
|
|
}
|
|
|
|
// 5. 탭 자동 선택 (목록에서 진입하거나 저장된 탭이 없을 때만)
|
|
if (from === 'list' || (typeof window !== 'undefined' && !localStorage.getItem(`cowDetailActiveTab_${cowNo}`))) {
|
|
if (genomeExists) {
|
|
setActiveTab('genome')
|
|
} else if (geneData && geneData.length > 0) {
|
|
setActiveTab('gene')
|
|
}
|
|
}
|
|
|
|
// 6. 비교 데이터 + 선발지수 조회
|
|
if (genomeDataResult.length > 0) {
|
|
try {
|
|
const comparisonData = await genomeApi.getComparisonAverages(cowNo)
|
|
setComparisonAverages(comparisonData)
|
|
|
|
const traitComparisonData = await genomeApi.getTraitComparisonAverages(cowNo)
|
|
setTraitComparisonAverages(traitComparisonData)
|
|
|
|
// 선발지수 계산
|
|
const traitConditions = Object.entries(filters.traitWeights as Record<string, number>)
|
|
.filter(([, weight]) => weight > 0)
|
|
.map(([traitNm, weight]) => ({ traitNm, weight }))
|
|
|
|
const finalConditions = filters.isActive && traitConditions.length > 0
|
|
? traitConditions
|
|
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }))
|
|
|
|
const indexResult = await genomeApi.getSelectionIndex(cowNo, finalConditions)
|
|
setSelectionIndex(indexResult)
|
|
} catch (compErr) {
|
|
console.error('비교 데이터 조회 실패:', compErr)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('데이터 조회 실패:', err)
|
|
toast({
|
|
variant: "destructive",
|
|
title: "데이터 로드 실패",
|
|
description: "개체 정보를 불러올 수 없습니다",
|
|
})
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
fetchData()
|
|
}, [cowNo, toast, filters.isActive, filters.selectedTraits, filters.traitWeights])
|
|
|
|
// ========================================
|
|
// 계산된 데이터 (useMemo)
|
|
// - 의존성 변경 시에만 재계산하여 성능 최적화
|
|
// ========================================
|
|
|
|
// API 응답 형질 데이터 (변환 없이 직접 사용)
|
|
const GENOMIC_TRAITS = useMemo(() => genomeData[0]?.genomeCows || [], [genomeData])
|
|
|
|
// 형질 카테고리 목록 (성장/생산/체형/무게/비율)
|
|
const CATEGORIES = useMemo(() => [...new Set(GENOMIC_TRAITS.map(t => t.traitCategory).filter((cat): cat is string => !!cat))], [GENOMIC_TRAITS])
|
|
|
|
// 종합 선발지수 점수 (API 값 우선, 없으면 형질 평균)
|
|
const overallScore = useMemo(() => {
|
|
if (selectionIndex?.score !== null && selectionIndex?.score !== undefined) {
|
|
return selectionIndex.score
|
|
}
|
|
if (GENOMIC_TRAITS.length === 0) return 0
|
|
return GENOMIC_TRAITS.reduce((sum, t) => sum + (t.breedVal || 0), 0) / GENOMIC_TRAITS.length
|
|
}, [GENOMIC_TRAITS, selectionIndex])
|
|
|
|
// 종합 백분위 (API 값 우선, 없으면 CDF 계산)
|
|
const overallPercentile = useMemo(() => {
|
|
if (selectionIndex?.percentile !== null && selectionIndex?.percentile !== undefined) {
|
|
return selectionIndex.percentile
|
|
}
|
|
if (overallScore !== 0) {
|
|
const cdf = 1 / (1 + Math.exp(-1.702 * overallScore))
|
|
return (1 - cdf) * 100
|
|
}
|
|
if (GENOMIC_TRAITS.length === 0) return 50
|
|
return GENOMIC_TRAITS.reduce((sum, t) => sum + (t.percentile || 0), 0) / GENOMIC_TRAITS.length
|
|
}, [GENOMIC_TRAITS, selectionIndex, overallScore])
|
|
|
|
// 카테고리별 평균 육종가/백분위 (카드 표시용)
|
|
const categoryStats = useMemo(() => {
|
|
return CATEGORIES.map(cat => {
|
|
const traits = GENOMIC_TRAITS.filter(t => t.traitCategory === cat)
|
|
const avgBreedVal = traits.reduce((sum, t) => sum + (t.breedVal || 0), 0) / traits.length
|
|
const avgPercentile = traits.reduce((sum, t) => sum + (t.percentile || 0), 0) / traits.length
|
|
return { category: cat, avgBreedVal, avgPercentile, count: traits.length }
|
|
})
|
|
}, [CATEGORIES, GENOMIC_TRAITS])
|
|
|
|
// 농가 평균 Z-Score (정규분포 차트용)
|
|
const farmAvgZ = useMemo(() => {
|
|
if (!comparisonAverages?.farm || comparisonAverages.farm.length === 0) {
|
|
return overallScore > 0.5 ? overallScore * 0.5 : 0.3
|
|
}
|
|
return comparisonAverages.farm.reduce((sum, cat) => sum + cat.avgEbv, 0) / comparisonAverages.farm.length
|
|
}, [comparisonAverages, overallScore])
|
|
|
|
// 지역 평균 Z-Score (정규분포 차트용)
|
|
const regionAvgZ = useMemo(() => {
|
|
if (!comparisonAverages?.region || comparisonAverages.region.length === 0) return -0.2
|
|
return comparisonAverages.region.reduce((sum, cat) => sum + cat.avgEbv, 0) / comparisonAverages.region.length
|
|
}, [comparisonAverages])
|
|
|
|
// 개체 EPD 평균 (필터 선택 형질 기준)
|
|
const cowAvgEpd = useMemo(() => {
|
|
const selectedTraitNames = Object.entries(filters.traitWeights)
|
|
.filter(([, weight]) => weight > 0)
|
|
.map(([traitNm]) => traitNm)
|
|
const targetTraits = selectedTraitNames.length > 0
|
|
? GENOMIC_TRAITS.filter(t => selectedTraitNames.includes(t.traitName || ''))
|
|
: GENOMIC_TRAITS
|
|
if (targetTraits.length === 0) return null
|
|
return targetTraits.reduce((sum, t) => sum + (t.traitVal || 0), 0) / targetTraits.length
|
|
}, [GENOMIC_TRAITS, filters.traitWeights])
|
|
|
|
// 농가 EPD 평균
|
|
const farmAvgEpdValue = useMemo(() => {
|
|
if (!comparisonAverages?.farm || comparisonAverages.farm.length === 0) return null
|
|
return comparisonAverages.farm.reduce((sum, cat) => sum + (cat.avgEpd || 0), 0) / comparisonAverages.farm.length
|
|
}, [comparisonAverages])
|
|
|
|
// 지역 EPD 평균
|
|
const regionAvgEpdValue = useMemo(() => {
|
|
if (!comparisonAverages?.region || comparisonAverages.region.length === 0) return null
|
|
return comparisonAverages.region.reduce((sum, cat) => sum + (cat.avgEpd || 0), 0) / comparisonAverages.region.length
|
|
}, [comparisonAverages])
|
|
|
|
// 전역 필터에서 선택한 형질 데이터
|
|
const filterSelectedTraitData = useMemo(() => {
|
|
const selectedTraitNames = Object.entries(filters.traitWeights)
|
|
.filter(([, weight]) => weight > 0)
|
|
.map(([traitNm]) => traitNm)
|
|
if (selectedTraitNames.length === 0) return []
|
|
return GENOMIC_TRAITS.filter(t => selectedTraitNames.includes(t.traitName || ''))
|
|
}, [filters.traitWeights, GENOMIC_TRAITS])
|
|
|
|
// 정규분포 곡선 데이터 (전국/지역/농가 비교 차트)
|
|
const multiDistribution = useMemo(() => generateMultipleDistributions(0, 1, regionAvgZ, 1, farmAvgZ, 1), [regionAvgZ, farmAvgZ])
|
|
|
|
// 유전자 데이터 필터링 및 정렬 (useMemo로 최상위에서 관리)
|
|
const filteredAndSortedGeneData = useMemo(() => {
|
|
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()
|
|
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
|
|
}
|
|
}
|
|
// 유전자형 필터
|
|
if (genotypeFilter !== 'all') {
|
|
const isHomozygous = gene.allele1 === gene.allele2
|
|
if (genotypeFilter === 'homozygous' && !isHomozygous) return false
|
|
if (genotypeFilter === 'heterozygous' && isHomozygous) return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
// 정렬
|
|
return [...filteredData].sort((a, b) => {
|
|
let aVal: string | number = ''
|
|
let bVal: string | number = ''
|
|
|
|
switch (geneSortBy) {
|
|
case 'snpName':
|
|
aVal = a.snpName || ''
|
|
bVal = b.snpName || ''
|
|
break
|
|
case 'chromosome':
|
|
aVal = parseInt(a.chromosome || '0') || 0
|
|
bVal = parseInt(b.chromosome || '0') || 0
|
|
break
|
|
case 'position':
|
|
aVal = parseInt(a.position || '0') || 0
|
|
bVal = parseInt(b.position || '0') || 0
|
|
break
|
|
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
|
|
}
|
|
|
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal
|
|
}
|
|
|
|
const strA = String(aVal)
|
|
const strB = String(bVal)
|
|
return geneSortOrder === 'asc'
|
|
? strA.localeCompare(strB)
|
|
: strB.localeCompare(strA)
|
|
})
|
|
}, [geneData, geneSearchKeyword, genotypeFilter, geneSortBy, geneSortOrder])
|
|
|
|
const toggleTraitSelection = (traitId: number) => {
|
|
setSelectedTraits(prev =>
|
|
prev.includes(traitId)
|
|
? prev.filter(id => id !== traitId)
|
|
: [...prev, traitId]
|
|
)
|
|
}
|
|
|
|
// 유전자 테이블 스크롤 핸들러 (간단하게 함수로만 정의)
|
|
const handleGeneTableScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
const target = e.currentTarget
|
|
const { scrollTop, scrollHeight, clientHeight } = target
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100
|
|
|
|
if (isNearBottom && !isLoadingMoreGenes) {
|
|
const totalPages = Math.ceil(filteredAndSortedGeneData.length / genesPerPage)
|
|
if (geneCurrentLoadedPage < totalPages) {
|
|
setIsLoadingMoreGenes(true)
|
|
setTimeout(() => {
|
|
setGeneCurrentLoadedPage(prev => prev + 1)
|
|
setIsLoadingMoreGenes(false)
|
|
}, 300)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<SidebarProvider>
|
|
<AppSidebar />
|
|
<SidebarInset>
|
|
<SiteHeader />
|
|
<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>
|
|
</SidebarInset>
|
|
</SidebarProvider>
|
|
)
|
|
}
|
|
|
|
// 본문시작 ====================================================================================================
|
|
return (
|
|
<AuthGuard>
|
|
<SidebarProvider>
|
|
<AppSidebar />
|
|
<SidebarInset>
|
|
<SiteHeader />
|
|
<main className="flex-1 overflow-y-auto bg-white">
|
|
{/* 메인 컨테이너 여백 : p-6 */}
|
|
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6" style={{ paddingBottom: '0px' }}>
|
|
{/* 헤더: 뒤로가기 + 타이틀 + 다운로드 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 sm:gap-4">
|
|
{/* 뒤로가기 버튼 */}
|
|
<Button
|
|
onClick={handleBack}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground hover:bg-muted gap-1.5 -ml-2 px-2 sm:px-3"
|
|
>
|
|
<ArrowLeft className="h-7 w-7 sm:h-4 sm:w-4" />
|
|
<span className="hidden sm:inline text-sm">목록으로</span>
|
|
</Button>
|
|
{/* 아이콘 + 타이틀 (클릭시 새로고침) */}
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="flex items-center gap-3 sm:gap-4 hover:opacity-80 transition-opacity cursor-pointer"
|
|
>
|
|
<div className="w-10 h-10 sm:w-14 sm:h-14 bg-primary rounded-xl flex items-center justify-center flex-shrink-0">
|
|
<BarChart3 className="h-5 w-5 sm:h-7 sm:w-7 text-primary-foreground" />
|
|
</div>
|
|
<div className="text-left">
|
|
<h1 className="text-lg sm:text-3xl lg:text-4xl font-bold text-foreground">개체 분석 보고서</h1>
|
|
<p className="text-sm sm:text-lg text-muted-foreground">Analysis Report</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 네비게이션 */}
|
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
|
<TabsList className="tabs_nav_area w-full grid grid-cols-3 bg-transparent p-0 h-auto gap-0 rounded-none border-b border-border">
|
|
<TabsTrigger
|
|
value="genome"
|
|
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
|
|
>
|
|
<BarChart3 className="hidden sm:block h-6 w-6 shrink-0" />
|
|
<span className="font-bold text-sm lg:!text-[1.5rem]">유전체</span>
|
|
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
|
{hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? '완료' : '미검사'}
|
|
</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="gene"
|
|
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
|
|
>
|
|
<Dna className="hidden sm:block h-6 w-6 shrink-0" />
|
|
<span className="font-bold text-sm lg:!text-[1.5rem]">유전자</span>
|
|
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
|
{hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? '완료' : '미검사'}
|
|
</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="reproduction"
|
|
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
|
|
>
|
|
<Activity className="hidden sm:block h-6 w-6 shrink-0" />
|
|
<span className="font-bold text-sm lg:!text-[1.5rem]">번식능력</span>
|
|
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasReproductionData ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
|
{hasReproductionData ? '완료' : '미검사'}
|
|
</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 탭 콘텐츠 영역 */}
|
|
<div className="tab_contents_area h-[calc(100vh-215px)] sm:h-[calc(100vh-260px)] lg:h-[calc(100vh-275px)] overflow-y-auto">
|
|
{/* 유전체 분석 탭 */}
|
|
<TabsContent value="genome" className="mt-6 space-y-6">
|
|
{hasGenomeData ? (
|
|
<>
|
|
{/* 개체 정보 섹션 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">개체 정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">생년월일</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">월령 (분석일 기준)</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
|
|
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">유전체 분석일자</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{genomeData[0]?.request?.requestDt
|
|
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 좌우 배치 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">생년월일</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">월령 (분석)</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
|
|
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">분석일자</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{genomeData[0]?.request?.requestDt
|
|
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 친자확인 섹션 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">혈통정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">부 KPN번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
<span className="text-2xl font-bold text-foreground break-all">
|
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
|
</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">모 개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-2xl font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 좌우 배치 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN번호</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
<span className="text-base font-bold text-foreground break-all">
|
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
|
</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">모 개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-base font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 친자확인 결과에 따른 분기 (유효성 조건: 아비일치 + 어미불일치/이력제부재 제외 + 제외목록) */}
|
|
{isValidGenomeAnalysis(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) ? (
|
|
<>
|
|
{/* 농가 및 보은군 내 개체 위치 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">농가 및 보은군 내 개체 위치</h3>
|
|
<div ref={distributionChartRef}>
|
|
<NormalDistributionChart
|
|
multiDistribution={multiDistribution}
|
|
cowName={cow?.cowId || cowNo}
|
|
cowNo={cow?.cowId || cowNo}
|
|
overallScore={overallScore}
|
|
overallPercentile={overallPercentile}
|
|
regionAvgZ={selectionIndex?.regionAvgScore ?? regionAvgScore}
|
|
farmAvgZ={selectionIndex?.farmAvgScore ?? farmAvgScore}
|
|
showNationwide={showNationwide}
|
|
showRegion={showRegion}
|
|
showFarm={showFarm}
|
|
allTraits={GENOMIC_TRAITS}
|
|
selectedTraitData={filterSelectedTraitData}
|
|
selectedTraitsCount={filterSelectedTraitData.length}
|
|
showAllTraits={showAllTraits}
|
|
traitWeights={filters.traitWeights}
|
|
onOpenTraitSheet={() => { }}
|
|
onToggleTraitSelection={toggleTraitSelection}
|
|
onClearSelectedTraits={() => setSelectedTraits([])}
|
|
onOpenChartModal={() => setIsChartModalOpen(true)}
|
|
distributionData={distributionData}
|
|
totalCowCount={totalCowCount}
|
|
traitComparisons={traitComparisons}
|
|
cowEpd={cowAvgEpd}
|
|
farmAvgEpd={farmAvgEpdValue}
|
|
regionAvgEpd={regionAvgEpdValue}
|
|
farmRank={selectionIndex?.farmRank}
|
|
farmTotal={selectionIndex?.farmTotal}
|
|
regionRank={selectionIndex?.regionRank}
|
|
highlightMode={highlightMode}
|
|
onHighlightModeChange={setHighlightMode}
|
|
selectionIndexHistogram={selectionIndex?.histogram || []}
|
|
regionTotal={selectionIndex?.regionTotal}
|
|
chartFilterTrait={chartFilterTrait}
|
|
onChartFilterTraitChange={setChartFilterTrait}
|
|
/>
|
|
</div>
|
|
|
|
{/* 유전체 형질별 육종가 비교 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">유전체 형질별 육종가 비교</h3>
|
|
<CategoryEvaluationCard
|
|
categoryStats={categoryStats}
|
|
comparisonAverages={comparisonAverages}
|
|
traitComparisonAverages={traitComparisonAverages}
|
|
regionAvgZ={regionAvgZ}
|
|
farmAvgZ={farmAvgZ}
|
|
allTraits={GENOMIC_TRAITS}
|
|
cowNo={cowNo}
|
|
hideTraitCards={true}
|
|
/>
|
|
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground mt-6">선택 형질 상세</h3>
|
|
|
|
<TraitDistributionCharts
|
|
allTraits={GENOMIC_TRAITS}
|
|
regionAvgZ={regionAvgZ}
|
|
farmAvgZ={farmAvgZ}
|
|
cowName={cow?.cowId || cowNo}
|
|
cowNo={cow?.cowId || cowNo}
|
|
totalCowCount={totalCowCount}
|
|
selectedTraits={filterSelectedTraitData}
|
|
traitWeights={filters.traitWeights}
|
|
/>
|
|
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground mt-6">분석 정보</h3>
|
|
<div className="analysis_info_notice bg-blue-50 border border-blue-200 rounded-xl p-4 sm:p-5 text-sm sm:text-base text-foreground leading-relaxed">
|
|
<p>본 유전체 분석 결과는 국가단위 '한우암소 유전체 분석 서비스'에서 제공하는 자료입니다.</p>
|
|
<p>농림축산식품부-국립축산과학원-농협한우개량사업소-도축산연구소는 협력 체계를 구축하여 농가 암소의 유전체 유전능력을 조기에 분석하여 개량에 활용할 수 있도록 서비스 하고 있습니다.</p>
|
|
<p>암소의 유전체 유전능력은 국가단위 보증씨수소 유전능력 평가결과를 활용하여 6개월 단위로 자료를 갱신하고 있으며, 이번 평가결과는 '25.8.1. ~ '26.1.31.까지 유효합니다.</p>
|
|
<p>씨수소 참조집단의 유전체(SNP) 분석칩과 암소의 능력 계산에 이용하는 암소의 유전체 분석칩이 다를 경우, 암소의 형질별 유전체 육종가 값이 일부 차이가 날 수 있습니다.</p>
|
|
</div>
|
|
<Card className="bg-white border border-border rounded-xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-border">
|
|
<div className="p-3 sm:p-4 flex justify-between sm:block">
|
|
<div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1">접수일</div>
|
|
<div className="text-[1.3rem] font-semibold text-foreground">
|
|
{genomeData[0]?.request?.requestDt
|
|
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</div>
|
|
</div>
|
|
<div className="p-3 sm:p-4 flex justify-between sm:block">
|
|
<div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1">분석 완료일</div>
|
|
<div className="text-[1.3rem] font-semibold text-foreground">
|
|
{genomeData[0]?.request?.chipReportDt
|
|
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</div>
|
|
</div>
|
|
<div className="p-3 sm:p-4 flex justify-between sm:block">
|
|
<div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1">칩 종류</div>
|
|
<div className="text-[1.3rem] font-semibold text-foreground">
|
|
{genomeData[0]?.request?.chipType || '-'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">유전체 분석 결과</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">
|
|
{getInvalidReason(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) || '분석 불가'}
|
|
</span>
|
|
</div>
|
|
<div className="px-5 py-6">
|
|
<p className="text-base text-muted-foreground mb-5">
|
|
{getInvalidMessage(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId)}
|
|
</p>
|
|
<div className="bg-slate-50 border border-slate-200 rounded-xl p-5">
|
|
<h4 className="text-base font-semibold text-foreground mb-3">안내사항</h4>
|
|
<ul className="text-base text-muted-foreground space-y-2">
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-slate-400 mt-1">•</span>
|
|
<span>유전체 분석 보고서는 친자확인이 완료된 개체에 한해 제공됩니다.</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-slate-400 mt-1">•</span>
|
|
<span>정확한 분석을 위해 재검사 또는 KPN 정보 확인이 필요합니다.</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* 개체 정보 섹션 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">개체 정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">생년월일</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">월령 (분석일 기준)</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
|
|
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">유전체 분석일자</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">-</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 좌우 배치 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">생년월일</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">월령 (분석)</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
|
|
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">분석일자</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">-</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 친자확인 섹션 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">혈통정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">부 KPN번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">모 개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-2xl font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 세로 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
<span className="text-base font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">모 개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-base font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 분석불가 메시지 */}
|
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전체 분석 결과</h3>
|
|
<Card className="bg-slate-100 border border-slate-300 rounded-2xl">
|
|
<CardContent className="p-8 text-center">
|
|
<BarChart3 className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
|
{genomeRequest ? '유전체 분석 불가' : '유전체 분석불가'}
|
|
</h3>
|
|
<p className="text-sm text-slate-500">
|
|
{genomeRequest
|
|
? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId)
|
|
: '이 개체는 아직 유전체 분석이 진행되지 않았습니다.'
|
|
}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 유전자 분석 탭 */}
|
|
<TabsContent value="gene" className="mt-6 space-y-6">
|
|
{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-[1.5rem] font-bold text-foreground">개체 정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">생년월일</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">월령 (분석일 기준)</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
|
|
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">유전자 분석일자</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{genomeData[0]?.request?.requestDt
|
|
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 좌우 배치 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">생년월일</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">월령 (분석)</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
|
|
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">분석일자</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{genomeData[0]?.request?.requestDt
|
|
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 친자확인 결과 섹션 (유전체 탭과 동일) */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">혈통정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">부 KPN번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
<span className="text-2xl font-bold text-foreground break-all">
|
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
|
</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">모 개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-2xl font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 좌우 배치 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN번호</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
<span className="text-base font-bold text-foreground break-all">
|
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
|
</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">모 개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-base font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 유전자 검색 및 필터 섹션 */}
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">유전자 분석 결과</h3>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={genesPerPage.toString()} onValueChange={(value) => {
|
|
setGenesPerPage(parseInt(value, 10))
|
|
setGeneCurrentLoadedPage(1)
|
|
}}>
|
|
<SelectTrigger className="w-[90px] h-9 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="50">50개</SelectItem>
|
|
<SelectItem value="100">100개</SelectItem>
|
|
<SelectItem value="1000">1000개</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 유전자 탭 분기: 분석불가/정보없음만 차단, 불일치/이력제부재는 유전자 데이터 표시 */}
|
|
{!(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? (
|
|
<>
|
|
<div className="flex flex-col gap-3 sm:gap-3 p-3.5 max-sm:p-3 sm:px-4 sm:py-3 rounded-xl bg-slate-50/50 border border-slate-200/50">
|
|
{/* 검색창 */}
|
|
<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 이름, 염색체 검색..."
|
|
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={geneSearchInput}
|
|
onChange={(e) => setGeneSearchInput(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* 필터 옵션들 */}
|
|
{/* <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3"> */}
|
|
{/* 유전자 타입 필터 */}
|
|
{/* <div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-slate-600 shrink-0">구분:</span>
|
|
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
|
<button
|
|
onClick={() => setGeneTypeFilter('all')}
|
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${geneTypeFilter === 'all'
|
|
? 'bg-white text-slate-900 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
전체
|
|
</button>
|
|
<button
|
|
onClick={() => setGeneTypeFilter('QTY')}
|
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${geneTypeFilter === 'QTY'
|
|
? 'bg-white text-slate-900 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
육량형
|
|
</button>
|
|
<button
|
|
onClick={() => setGeneTypeFilter('QLT')}
|
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${geneTypeFilter === 'QLT'
|
|
? 'bg-white text-slate-900 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
육질형
|
|
</button>
|
|
</div>
|
|
</div> */}
|
|
|
|
{/* 정렬 드롭다운 */}
|
|
{/* <div className="flex items-center gap-2 sm:ml-auto">
|
|
<Select
|
|
value={geneSortBy}
|
|
onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks') => setGeneSortBy(value)}
|
|
>
|
|
<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">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-[110px] h-9 text-sm border-slate-200 bg-white">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="asc">오름차순</SelectItem>
|
|
<SelectItem value="desc">내림차순</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div> */}
|
|
{/* </div> */}
|
|
</div>
|
|
|
|
{/* 유전자 테이블/카드 */}
|
|
{(() => {
|
|
// 무한 스크롤 계산
|
|
const totalItems = geneCurrentLoadedPage * genesPerPage
|
|
const displayData = filteredAndSortedGeneData.length > 0
|
|
? filteredAndSortedGeneData.slice(0, totalItems)
|
|
: []
|
|
|
|
return (
|
|
<>
|
|
{/* 데스크톱: 테이블 */}
|
|
<div className="hidden lg:block mb-0">
|
|
<Card className="snp_result_table bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
<div onScroll={handleGeneTableScroll} className="overflow-y-auto" style={{ height: 'calc(100vh - 470px)', maxHeight: 'calc(100vh - 420px)' }}>
|
|
<table className="w-full table-fixed text-[1.5rem]">
|
|
<thead className="bg-slate-50 border-b border-border sticky top-0 z-1">
|
|
<tr>
|
|
<th className="px-4 py-3 text-center font-semibold text-muted-foreground w-[22%]">SNP 이름</th>
|
|
<th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[10%]">염색체 위치</th>
|
|
<th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[11%]">Position</th>
|
|
<th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[11%]">SNP 구분</th>
|
|
<th className="px-2 py-3 text-center font-semibold text-muted-foreground w-[13%]">첫번째 대립유전자</th>
|
|
<th className="px-2 py-3 text-center font-semibold text-muted-foreground w-[13%]">두번째 대립유전자</th>
|
|
<th className="px-4 py-3 text-center font-semibold text-muted-foreground w-[20%]">설명</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{displayData.map((gene, idx) => (
|
|
<tr key={idx} className="hover:bg-muted/30">
|
|
<td className="px-4 py-3 text-center font-medium text-foreground">{gene.snpName || '-'}</td>
|
|
<td className="px-3 py-3 text-center text-foreground">{gene.chromosome || '-'}</td>
|
|
<td className="px-3 py-3 text-center text-foreground">{gene.position || '-'}</td>
|
|
<td className="px-3 py-3 text-center text-foreground">{gene.snpType || '-'}</td>
|
|
<td className="px-2 py-3 text-center text-foreground">{gene.allele1 || '-'}</td>
|
|
<td className="px-2 py-3 text-center text-foreground">{gene.allele2 || '-'}</td>
|
|
<td className="px-4 py-3 text-center text-muted-foreground">{gene.remarks || '-'}</td>
|
|
</tr>
|
|
))}
|
|
{isLoadingMoreGenes && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-3 text-center text-sm text-muted-foreground">
|
|
로딩 중...
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
{/* 현황 정보 표시 */}
|
|
<div className="flex items-center justify-center py-4 border-t">
|
|
<span className="text-base font-bold text-muted-foreground">
|
|
{filteredAndSortedGeneData.length > 0 ? (
|
|
<>
|
|
전체 {filteredAndSortedGeneData.length.toLocaleString()}개 중 1-{displayData.length.toLocaleString()}번째
|
|
{isLoadingMoreGenes && ' (로딩 중...)'}
|
|
</>
|
|
) : (
|
|
'데이터 없음'
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모바일: 카드 뷰 */}
|
|
<div className="lg:hidden">
|
|
<div onScroll={handleGeneTableScroll} className="space-y-3 overflow-y-auto" style={{ height: 'calc(100vh - 470px)', maxHeight: 'calc(100vh - 420px)' }}>
|
|
{displayData.map((gene, idx) => (
|
|
<Card key={idx} className="bg-white border border-border shadow-sm rounded-xl">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">SNP 이름</span>
|
|
<span className="text-base font-semibold text-foreground">{gene?.snpName || '-'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">염색체 위치</span>
|
|
<span className="text-base text-foreground">{gene?.chromosome || '-'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">Position</span>
|
|
<span className="text-base text-foreground">{gene?.position || '-'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">SNP 구분</span>
|
|
<span className="text-base text-foreground">{gene?.snpType || '-'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">첫번째 대립유전자</span>
|
|
<span className="text-base text-foreground">{gene?.allele1 || '-'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">두번째 대립유전자</span>
|
|
<span className="text-base text-foreground">{gene?.allele2 || '-'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">설명</span>
|
|
<span className="text-base text-muted-foreground">{gene?.remarks || '-'}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
{isLoadingMoreGenes && (
|
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
로딩 중...
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* 현황 정보 표시 */}
|
|
<div className="flex items-center justify-center py-4 border-t">
|
|
<span className="text-sm font-bold text-muted-foreground">
|
|
{filteredAndSortedGeneData.length > 0 ? (
|
|
<>
|
|
전체 {filteredAndSortedGeneData.length.toLocaleString()}개 중 1-{displayData.length.toLocaleString()}번째
|
|
{isLoadingMoreGenes && ' (로딩 중...)'}
|
|
</>
|
|
) : (
|
|
'데이터 없음'
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
})()}
|
|
</>
|
|
) : (
|
|
<Card className="bg-slate-100 border border-slate-300 rounded-2xl">
|
|
<CardContent className="p-8 text-center">
|
|
<Dna className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
|
{getInvalidReason(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) || '유전자 분석 불가'}
|
|
</h3>
|
|
<p className="text-sm text-slate-500">
|
|
{getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId).replace('유전체', '유전자')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* 개체 정보 섹션 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">개체 정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">생년월일</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">월령 (분석일 기준)</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">-</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">분석일자</span>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<span className="text-2xl font-bold text-foreground">-</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 좌우 배치 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5">
|
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">생년월일</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">월령 (분석)</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
|
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
|
|
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
|
: '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">분석일자</span>
|
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">-</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 혈통정보 섹션 */}
|
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">혈통정보</h3>
|
|
|
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{/* 데스크탑: 가로 그리드 */}
|
|
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">부 KPN번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
|
<span className="text-base font-semibold text-muted-foreground">모 개체번호</span>
|
|
</div>
|
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-2xl font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 모바일: 세로 리스트 */}
|
|
<div className="lg:hidden divide-y divide-border">
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
<span className="text-base font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">모 개체번호</span>
|
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
|
|
) : (
|
|
<span className="text-base font-bold text-foreground">-</span>
|
|
)}
|
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 유전자 분석 결과 섹션 */}
|
|
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">유전자 분석 결과</h3>
|
|
<Card className="bg-slate-100 border border-slate-300 rounded-2xl">
|
|
<CardContent className="p-8 text-center">
|
|
<Dna className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
|
{genomeRequest ? '유전자 분석 불가' : '유전자 분석불가'}
|
|
</h3>
|
|
<p className="text-sm text-slate-500">
|
|
{genomeRequest
|
|
? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId).replace('유전체', '유전자')
|
|
: '이 개체는 아직 유전자(SNP) 분석이 진행되지 않았습니다.'
|
|
}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 번식능력 탭 */}
|
|
<TabsContent value="reproduction" className="mt-6 space-y-6">
|
|
{/* 혈액화학검사(MPT) 테이블 */}
|
|
<MptTable cowShortNo={cowNo?.slice(-4)} cowNo={cowNo} farmNo={cow?.fkFarmNo} cow={cow} genomeRequest={genomeRequest} />
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
</main>
|
|
</SidebarInset>
|
|
|
|
{/* 차트 전체화면 모달 */}
|
|
<Dialog open={isChartModalOpen} onOpenChange={(open) => setIsChartModalOpen(open ?? false)}>
|
|
<DialogContent className="!max-w-[95vw] !w-[95vw] !h-[90vh] !max-h-[90vh] p-0 flex flex-col" showCloseButton={false}>
|
|
{/* 모달 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-white shrink-0">
|
|
<DialogTitle className="text-lg font-bold">개체 분포 위치</DialogTitle>
|
|
<button
|
|
onClick={() => setIsChartModalOpen(false)}
|
|
className="p-2 hover:bg-muted rounded-lg transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
{/* 모달 콘텐츠 - 차트 */}
|
|
<div className="flex-1 overflow-y-auto p-4 bg-muted/20">
|
|
<NormalDistributionChart
|
|
multiDistribution={multiDistribution}
|
|
cowName={cow?.cowId || cowNo}
|
|
cowNo={cow?.cowId || cowNo}
|
|
overallScore={overallScore}
|
|
overallPercentile={overallPercentile}
|
|
regionAvgZ={selectionIndex?.regionAvgScore ?? regionAvgScore}
|
|
farmAvgZ={selectionIndex?.farmAvgScore ?? farmAvgScore}
|
|
showNationwide={showNationwide}
|
|
showRegion={showRegion}
|
|
showFarm={showFarm}
|
|
allTraits={GENOMIC_TRAITS}
|
|
selectedTraitData={filterSelectedTraitData}
|
|
selectedTraitsCount={filterSelectedTraitData.length}
|
|
showAllTraits={showAllTraits}
|
|
traitWeights={filters.traitWeights}
|
|
onOpenTraitSheet={() => { }}
|
|
onToggleTraitSelection={toggleTraitSelection}
|
|
onClearSelectedTraits={() => setSelectedTraits([])}
|
|
onOpenChartModal={() => { }}
|
|
distributionData={distributionData}
|
|
totalCowCount={totalCowCount}
|
|
traitComparisons={traitComparisons}
|
|
cowEpd={cowAvgEpd}
|
|
farmAvgEpd={farmAvgEpdValue}
|
|
regionAvgEpd={regionAvgEpdValue}
|
|
farmRank={selectionIndex?.farmRank}
|
|
farmTotal={selectionIndex?.farmTotal}
|
|
regionRank={selectionIndex?.regionRank}
|
|
regionTotal={selectionIndex?.regionTotal}
|
|
highlightMode={highlightMode}
|
|
selectionIndexHistogram={selectionIndex?.histogram || []}
|
|
onHighlightModeChange={setHighlightMode}
|
|
chartFilterTrait={chartFilterTrait}
|
|
onChartFilterTraitChange={setChartFilterTrait}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 플로팅 맨 위로 버튼 - 글래스모피즘 */}
|
|
{showScrollTop && (
|
|
<button
|
|
onClick={scrollToTop}
|
|
className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-50 w-10 h-10 sm:w-12 sm:h-12 bg-white/80 backdrop-blur-md border border-white/50 text-slate-700 rounded-full shadow-lg hover:bg-white/90 transition-all duration-300 flex items-center justify-center hover:scale-110 active:scale-95"
|
|
aria-label="맨 위로"
|
|
>
|
|
<ChevronUp className="w-5 h-5 sm:w-6 sm:h-6" />
|
|
</button>
|
|
)}
|
|
</SidebarProvider>
|
|
</AuthGuard>
|
|
)
|
|
}
|