entity 연결 수정 및 코드정리

This commit is contained in:
2025-12-29 13:55:43 +09:00
parent 5204000d34
commit 9de32fe394
15 changed files with 321 additions and 978 deletions

View File

@@ -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<string, TraitData | number> // 형질명 → { 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<CowWithGenes[]>([])
const [filteredCows, setFilteredCows] = useState<CowWithGenes[]>([])
@@ -71,7 +37,6 @@ function MyCowContent() {
const router = useRouter()
const { user } = useAuthStore()
const { filters, isLoading: isFilterLoading } = useFilterStore()
const [markerTypes, setMarkerTypes] = useState<Record<string, string>>({}) // 마커명 → 타입(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<string, number>; 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<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 };
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<string, { breedVal: number | null, traitVal: number | null }>, 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 (
<SidebarProvider>
@@ -733,7 +584,7 @@ function MyCowContent() {
htmlFor={`trait-${trait}`}
className="text-sm font-normal cursor-pointer"
>
{trait}
{TRAIT_DISPLAY_NAMES[trait] || trait}
</Label>
</div>
))}
@@ -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 (
<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 ${badgeClass}`}>
<Badge className="text-xs font-semibold bg-blue-500 text-white">
{genotype}
</Badge>
) : (
@@ -1002,7 +848,7 @@ function MyCowContent() {
return (
<div key={trait} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground min-w-[65px]">{trait}</span>
<span className="text-[10px] text-muted-foreground min-w-[65px]">{TRAIT_DISPLAY_NAMES[trait] || trait}</span>
<span className="font-semibold text-xs">{traitValue}</span>
</div>
)
@@ -1179,25 +1025,18 @@ function MyCowContent() {
<span className="text-xs text-muted-foreground mr-0.5"></span>
{(() => {
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 (
<Badge key={geneName} className={`text-xs px-1.5 py-0.5 font-medium ${badgeClass}`}>
<Badge key={geneName} className="text-xs px-1.5 py-0.5 font-medium bg-blue-500 text-white">
{geneName} {genotype}
</Badge>
)
@@ -1251,7 +1090,7 @@ function MyCowContent() {
}
return (
<div key={trait} className="flex items-center justify-between">
<span className="text-muted-foreground">{trait}</span>
<span className="text-muted-foreground">{TRAIT_DISPLAY_NAMES[trait] || trait}</span>
<span className="font-medium">{traitValue}</span>
</div>
)