파일 정리
This commit is contained in:
@@ -463,11 +463,11 @@ export function GenomeIntegratedComparison({
|
||||
|
||||
setTrendLoading(true)
|
||||
try {
|
||||
const dashboardStats = await genomeApi.getDashboardStats(farmNo)
|
||||
const ebvStats = await genomeApi.getYearlyEbvStats(farmNo)
|
||||
|
||||
// yearlyStats와 yearlyAvgEbv 합치기
|
||||
const yearlyStats = dashboardStats.yearlyStats || []
|
||||
const yearlyAvgEbv = dashboardStats.yearlyAvgEbv || []
|
||||
const yearlyStats = ebvStats.yearlyStats || []
|
||||
const yearlyAvgEbv = ebvStats.yearlyAvgEbv || []
|
||||
|
||||
// 연도별 데이터 맵 생성
|
||||
const yearMap = new Map<number, { analyzedCount: number; avgEbv: number }>()
|
||||
|
||||
@@ -181,7 +181,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
{cow?.cowBirthDt && selectedMpt.testDt
|
||||
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +219,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">월령</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
{cow?.cowBirthDt && selectedMpt.testDt
|
||||
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@@ -242,7 +246,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
{selectedMpt ? (
|
||||
<>
|
||||
<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">
|
||||
|
||||
{/* 데스크탑: 테이블 */}
|
||||
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -310,6 +316,60 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 모바일: 카드 레이아웃 */}
|
||||
<div className="lg:hidden space-y-4">
|
||||
{categories.map((category) => (
|
||||
<Card key={category.key} className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-3 border-b border-border">
|
||||
<span className="text-sm font-semibold text-foreground">{category.name}</span>
|
||||
</div>
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
{category.items.map((itemKey) => {
|
||||
const ref = references[itemKey]
|
||||
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
||||
const status = getMptValueStatus(itemKey, value, references)
|
||||
|
||||
return (
|
||||
<div key={itemKey} className="py-2">
|
||||
<div className="flex items-center border-b border-border/50">
|
||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">검사항목</span>
|
||||
<span className="flex-1 px-3 py-2 text-sm font-semibold text-foreground">{ref?.name || itemKey}</span>
|
||||
</div>
|
||||
<div className="flex items-center border-b border-border/50">
|
||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">측정값</span>
|
||||
<div className="flex-1 px-3 py-2 flex items-center justify-between">
|
||||
<span className={`text-base font-bold ${
|
||||
status === 'safe' ? 'text-green-600' :
|
||||
status === 'caution' ? 'text-amber-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
||||
</span>
|
||||
{value !== null && value !== undefined ? (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
status === 'safe' ? 'bg-green-100 text-green-700' :
|
||||
status === 'caution' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">참조범위</span>
|
||||
<span className="flex-1 px-3 py-2 text-sm text-muted-foreground">
|
||||
{ref?.lowerLimit ?? '-'} ~ {ref?.upperLimit ?? '-'} {ref?.unit || ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
||||
{mptData.length > 1 && (
|
||||
<>
|
||||
|
||||
@@ -182,9 +182,6 @@ function MyCowContent() {
|
||||
|
||||
setError(null)
|
||||
|
||||
// 마커 타입 정보 (gene.api 제거됨 - 추후 백엔드 구현 시 복구)
|
||||
const currentMarkerTypes = markerTypes
|
||||
|
||||
// 전역 필터 + 랭킹 모드를 기반으로 랭킹 옵션 구성
|
||||
// 타입을 any로 지정하여 백엔드 API와의 호환성 유지
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -279,94 +276,24 @@ function MyCowContent() {
|
||||
ranking?: { traits?: { traitName: string; traitEbv: number | null; traitVal: number | null }[] }; // 랭킹 형질
|
||||
}
|
||||
const cowsWithMockGenes = response.items.map((item: RankingItem) => {
|
||||
// 백엔드에서 genes 객체를 배열로 변환
|
||||
// genes 객체 형식: { "PLAG1": 2, "NCAPG": 1, ... }
|
||||
// 배열 형식으로 변환: [{ name: "PLAG1", genotype: "AA", favorable: true }, ...]
|
||||
let genesArray = []
|
||||
|
||||
if (item.entity.genes && typeof item.entity.genes === 'object') {
|
||||
// 백엔드 genes 객체를 배열로 변환
|
||||
genesArray = Object.entries(item.entity.genes).map(([markerName, count]) => {
|
||||
const favorableCount = count as number
|
||||
let genotype = 'N/A'
|
||||
let favorable = false
|
||||
|
||||
// favorableCount에 따라 유전자형 결정
|
||||
if (favorableCount === 2) {
|
||||
genotype = 'AA' // 동형 접합 (유리)
|
||||
favorable = true
|
||||
} else if (favorableCount === 1) {
|
||||
genotype = 'AG' // 이형 접합 (중간)
|
||||
favorable = true
|
||||
} else {
|
||||
genotype = 'GG' // 동형 접합 (불리)
|
||||
favorable = false
|
||||
}
|
||||
|
||||
return {
|
||||
name: markerName,
|
||||
genotype,
|
||||
favorable,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 백엔드에서 genes 데이터가 없으면 mock 생성
|
||||
genesArray = generateMockGenes()
|
||||
}
|
||||
|
||||
// currentMarkerTypes를 사용하여 동적으로 육량형/육질형 개수 계산
|
||||
// 동형접합(AA)과 이형접합(AG)을 구분하여 계산
|
||||
const isHomozygous = (genotype: string) => genotype.length === 2 && genotype[0] === genotype[1]
|
||||
|
||||
const quantityHomoCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QTY' && g.favorable && isHomozygous(g.genotype)
|
||||
).length
|
||||
const quantityHeteroCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QTY' && g.favorable && !isHomozygous(g.genotype)
|
||||
).length
|
||||
const qualityHomoCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QLT' && g.favorable && isHomozygous(g.genotype)
|
||||
).length
|
||||
const qualityHeteroCount = genesArray.filter(g =>
|
||||
currentMarkerTypes[g.name] === 'QLT' && g.favorable && !isHomozygous(g.genotype)
|
||||
).length
|
||||
|
||||
return {
|
||||
...item.entity, // 실제 cow 데이터
|
||||
rank: item.rank, // 백엔드에서 계산한 랭킹
|
||||
rankScore: item.sortValue, // 백엔드에서 계산한 점수
|
||||
grade: item.grade, // 백엔드에서 계산한 등급 (A~E)
|
||||
genes: genesArray,
|
||||
quantityGeneCount: quantityHomoCount + quantityHeteroCount,
|
||||
qualityGeneCount: qualityHomoCount + qualityHeteroCount,
|
||||
quantityHomoCount,
|
||||
quantityHeteroCount,
|
||||
qualityHomoCount,
|
||||
qualityHeteroCount,
|
||||
// 유전체 점수는 sortValue에서 가져옴 (백엔드 랭킹 엔진이 계산한 값)
|
||||
...item.entity,
|
||||
rank: item.rank,
|
||||
rankScore: item.sortValue,
|
||||
grade: item.grade,
|
||||
genomeScore: item.sortValue,
|
||||
geneScore: item.compositeScores?.geneScore,
|
||||
// 번식 정보 (백엔드에서 가져옴 - 암소만)
|
||||
// 번식 정보
|
||||
calvingCount: item.entity.calvingCount,
|
||||
bcs: item.entity.bcs,
|
||||
inseminationCount: item.entity.inseminationCount,
|
||||
// 근친도 (백엔드에서 계산된 근친계수 백분율)
|
||||
inbreedingPercent: item.entity.inbreedingPercent ?? 0,
|
||||
// 아비 KPN 번호 (genome trait에서 가져옴)
|
||||
sireKpn: item.entity.sireKpn ?? null,
|
||||
// 분석일자
|
||||
anlysDt: item.entity.anlysDt ?? null,
|
||||
// 분석불가 사유
|
||||
unavailableReason: item.entity.unavailableReason ?? null,
|
||||
// 번식능력검사(MPT) 여부
|
||||
hasMpt: item.entity.hasMpt ?? false,
|
||||
// MPT 검사일
|
||||
mptTestDt: item.entity.mptTestDt ?? null,
|
||||
// MPT 월령
|
||||
mptMonthAge: item.entity.mptMonthAge ?? null,
|
||||
//====================================================================================================================
|
||||
// 형질 데이터 (백엔드에서 계산됨, 형질명 → 표준화육종가 매핑)
|
||||
// 백엔드 응답: { traitName: string, traitVal: number, traitEbv: number, traitPercentile: number }
|
||||
// 형질 데이터
|
||||
traits: item.ranking?.traits?.reduce((acc: Record<string,
|
||||
{ breedVal: number | null, traitVal: number | null }>, t: { traitName: string; traitEbv: number | null; traitVal: number | null }) => {
|
||||
acc[t.traitName] = { breedVal: t.traitEbv, traitVal: t.traitVal };
|
||||
@@ -389,98 +316,6 @@ function MyCowContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, rankingMode, isFilterSet])
|
||||
|
||||
// Mock 유전자 생성 함수 (실제로는 API에서 가져와야 함)
|
||||
const generateMockGenes = () => {
|
||||
// 모든 소가 다양한 유전자를 가지도록 더 많은 유전자 풀 생성
|
||||
const genePool = [
|
||||
// 육량형 유전자
|
||||
{ name: 'PLAG1', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'NCAPG', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'LCORL', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'MSTN', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA'] },
|
||||
{ name: 'IGF1', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'GH1', genotypes: ['CC', 'CT', 'TT'], favorable: ['CC', 'CT'] },
|
||||
{ name: 'LAP3', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA'] },
|
||||
{ name: 'ARRDC3', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
// 육질형 유전자
|
||||
{ name: 'CAPN1', genotypes: ['CC', 'CG', 'GG'], favorable: ['CC', 'CG'] },
|
||||
{ name: 'CAST', genotypes: ['AA', 'AG', 'GG'], favorable: ['GG', 'AG'] },
|
||||
{ name: 'FASN', genotypes: ['AA', 'AG', 'GG'], favorable: ['GG', 'AG'] },
|
||||
{ name: 'SCD', genotypes: ['AA', 'AV', 'VV'], favorable: ['VV', 'AV'] },
|
||||
{ name: 'FABP4', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
|
||||
{ name: 'SREBP1', genotypes: ['CC', 'CT', 'TT'], favorable: ['CC', 'CT'] },
|
||||
{ name: 'DGAT1', genotypes: ['AA', 'AK', 'KK'], favorable: ['KK', 'AK'] },
|
||||
{ name: 'LEP', genotypes: ['CC', 'CT', 'TT'], favorable: ['TT', 'CT'] },
|
||||
]
|
||||
|
||||
// 모든 유전자를 포함 (랜덤 유전자형)
|
||||
return genePool.map(gene => {
|
||||
const genotype = gene.genotypes[Math.floor(Math.random() * gene.genotypes.length)]
|
||||
return {
|
||||
name: gene.name,
|
||||
genotype,
|
||||
favorable: gene.favorable.includes(genotype),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 유전자형 판단 및 스타일 정의
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 동형접합 여부 판단
|
||||
* AA, GG, CC, TT 등 → true
|
||||
* AG, CT, AK 등 → false
|
||||
*/
|
||||
const isHomozygous = (genotype: string): boolean => {
|
||||
return genotype.length === 2 && genotype[0] === genotype[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 뱃지 스타일 정의
|
||||
* @param genotype 유전자형 (AA, AG, GG 등)
|
||||
* @param favorable 우량 유전자 여부
|
||||
* @param geneCategory 유전자 카테고리 ('QTY': 육량형, 'QLT': 육질형)
|
||||
*/
|
||||
type GeneBadgeStyle = {
|
||||
className: string
|
||||
icon: 'star' | 'circle' | 'double-circle' | 'minus' | 'none'
|
||||
}
|
||||
|
||||
const getGeneBadgeStyle = (
|
||||
genotype: string,
|
||||
favorable: boolean,
|
||||
geneCategory: 'QTY' | 'QLT'
|
||||
): GeneBadgeStyle => {
|
||||
const isHomo = isHomozygous(genotype)
|
||||
|
||||
// 1. 동형접합 우량 (AA형) → 진한 색 (육량: 파랑, 육질: 주황)
|
||||
if (isHomo && favorable) {
|
||||
return {
|
||||
className: geneCategory === 'QTY'
|
||||
? 'bg-blue-600 text-white border-blue-700'
|
||||
: 'bg-orange-600 text-white border-orange-700',
|
||||
icon: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 이형접합 우량 (AG형) → 중간 색 (육량: 파랑, 육질: 주황)
|
||||
if (!isHomo && favorable) {
|
||||
return {
|
||||
className: geneCategory === 'QTY'
|
||||
? 'bg-blue-400 text-white border-blue-500'
|
||||
: 'bg-orange-400 text-white border-orange-500',
|
||||
icon: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 불량형 (GG형) → 연한 회색
|
||||
return {
|
||||
className: 'bg-gray-300 text-gray-600 border-gray-400',
|
||||
icon: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 컬럼 스타일은 globals.css의 CSS 변수로 관리됨
|
||||
@@ -962,10 +797,10 @@ function MyCowContent() {
|
||||
선발지수
|
||||
</th>
|
||||
{selectedDisplayGenes.length > 0 && (
|
||||
<th className="cow-table-header bg-blue-50" style={{ width: '140px' }}>유전자형</th>
|
||||
<th className="cow-table-header" style={{ width: '140px' }}>유전자형</th>
|
||||
)}
|
||||
{selectedDisplayTraits.length > 0 && (
|
||||
<th className="cow-table-header bg-teal-50" style={{ width: '140px' }}>형질</th>
|
||||
<th className="cow-table-header" style={{ width: '140px' }}>형질</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1022,15 +857,20 @@ function MyCowContent() {
|
||||
{(() => {
|
||||
// 번식능력만 있는 개체 판단
|
||||
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
|
||||
if (analysisFilter === 'mptOnly' || hasMptOnly) {
|
||||
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-'
|
||||
if (cow.cowBirthDt && cow.mptTestDt) {
|
||||
const birthDate = new Date(cow.cowBirthDt)
|
||||
const refDate = new Date(cow.mptTestDt)
|
||||
return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
// 유전체 분석일 기준 월령
|
||||
if (cow.cowBirthDt && cow.anlysDt) {
|
||||
const birthDate = new Date(cow.cowBirthDt)
|
||||
const refDate = new Date(cow.anlysDt)
|
||||
const ageInMonths = Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||||
return `${ageInMonths}개월`
|
||||
return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
return '-'
|
||||
})()}
|
||||
@@ -1079,7 +919,7 @@ function MyCowContent() {
|
||||
</td>
|
||||
{selectedDisplayGenes.length > 0 && (
|
||||
<td
|
||||
className="py-2 px-2 text-sm bg-blue-50/30"
|
||||
className="py-2 px-2 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(() => {
|
||||
@@ -1092,15 +932,17 @@ function MyCowContent() {
|
||||
{displayGenes.map((geneName) => {
|
||||
const gene = cow.genes?.find(g => g.name === geneName)
|
||||
const genotype = gene?.genotype || '-'
|
||||
const favorable = gene?.favorable || false
|
||||
const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT'
|
||||
const badgeStyle = gene ? getGeneBadgeStyle(genotype, favorable, geneCategory) : null
|
||||
// 육량형: 파랑, 육질형: 주황
|
||||
const badgeClass = geneCategory === 'QTY'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-orange-500 text-white'
|
||||
|
||||
return (
|
||||
<div key={geneName} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-600 min-w-[60px]">{geneName}</span>
|
||||
{gene ? (
|
||||
<Badge className={`text-xs font-semibold ${badgeStyle?.className}`}>
|
||||
<Badge className={`text-xs font-semibold ${badgeClass}`}>
|
||||
{genotype}
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -1134,7 +976,7 @@ function MyCowContent() {
|
||||
)}
|
||||
{selectedDisplayTraits.length > 0 && (
|
||||
<td
|
||||
className="py-2 px-2 text-sm bg-teal-50/30"
|
||||
className="py-2 px-2 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
@@ -1264,10 +1106,14 @@ function MyCowContent() {
|
||||
{(() => {
|
||||
// 번식능력만 있는 개체 판단
|
||||
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용
|
||||
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
|
||||
if (analysisFilter === 'mptOnly' || hasMptOnly) {
|
||||
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-'
|
||||
if (cow.cowBirthDt && cow.mptTestDt) {
|
||||
return `${Math.floor((new Date(cow.mptTestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
// 유전체 분석일 기준 월령
|
||||
if (cow.cowBirthDt && cow.anlysDt) {
|
||||
return `${Math.floor((new Date(cow.anlysDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||
}
|
||||
@@ -1344,12 +1190,14 @@ function MyCowContent() {
|
||||
{displayGenes.map((geneName) => {
|
||||
const gene = cow.genes?.find(g => g.name === geneName)
|
||||
const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT'
|
||||
const genotype = gene?.genotype || 'GG'
|
||||
const favorable = gene?.favorable || false
|
||||
const badgeStyle = getGeneBadgeStyle(genotype, favorable, geneCategory)
|
||||
const genotype = gene?.genotype || '-'
|
||||
// 육량형: 파랑, 육질형: 주황
|
||||
const badgeClass = geneCategory === 'QTY'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-orange-500 text-white'
|
||||
|
||||
return (
|
||||
<Badge key={geneName} className={`text-xs px-1.5 py-0.5 font-medium ${badgeStyle.className}`}>
|
||||
<Badge key={geneName} className={`text-xs px-1.5 py-0.5 font-medium ${badgeClass}`}>
|
||||
{geneName} {genotype}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
448
frontend/src/app/demo/test-summary/page.tsx
Normal file
448
frontend/src/app/demo/test-summary/page.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||||
import { SiteHeader } from "@/components/layout/site-header"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useEffect, useState } from "react"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { ChevronDown, ChevronRight, Check, X, Dna, TestTube, Baby } from "lucide-react"
|
||||
|
||||
// 타입 정의
|
||||
interface CowTestDetail {
|
||||
cowId: string
|
||||
cowBirthDt: string | null
|
||||
cowSex: string | null
|
||||
hasGenome: boolean
|
||||
hasGene: boolean
|
||||
hasMpt: boolean
|
||||
testCount: number
|
||||
testTypes: string[]
|
||||
}
|
||||
|
||||
interface FarmTestSummary {
|
||||
farmNo: number
|
||||
farmerName: string | null
|
||||
regionSi: string | null
|
||||
genomeCowCount: number
|
||||
geneCowCount: number
|
||||
mptCowCount: number
|
||||
genomeOnly: number
|
||||
geneOnly: number
|
||||
mptOnly: number
|
||||
genomeAndGene: number
|
||||
genomeAndMpt: number
|
||||
geneAndMpt: number
|
||||
allThree: number
|
||||
totalCows: number
|
||||
totalTests: number
|
||||
cows?: CowTestDetail[]
|
||||
}
|
||||
|
||||
interface TestSummary {
|
||||
totalFarms: number
|
||||
totalCows: number
|
||||
totalTests: number
|
||||
genomeCowCount: number
|
||||
geneCowCount: number
|
||||
mptCowCount: number
|
||||
genomeOnly: number
|
||||
geneOnly: number
|
||||
mptOnly: number
|
||||
genomeAndGene: number
|
||||
genomeAndMpt: number
|
||||
geneAndMpt: number
|
||||
allThree: number
|
||||
farms: FarmTestSummary[]
|
||||
}
|
||||
|
||||
export default function TestSummaryPage() {
|
||||
const [data, setData] = useState<TestSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedFarms, setExpandedFarms] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/system/test-summary') as TestSummary
|
||||
setData(response)
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const toggleFarm = (farmNo: number) => {
|
||||
setExpandedFarms(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(farmNo)) {
|
||||
next.delete(farmNo)
|
||||
} else {
|
||||
next.add(farmNo)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const formatCowId = (cowId: string) => {
|
||||
const digits = cowId.replace(/\D/g, '')
|
||||
if (digits.length === 12) {
|
||||
return `${digits.slice(0, 3)} ${digits.slice(3, 7)} ${digits.slice(7, 11)} ${digits.slice(11)}`
|
||||
}
|
||||
return cowId
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 items-center justify-center text-red-500">
|
||||
데이터를 불러올 수 없습니다
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 bg-slate-50 min-h-screen">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">검사 집계표</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
농가별/개체별 유전체, 유전자, 번식능력 검사 현황
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 전체 요약 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">총 농가 수</p>
|
||||
<p className="text-3xl font-bold text-slate-900">{data.totalFarms}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">총 검사 개체 수</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{data.totalCows}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">총 검사 건수</p>
|
||||
<p className="text-3xl font-bold text-emerald-600">{data.totalTests}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-500">평균 검사/개체</p>
|
||||
<p className="text-3xl font-bold text-amber-600">
|
||||
{data.totalCows > 0 ? (data.totalTests / data.totalCows).toFixed(1) : 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사별 집계 */}
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b bg-slate-50">
|
||||
<h2 className="font-semibold text-slate-900">검사별 개체 수</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-lg">
|
||||
<Dna className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-blue-600 font-medium">유전체</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{data.genomeCowCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 bg-purple-50 rounded-lg">
|
||||
<TestTube className="w-8 h-8 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-sm text-purple-600 font-medium">유전자</p>
|
||||
<p className="text-2xl font-bold text-purple-700">{data.geneCowCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 bg-pink-50 rounded-lg">
|
||||
<Baby className="w-8 h-8 text-pink-600" />
|
||||
<div>
|
||||
<p className="text-sm text-pink-600 font-medium">번식능력</p>
|
||||
<p className="text-2xl font-bold text-pink-700">{data.mptCowCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중복 검사 조합 */}
|
||||
<h3 className="font-medium text-slate-700 mb-3">검사 조합별 개체 수</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-100">
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-600">조합</th>
|
||||
<th className="px-4 py-2 text-center font-medium text-slate-600">유전체</th>
|
||||
<th className="px-4 py-2 text-center font-medium text-slate-600">유전자</th>
|
||||
<th className="px-4 py-2 text-center font-medium text-slate-600">번식능력</th>
|
||||
<th className="px-4 py-2 text-right font-medium text-slate-600">개체 수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="px-4 py-2 text-slate-700">유전체만</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.genomeOnly}</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="px-4 py-2 text-slate-700">유전자만</td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.geneOnly}</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="px-4 py-2 text-slate-700">번식능력만</td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.mptOnly}</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-blue-50/30">
|
||||
<td className="px-4 py-2 text-slate-700">유전체 + 유전자</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.genomeAndGene}</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-blue-50/30">
|
||||
<td className="px-4 py-2 text-slate-700">유전체 + 번식능력</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.genomeAndMpt}</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-purple-50/30">
|
||||
<td className="px-4 py-2 text-slate-700">유전자 + 번식능력</td>
|
||||
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-semibold">{data.geneAndMpt}</td>
|
||||
</tr>
|
||||
<tr className="bg-emerald-50">
|
||||
<td className="px-4 py-2 text-emerald-700 font-medium">3종 모두</td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
|
||||
<td className="px-4 py-2 text-right font-bold text-emerald-700">{data.allThree}</td>
|
||||
</tr>
|
||||
<tr className="bg-slate-100 font-semibold">
|
||||
<td className="px-4 py-2 text-slate-900" colSpan={4}>합계 (총 검사 개체)</td>
|
||||
<td className="px-4 py-2 text-right text-slate-900">{data.totalCows}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 농가별 집계 */}
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b bg-slate-50">
|
||||
<h2 className="font-semibold text-slate-900">농가별 검사 현황</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-100 border-b">
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 w-8"></th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600">농가</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">유전체</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">유전자</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">번식능력</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">개체 수</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600">검사 건수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.farms.map((farm) => (
|
||||
<>
|
||||
<tr
|
||||
key={farm.farmNo}
|
||||
className="border-b hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => toggleFarm(farm.farmNo)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{expandedFarms.has(farm.farmNo) ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-slate-900">{farm.farmerName || `농가 ${farm.farmNo}`}</span>
|
||||
{farm.regionSi && (
|
||||
<span className="text-slate-400 text-xs ml-2">{farm.regionSi}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
|
||||
{farm.genomeCowCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
|
||||
{farm.geneCowCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-pink-100 text-pink-700 font-medium">
|
||||
{farm.mptCowCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-semibold text-slate-900">
|
||||
{farm.totalCows}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-semibold text-emerald-600">
|
||||
{farm.totalTests}
|
||||
</td>
|
||||
</tr>
|
||||
{/* 펼쳐진 개체 목록 */}
|
||||
{expandedFarms.has(farm.farmNo) && farm.cows && farm.cows.length > 0 && (
|
||||
<tr key={`${farm.farmNo}-detail`}>
|
||||
<td colSpan={7} className="bg-slate-50 px-4 py-2">
|
||||
<div className="ml-6 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-white">
|
||||
<th className="px-3 py-2 text-left font-medium text-slate-500">개체번호</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">생년월일</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">성별</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">유전체</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">유전자</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">번식능력</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-slate-500">검사 수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{farm.cows.map((cow) => (
|
||||
<tr key={cow.cowId} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 font-mono text-slate-700">
|
||||
{formatCowId(cow.cowId)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-slate-600">
|
||||
{cow.cowBirthDt || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
cow.cowSex === '암' || cow.cowSex === 'F'
|
||||
? 'bg-pink-100 text-pink-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{cow.cowSex === '암' || cow.cowSex === 'F' ? '암' : '수'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{cow.hasGenome ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white font-bold">O</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{cow.hasGene ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-500 text-white font-bold">O</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{cow.hasMpt ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-pink-500 text-white font-bold">O</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${
|
||||
cow.testCount === 3 ? 'bg-emerald-100 text-emerald-700' :
|
||||
cow.testCount === 2 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{cow.testCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* 농가별 중복 검사 요약 */}
|
||||
<div className="ml-6 mt-3 p-3 bg-white rounded-lg border text-xs">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{farm.genomeOnly > 0 && (
|
||||
<span className="text-slate-600">유전체만: <span className="font-bold">{farm.genomeOnly}</span></span>
|
||||
)}
|
||||
{farm.geneOnly > 0 && (
|
||||
<span className="text-slate-600">유전자만: <span className="font-bold">{farm.geneOnly}</span></span>
|
||||
)}
|
||||
{farm.mptOnly > 0 && (
|
||||
<span className="text-slate-600">번식능력만: <span className="font-bold">{farm.mptOnly}</span></span>
|
||||
)}
|
||||
{farm.genomeAndGene > 0 && (
|
||||
<span className="text-blue-600">유전체+유전자: <span className="font-bold">{farm.genomeAndGene}</span></span>
|
||||
)}
|
||||
{farm.genomeAndMpt > 0 && (
|
||||
<span className="text-blue-600">유전체+번식능력: <span className="font-bold">{farm.genomeAndMpt}</span></span>
|
||||
)}
|
||||
{farm.geneAndMpt > 0 && (
|
||||
<span className="text-purple-600">유전자+번식능력: <span className="font-bold">{farm.geneAndMpt}</span></span>
|
||||
)}
|
||||
{farm.allThree > 0 && (
|
||||
<span className="text-emerald-600">3종 모두: <span className="font-bold">{farm.allThree}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-slate-200 font-semibold">
|
||||
<td className="px-4 py-3"></td>
|
||||
<td className="px-4 py-3 text-slate-900">합계</td>
|
||||
<td className="px-4 py-3 text-center text-blue-700">{data.genomeCowCount}</td>
|
||||
<td className="px-4 py-3 text-center text-purple-700">{data.geneCowCount}</td>
|
||||
<td className="px-4 py-3 text-center text-pink-700">{data.mptCowCount}</td>
|
||||
<td className="px-4 py-3 text-center text-slate-900">{data.totalCows}</td>
|
||||
<td className="px-4 py-3 text-center text-emerald-700">{data.totalTests}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
interface GeneData {
|
||||
geneName: string
|
||||
geneType: '육량' | '육질' // 유전자 분류
|
||||
farmRate: number // 우리 농장 우량형(AA) 보유율
|
||||
regionAvgRate: number // 지역 평균
|
||||
}
|
||||
|
||||
interface GenePossessionStatusProps {
|
||||
farmNo: number | null
|
||||
}
|
||||
|
||||
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
|
||||
const { selectedYear } = useAnalysisYear()
|
||||
const { filters } = useFilterStore()
|
||||
const [allGenes, setAllGenes] = useState<GeneData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// 선택된 유전자 확인
|
||||
const selectedGenes = filters.selectedGenes || []
|
||||
const hasFilter = selectedGenes.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
|
||||
// TODO: 백엔드 API 연동 시 실제 데이터 fetch
|
||||
// 현재는 목업 데이터 사용 (전체 유전자 리스트)
|
||||
const mockAllGenes: GeneData[] = [
|
||||
// 육량 관련
|
||||
{ geneName: 'PLAG1', geneType: '육량', farmRate: 85, regionAvgRate: 72 },
|
||||
{ geneName: 'NCAPG', geneType: '육량', farmRate: 82, regionAvgRate: 75 },
|
||||
{ geneName: 'LCORL', geneType: '육량', farmRate: 78, regionAvgRate: 68 },
|
||||
{ geneName: 'LAP3', geneType: '육량', farmRate: 65, regionAvgRate: 58 },
|
||||
|
||||
// 육질 관련
|
||||
{ geneName: 'FABP4', geneType: '육질', farmRate: 88, regionAvgRate: 70 },
|
||||
{ geneName: 'SCD', geneType: '육질', farmRate: 80, regionAvgRate: 72 },
|
||||
{ geneName: 'DGAT1', geneType: '육질', farmRate: 75, regionAvgRate: 65 },
|
||||
{ geneName: 'FASN', geneType: '육질', farmRate: 70, regionAvgRate: 62 },
|
||||
{ geneName: 'CAPN1', geneType: '육질', farmRate: 82, regionAvgRate: 68 },
|
||||
{ geneName: 'CAST', geneType: '육질', farmRate: 77, regionAvgRate: 64 },
|
||||
]
|
||||
|
||||
// 선택된 유전자 중 목업 데이터에 없는 유전자가 있다면 추가
|
||||
if (selectedGenes.length > 0) {
|
||||
selectedGenes.forEach(geneName => {
|
||||
if (!mockAllGenes.find(g => g.geneName === geneName)) {
|
||||
// 선택된 유전자가 목업 데이터에 없으면 기본값으로 추가
|
||||
mockAllGenes.push({
|
||||
geneName: geneName,
|
||||
geneType: geneName.includes('PLAG') || geneName.includes('NCAPG') || geneName.includes('LCORL') || geneName.includes('LAP') ? '육량' : '육질',
|
||||
farmRate: Math.floor(Math.random() * 30) + 60, // 60-90 사이 랜덤값
|
||||
regionAvgRate: Math.floor(Math.random() * 20) + 55, // 55-75 사이 랜덤값
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setAllGenes(mockAllGenes)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [selectedYear, farmNo, selectedGenes])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<p className="text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!farmNo) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-2">농장 정보가 없습니다</p>
|
||||
<p className="text-sm text-muted-foreground">로그인 후 다시 시도해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 필터에 따라 표시할 유전자 선택
|
||||
const allDisplayGenes = hasFilter
|
||||
? allGenes.filter(g => selectedGenes.includes(g.geneName))
|
||||
: allGenes.slice(0, 6) // TOP 6 (보유율 높은 순으로 이미 정렬됨)
|
||||
|
||||
// 접기/펼치기 적용 (4개 기준)
|
||||
// 단, 선택된 유전자가 있을 때는 모두 표시
|
||||
const DISPLAY_LIMIT = 4
|
||||
const displayGenes = hasFilter || isExpanded ? allDisplayGenes : allDisplayGenes.slice(0, DISPLAY_LIMIT)
|
||||
const hasMore = !hasFilter && allDisplayGenes.length > DISPLAY_LIMIT
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필터 배지 표시 */}
|
||||
{hasFilter && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">타겟 유전자:</span>
|
||||
</div>
|
||||
{selectedGenes.map(gene => (
|
||||
<Badge
|
||||
key={gene}
|
||||
variant="secondary"
|
||||
className="text-xs font-medium bg-blue-50 text-blue-700 border-blue-200"
|
||||
>
|
||||
{gene}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 유전자별 바 차트 */}
|
||||
<div className="space-y-2.5">
|
||||
{displayGenes.map((gene, index) => (
|
||||
<div key={gene.geneName} className="space-y-1">
|
||||
{/* 유전자명 + 타입 배지 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 min-w-[60px]">
|
||||
{gene.geneName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs px-2 py-0 ${
|
||||
gene.geneType === '육량'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: 'bg-purple-50 text-purple-700 border-purple-200'
|
||||
}`}
|
||||
>
|
||||
{gene.geneType}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{gene.farmRate}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프로그레스 바 */}
|
||||
<div className="relative h-7 bg-gray-100 rounded-full overflow-hidden">
|
||||
{/* 우리 농장 */}
|
||||
<div
|
||||
className={`absolute h-full transition-all duration-800 ${
|
||||
gene.geneType === '육량' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`}
|
||||
style={{ width: `${gene.farmRate}%` }}
|
||||
/>
|
||||
{/* 지역 평균 표시 (점선) */}
|
||||
<div
|
||||
className="absolute h-full border-l-2 border-dashed border-gray-400"
|
||||
style={{ left: `${gene.regionAvgRate}%` }}
|
||||
title={`지역 평균: ${gene.regionAvgRate}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지역 평균 레이블 */}
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-gray-500">
|
||||
지역 평균: {gene.regionAvgRate}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 더보기/접기 버튼 */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
접기
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
나머지 {allDisplayGenes.length - DISPLAY_LIMIT}개 더보기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -131,6 +131,13 @@ export const genomeApi = {
|
||||
return await apiClient.get(`/genome/dashboard-stats/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/yearly-ebv-stats/:farmNo - 연도별 EBV 통계 (개체상세용)
|
||||
*/
|
||||
getYearlyEbvStats: async (farmNo: number): Promise<YearlyEbvStatsDto> => {
|
||||
return await apiClient.get(`/genome/yearly-ebv-stats/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/farm-region-ranking/:farmNo - 농가의 보은군 내 순위 조회 (대시보드용)
|
||||
*/
|
||||
@@ -222,7 +229,7 @@ export interface FarmRegionRankingDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 통계 데이터 타입
|
||||
* 대시보드 통계 데이터 타입 (필수 4개만)
|
||||
*/
|
||||
export interface DashboardStatsDto {
|
||||
// 연도별 분석 현황
|
||||
@@ -231,91 +238,63 @@ export interface DashboardStatsDto {
|
||||
totalRequests: number;
|
||||
analyzedCount: number;
|
||||
pendingCount: number;
|
||||
sireMatchCount: number; // 친자 일치 수
|
||||
analyzeRate: number; // 분석 완료율 (%)
|
||||
sireMatchRate: number; // 친자 일치율 (%)
|
||||
sireMatchCount: number;
|
||||
analyzeRate: number;
|
||||
sireMatchRate: number;
|
||||
}[];
|
||||
// 형질별 농장 평균
|
||||
traitAverages: {
|
||||
traitName: string;
|
||||
category: string;
|
||||
avgEbv: number;
|
||||
avgEpd: number; // 농가 육종가(EPD) 평균
|
||||
regionAvgEpd: number; // 보은군 육종가(EPD) 평균
|
||||
avgEpd: number;
|
||||
regionAvgEpd?: number;
|
||||
avgPercentile: number;
|
||||
count: number;
|
||||
rank: number | null; // 보은군 내 농가 순위
|
||||
totalFarms: number; // 보은군 내 총 농가 수
|
||||
percentile: number | null; // 상위 백분율
|
||||
}[];
|
||||
// 접수 내역 목록
|
||||
requestHistory: {
|
||||
pkRequestNo: number;
|
||||
cowId: string;
|
||||
cowRemarks: string | null;
|
||||
requestDt: string | null;
|
||||
chipSireName: string | null;
|
||||
chipReportDt: string | null;
|
||||
status: string;
|
||||
rank: number | null;
|
||||
totalFarms: number;
|
||||
percentile: number | null;
|
||||
}[];
|
||||
// 요약
|
||||
summary: {
|
||||
totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외)
|
||||
genomeCowCount: number; // 유전체 분석 개체 수
|
||||
geneCowCount: number; // 유전자검사 개체 수
|
||||
mptCowCount: number; // 번식능력검사 개체 수
|
||||
totalRequests: number; // 유전체 의뢰 건수 (기존 호환성)
|
||||
totalCows: number;
|
||||
genomeCowCount: number;
|
||||
geneCowCount: number;
|
||||
mptCowCount: number;
|
||||
totalRequests: number;
|
||||
analyzedCount: number;
|
||||
pendingCount: number;
|
||||
mismatchCount: number;
|
||||
maleCount: number; // 수컷 수
|
||||
femaleCount: number; // 암컷 수
|
||||
maleCount: number;
|
||||
femaleCount: number;
|
||||
};
|
||||
// 검사 종류별 현황
|
||||
testTypeStats: {
|
||||
snp: { total: number; completed: number };
|
||||
ms: { total: number; completed: number };
|
||||
};
|
||||
// 친자감별 결과 현황 (상호 배타적 분류)
|
||||
// 친자감별 결과 현황
|
||||
paternityStats: {
|
||||
analysisComplete: number; // 분석 완료 (부 일치 + 모 일치/null/정보없음)
|
||||
sireMismatch: number; // 부 불일치
|
||||
damMismatch: number; // 모 불일치 (부 일치 + 모 불일치)
|
||||
damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재)
|
||||
pending: number; // 대기
|
||||
analysisComplete: number;
|
||||
sireMismatch: number;
|
||||
damMismatch: number;
|
||||
damNoRecord: number;
|
||||
notAnalyzed: number;
|
||||
};
|
||||
// 월별 접수 현황
|
||||
monthlyStats: {
|
||||
month: number;
|
||||
count: number;
|
||||
}[];
|
||||
// 칩 종류별 분포
|
||||
chipTypeStats: {
|
||||
chipType: string;
|
||||
count: number;
|
||||
}[];
|
||||
// 모근량별 분포
|
||||
sampleAmountStats: {
|
||||
sampleAmount: string;
|
||||
count: number;
|
||||
}[];
|
||||
// 연도별 주요 형질 평균 (차트용)
|
||||
yearlyTraitAverages: {
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도별 EBV 통계 (개체상세용)
|
||||
*/
|
||||
export interface YearlyEbvStatsDto {
|
||||
yearlyStats: {
|
||||
year: number;
|
||||
traits: { traitName: string; avgEbv: number | null }[];
|
||||
totalRequests: number;
|
||||
analyzedCount: number;
|
||||
pendingCount: number;
|
||||
sireMatchCount: number;
|
||||
analyzeRate: number;
|
||||
sireMatchRate: number;
|
||||
}[];
|
||||
// 연도별 평균 표준화육종가 (농가 vs 보은군 비교)
|
||||
yearlyAvgEbv: {
|
||||
year: number;
|
||||
farmAvgEbv: number; // 농가 평균
|
||||
regionAvgEbv: number; // 보은군 평균
|
||||
farmAvgEbv: number;
|
||||
regionAvgEbv: number;
|
||||
traitCount: number;
|
||||
}[];
|
||||
// 우수 개체 TOP 5 (옵션)
|
||||
topAnimals?: {
|
||||
animalId?: string;
|
||||
identNo?: string;
|
||||
birthDt?: string;
|
||||
avgEbv?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user