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