1924 lines
109 KiB
TypeScript
1924 lines
109 KiB
TypeScript
'use client'
|
||
|
||
import { useSearchParams, useParams, useRouter } from "next/navigation"
|
||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||
import { SiteHeader } from "@/components/layout/site-header"
|
||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
import { useToast } from "@/hooks/use-toast"
|
||
import { useMediaQuery } from "@/hooks/use-media-query"
|
||
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail, GenomeRequestDto, mptApi, MptDto } from "@/lib/api"
|
||
import { CowDetail } from "@/types/cow.types"
|
||
import { GenomeTrait } from "@/types/genome.types"
|
||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||
import {
|
||
ArrowLeft,
|
||
BarChart3,
|
||
CheckCircle2,
|
||
Download,
|
||
Dna,
|
||
Activity,
|
||
X,
|
||
XCircle,
|
||
Search,
|
||
} from 'lucide-react'
|
||
import { Input } from "@/components/ui/input"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
|
||
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
|
||
import { TraitDistributionCharts } from "./genome/_components/trait-distribution-charts"
|
||
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
|
||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||
import { isValidGenomeAnalysis, getInvalidReason, getInvalidMessage } from "@/lib/utils/genome-analysis-config"
|
||
import { AuthGuard } from "@/components/auth/auth-guard"
|
||
import { MptTable } from "./reproduction/_components/mpt-table"
|
||
|
||
// 형질명 → 카테고리 매핑 (한우 35개 형질)
|
||
const TRAIT_CATEGORY_MAP: Record<string, string> = {
|
||
// 성장형질 (1개)
|
||
'12개월령체중': '성장',
|
||
// 생산형질 (4개)
|
||
'도체중': '생산',
|
||
'등심단면적': '생산',
|
||
'등지방두께': '생산',
|
||
'근내지방도': '생산',
|
||
// 체형형질 (10개)
|
||
'체고': '체형',
|
||
'십자': '체형',
|
||
'체장': '체형',
|
||
'흉심': '체형',
|
||
'흉폭': '체형',
|
||
'고장': '체형',
|
||
'요각폭': '체형',
|
||
'곤폭': '체형',
|
||
'좌골폭': '체형',
|
||
'흉위': '체형',
|
||
// 부위별 weight (10개)
|
||
'안심weight': '무게',
|
||
'등심weight': '무게',
|
||
'채끝weight': '무게',
|
||
'목심weight': '무게',
|
||
'앞다리weight': '무게',
|
||
'우둔weight': '무게',
|
||
'설도weight': '무게',
|
||
'사태weight': '무게',
|
||
'양지weight': '무게',
|
||
'갈비weight': '무게',
|
||
// 부위별 rate (10개)
|
||
'안심rate': '비율',
|
||
'등심rate': '비율',
|
||
'채끝rate': '비율',
|
||
'목심rate': '비율',
|
||
'앞다리rate': '비율',
|
||
'우둔rate': '비율',
|
||
'설도rate': '비율',
|
||
'사태rate': '비율',
|
||
'양지rate': '비율',
|
||
'갈비rate': '비율',
|
||
}
|
||
|
||
// 형질명으로 카테고리 찾기
|
||
function getTraitCategory(traitName: string): string {
|
||
if (TRAIT_CATEGORY_MAP[traitName]) {
|
||
return TRAIT_CATEGORY_MAP[traitName]
|
||
}
|
||
if (traitName.includes('체중') || traitName.includes('개월령')) return '성장'
|
||
if (traitName.includes('도체') || traitName.includes('등심단면적') || traitName.includes('지방') || traitName.includes('근내')) return '생산'
|
||
if (traitName.includes('weight')) return '무게'
|
||
if (traitName.includes('rate')) return '비율'
|
||
return '체형'
|
||
}
|
||
|
||
// API 데이터를 화면 표시용으로 변환
|
||
function transformGenomeData(genomeData: GenomeTrait[]) {
|
||
if (genomeData.length === 0) return []
|
||
return genomeData[0].genomeCows?.map((trait, index) => {
|
||
const traitName = trait.traitInfo?.traitNm || ''
|
||
return {
|
||
id: index + 1,
|
||
name: traitName,
|
||
category: getTraitCategory(traitName),
|
||
breedVal: trait.breedVal || 0,
|
||
percentile: trait.percentile || 0,
|
||
actualValue: trait.traitVal || 0,
|
||
unit: '',
|
||
description: trait.traitInfo?.traitDesc || '',
|
||
importance: 'medium' as 'low' | 'medium' | 'high' | 'critical',
|
||
}
|
||
}) || []
|
||
}
|
||
|
||
// 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 } = useGlobalFilter()
|
||
const isMobile = useMediaQuery("(max-width: 640px)")
|
||
|
||
const [cow, setCow] = useState<CowDetail | null>(null)
|
||
const [genomeData, setGenomeData] = useState<GenomeTrait[]>([])
|
||
const [geneData, setGeneData] = useState<GeneDetail[]>([])
|
||
const [geneDataLoaded, setGeneDataLoaded] = useState(false) // 유전자 데이터 로드 여부
|
||
const [geneDataLoading, setGeneDataLoading] = useState(false) // 유전자 데이터 로딩 중
|
||
const [loading, setLoading] = useState(true)
|
||
const [activeTab, setActiveTab] = useState<string>('genome')
|
||
|
||
// 검사 상태
|
||
const [hasGenomeData, setHasGenomeData] = useState(false)
|
||
const [hasGeneData, setHasGeneData] = useState(false)
|
||
const [hasReproductionData, setHasReproductionData] = useState(false)
|
||
|
||
// 분석 의뢰 정보 (친자감별 결과 포함)
|
||
const [genomeRequest, setGenomeRequest] = useState<GenomeRequestDto | null>(null)
|
||
|
||
// 선발지수 상태
|
||
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;
|
||
} | null>(null)
|
||
|
||
// 분포 데이터
|
||
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[]>([])
|
||
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 [geneCurrentPage, setGeneCurrentPage] = useState(1)
|
||
const GENES_PER_PAGE = 50
|
||
|
||
// 농가/보은군 비교 하이라이트 모드
|
||
const [highlightMode, setHighlightMode] = useState<'farm' | 'region' | null>(null)
|
||
const distributionChartRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 필터에서 고정된 첫 번째 형질 (없으면 '도체중')
|
||
const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중'
|
||
|
||
// 차트 형질 필터 (전체 선발지수 또는 개별 형질)
|
||
// 필터 비활성 시 기본값은 첫 번째 고정 형질
|
||
const [chartFilterTrait, setChartFilterTrait] = useState<string>(() => {
|
||
return filters.isActive ? 'overall' : firstPinnedTrait
|
||
})
|
||
|
||
// 필터 활성 상태 변경 시 기본값 업데이트
|
||
useEffect(() => {
|
||
if (!filters.isActive && chartFilterTrait === 'overall') {
|
||
setChartFilterTrait(firstPinnedTrait)
|
||
}
|
||
}, [filters.isActive, firstPinnedTrait, chartFilterTrait])
|
||
|
||
// 유전자 탭 필터 상태
|
||
const [geneSearchInput, setGeneSearchInput] = useState('') // 실시간 입력값
|
||
const [geneSearchKeyword, setGeneSearchKeyword] = useState('') // 디바운스된 검색값
|
||
const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all')
|
||
|
||
// 검색어 디바운스 (300ms) 실시간 필터링 너무 느림
|
||
// 타이핑이 멈추고 0.3초 후에 검색이 실행
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
setGeneSearchKeyword(geneSearchInput)
|
||
setGeneCurrentPage(1)
|
||
}, 300)
|
||
return () => clearTimeout(timer)
|
||
}, [geneSearchInput])
|
||
const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all')
|
||
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
|
||
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
|
||
|
||
// 유전자 데이터 지연 로드 함수
|
||
const loadGeneData = async () => {
|
||
if (geneDataLoaded || geneDataLoading) return // 이미 로드했거나 로딩 중이면 스킵
|
||
|
||
setGeneDataLoading(true)
|
||
try {
|
||
const geneDataResult = await geneApi.findByCowId(cowNo)
|
||
const geneList = geneDataResult || []
|
||
setGeneData(geneList)
|
||
setGeneDataLoaded(true)
|
||
} catch (geneErr) {
|
||
console.error('유전자 데이터 조회 실패:', geneErr)
|
||
setGeneData([])
|
||
setGeneDataLoaded(true)
|
||
} finally {
|
||
setGeneDataLoading(false)
|
||
}
|
||
}
|
||
|
||
// 탭 변경 핸들러
|
||
const handleTabChange = (value: string) => {
|
||
setActiveTab(value)
|
||
if (value === 'gene' && !geneDataLoaded) {
|
||
loadGeneData()
|
||
}
|
||
}
|
||
|
||
// 농가/보은군 배지 클릭 시 차트로 스크롤 + 하이라이트
|
||
const handleComparisonClick = (mode: 'farm' | 'region') => {
|
||
// 토글: 같은 모드 클릭 시 해제
|
||
setHighlightMode(prev => prev === mode ? null : mode)
|
||
|
||
// 차트로 스크롤
|
||
if (distributionChartRef.current) {
|
||
distributionChartRef.current.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center'
|
||
})
|
||
}
|
||
}
|
||
|
||
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)
|
||
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)
|
||
|
||
// 유전체 데이터 가져오기
|
||
const genomeDataResult = await genomeApi.findByCowNo(cowNo)
|
||
setGenomeData(genomeDataResult)
|
||
const genomeExists = genomeDataResult.length > 0 && !!genomeDataResult[0].genomeCows && genomeDataResult[0].genomeCows.length > 0
|
||
setHasGenomeData(genomeExists)
|
||
|
||
// 분석 의뢰 정보 가져오기 (친자감별 결과 포함)
|
||
try {
|
||
const requestData = await genomeApi.getRequest(cowNo)
|
||
setGenomeRequest(requestData)
|
||
} catch (reqErr) {
|
||
console.error('분석 의뢰 정보 조회 실패:', reqErr)
|
||
setGenomeRequest(null)
|
||
}
|
||
|
||
// 유전자(SNP) 데이터는 탭 클릭 시 로드 (지연 로딩)
|
||
setHasGeneData(true) // 탭은 보여주되, 데이터는 나중에 로드
|
||
|
||
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
|
||
// TODO: 번식능력 API 연동
|
||
setHasReproductionData(false)
|
||
|
||
// 첫 번째 사용 가능한 탭 자동 선택
|
||
if (genomeExists) {
|
||
setActiveTab('genome')
|
||
} else if (geneData && geneData.length > 0) {
|
||
setActiveTab('gene')
|
||
}
|
||
|
||
// 비교 데이터 가져오기
|
||
if (genomeDataResult.length > 0) {
|
||
try {
|
||
const comparisonData = await genomeApi.getComparisonAverages(cowNo)
|
||
setComparisonAverages(comparisonData)
|
||
|
||
// 형질별 비교 평균 가져오기 (폴리곤 차트용)
|
||
const traitComparisonData = await genomeApi.getTraitComparisonAverages(cowNo)
|
||
setTraitComparisonAverages(traitComparisonData)
|
||
|
||
// 선발지수 계산
|
||
const ALL_TRAITS = [
|
||
'12개월령체중',
|
||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||
]
|
||
|
||
// 필터가 활성화되어 있으면 가중치 > 0인 형질만 사용 (리스트와 동일 로직)
|
||
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])
|
||
|
||
// API 데이터를 화면용으로 변환
|
||
const GENOMIC_TRAITS = useMemo(() => {
|
||
return transformGenomeData(genomeData)
|
||
}, [genomeData])
|
||
|
||
// 고유 카테고리 목록
|
||
const CATEGORIES = useMemo(() => {
|
||
return [...new Set(GENOMIC_TRAITS.map(t => t.category).filter(Boolean))]
|
||
}, [GENOMIC_TRAITS])
|
||
|
||
// 종합 지표
|
||
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) / GENOMIC_TRAITS.length
|
||
}, [GENOMIC_TRAITS, selectionIndex])
|
||
|
||
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) / GENOMIC_TRAITS.length
|
||
}, [GENOMIC_TRAITS, selectionIndex, overallScore])
|
||
|
||
// 카테고리별 평균
|
||
const categoryStats = useMemo(() => {
|
||
return CATEGORIES.map(cat => {
|
||
const traits = GENOMIC_TRAITS.filter(t => t.category === cat)
|
||
const avgBreedVal = traits.reduce((sum, t) => sum + t.breedVal, 0) / traits.length
|
||
const avgPercentile = traits.reduce((sum, t) => sum + t.percentile, 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
|
||
}
|
||
const totalEbv = comparisonAverages.farm.reduce((sum, cat) => sum + cat.avgEbv, 0)
|
||
return totalEbv / comparisonAverages.farm.length
|
||
}, [comparisonAverages, overallScore])
|
||
|
||
const regionAvgZ = useMemo(() => {
|
||
if (!comparisonAverages?.region || comparisonAverages.region.length === 0) {
|
||
return -0.2
|
||
}
|
||
const totalEbv = comparisonAverages.region.reduce((sum, cat) => sum + cat.avgEbv, 0)
|
||
return totalEbv / 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.name))
|
||
: GENOMIC_TRAITS
|
||
|
||
if (targetTraits.length === 0) return null
|
||
|
||
const totalEpd = targetTraits.reduce((sum, t) => sum + t.actualValue, 0)
|
||
return totalEpd / targetTraits.length
|
||
}, [GENOMIC_TRAITS, filters.traitWeights])
|
||
|
||
// 농가 EPD 평균
|
||
const farmAvgEpdValue = useMemo(() => {
|
||
if (!comparisonAverages?.farm || comparisonAverages.farm.length === 0) return null
|
||
const totalEpd = comparisonAverages.farm.reduce((sum, cat) => sum + (cat.avgEpd || 0), 0)
|
||
return totalEpd / comparisonAverages.farm.length
|
||
}, [comparisonAverages])
|
||
|
||
// 보은군 EPD 평균
|
||
const regionAvgEpdValue = useMemo(() => {
|
||
if (!comparisonAverages?.region || comparisonAverages.region.length === 0) return null
|
||
const totalEpd = comparisonAverages.region.reduce((sum, cat) => sum + (cat.avgEpd || 0), 0)
|
||
return totalEpd / 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.name))
|
||
}, [filters.traitWeights, GENOMIC_TRAITS])
|
||
|
||
// 정규분포 데이터
|
||
const multiDistribution = useMemo(() => {
|
||
return generateMultipleDistributions(0, 1, regionAvgZ, 1, farmAvgZ, 1)
|
||
}, [regionAvgZ, farmAvgZ])
|
||
|
||
const toggleTraitSelection = (traitId: number) => {
|
||
setSelectedTraits(prev =>
|
||
prev.includes(traitId)
|
||
? prev.filter(id => id !== traitId)
|
||
: [...prev, traitId]
|
||
)
|
||
}
|
||
|
||
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 min-h-screen">
|
||
{/* 메인 컨테이너 여백 : p-6 */}
|
||
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6">
|
||
{/* 헤더: 뒤로가기 + 타이틀 + 다운로드 */}
|
||
<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>
|
||
{/* 아이콘 */}
|
||
<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>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 탭 네비게이션 */}
|
||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||
<TabsList className="w-full grid grid-cols-3 bg-transparent p-0 h-auto gap-0 rounded-none border-b border-border">
|
||
<TabsTrigger
|
||
value="genome"
|
||
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 sm:text-xl">유전체</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 ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
||
{hasGenomeData ? '완료' : '미검사'}
|
||
</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 sm:text-xl">유전자</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 sm:text-xl">번식능력</span>
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* 유전체 분석 탭 */}
|
||
<TabsContent value="genome" className="mt-6 space-y-6">
|
||
{hasGenomeData ? (
|
||
<>
|
||
{/* 개체 정보 섹션 */}
|
||
<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-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
|
||
? `${Math.floor((new Date().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
|
||
? `${Math.floor((new Date().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-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 break-all">
|
||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||
</span>
|
||
{(() => {
|
||
const chipSireName = genomeRequest?.chipSireName
|
||
if (chipSireName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipSireName && chipSireName !== '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<span>분석불가</span>
|
||
</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 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>
|
||
)}
|
||
{(() => {
|
||
const chipDamName = genomeRequest?.chipDamName
|
||
if (chipDamName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '불일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '이력제부재') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>이력제부재</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
||
return null
|
||
}
|
||
})()}
|
||
</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>
|
||
{(() => {
|
||
const chipSireName = genomeRequest?.chipSireName
|
||
if (chipSireName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipSireName && chipSireName !== '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<span>분석불가</span>
|
||
</span>
|
||
)
|
||
}
|
||
})()}
|
||
</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>
|
||
)}
|
||
{(() => {
|
||
const chipDamName = genomeRequest?.chipDamName
|
||
if (chipDamName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '불일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '이력제부재') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>이력제부재</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
||
return null
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 친자확인 결과에 따른 분기 (유효성 조건: 아비일치 + 어미불일치/이력제부재 제외 + 제외목록) */}
|
||
{isValidGenomeAnalysis(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) ? (
|
||
<>
|
||
{/* 농가 및 보은군 내 개체 위치 */}
|
||
<h3 className="text-lg lg:text-xl 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}
|
||
regionTotal={selectionIndex?.regionTotal}
|
||
chartFilterTrait={chartFilterTrait}
|
||
onChartFilterTraitChange={setChartFilterTrait}
|
||
/>
|
||
</div>
|
||
|
||
{/* 유전체 형질별 육종가 비교 */}
|
||
<h3 className="text-lg lg:text-xl 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-xl font-bold text-foreground mt-6">선택 형질 상세</h3>
|
||
|
||
<TraitDistributionCharts
|
||
allTraits={GENOMIC_TRAITS}
|
||
regionAvgZ={regionAvgZ}
|
||
farmAvgZ={farmAvgZ}
|
||
cowName={cow?.cowId || cowNo}
|
||
totalCowCount={totalCowCount}
|
||
selectedTraits={filterSelectedTraitData}
|
||
traitWeights={filters.traitWeights}
|
||
/>
|
||
|
||
<h3 className="text-lg lg:text-xl font-bold text-foreground mt-6">분석 정보</h3>
|
||
|
||
<Card className="bg-white border border-border rounded-xl overflow-hidden">
|
||
<CardContent className="p-0">
|
||
<div className="grid grid-cols-3 divide-x divide-border">
|
||
<div className="p-4">
|
||
<div className="text-xs font-medium text-muted-foreground mb-1">접수일</div>
|
||
<div className="text-sm font-semibold text-foreground truncate">
|
||
{genomeData[0]?.request?.requestDt
|
||
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
|
||
: '-'}
|
||
</div>
|
||
</div>
|
||
<div className="p-4">
|
||
<div className="text-xs font-medium text-muted-foreground mb-1">분석 완료일</div>
|
||
<div className="text-sm font-semibold text-foreground truncate">
|
||
{genomeData[0]?.request?.chipReportDt
|
||
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
|
||
: '-'}
|
||
</div>
|
||
</div>
|
||
<div className="p-4">
|
||
<div className="text-xs font-medium text-muted-foreground mb-1">칩 종류</div>
|
||
<div className="text-sm font-semibold text-foreground truncate">
|
||
{genomeData[0]?.request?.chipType || '-'}
|
||
</div>
|
||
</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="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-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-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
|
||
? `${Math.floor((new Date().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
|
||
? `${Math.floor((new Date().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>
|
||
{(() => {
|
||
const chipSireName = genomeRequest?.chipSireName
|
||
if (chipSireName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipSireName && chipSireName !== '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<span>분석불가</span>
|
||
</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 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>
|
||
)}
|
||
{(() => {
|
||
const chipDamName = genomeRequest?.chipDamName
|
||
if (chipDamName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '불일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '이력제부재') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>이력제부재</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
||
return null
|
||
}
|
||
})()}
|
||
</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>
|
||
{(() => {
|
||
const chipSireName = genomeRequest?.chipSireName
|
||
if (chipSireName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipSireName && chipSireName !== '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<span>분석불가</span>
|
||
</span>
|
||
)
|
||
}
|
||
})()}
|
||
</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>
|
||
)}
|
||
{(() => {
|
||
const chipDamName = genomeRequest?.chipDamName
|
||
if (chipDamName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '불일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '이력제부재') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>이력제부재</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
||
return null
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 분석불가 메시지 */}
|
||
<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-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-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
|
||
? `${Math.floor((new Date().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
|
||
? `${Math.floor((new Date().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-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 break-all">
|
||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||
</span>
|
||
{(() => {
|
||
const chipSireName = genomeRequest?.chipSireName
|
||
if (chipSireName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipSireName && chipSireName !== '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<span>분석불가</span>
|
||
</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 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>
|
||
)}
|
||
{(() => {
|
||
const chipDamName = genomeRequest?.chipDamName
|
||
if (chipDamName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '불일치') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '이력제부재') {
|
||
return (
|
||
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||
<XCircle className="w-4 h-4" />
|
||
<span>이력제부재</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return null
|
||
}
|
||
})()}
|
||
</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>
|
||
{(() => {
|
||
const chipSireName = genomeRequest?.chipSireName
|
||
if (chipSireName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipSireName && chipSireName !== '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<span>분석불가</span>
|
||
</span>
|
||
)
|
||
}
|
||
})()}
|
||
</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>
|
||
)}
|
||
{(() => {
|
||
const chipDamName = genomeRequest?.chipDamName
|
||
if (chipDamName === '일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
<span>일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '불일치') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>불일치</span>
|
||
</span>
|
||
)
|
||
} else if (chipDamName === '이력제부재') {
|
||
return (
|
||
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>이력제부재</span>
|
||
</span>
|
||
)
|
||
} else {
|
||
return null
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 유전자 검색 및 필터 섹션 */}
|
||
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전자 분석 결과</h3>
|
||
|
||
<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 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
|
||
})
|
||
|
||
// 정렬
|
||
const sortedData = [...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)
|
||
})
|
||
|
||
// 페이지네이션 계산
|
||
const totalPages = Math.ceil(sortedData.length / GENES_PER_PAGE)
|
||
const startIndex = (geneCurrentPage - 1) * GENES_PER_PAGE
|
||
const endIndex = startIndex + GENES_PER_PAGE
|
||
const displayData = sortedData.length > 0
|
||
? sortedData.slice(startIndex, endIndex)
|
||
: Array(10).fill(null)
|
||
|
||
// 페이지네이션 UI 컴포넌트
|
||
const PaginationUI = () => {
|
||
if (sortedData.length <= GENES_PER_PAGE) return null
|
||
|
||
// 표시할 페이지 번호들 계산 (모바일: 3개 단순, 데스크탑: 5개 + 1/마지막 고정)
|
||
const getPageNumbers = () => {
|
||
const pages: (number | string)[] = []
|
||
const showPages = isMobile ? 3 : 5
|
||
const offset = isMobile ? 1 : 2
|
||
let start = Math.max(1, geneCurrentPage - offset)
|
||
let end = Math.min(totalPages, start + showPages - 1)
|
||
|
||
if (end - start < showPages - 1) {
|
||
start = Math.max(1, end - showPages + 1)
|
||
}
|
||
|
||
// 모바일: 현재 페이지 기준 앞뒤만 표시 (1, 마지막 고정 없음)
|
||
if (isMobile) {
|
||
for (let i = start; i <= end; i++) {
|
||
pages.push(i)
|
||
}
|
||
return pages
|
||
}
|
||
|
||
// 데스크탑: 1과 마지막 페이지 고정
|
||
if (start > 1) {
|
||
pages.push(1)
|
||
if (start > 2) pages.push('...')
|
||
}
|
||
|
||
for (let i = start; i <= end; i++) {
|
||
pages.push(i)
|
||
}
|
||
|
||
if (end < totalPages) {
|
||
if (end < totalPages - 1) pages.push('...')
|
||
pages.push(totalPages)
|
||
}
|
||
|
||
return pages
|
||
}
|
||
|
||
return (
|
||
<div className="px-3 sm:px-4 py-3 bg-muted/30 border-t flex flex-col sm:flex-row items-center justify-between gap-3">
|
||
<span className="text-sm text-muted-foreground">
|
||
전체 {sortedData.length.toLocaleString()}개 중 {startIndex + 1}-{Math.min(endIndex, sortedData.length)}번째
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setGeneCurrentPage(1)}
|
||
disabled={geneCurrentPage === 1}
|
||
className="px-2.5 h-9 text-sm"
|
||
>
|
||
«
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setGeneCurrentPage(p => Math.max(1, p - 1))}
|
||
disabled={geneCurrentPage === 1}
|
||
className="px-2.5 h-9 text-sm"
|
||
>
|
||
‹
|
||
</Button>
|
||
{getPageNumbers().map((page, idx) => (
|
||
typeof page === 'number' ? (
|
||
<Button
|
||
key={idx}
|
||
variant={geneCurrentPage === page ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setGeneCurrentPage(page)}
|
||
className="px-2.5 min-w-[36px] h-9 text-sm"
|
||
>
|
||
{page}
|
||
</Button>
|
||
) : (
|
||
<span key={idx} className="px-1 text-sm text-muted-foreground">...</span>
|
||
)
|
||
))}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setGeneCurrentPage(p => Math.min(totalPages, p + 1))}
|
||
disabled={geneCurrentPage === totalPages}
|
||
className="px-2.5 h-9 text-sm"
|
||
>
|
||
›
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setGeneCurrentPage(totalPages)}
|
||
disabled={geneCurrentPage === totalPages}
|
||
className="px-2.5 h-9 text-sm"
|
||
>
|
||
»
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 데스크톱: 테이블 */}
|
||
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||
<CardContent className="p-0">
|
||
<div>
|
||
<table className="w-full table-fixed">
|
||
<thead className="bg-muted/50 border-b border-border">
|
||
<tr>
|
||
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[22%]">SNP 이름</th>
|
||
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[10%]">염색체 위치</th>
|
||
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">Position</th>
|
||
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">SNP 구분</th>
|
||
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]">첫번째 대립유전자</th>
|
||
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]">두번째 대립유전자</th>
|
||
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[20%]">설명</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{displayData.map((gene, idx) => {
|
||
if (!gene) {
|
||
return (
|
||
<tr key={idx} className="hover:bg-muted/30">
|
||
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td>
|
||
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
|
||
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
|
||
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
|
||
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td>
|
||
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td>
|
||
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td>
|
||
</tr>
|
||
)
|
||
}
|
||
return (
|
||
<tr key={idx} className="hover:bg-muted/30">
|
||
<td className="px-4 py-3 text-center text-base font-medium text-foreground">{gene.snpName || '-'}</td>
|
||
<td className="px-3 py-3 text-center text-base text-foreground">{gene.chromosome || '-'}</td>
|
||
<td className="px-3 py-3 text-center text-base text-foreground">{gene.position || '-'}</td>
|
||
<td className="px-3 py-3 text-center text-base text-foreground">{gene.snpType || '-'}</td>
|
||
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele1 || '-'}</td>
|
||
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele2 || '-'}</td>
|
||
<td className="px-4 py-3 text-center text-base text-muted-foreground">{gene.remarks || '-'}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<PaginationUI />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 모바일: 카드 뷰 */}
|
||
<div className="lg:hidden space-y-3">
|
||
{displayData.map((gene, idx) => {
|
||
return (
|
||
<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>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="lg:hidden">
|
||
<PaginationUI />
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</>
|
||
) : (
|
||
<Card className="bg-slate-50 border border-border rounded-2xl">
|
||
<CardContent className="p-8 text-center">
|
||
<Dna className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||
<h3 className="text-lg font-semibold text-foreground mb-2">유전자 분석 데이터 없음</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
이 개체는 아직 유전자(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} />
|
||
*/}
|
||
|
||
<Card className="bg-slate-50 border border-border rounded-2xl">
|
||
<CardContent className="p-8 text-center">
|
||
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||
<h3 className="text-lg font-semibold text-foreground mb-2">번식능력 분석 데이터 없음</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
이 개체는 아직 번식능력 분석이 완료되지 않았습니다.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
</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}
|
||
onHighlightModeChange={setHighlightMode}
|
||
chartFilterTrait={chartFilterTrait}
|
||
onChartFilterTraitChange={setChartFilterTrait}
|
||
/>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</SidebarProvider>
|
||
</AuthGuard>
|
||
)
|
||
}
|