diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index 1510666..db4c914 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -723,11 +723,9 @@ export class GenomeService { traitVal: detail.traitVal, // 형질 측정값 breedVal: detail.traitEbv, // EBV (추정육종가) percentile: detail.traitPercentile, // 백분위 순위 - traitInfo: { - traitNm: detail.traitName, // 형질명 - traitCtgry: getTraitCategory(detail.traitName || ''), // 카테고리 (공통 함수 사용) - traitDesc: '', // 형질 설명 (빈값) - }, + traitName: detail.traitName, // 형질명 (평평한 구조) + traitCategory: getTraitCategory(detail.traitName || ''), // 카테고리 + traitDesc: '', // 형질 설명 (빈값) })), }]; } diff --git a/backend/src/mpt/entities/mpt.entity.ts b/backend/src/mpt/entities/mpt.entity.ts index 2f24cab..be9f407 100644 --- a/backend/src/mpt/entities/mpt.entity.ts +++ b/backend/src/mpt/entities/mpt.entity.ts @@ -1,5 +1,5 @@ import { BaseModel } from 'src/common/entities/base.entity'; -import { FarmModel } from 'src/farm/entities/farm.entity'; +import { CowModel } from 'src/cow/entities/cow.entity'; import { Column, Entity, @@ -254,7 +254,10 @@ export class MptModel extends BaseModel { creatine: number; // Relations - @ManyToOne(() => FarmModel, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'fk_farm_no' }) - farm: FarmModel; + @ManyToOne(() => CowModel, { + onDelete: 'CASCADE', + createForeignKeyConstraints: false + }) + @JoinColumn({ name: 'cow_id', referencedColumnName: 'cowId' }) + cow: CowModel; } diff --git a/backend/src/mpt/mpt.service.ts b/backend/src/mpt/mpt.service.ts index 431e35e..90588f9 100644 --- a/backend/src/mpt/mpt.service.ts +++ b/backend/src/mpt/mpt.service.ts @@ -20,7 +20,7 @@ export class MptService { async findByCowShortNo(cowShortNo: string): Promise { return this.mptRepository.find({ where: { cowShortNo: cowShortNo, delDt: IsNull() }, - relations: ['farm'], + relations: ['cow', 'cow.farm'], order: { testDt: 'DESC' }, }); } @@ -28,7 +28,7 @@ export class MptService { async findByCowId(cowId: string): Promise { return this.mptRepository.find({ where: { cowId: cowId, delDt: IsNull() }, - relations: ['farm'], + relations: ['cow', 'cow.farm'], order: { testDt: 'DESC' }, }); } diff --git a/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx b/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx index 1b08c94..1c89c2d 100644 --- a/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx +++ b/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx @@ -75,15 +75,12 @@ interface CategoryStat { } interface TraitData { - id: number - name: string - category: string - breedVal: number // 표준화육종가 (σ 단위) - percentile: number - actualValue: number // EPD (예상후대차이) 원래 값 - unit: string - description: string - importance: string + id?: number + traitName?: string // 형질명 + traitCategory?: string // 카테고리 + breedVal?: number // 표준화육종가 (σ 단위) + percentile?: number + traitVal?: number // EPD (예상후대차이) 원래 값 } interface CategoryEvaluationCardProps { @@ -154,7 +151,7 @@ export function CategoryEvaluationCard({ // 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교 const traitChartData = chartTraits.map(traitName => { - const trait = allTraits.find((t: TraitData) => t.name === traitName) + const trait = allTraits.find((t: TraitData) => t.traitName === traitName) // 형질별 평균 데이터에서 해당 형질 찾기 const traitAvgRegion = traitComparisonAverages?.region?.find(t => t.traitName === traitName) @@ -171,13 +168,13 @@ export function CategoryEvaluationCard({ name: traitName, shortName: TRAIT_SHORT_NAMES[traitName] || traitName, breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ) - epd: trait?.actualValue ?? 0, // 이 개체 EPD (육종가) + epd: trait?.traitVal ?? 0, // 이 개체 EPD (육종가) regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가) farmVal: farmTraitAvg, // 농가 평균 (표준화육종가) regionEpd: regionEpdAvg, // 보은군 평균 (육종가) farmEpd: farmEpdAvg, // 농가 평균 (육종가) percentile: trait?.percentile ?? 50, - category: trait?.category ?? '체형', + category: trait?.traitCategory ?? '체형', diff: trait?.breedVal ?? 0, hasData: !!trait } @@ -265,7 +262,7 @@ export function CategoryEvaluationCard({
{traits.map(trait => { const isSelected = chartTraits.includes(trait) - const traitData = allTraits.find((t: TraitData) => t.name === trait) + const traitData = allTraits.find((t: TraitData) => t.traitName === trait) return (
diff --git a/frontend/src/app/cow/[cowNo]/genome/_components/trait-distribution-charts.tsx b/frontend/src/app/cow/[cowNo]/genome/_components/trait-distribution-charts.tsx index 9c6902a..b7ce1a8 100644 --- a/frontend/src/app/cow/[cowNo]/genome/_components/trait-distribution-charts.tsx +++ b/frontend/src/app/cow/[cowNo]/genome/_components/trait-distribution-charts.tsx @@ -29,13 +29,12 @@ const CATEGORY_STYLES: Record {traits.map((trait, idx) => ( - + {trait.shortName} - {trait.category && ( + {trait.traitCategory && ( - {trait.category} + {trait.traitCategory} )}
{ - const value = trait.actualValue ?? 0 - const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.name) + const value = trait.traitVal ?? 0 + const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '') // 등지방두께: 음수가 좋음(녹색), 양수가 나쁨(빨간색) // 나머지: 양수가 좋음(녹색), 음수가 나쁨(빨간색) if (value === 0) return 'text-muted-foreground' @@ -91,15 +90,15 @@ function TraitListView({ traits, cowName }: { traits: Array<{ name: string; shor } return value > 0 ? 'text-green-600' : 'text-red-600' })()}`}> - {trait.actualValue !== undefined ? ( - <>{trait.actualValue > 0 ? '+' : ''}{trait.actualValue.toFixed(1)} + {trait.traitVal !== undefined ? ( + <>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)} ) : '-'}
- 상위 {trait.percentile.toFixed(0)}% + 상위 {(trait.percentile || 0).toFixed(0)}% @@ -134,29 +133,29 @@ export function TraitDistributionCharts({ const displayTraits = useMemo(() => { if (selectedTraits.length > 0) { return selectedTraits.map(trait => { - const weight = traitWeights[trait.name] || 1 + const weight = traitWeights[trait.traitName || ''] || 1 return { - name: trait.name, - shortName: TRAIT_SHORT_NAMES[trait.name] || trait.name, - breedVal: trait.breedVal * weight, + traitName: trait.traitName, + shortName: TRAIT_SHORT_NAMES[trait.traitName || ''] || trait.traitName, + breedVal: (trait.breedVal || 0) * weight, percentile: trait.percentile, - category: trait.category, - actualValue: trait.actualValue, + traitCategory: trait.traitCategory, + traitVal: trait.traitVal, hasData: true } }) } // 기본 7개 형질 return DEFAULT_TRAITS.map(traitName => { - const trait = allTraits.find(t => t.name === traitName) + const trait = allTraits.find(t => t.traitName === traitName) const weight = traitWeights[traitName] || 1 return { - name: traitName, + traitName: traitName, shortName: TRAIT_SHORT_NAMES[traitName] || traitName, breedVal: (trait?.breedVal ?? 0) * weight, percentile: trait?.percentile ?? 50, - category: trait?.category, - actualValue: trait?.actualValue, + traitCategory: trait?.traitCategory, + traitVal: trait?.traitVal, hasData: !!trait } }) diff --git a/frontend/src/app/cow/[cowNo]/page.tsx b/frontend/src/app/cow/[cowNo]/page.tsx index e8bca2e..3b8a101 100644 --- a/frontend/src/app/cow/[cowNo]/page.tsx +++ b/frontend/src/app/cow/[cowNo]/page.tsx @@ -19,6 +19,7 @@ import { geneApi, GeneDetail } from "@/lib/api/gene.api" import { genomeApi, ComparisonAveragesDto, GenomeRequestDto, TraitComparisonAveragesDto } from "@/lib/api/genome.api" import { mptApi } from "@/lib/api/mpt.api" import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config" +import { ALL_TRAITS } from "@/constants/traits" import { CowDetail } from "@/types/cow.types" import { GenomeTrait } from "@/types/genome.types" import { @@ -40,82 +41,7 @@ import { NormalDistributionChart } from "./genome/_components/normal-distributio import { TraitDistributionCharts } from "./genome/_components/trait-distribution-charts" import { MptTable } from "./reproduction/_components/mpt-table" -// 형질명 → 카테고리 매핑 (한우 35개 형질) -const TRAIT_CATEGORY_MAP: Record = { - // 성장형질 (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개의 정규분포 곡선 생성 +// 유전체 차트 3개의 정규분포 곡선 생성 function generateMultipleDistributions( nationwideMean: number, nationwideStd: number, regionMean: number, regionStd: number, @@ -142,6 +68,9 @@ function generateMultipleDistributions( } export default function CowOverviewPage() { + // ======================================== + // 기본 훅 + // ======================================== const params = useParams() const searchParams = useSearchParams() const router = useRouter() @@ -151,24 +80,25 @@ export default function CowOverviewPage() { const { filters } = useFilterStore() const isMobile = useMediaQuery("(max-width: 640px)") + // ======================================== + // 상태 정의 + // ======================================== + // 1. 개체/유전체 데이터 const [cow, setCow] = useState(null) const [genomeData, setGenomeData] = useState([]) const [geneData, setGeneData] = useState([]) - const [geneDataLoaded, setGeneDataLoaded] = useState(false) // 유전자 데이터 로드 여부 - const [geneDataLoading, setGeneDataLoading] = useState(false) // 유전자 데이터 로딩 중 + const [geneDataLoaded, setGeneDataLoaded] = useState(false) + const [geneDataLoading, setGeneDataLoading] = useState(false) const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState('genome') - const [showScrollTop, setShowScrollTop] = useState(false) - // 검사 상태 + // 2. 검사 상태 const [hasGenomeData, setHasGenomeData] = useState(false) const [hasGeneData, setHasGeneData] = useState(false) const [hasReproductionData, setHasReproductionData] = useState(false) - - // 분석 의뢰 정보 (친자감별 결과 포함) const [genomeRequest, setGenomeRequest] = useState(null) - // 선발지수 상태 + // 3. 선발지수 const [selectionIndex, setSelectionIndex] = useState<{ score: number | null; percentile: number | null; @@ -182,7 +112,7 @@ export default function CowOverviewPage() { regionAvgScore: number | null; } | null>(null) - // 분포 데이터 + // 4. 분포/비교 데이터 const [comparisonAverages, setComparisonAverages] = useState(null) const [traitComparisonAverages, setTraitComparisonAverages] = useState(null) const [distributionData, setDistributionData] = useState<{ range: string; count: number; farmCount: number; min: number; max: number }[]>([]) @@ -191,39 +121,46 @@ export default function CowOverviewPage() { const [farmAvgScore, setFarmAvgScore] = useState(0) const [regionAvgScore, setRegionAvgScore] = useState(0) const [traitComparisons, setTraitComparisons] = useState([]) + + // 5. UI 상태 + const [showScrollTop, setShowScrollTop] = useState(false) const [showAllAppliedTraits, setShowAllAppliedTraits] = useState(false) const [isChartModalOpen, setIsChartModalOpen] = useState(false) - - // 분포 곡선 표시 토글 const [showNationwide] = useState(true) const [showRegion] = useState(true) const [showFarm] = useState(true) const [showAllTraits] = useState(false) const [selectedTraits, setSelectedTraits] = useState([]) - const [geneCurrentPage, setGeneCurrentPage] = useState(1) - const GENES_PER_PAGE = 50 - - // 농가/보은군 비교 하이라이트 모드 const [highlightMode, setHighlightMode] = useState<'farm' | 'region' | null>(null) const distributionChartRef = useRef(null) - // 필터에서 고정된 첫 번째 형질 (없으면 '도체중') + // 6. 차트 필터 const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중' - - // 차트 형질 필터 (전체 선발지수 또는 개별 형질) - // 필터 비활성 시 기본값은 첫 번째 고정 형질 const [chartFilterTrait, setChartFilterTrait] = useState(() => { return filters.isActive ? 'overall' : firstPinnedTrait }) - // 필터 활성 상태 변경 시 기본값 업데이트 + // 7. 유전자 탭 필터/정렬 + const [geneSearchInput, setGeneSearchInput] = useState('') + const [geneSearchKeyword, setGeneSearchKeyword] = useState('') + const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all') + const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all') + const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName') + const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc') + const [geneCurrentPage, setGeneCurrentPage] = useState(1) + const GENES_PER_PAGE = 50 + + // ======================================== + // useEffect - UI 이벤트 + // ======================================== + // 필터 활성 상태 변경 시 차트 기본값 업데이트 useEffect(() => { if (!filters.isActive && chartFilterTrait === 'overall') { setChartFilterTrait(firstPinnedTrait) } }, [filters.isActive, firstPinnedTrait, chartFilterTrait]) - // 스크롤 투 탑 버튼 표시 여부 + // 스크롤 투 탑 버튼 표시 useEffect(() => { const handleScroll = () => { setShowScrollTop(window.scrollY > 400) @@ -232,18 +169,9 @@ export default function CowOverviewPage() { return () => window.removeEventListener('scroll', handleScroll) }, []) - // 맨 위로 스크롤 - const scrollToTop = () => { - window.scrollTo({ top: 0, behavior: 'smooth' }) - } - - // 유전자 탭 필터 상태 - const [geneSearchInput, setGeneSearchInput] = useState('') // 실시간 입력값 - const [geneSearchKeyword, setGeneSearchKeyword] = useState('') // 디바운스된 검색값 - const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all') - - // 검색어 디바운스 (300ms) 실시간 필터링 너무 느림 - // 타이핑이 멈추고 0.3초 후에 검색이 실행 + // 검색어 디바운스 (300ms) + // 유전자 데이터가 너무 많아서 검색창에 입력할 때마다 모든 데이터를 필터링하지 않고 + // 검색어가 변경된 후 300ms 후에 필터링을 적용 useEffect(() => { const timer = setTimeout(() => { setGeneSearchKeyword(geneSearchInput) @@ -251,11 +179,16 @@ export default function CowOverviewPage() { }, 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 scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } // 부 KPN 배지 렌더링 (분석불가/일치/불일치) + // 항상 분석 불가 상태를 체크 후에 데이터를 보여줘야함 const renderSireBadge = (chipSireName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => { const sizeClasses = size === 'lg' ? 'gap-1.5 text-sm px-3 py-1.5' @@ -289,6 +222,7 @@ export default function CowOverviewPage() { } // 모 개체 배지 렌더링 (일치/불일치/이력제부재) + // 모 불일치일 경우도 유전체 분석결과가 안나옴 체크 후 데이터를 보여줘야함 const renderDamBadge = (chipDamName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => { // 분석불가 개체는 어미 배지 표시 안 함 if (isExcludedCow(cow?.cowId)) return null @@ -345,6 +279,7 @@ export default function CowOverviewPage() { } // 탭 변경 핸들러 + // 유전자 탭이 활성화되면 유전자 데이터 로드 const handleTabChange = (value: string) => { setActiveTab(value) if (value === 'gene' && !geneDataLoaded) { @@ -352,20 +287,6 @@ export default function CowOverviewPage() { } } - // 농가/보은군 배지 클릭 시 차트로 스크롤 + 하이라이트 - 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') @@ -376,10 +297,15 @@ export default function CowOverviewPage() { } } + // ======================================== + // 개체 상세 데이터 조회 + // ======================================== useEffect(() => { const fetchData = async () => { try { setLoading(true) + + // 1. 개체 정보 조회 const cowData = await cowApi.findOne(cowNo) const cowDetail: CowDetail = { ...cowData, @@ -388,19 +314,17 @@ export default function CowOverviewPage() { : undefined, } setCow(cowDetail) - - // dataStatus에서 데이터 존재 여부 설정 (백엔드에서 가벼운 COUNT 쿼리로 확인) if (cowData.dataStatus) { setHasGeneData(cowData.dataStatus.hasGeneData) } - // 유전체 데이터 가져오기 + // 2. 유전체 데이터 조회 const genomeDataResult = await genomeApi.findByCowNo(cowNo) setGenomeData(genomeDataResult) const genomeExists = genomeDataResult.length > 0 && !!genomeDataResult[0].genomeCows && genomeDataResult[0].genomeCows.length > 0 setHasGenomeData(genomeExists) - // 분석 의뢰 정보 가져오기 (친자감별 결과 포함) + // 3. 분석 의뢰 정보 조회 (친자감별 결과) try { const requestData = await genomeApi.getRequest(cowNo) setGenomeRequest(requestData) @@ -409,7 +333,7 @@ export default function CowOverviewPage() { setGenomeRequest(null) } - // 번식능력 데이터 조회 + // 4. 번식능력(MPT) 데이터 조회 try { const mptData = await mptApi.findByCowId(cowNo) setHasReproductionData(mptData && mptData.length > 0) @@ -417,35 +341,23 @@ export default function CowOverviewPage() { setHasReproductionData(false) } - // 첫 번째 사용 가능한 탭 자동 선택 + // 5. 탭 자동 선택 if (genomeExists) { setActiveTab('genome') } else if (geneData && geneData.length > 0) { setActiveTab('gene') } - // 비교 데이터 가져오기 + // 6. 비교 데이터 + 선발지수 조회 if (genomeDataResult.length > 0) { try { const comparisonData = await genomeApi.getComparisonAverages(cowNo) setComparisonAverages(comparisonData) - // 형질별 비교 평균 가져오기 (폴리곤 차트용) const traitComparisonData = await genomeApi.getTraitComparisonAverages(cowNo) setTraitComparisonAverages(traitComparisonData) // 선발지수 계산 - const 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) .filter(([, weight]) => weight > 0) .map(([traitNm, weight]) => ({ traitNm, weight })) @@ -454,7 +366,6 @@ export default function CowOverviewPage() { ? traitConditions : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })) - const indexResult = await genomeApi.getSelectionIndex(cowNo, finalConditions) setSelectionIndex(indexResult) } catch (compErr) { @@ -476,25 +387,27 @@ export default function CowOverviewPage() { fetchData() }, [cowNo, toast, filters.isActive, filters.selectedTraits, filters.traitWeights]) - // API 데이터를 화면용으로 변환 - const GENOMIC_TRAITS = useMemo(() => { - return transformGenomeData(genomeData) - }, [genomeData]) + // ======================================== + // 계산된 데이터 (useMemo) + // - 의존성 변경 시에만 재계산하여 성능 최적화 + // ======================================== - // 고유 카테고리 목록 - const CATEGORIES = useMemo(() => { - return [...new Set(GENOMIC_TRAITS.map(t => t.category).filter(Boolean))] - }, [GENOMIC_TRAITS]) + // API 응답 형질 데이터 (변환 없이 직접 사용) + const GENOMIC_TRAITS = useMemo(() => genomeData[0]?.genomeCows || [], [genomeData]) - // 종합 지표 + // 형질 카테고리 목록 (성장/생산/체형/무게/비율) + const CATEGORIES = useMemo(() => [...new Set(GENOMIC_TRAITS.map(t => t.traitCategory).filter((cat): cat is string => !!cat))], [GENOMIC_TRAITS]) + + // 종합 선발지수 점수 (API 값 우선, 없으면 형질 평균) const overallScore = useMemo(() => { if (selectionIndex?.score !== null && selectionIndex?.score !== undefined) { - return selectionIndex.score // 내개체 + return selectionIndex.score } if (GENOMIC_TRAITS.length === 0) return 0 - return GENOMIC_TRAITS.reduce((sum, t) => sum + t.breedVal, 0) / GENOMIC_TRAITS.length + return GENOMIC_TRAITS.reduce((sum, t) => sum + (t.breedVal || 0), 0) / GENOMIC_TRAITS.length }, [GENOMIC_TRAITS, selectionIndex]) + // 종합 백분위 (API 값 우선, 없으면 CDF 계산) const overallPercentile = useMemo(() => { if (selectionIndex?.percentile !== null && selectionIndex?.percentile !== undefined) { return selectionIndex.percentile @@ -504,79 +417,68 @@ export default function CowOverviewPage() { return (1 - cdf) * 100 } if (GENOMIC_TRAITS.length === 0) return 50 - return GENOMIC_TRAITS.reduce((sum, t) => sum + t.percentile, 0) / GENOMIC_TRAITS.length + return GENOMIC_TRAITS.reduce((sum, t) => sum + (t.percentile || 0), 0) / GENOMIC_TRAITS.length }, [GENOMIC_TRAITS, selectionIndex, overallScore]) - // 카테고리별 평균 + // 카테고리별 평균 육종가/백분위 (카드 표시용) const categoryStats = useMemo(() => { return CATEGORIES.map(cat => { - const traits = GENOMIC_TRAITS.filter(t => t.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 + const traits = GENOMIC_TRAITS.filter(t => t.traitCategory === cat) + const avgBreedVal = traits.reduce((sum, t) => sum + (t.breedVal || 0), 0) / traits.length + const avgPercentile = traits.reduce((sum, t) => sum + (t.percentile || 0), 0) / traits.length return { category: cat, avgBreedVal, avgPercentile, count: traits.length } }) }, [CATEGORIES, GENOMIC_TRAITS]) - // 평균 Z-Score + // 농가 평균 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 + return comparisonAverages.farm.reduce((sum, cat) => sum + cat.avgEbv, 0) / comparisonAverages.farm.length }, [comparisonAverages, overallScore]) + // 지역 평균 Z-Score (정규분포 차트용) const regionAvgZ = useMemo(() => { - if (!comparisonAverages?.region || comparisonAverages.region.length === 0) { - return -0.2 - } - const totalEbv = comparisonAverages.region.reduce((sum, cat) => sum + cat.avgEbv, 0) - return totalEbv / comparisonAverages.region.length + if (!comparisonAverages?.region || comparisonAverages.region.length === 0) return -0.2 + return comparisonAverages.region.reduce((sum, cat) => sum + cat.avgEbv, 0) / comparisonAverages.region.length }, [comparisonAverages]) - // 개체 EPD 평균 (선택된 형질 기준) + // 개체 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.filter(t => selectedTraitNames.includes(t.traitName || '')) : GENOMIC_TRAITS - if (targetTraits.length === 0) return null - - const totalEpd = targetTraits.reduce((sum, t) => sum + t.actualValue, 0) - return totalEpd / targetTraits.length + return targetTraits.reduce((sum, t) => sum + (t.traitVal || 0), 0) / targetTraits.length }, [GENOMIC_TRAITS, filters.traitWeights]) // 농가 EPD 평균 const farmAvgEpdValue = useMemo(() => { if (!comparisonAverages?.farm || comparisonAverages.farm.length === 0) return null - const totalEpd = comparisonAverages.farm.reduce((sum, cat) => sum + (cat.avgEpd || 0), 0) - return totalEpd / comparisonAverages.farm.length + return comparisonAverages.farm.reduce((sum, cat) => sum + (cat.avgEpd || 0), 0) / comparisonAverages.farm.length }, [comparisonAverages]) - // 보은군 EPD 평균 + // 지역 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 + return comparisonAverages.region.reduce((sum, cat) => sum + (cat.avgEpd || 0), 0) / comparisonAverages.region.length }, [comparisonAverages]) - // 필터에서 선택한 형질 데이터 + // 전역 필터에서 선택한 형질 데이터 const filterSelectedTraitData = useMemo(() => { const selectedTraitNames = Object.entries(filters.traitWeights) .filter(([, weight]) => weight > 0) .map(([traitNm]) => traitNm) if (selectedTraitNames.length === 0) return [] - return GENOMIC_TRAITS.filter(t => selectedTraitNames.includes(t.name)) + return GENOMIC_TRAITS.filter(t => selectedTraitNames.includes(t.traitName || '')) }, [filters.traitWeights, GENOMIC_TRAITS]) - // 정규분포 데이터 - const multiDistribution = useMemo(() => { - return generateMultipleDistributions(0, 1, regionAvgZ, 1, farmAvgZ, 1) - }, [regionAvgZ, farmAvgZ]) + // 정규분포 곡선 데이터 (전국/지역/농가 비교 차트) + const multiDistribution = useMemo(() => generateMultipleDistributions(0, 1, regionAvgZ, 1, farmAvgZ, 1), [regionAvgZ, farmAvgZ]) const toggleTraitSelection = (traitId: number) => { setSelectedTraits(prev => @@ -603,6 +505,7 @@ export default function CowOverviewPage() { ) } + // 본문시작 ==================================================================================================== return ( diff --git a/frontend/src/app/cow/page.tsx b/frontend/src/app/cow/page.tsx index b0b40b4..9eb8c25 100644 --- a/frontend/src/app/cow/page.tsx +++ b/frontend/src/app/cow/page.tsx @@ -8,7 +8,7 @@ import { SidebarProvider, } from "@/components/ui/sidebar" import { Button } from "@/components/ui/button" -import { Cow } from "@/types/cow.types" +import { Cow, CowWithGenes, RankingItem } from "@/types/cow.types" import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import { ChevronLeft, ChevronRight, Search, ChevronsUpDown, Filter, Settings } from "lucide-react" @@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { ScrollArea } from "@/components/ui/scroll-area" import { cowApi } from "@/lib/api/cow.api" +import { TRAIT_DISPLAY_NAMES } from "@/constants/traits" import { useAuthStore } from "@/store/auth-store" import { useFilterStore } from "@/store/filter-store" import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext" @@ -28,41 +29,6 @@ import { AuthGuard } from "@/components/auth/auth-guard" /** * 개체 리스트 페이지 */ -/** - * 유전자 정보를 포함한 Cow 타입 (임시 - 실제로는 API에서 가져와야 함) - */ -interface TraitData { - breedVal: number | null - traitVal: number | null -} - -interface CowWithGenes extends Cow { - genes?: { - name: string - genotype: string - favorable: boolean // 유리대립유전자 여부 - }[] - traits?: Record // 형질명 → { breedVal, traitVal } 또는 number 매핑 - grade?: 'A' | 'B' | 'C' | 'D' | 'E' - quantityGeneCount?: number - qualityGeneCount?: number - quantityHomoCount?: number // 육량형 동형접합(AA) 개수 - quantityHeteroCount?: number // 육량형 이형접합(AG) 개수 - qualityHomoCount?: number // 육질형 동형접합(AA) 개수 - qualityHeteroCount?: number // 육질형 이형접합(AG) 개수 - rankScore?: number // 백엔드 랭킹 API에서 받은 점수 - genomeScore?: number // COMPOSITE 모드에서 유전체 점수 - geneScore?: number // COMPOSITE 모드에서 유전자 점수 - rank?: number // 랭킹 순위 - cowShortNo?: string // 개체 요약번호 - cowReproType?: string // 번식 타입 - anlysDt?: string // 분석일자 (유전체) - unavailableReason?: string // 분석불가 사유 (부불일치, 모불일치, 모이력제부재 등) - hasMpt?: boolean // 번식능력검사(MPT) 여부 - mptTestDt?: string // MPT 검사일 - mptMonthAge?: number // MPT 검사일 기준 월령 -} - function MyCowContent() { const [cows, setCows] = useState([]) const [filteredCows, setFilteredCows] = useState([]) @@ -71,7 +37,6 @@ function MyCowContent() { const router = useRouter() const { user } = useAuthStore() const { filters, isLoading: isFilterLoading } = useFilterStore() - const [markerTypes, setMarkerTypes] = useState>({}) // 마커명 → 타입(QTY/QLT) 매핑 // 로컬 필터 상태 (검색, 랭킹모드, 정렬) const [searchKeyword, setSearchKeyword] = useState('') @@ -92,78 +57,69 @@ function MyCowContent() { const itemsPerPage = 12 - // 필터가 설정되었는지 확인 (형질 가중치가 1개 이상 설정됨 - 유전체 필수) + // 형질 가중치가 1개 이상 설정되어야 필터 활성화 const isFilterSet = filters.isActive && Object.values(filters.traitWeights).some(w => w > 0) - // 마커 타입 정보 로드 (gene.api 제거됨 - 추후 백엔드 구현 시 복구) + // ======================================== + // 전역 필터 → 개체 리스트 표시 항목 동기화 + // ======================================== useEffect(() => { - // TODO: gene API 구현 후 마커 타입 정보 로드 복구 - setMarkerTypes({}) - }, []) + if (isFilterLoading) return // 필터 로딩 중이면 건너뛰기 - // 전역 필터의 선택된 유전자를 선택 가능한 목록으로 설정 + 자동 랭킹 모드 설정 - // 필터 변경 시 표시 항목 업데이트 - useEffect(() => { - // 필터 로딩 중이면 건너뛰기 - if (isFilterLoading) { - return - } const hasGenes = filters.isActive && filters.selectedGenes && filters.selectedGenes.length > 0 const hasTraits = filters.isActive && Object.values(filters.traitWeights).some(w => w > 0) - // 유전자 처리 (고정 항목 우선) + // 1. 마커 유전자 표시 목록 동기화 (육량형 , 육질형 추후 추가 연동 예정) if (hasGenes) { const pinnedGenes = filters.pinnedGenes || [] - const restGenes = filters.selectedGenes.filter(g => !pinnedGenes.includes(g)) - const orderedGenes = [...pinnedGenes, ...restGenes] - - setAvailableGenes(orderedGenes) - // 고정된 항목이 있으면 고정 항목만, 없으면 전체 선택 - setSelectedDisplayGenes(pinnedGenes.length > 0 ? pinnedGenes : orderedGenes) + setAvailableGenes(filters.selectedGenes) + const pinnedInOrder = filters.selectedGenes.filter(g => pinnedGenes.includes(g)) + setSelectedDisplayGenes(pinnedGenes.length > 0 ? pinnedInOrder : filters.selectedGenes) } else { setAvailableGenes([]) setSelectedDisplayGenes([]) } - // 형질 처리 (고정 항목 우선) + // 2. 형질 표시 목록 동기화 if (filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0) { const pinnedTraits = filters.pinnedTraits || [] - const restTraits = filters.selectedTraits.filter(t => !pinnedTraits.includes(t)) - const orderedTraits = [...pinnedTraits, ...restTraits] - - setAvailableTraits(orderedTraits) - // 고정된 항목이 있으면 고정 항목만, 없으면 전체 선택 - setSelectedDisplayTraits(pinnedTraits.length > 0 ? pinnedTraits : orderedTraits) + setAvailableTraits(filters.selectedTraits) + const pinnedInOrder = filters.selectedTraits.filter(t => pinnedTraits.includes(t)) + setSelectedDisplayTraits(pinnedTraits.length > 0 ? pinnedInOrder : filters.selectedTraits) } else { setAvailableTraits([]) setSelectedDisplayTraits([]) } - // 자동 랭킹 모드 설정 + // 3. 랭킹 모드 자동 설정 + // - 유전자 선택됨 → 유전자순 (GENE 모드) + // - 형질만 선택됨 → 유전체순 (GENOME 모드) if (filters.isActive) { if (hasGenes) { - // 유전자가 선택되어 있으면 → 유전자순 (형질 유무 상관없이) setRankingMode('gene') } else if (hasTraits) { - // 유전자 없이 형질만 선택 → 유전체순 setRankingMode('genome') } } }, [isFilterLoading, filters.isActive, filters.selectedGenes, filters.selectedTraits, filters.pinnedGenes, filters.pinnedTraits, filters.traitWeights]) + // ======================================== + // 개체 데이터 조회 (Ranking API) + // ======================================== useEffect(() => { const fetchCows = async () => { - // 필터가 설정되지 않았으면 API 호출하지 않음 if (!isFilterSet) { - setLoading(false) + setLoading(false) // 필터가 설정되지 않았으면 API 호출하지 않음 setCows([]) setFilteredCows([]) return } try { - setLoading(true) - // 사용자의 농장 목록 조회하여 농장 필터 생성 + setLoading(true) // 필터가 설정되면 API 호출 + setError(null) + + // 1. 사용자 농장 필터 생성 let farmFilters: { field: string; operator: 'in'; value: number[] }[] = [] try { const userNo = user?.pkUserNo @@ -180,130 +136,72 @@ function MyCowContent() { console.error('Failed to fetch farms:', err) } - setError(null) - - // 전역 필터 + 랭킹 모드를 기반으로 랭킹 옵션 구성 - // 타입을 any로 지정하여 백엔드 API와의 호환성 유지 - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // 2. 랭킹 옵션 구성 + // - GENE 모드: 마커 유전자 기반 정렬 (우량동형 → 이형 → 유전체점수) 추후 구현 예정 + // - GENOME 모드: 형질 가중치 기반 정렬 let rankingOptions: any - // 형질 가중치 조건 (유전체 점수 계산용) - const traitConditions = Object.entries(filters.traitWeights) + const traitConditions = Object.entries(filters.traitWeights) // 형질 가중치 조건 1이 기본 (유전체 점수 계산용) .filter(([, weight]) => weight > 0) - .map(([traitNm, weight]) => ({ - traitNm, - weight // 0-10 가중치 그대로 사용 - })) + .map(([traitNm, weight]) => ({ traitNm, weight })) - // 랭킹 모드에 따라 criteriaType 결정 if (rankingMode === 'gene' && filters.isActive && filters.selectedGenes && filters.selectedGenes.length > 0) { - // 유전자순 랭킹 → GENE 모드 - // 정렬 기준: 1차 AA 개수, 2차 Aa 개수, 3차 유전체 점수 + // GENE 모드 rankingOptions = { criteriaType: 'GENE', - geneConditions: filters.selectedGenes.map(markerNm => ({ - markerNm, - order: 'DESC' - })), - traitConditions // 유전체 점수 계산용 + geneConditions: filters.selectedGenes.map(markerNm => ({ markerNm, order: 'DESC' })), + traitConditions } } else if (rankingMode === 'genome' || !filters.isActive || !filters.selectedGenes || filters.selectedGenes.length === 0) { - // 유전체순 랭킹 또는 필터 비활성화 → GENOME 모드 - // 정렬 기준: 유전체 점수 (형질 가중치 평균) - - // 전역 필터가 비활성화되어 있으면 기본 가중치로 전체 개체 표시 + // GENOME 모드 if (!filters.isActive) { rankingOptions = { criteriaType: 'GENOME', - traitConditions: [], // 빈 배열 → 백엔드 기본 가중치 사용 - inbreedingCondition: { - maxThreshold: 0, - order: 'ASC' - } + traitConditions: [], + inbreedingCondition: { maxThreshold: 0, order: 'ASC' } } } else if (rankingMode === 'genome' && traitConditions.length === 0) { - // 유전체순인데 형질 가중치가 비어있으면 기본 가중치 사용 rankingOptions = { criteriaType: 'GENOME', - traitConditions: [], // 빈 배열 → 백엔드 기본 가중치 사용 - inbreedingCondition: { - maxThreshold: filters.inbreedingThreshold !== undefined ? filters.inbreedingThreshold : 0, - order: 'ASC' - } + traitConditions: [], + inbreedingCondition: { maxThreshold: filters.inbreedingThreshold ?? 0, order: 'ASC' } } } else { - // 정상적으로 가중치가 있는 경우 rankingOptions = { criteriaType: 'GENOME', - traitConditions, // 가중치 > 0인 형질만 - inbreedingCondition: { - maxThreshold: filters.inbreedingThreshold !== undefined ? filters.inbreedingThreshold : 0, - order: 'ASC' - } + traitConditions, + inbreedingCondition: { maxThreshold: filters.inbreedingThreshold ?? 0, order: 'ASC' } } } } else { - // 기본값 (유전체순) + // 기본값 (GENOME 모드) rankingOptions = { criteriaType: 'GENOME', - traitConditions: [], // 빈 배열 - inbreedingCondition: { - maxThreshold: 0, - order: 'ASC' - } + traitConditions: [], + inbreedingCondition: { maxThreshold: 0, order: 'ASC' } } } - // 백엔드 ranking API 호출 + // 3. API 호출 및 응답 매핑 const rankingRequest = { - filterOptions: { - filters: farmFilters // 농장 필터 적용 - }, - rankingOptions + filterOptions: { filters: farmFilters }, // 필터옵션과 + rankingOptions // 랭킹옵션을 담아서 백엔드로 전달 } - // 백엔드 ranking API 호출 const response = await cowApi.getRanking(rankingRequest) - // ========================================================================================================== - // response는 { items: RankingResultItem[], total, criteriaType, timestamp } 형식 - // items의 각 요소는 { entity, rank, sortValue, grade, details } 형식 - interface RankingItem { - entity: Cow & { genes?: Record; calvingCount?: number; bcs?: number; inseminationCount?: number; inbreedingPercent?: number; sireKpn?: string; anlysDt?: string; unavailableReason?: string }; - rank: number; - sortValue: number; - grade: string; - compositeScores?: { geneScore?: number }; - ranking?: { traits?: { traitName: string; traitEbv: number | null; traitVal: number | null }[] }; // 랭킹 형질 - } - const cowsWithMockGenes = response.items.map((item: RankingItem) => { - return { - ...item.entity, - rank: item.rank, - rankScore: item.sortValue, - grade: item.grade, - genomeScore: item.sortValue, - // 번식 정보 - calvingCount: item.entity.calvingCount, - bcs: item.entity.bcs, - inseminationCount: item.entity.inseminationCount, - inbreedingPercent: item.entity.inbreedingPercent ?? 0, - sireKpn: item.entity.sireKpn ?? null, - anlysDt: item.entity.anlysDt ?? null, - unavailableReason: item.entity.unavailableReason ?? null, - hasMpt: item.entity.hasMpt ?? false, - mptTestDt: item.entity.mptTestDt ?? null, - mptMonthAge: item.entity.mptMonthAge ?? null, - // 형질 데이터 - traits: item.ranking?.traits?.reduce((acc: Record, t: { traitName: string; traitEbv: number | null; traitVal: number | null }) => { - acc[t.traitName] = { breedVal: t.traitEbv, traitVal: t.traitVal }; - return acc - }, {}) || {}, - } - }) + const cowsData = response.items.map((item: RankingItem) => ({ + ...item.entity, + rank: item.rank, + genomeScore: item.sortValue, + inbreedingPercent: item.entity.inbreedingPercent ?? 0, + traits: item.ranking?.traits?.reduce((acc: Record, t) => { + acc[t.traitName] = { breedVal: t.traitEbv, traitVal: t.traitVal } + return acc + }, {}) || {}, + })) - setCows(cowsWithMockGenes) - setFilteredCows(cowsWithMockGenes) + setCows(cowsData) + setFilteredCows(cowsData) } catch (err) { console.error('개체 데이터 조회 실패:', err) setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다') @@ -316,17 +214,13 @@ function MyCowContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters, rankingMode, isFilterSet]) - - // ============================================ - // 컬럼 스타일은 globals.css의 CSS 변수로 관리됨 - // .cow-col-* 클래스 사용 - // ============================================ - - // 필터링 및 정렬 + // ======================================== + // 클라이언트 측 필터링 및 정렬 + // ======================================== useEffect(() => { let result = [...cows] - // 검색 필터 (전체 개체번호 또는 4자리 약식 번호로 검색) + // 1. 검색 필터 if (searchKeyword) { const keyword = searchKeyword.toLowerCase() result = result.filter(cow => @@ -336,42 +230,29 @@ function MyCowContent() { ) } - - // 전역 필터 적용 (유전자 기반 분석) + // 2. 전역 필터 (유전자 기반) if (filters.isActive && filters.analysisIndex === 'GENE' && filters.selectedGenes.length > 0) { result = result.filter(cow => { if (!cow.genes || !Array.isArray(cow.genes)) return false - // 선택된 유전자를 모두 보유한 개체만 표시 return filters.selectedGenes.every(selectedGene => cow.genes!.some(g => g.name === selectedGene) ) }) } - // 전역 필터 적용 (유전능력 기반 분석) - // TODO: 유전능력 데이터가 있을 경우 형질 기반 필터링 추가 - if (filters.isActive && filters.analysisIndex === 'ABILITY' && (filters.selectedTraits?.length ?? 0) > 0) { - // 현재는 mock 데이터이므로 필터링하지 않음 - // 실제로는 API에서 가져온 유전능력 데이터를 기반으로 필터링 - } - - // 분석 상태 필터 + // 3. 분석 상태 필터 if (analysisFilter === 'completed') { - // 유전체 완료 result = result.filter(cow => cow.genomeScore !== undefined && cow.genomeScore !== null) } else if (analysisFilter === 'mptOnly') { - // 번식능력 검사 완료 (유전체 유무 상관없이) result = result.filter(cow => cow.hasMpt === true) } else if (analysisFilter === 'unavailable') { - // 유전체 분석불가 (부불일치, 모불일치 등) result = result.filter(cow => cow.unavailableReason !== null && cow.unavailableReason !== undefined) } - // 정렬 (sortBy가 'none'이면 정렬하지 않음 - 전역 필터 순서 유지) + // 4. 정렬 if (sortBy !== 'none') { switch (sortBy) { case 'rank': - // 순위 정렬 result.sort((a, b) => { const rankA = a.rank ?? 9999 const rankB = b.rank ?? 9999 @@ -379,24 +260,19 @@ function MyCowContent() { }) break case 'number': - // 개체번호 정렬 (cowId 문자열 기준) result.sort((a, b) => { const comparison = (a.cowId || '').localeCompare(b.cowId || '') return sortOrder === 'asc' ? comparison : -comparison }) break case 'age': - // 월령 정렬 (생년월일 기준) result.sort((a, b) => { const dateA = a.cowBirthDt ? new Date(a.cowBirthDt).getTime() : 0 const dateB = b.cowBirthDt ? new Date(b.cowBirthDt).getTime() : 0 - // 오름차순: 오래된 것(작은 날짜) → 최근 것(큰 날짜), 즉 나이가 많은 순 - // 내림차순: 최근 것(큰 날짜) → 오래된 것(작은 날짜), 즉 나이가 적은 순 return sortOrder === 'asc' ? dateA - dateB : dateB - dateA }) break case 'score': - // 점수 정렬 result.sort((a, b) => { const scoreA = a.genomeScore ?? 0 const scoreB = b.genomeScore ?? 0 @@ -407,7 +283,7 @@ function MyCowContent() { } setFilteredCows(result) - setCurrentPage(1) // 필터 변경시 첫 페이지로 + setCurrentPage(1) }, [searchKeyword, sortBy, sortOrder, cows, filters, analysisFilter]) // 페이지네이션 @@ -430,31 +306,6 @@ function MyCowContent() { return cow.rank ?? 9999 } - - - // 형질 카테고리 정의 - const traitCategories = { - production: { - name: '생산', - traits: ['12개월령체중', '도체중', '등심단면적', '등지방두께', '근내지방도'] - }, - body: { - name: '체형', - traits: ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'] - }, - weight: { - name: '부분육중량', - traits: ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'] - }, - rate: { - name: '부분육비율', - traits: ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'] - } - } - - // traitCategories는 형질 카테고리 정보 제공용으로 유지 - void traitCategories - if (loading) { return ( @@ -733,7 +584,7 @@ function MyCowContent() { htmlFor={`trait-${trait}`} className="text-sm font-normal cursor-pointer" > - {trait} + {TRAIT_DISPLAY_NAMES[trait] || trait} ))} @@ -932,17 +783,12 @@ function MyCowContent() { {displayGenes.map((geneName) => { const gene = cow.genes?.find(g => g.name === geneName) const genotype = gene?.genotype || '-' - const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT' - // 육량형: 파랑, 육질형: 주황 - const badgeClass = geneCategory === 'QTY' - ? 'bg-blue-500 text-white' - : 'bg-orange-500 text-white' return (
{geneName} {gene ? ( - + {genotype} ) : ( @@ -1002,7 +848,7 @@ function MyCowContent() { return (
- {trait} + {TRAIT_DISPLAY_NAMES[trait] || trait} {traitValue}
) @@ -1179,25 +1025,18 @@ function MyCowContent() { 유전자 {(() => { const isExpanded = expandedRows.has(`${cow.pkCowNo}-mobile-genes`) - const pinnedGenes = filters.pinnedGenes || [] - const unpinnedGenes = selectedDisplayGenes.filter(g => !pinnedGenes.includes(g)) - const allGenes = [...pinnedGenes, ...unpinnedGenes] - const displayGenes = isExpanded ? allGenes : allGenes.slice(0, 4) - const remainingCount = allGenes.length - 4 + // selectedDisplayGenes가 이미 전역 필터 순서를 반영하고 있음 + const displayGenes = isExpanded ? selectedDisplayGenes : selectedDisplayGenes.slice(0, 4) + const remainingCount = selectedDisplayGenes.length - 4 return ( <> {displayGenes.map((geneName) => { const gene = cow.genes?.find(g => g.name === geneName) - const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT' const genotype = gene?.genotype || '-' - // 육량형: 파랑, 육질형: 주황 - const badgeClass = geneCategory === 'QTY' - ? 'bg-blue-500 text-white' - : 'bg-orange-500 text-white' return ( - + {geneName} {genotype} ) @@ -1251,7 +1090,7 @@ function MyCowContent() { } return (
- {trait} + {TRAIT_DISPLAY_NAMES[trait] || trait} {traitValue}
) diff --git a/frontend/src/app/demo/test-summary/page.tsx b/frontend/src/app/demo/test-summary/page.tsx deleted file mode 100644 index 7d28806..0000000 --- a/frontend/src/app/demo/test-summary/page.tsx +++ /dev/null @@ -1,448 +0,0 @@ -'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(null) - const [loading, setLoading] = useState(true) - const [expandedFarms, setExpandedFarms] = useState>(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 ( - - - - -
-
-
- - - ) - } - - if (!data) { - return ( - - - - -
- 데이터를 불러올 수 없습니다 -
-
-
- ) - } - - return ( - - - - -
- {/* 헤더 */} -
-

검사 집계표

-

- 농가별/개체별 유전체, 유전자, 번식능력 검사 현황 -

-
- - {/* 전체 요약 카드 */} -
-
-

총 농가 수

-

{data.totalFarms}

-
-
-

총 검사 개체 수

-

{data.totalCows}

-
-
-

총 검사 건수

-

{data.totalTests}

-
-
-

평균 검사/개체

-

- {data.totalCows > 0 ? (data.totalTests / data.totalCows).toFixed(1) : 0} -

-
-
- - {/* 검사별 집계 */} -
-
-

검사별 개체 수

-
-
-
-
- -
-

유전체

-

{data.genomeCowCount}

-
-
-
- -
-

유전자

-

{data.geneCowCount}

-
-
-
- -
-

번식능력

-

{data.mptCowCount}

-
-
-
- - {/* 중복 검사 조합 */} -

검사 조합별 개체 수

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
조합유전체유전자번식능력개체 수
유전체만{data.genomeOnly}
유전자만{data.geneOnly}
번식능력만{data.mptOnly}
유전체 + 유전자{data.genomeAndGene}
유전체 + 번식능력{data.genomeAndMpt}
유전자 + 번식능력{data.geneAndMpt}
3종 모두{data.allThree}
합계 (총 검사 개체){data.totalCows}
-
-
-
- - {/* 농가별 집계 */} -
-
-

농가별 검사 현황

-
-
- - - - - - - - - - - - - - {data.farms.map((farm) => ( - <> - toggleFarm(farm.farmNo)} - > - - - - - - - - - {/* 펼쳐진 개체 목록 */} - {expandedFarms.has(farm.farmNo) && farm.cows && farm.cows.length > 0 && ( - - - - )} - - ))} - - - - - - - - - - - - -
농가유전체유전자번식능력개체 수검사 건수
- {expandedFarms.has(farm.farmNo) ? ( - - ) : ( - - )} - - {farm.farmerName || `농가 ${farm.farmNo}`} - {farm.regionSi && ( - {farm.regionSi} - )} - - - {farm.genomeCowCount} - - - - {farm.geneCowCount} - - - - {farm.mptCowCount} - - - {farm.totalCows} - - {farm.totalTests} -
-
- - - - - - - - - - - - - - {farm.cows.map((cow) => ( - - - - - - - - - - ))} - -
개체번호생년월일성별유전체유전자번식능력검사 수
- {formatCowId(cow.cowId)} - - {cow.cowBirthDt || '-'} - - - {cow.cowSex === '암' || cow.cowSex === 'F' ? '암' : '수'} - - - {cow.hasGenome ? ( - O - ) : ( - X - )} - - {cow.hasGene ? ( - O - ) : ( - X - )} - - {cow.hasMpt ? ( - O - ) : ( - X - )} - - - {cow.testCount} - -
-
- {/* 농가별 중복 검사 요약 */} -
-
- {farm.genomeOnly > 0 && ( - 유전체만: {farm.genomeOnly} - )} - {farm.geneOnly > 0 && ( - 유전자만: {farm.geneOnly} - )} - {farm.mptOnly > 0 && ( - 번식능력만: {farm.mptOnly} - )} - {farm.genomeAndGene > 0 && ( - 유전체+유전자: {farm.genomeAndGene} - )} - {farm.genomeAndMpt > 0 && ( - 유전체+번식능력: {farm.genomeAndMpt} - )} - {farm.geneAndMpt > 0 && ( - 유전자+번식능력: {farm.geneAndMpt} - )} - {farm.allThree > 0 && ( - 3종 모두: {farm.allThree} - )} -
-
-
합계{data.genomeCowCount}{data.geneCowCount}{data.mptCowCount}{data.totalCows}{data.totalTests}
-
-
-
-
-
- ) -} diff --git a/frontend/src/components/common/global-filter-dialog.tsx b/frontend/src/components/common/global-filter-dialog.tsx index 9e22703..1202322 100644 --- a/frontend/src/components/common/global-filter-dialog.tsx +++ b/frontend/src/components/common/global-filter-dialog.tsx @@ -11,7 +11,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin, GripVertical } from "lucide-react" import { useFilterStore } from "@/store/filter-store" import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types" -import { TRAIT_CATEGORY_LIST as TRAIT_CATEGORIES, TRAIT_DESCRIPTIONS } from "@/constants/traits" +import { TRAIT_CATEGORY_LIST as TRAIT_CATEGORIES, TRAIT_DESCRIPTIONS, TRAIT_DISPLAY_NAMES } from "@/constants/traits" import { geneApi } from "@/lib/api/gene.api" import { DndContext, @@ -174,30 +174,6 @@ function SortableTraitItem({ ) } -// 형질 표시 이름 (DB 키 -> 화면 표시용) -const TRAIT_DISPLAY_NAMES: Record = { - '안심weight': '안심중량', - '등심weight': '등심중량', - '채끝weight': '채끝중량', - '목심weight': '목심중량', - '앞다리weight': '앞다리중량', - '우둔weight': '우둔중량', - '설도weight': '설도중량', - '사태weight': '사태중량', - '양지weight': '양지중량', - '갈비weight': '갈비중량', - '안심rate': '안심비율', - '등심rate': '등심비율', - '채끝rate': '채끝비율', - '목심rate': '목심비율', - '앞다리rate': '앞다리비율', - '우둔rate': '우둔비율', - '설도rate': '설도비율', - '사태rate': '사태비율', - '양지rate': '양지비율', - '갈비rate': '갈비비율', -} - type TraitName = keyof typeof DEFAULT_FILTER_SETTINGS.traitWeights interface GlobalFilterDialogProps { diff --git a/frontend/src/constants/traits.ts b/frontend/src/constants/traits.ts index 8db096a..265cc1e 100644 --- a/frontend/src/constants/traits.ts +++ b/frontend/src/constants/traits.ts @@ -178,3 +178,37 @@ export function getTraitCategory(traitName: string): TraitCategory | '기타' { export function getTraitDescription(traitName: string): string { return TRAIT_DESCRIPTIONS[traitName] ?? traitName; } + +/** + * 형질 표시 이름 (DB 키 -> 화면 표시용) + * weight → 중량, rate → 비율로 변환 + */ +export const TRAIT_DISPLAY_NAMES: Record = { + '안심weight': '안심중량', + '등심weight': '등심중량', + '채끝weight': '채끝중량', + '목심weight': '목심중량', + '앞다리weight': '앞다리중량', + '우둔weight': '우둔중량', + '설도weight': '설도중량', + '사태weight': '사태중량', + '양지weight': '양지중량', + '갈비weight': '갈비중량', + '안심rate': '안심비율', + '등심rate': '등심비율', + '채끝rate': '채끝비율', + '목심rate': '목심비율', + '앞다리rate': '앞다리비율', + '우둔rate': '우둔비율', + '설도rate': '설도비율', + '사태rate': '사태비율', + '양지rate': '양지비율', + '갈비rate': '갈비비율', +}; + +/** + * 형질명을 화면 표시용 이름으로 변환 + */ +export function getTraitDisplayName(traitName: string): string { + return TRAIT_DISPLAY_NAMES[traitName] ?? traitName; +} diff --git a/frontend/src/types/cow.types.ts b/frontend/src/types/cow.types.ts index 53d8890..5641a13 100644 --- a/frontend/src/types/cow.types.ts +++ b/frontend/src/types/cow.types.ts @@ -48,8 +48,6 @@ export interface CowDetailResponseDto extends CowDto { cowShortNo?: string; // 개체 요약번호 (4자리, cowId에서 추출) // 추가 분석 정보 (백엔드 별도 API에서 조회) - grade?: 'A' | 'B' | 'C' | 'D' | 'E'; // 등급 - overallScore?: number; // 종합지수 genomeScore?: number; // 유전체 점수 farmRank?: number; // 농장 내 순위 totalCows?: number; // 농장 총 개체 수 @@ -101,3 +99,51 @@ export interface UpdateCowDto { export type Cow = CowDto; export type CowList = CowListResponseDto; export type CowDetail = CowDetailResponseDto; + +/** + * 형질 데이터 (육종가/형질값) + */ +export interface TraitData { + breedVal: number | null + traitVal: number | null +} + +/** + * 유전자 정보를 포함한 개체 (개체 목록용) + */ +export interface CowWithGenes extends Cow { + genes?: { name: string; genotype: string }[] + traits?: Record + rank?: number + genomeScore?: number + cowShortNo?: string + anlysDt?: string + unavailableReason?: string + hasMpt?: boolean + mptTestDt?: string + mptMonthAge?: number +} + +/** + * Ranking API 응답 아이템 + */ +export interface RankingItem { + entity: Cow & { + genes?: Record + calvingCount?: number + bcs?: number + inseminationCount?: number + inbreedingPercent?: number + sireKpn?: string + anlysDt?: string + unavailableReason?: string + hasMpt?: boolean + mptTestDt?: string + mptMonthAge?: number + } + rank: number + sortValue: number + ranking?: { + traits?: { traitName: string; traitEbv: number | null; traitVal: number | null }[] + } +} diff --git a/frontend/src/types/genome.types.ts b/frontend/src/types/genome.types.ts index fa28a12..9f18f54 100644 --- a/frontend/src/types/genome.types.ts +++ b/frontend/src/types/genome.types.ts @@ -52,7 +52,9 @@ export interface GenomeCow { traitVal?: number; // 형질 측정값 breedVal?: number; // EBV (추정육종가) percentile?: number; // 백분위 순위 - traitInfo?: TraitInfo; // 형질 정보 + traitName?: string; // 형질명 (평평한 구조) + traitCategory?: string; // 형질 카테고리 + traitDesc?: string; // 형질 설명 [key: string]: any; }