Merge branch 'main' of http://gitea.turbosoft.kr:80/turbosoft/genome2025
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface CowHeaderProps {
|
||||
from?: string | null
|
||||
}
|
||||
|
||||
export function CowHeader({ from }: CowHeaderProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
if (from === 'ranking') {
|
||||
router.push('/ranking')
|
||||
} else if (from === 'list') {
|
||||
router.push('/list')
|
||||
} else {
|
||||
router.push('/cow')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 뒤로가기 버튼 */}
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted -ml-2 gap-1.5"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="text-sm">목록으로</span>
|
||||
</Button>
|
||||
|
||||
{/* 페이지 헤더 카드 */}
|
||||
<div className="rounded-lg p-6 border bg-slate-50">
|
||||
<h1 className="text-2xl font-bold mb-2">개체 상세 정보</h1>
|
||||
<p className="text-sm text-muted-foreground">개체의 기본 정보와 분석 현황을 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,46 +26,8 @@ import {
|
||||
ResponsiveContainer
|
||||
} from 'recharts'
|
||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||
import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES } from "@/constants/traits"
|
||||
|
||||
// 형질명 표시 (전체 이름)
|
||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||
'도체중': '도체중',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'근내지방도': '근내지방도',
|
||||
'체장': '체장',
|
||||
'체고': '체고',
|
||||
'등심weight': '등심중량',
|
||||
'12개월령체중': '12개월령체중',
|
||||
'십자': '십자',
|
||||
'흉심': '흉심',
|
||||
'흉폭': '흉폭',
|
||||
'고장': '고장',
|
||||
'요각폭': '요각폭',
|
||||
'좌골폭': '좌골폭',
|
||||
'곤폭': '곤폭',
|
||||
'흉위': '흉위',
|
||||
'안심weight': '안심무게',
|
||||
'채끝weight': '채끝무게',
|
||||
'목심weight': '목심무게',
|
||||
'앞다리weight': '앞다리무게',
|
||||
'우둔weight': '우둔무게',
|
||||
'설도weight': '설도무게',
|
||||
'사태weight': '사태무게',
|
||||
'양지weight': '양지무게',
|
||||
'갈비weight': '갈비무게',
|
||||
'안심rate': '안심비율',
|
||||
'등심rate': '등심비율',
|
||||
'채끝rate': '채끝비율',
|
||||
'목심rate': '목심비율',
|
||||
'앞다리rate': '앞다리비율',
|
||||
'우둔rate': '우둔비율',
|
||||
'설도rate': '설도비율',
|
||||
'사태rate': '사태비율',
|
||||
'양지rate': '양지비율',
|
||||
'갈비rate': '갈비비율',
|
||||
}
|
||||
import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES, getTraitDisplayName, TRAIT_DISPLAY_NAMES } from "@/constants/traits"
|
||||
import { GenomeCowTraitDto } from "@/types/genome.types"
|
||||
|
||||
interface CategoryStat {
|
||||
category: string
|
||||
@@ -74,22 +36,13 @@ interface CategoryStat {
|
||||
count: number
|
||||
}
|
||||
|
||||
interface TraitData {
|
||||
id?: number
|
||||
traitName?: string // 형질명
|
||||
traitCategory?: string // 카테고리
|
||||
breedVal?: number // 표준화육종가 (σ 단위)
|
||||
percentile?: number
|
||||
traitVal?: number // EPD (예상후대차이) 원래 값
|
||||
}
|
||||
|
||||
interface CategoryEvaluationCardProps {
|
||||
categoryStats: CategoryStat[]
|
||||
comparisonAverages: ComparisonAveragesDto | null
|
||||
traitComparisonAverages?: TraitComparisonAveragesDto | null // 형질별 평균 비교 데이터 (폴리곤 차트용)
|
||||
regionAvgZ: number
|
||||
farmAvgZ: number
|
||||
allTraits?: TraitData[]
|
||||
allTraits?: GenomeCowTraitDto[]
|
||||
cowNo?: string
|
||||
hideTraitCards?: boolean // 형질 카드 숨김 여부
|
||||
}
|
||||
@@ -151,7 +104,7 @@ export function CategoryEvaluationCard({
|
||||
|
||||
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
|
||||
const traitChartData = chartTraits.map(traitName => {
|
||||
const trait = allTraits.find((t: TraitData) => t.traitName === traitName)
|
||||
const trait = allTraits.find((t: GenomeCowTraitDto) => t.traitName === traitName)
|
||||
|
||||
// 형질별 평균 데이터에서 해당 형질 찾기
|
||||
const traitAvgRegion = traitComparisonAverages?.region?.find(t => t.traitName === traitName)
|
||||
@@ -166,7 +119,7 @@ export function CategoryEvaluationCard({
|
||||
|
||||
return {
|
||||
name: traitName,
|
||||
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
||||
shortName: getTraitDisplayName(traitName),
|
||||
breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ)
|
||||
epd: trait?.traitVal ?? 0, // 이 개체 EPD (육종가)
|
||||
regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가)
|
||||
@@ -192,7 +145,7 @@ export function CategoryEvaluationCard({
|
||||
|
||||
// 형질 이름으로 원본 형질명 찾기 (shortName -> name)
|
||||
const findTraitNameByShortName = (shortName: string) => {
|
||||
const entry = Object.entries(TRAIT_SHORT_NAMES).find(([, short]) => short === shortName)
|
||||
const entry = Object.entries(TRAIT_DISPLAY_NAMES).find(([, short]) => short === shortName)
|
||||
return entry ? entry[0] : shortName
|
||||
}
|
||||
|
||||
@@ -262,7 +215,7 @@ export function CategoryEvaluationCard({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{traits.map(trait => {
|
||||
const isSelected = chartTraits.includes(trait)
|
||||
const traitData = allTraits.find((t: TraitData) => t.traitName === trait)
|
||||
const traitData = allTraits.find((t: GenomeCowTraitDto) => t.traitName === trait)
|
||||
return (
|
||||
<button
|
||||
key={trait}
|
||||
@@ -273,8 +226,8 @@ export function CategoryEvaluationCard({
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
{TRAIT_SHORT_NAMES[trait] || trait}
|
||||
{traitData && (
|
||||
{getTraitDisplayName(trait)}
|
||||
{traitData && traitData.breedVal !== undefined && (
|
||||
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
|
||||
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
|
||||
</span>
|
||||
@@ -348,7 +301,7 @@ export function CategoryEvaluationCard({
|
||||
key={trait}
|
||||
className="inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 bg-primary/10 text-primary rounded-full text-sm lg:text-base font-medium group"
|
||||
>
|
||||
{TRAIT_SHORT_NAMES[trait] || trait}
|
||||
{getTraitDisplayName(trait)}
|
||||
{chartTraits.length > 3 && (
|
||||
<button
|
||||
onClick={() => removeTrait(trait)}
|
||||
|
||||
@@ -21,6 +21,8 @@ interface GenomicTrait {
|
||||
breedVal: number
|
||||
percentile: number
|
||||
actualValue: number
|
||||
description?: string // 형질 설명
|
||||
unit?: string // 단위 (kg, cm 등)
|
||||
}
|
||||
|
||||
interface CategoryTraitGridProps {
|
||||
|
||||
@@ -579,8 +579,8 @@ export function GenomeIntegratedComparison({
|
||||
: null
|
||||
|
||||
// 표시할 값 결정
|
||||
const displayScore = isTraitMode && selectedTrait ? selectedTrait.breedVal : overallScore
|
||||
const displayPercentile = isTraitMode && selectedTrait ? selectedTrait.percentile : (selectionIndex?.percentile || 50)
|
||||
const displayScore = isTraitMode && selectedTrait ? (selectedTrait.breedVal ?? 0) : overallScore
|
||||
const displayPercentile = isTraitMode && selectedTrait ? (selectedTrait.percentile ?? 50) : (selectionIndex?.percentile || 50)
|
||||
// 형질 모드일 때는 API에서 가져온 평균값 사용, 없으면 traitComparison 사용
|
||||
const displayFarmAvg = isTraitMode
|
||||
? (traitRank?.farmAvgEbv ?? traitComparison?.myFarm ?? 0)
|
||||
|
||||
@@ -2,24 +2,22 @@
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Info, Maximize2, X } from 'lucide-react'
|
||||
import { NEGATIVE_TRAITS } from "@/constants/traits"
|
||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Customized,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts'
|
||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { NEGATIVE_TRAITS } from "@/constants/traits"
|
||||
|
||||
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
|
||||
// 형질 카테고리별 색상 매핑
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'성장': '#3b82f6', // 블루
|
||||
'생산': '#f59e0b', // 앰버
|
||||
@@ -28,71 +26,25 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
'비율': '#ec4899' // 핑크
|
||||
}
|
||||
|
||||
// 형질 비교용 색상 배열
|
||||
const TRAIT_COLORS = [
|
||||
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
|
||||
'#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6',
|
||||
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
|
||||
'#f43f5e', '#fb923c', '#fbbf24', '#a3e635', '#4ade80',
|
||||
'#2dd4bf', '#22d3ee', '#38bdf8', '#60a5fa', '#818cf8',
|
||||
'#a78bfa', '#c084fc', '#e879f9', '#f472b6', '#fb7185',
|
||||
'#fdba74', '#fcd34d', '#bef264', '#86efac', '#5eead4'
|
||||
]
|
||||
|
||||
// 정규분포 CDF (누적분포함수) - σ값을 백분위로 변환
|
||||
// 표준정규분포에서 z값 이하의 확률을 반환 (0~1)
|
||||
function normalCDF(z: number): number {
|
||||
// Abramowitz and Stegun 근사법 (오차 < 7.5×10^-8)
|
||||
const a1 = 0.254829592
|
||||
const a2 = -0.284496736
|
||||
const a3 = 1.421413741
|
||||
const a4 = -1.453152027
|
||||
const a5 = 1.061405429
|
||||
const p = 0.3275911
|
||||
|
||||
const sign = z < 0 ? -1 : 1
|
||||
z = Math.abs(z)
|
||||
|
||||
const t = 1.0 / (1.0 + p * z)
|
||||
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-z * z / 2)
|
||||
|
||||
return 0.5 * (1.0 + sign * y)
|
||||
}
|
||||
|
||||
// σ값을 상위 백분위(%)로 변환 (예: +1σ → 상위 15.87%)
|
||||
function sigmaToPercentile(sigma: number): number {
|
||||
// CDF는 "이하" 확률이므로, 상위 %는 (1 - CDF) * 100
|
||||
const percentile = (1 - normalCDF(sigma)) * 100
|
||||
return Math.max(1, Math.min(99, percentile))
|
||||
}
|
||||
|
||||
// σ 값을 등급으로 변환
|
||||
function getGradeFromSigma(sigmaValue: number): { grade: string; color: string; bg: string } {
|
||||
if (sigmaValue >= 1) {
|
||||
return { grade: '우수', color: 'text-green-600', bg: 'bg-green-50' }
|
||||
} else if (sigmaValue >= -1) {
|
||||
return { grade: '보통', color: 'text-gray-600', bg: 'bg-gray-100' }
|
||||
} else {
|
||||
return { grade: '개선필요', color: 'text-orange-600', bg: 'bg-orange-50' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 유전체 형질 데이터 타입 */
|
||||
interface GenomicTrait {
|
||||
id?: number
|
||||
traitName?: string
|
||||
traitCategory?: string
|
||||
breedVal?: number
|
||||
percentile?: number
|
||||
traitVal?: number
|
||||
traitName?: string // 형질명 (예: 도체중, 등지방두께)
|
||||
traitCategory?: string // 형질 카테고리 (성장/생산/체형/무게/비율)
|
||||
breedVal?: number // 육종가 값
|
||||
percentile?: number // 백분위 순위
|
||||
traitVal?: number // 형질 값 (EPD)
|
||||
}
|
||||
|
||||
// 형질별 비교 데이터 타입
|
||||
/** 형질별 농가/보은군 비교 데이터 */
|
||||
interface TraitComparison {
|
||||
trait: string
|
||||
shortName: string
|
||||
myFarm: number // 농가 평균
|
||||
region: number // 보은군 평균
|
||||
diff: number // 차이
|
||||
trait: string // 형질명
|
||||
shortName: string // 짧은 형질명 (차트 표시용)
|
||||
myFarm: number // 농가 평균 값
|
||||
region: number // 보은군 평균 값
|
||||
diff: number // 농가와 보은군 간 차이
|
||||
}
|
||||
|
||||
interface NormalDistributionChartProps {
|
||||
|
||||
@@ -2,18 +2,8 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DEFAULT_TRAITS, NEGATIVE_TRAITS } from "@/constants/traits"
|
||||
|
||||
// 형질명 표시 (전체 이름)
|
||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||
'도체중': '도체중',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'근내지방도': '근내지방도',
|
||||
'체장': '체장',
|
||||
'체고': '체고',
|
||||
'등심weight': '등심중량'
|
||||
}
|
||||
import { DEFAULT_TRAITS, NEGATIVE_TRAITS, getTraitDisplayName } from "@/constants/traits"
|
||||
import { GenomeCowTraitDto } from "@/types/genome.types"
|
||||
|
||||
// 카테고리별 배지 스타일 (진한 톤)
|
||||
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
|
||||
@@ -28,27 +18,29 @@ const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string
|
||||
'비율': { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300' },
|
||||
}
|
||||
|
||||
interface TraitData {
|
||||
id?: number
|
||||
traitName?: string
|
||||
traitCategory?: string
|
||||
breedVal?: number
|
||||
percentile?: number
|
||||
traitVal?: number
|
||||
}
|
||||
|
||||
interface TraitDistributionChartsProps {
|
||||
allTraits: TraitData[]
|
||||
allTraits: GenomeCowTraitDto[]
|
||||
regionAvgZ: number
|
||||
farmAvgZ: number
|
||||
cowName?: string
|
||||
totalCowCount?: number
|
||||
selectedTraits?: TraitData[]
|
||||
selectedTraits?: GenomeCowTraitDto[]
|
||||
traitWeights?: Record<string, number>
|
||||
}
|
||||
|
||||
// 리스트 뷰 컴포넌트
|
||||
function TraitListView({ traits, cowName }: { traits: Array<{ name: string; shortName: string; breedVal: number; percentile: number; category?: string; actualValue?: number }>; cowName: string }) {
|
||||
function TraitListView({ traits, cowName }: {
|
||||
traits: Array<{
|
||||
traitName?: string;
|
||||
shortName: string;
|
||||
breedVal: number;
|
||||
percentile?: number;
|
||||
traitCategory?: string;
|
||||
traitVal?: number;
|
||||
hasData?: boolean;
|
||||
}>;
|
||||
cowName: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md">
|
||||
<CardContent className="p-0">
|
||||
@@ -136,7 +128,7 @@ export function TraitDistributionCharts({
|
||||
const weight = traitWeights[trait.traitName || ''] || 1
|
||||
return {
|
||||
traitName: trait.traitName,
|
||||
shortName: TRAIT_SHORT_NAMES[trait.traitName || ''] || trait.traitName,
|
||||
shortName: getTraitDisplayName(trait.traitName || ''),
|
||||
breedVal: (trait.breedVal || 0) * weight,
|
||||
percentile: trait.percentile,
|
||||
traitCategory: trait.traitCategory,
|
||||
@@ -151,7 +143,7 @@ export function TraitDistributionCharts({
|
||||
const weight = traitWeights[traitName] || 1
|
||||
return {
|
||||
traitName: traitName,
|
||||
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
||||
shortName: getTraitDisplayName(traitName),
|
||||
breedVal: (trait?.breedVal ?? 0) * weight,
|
||||
percentile: trait?.percentile ?? 50,
|
||||
traitCategory: trait?.traitCategory,
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
@@ -1,3 +1,19 @@
|
||||
/**
|
||||
* ========================================
|
||||
* 대시보드 메인 페이지
|
||||
* ========================================
|
||||
*
|
||||
* @description
|
||||
* 농장의 유전체/유전자/MPT 검사 현황을 한눈에 보여주는 대시보드
|
||||
*
|
||||
* @features
|
||||
* 1. 총 검사 개체 수 (유전체/유전자/번식능력 구분)
|
||||
* 2. 친자감별 결과 (도넛 차트)
|
||||
* 3. MPT 검사 현황 (에너지/단백질/간/미네랄)
|
||||
* 4. 보은군 내 농가 위치 (정규분포 차트)
|
||||
* 5. 연도별 육종가 추이 (막대 차트)
|
||||
* 6. 카테고리별 보은군 대비 비교 (레이더 + 바 차트)
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||||
@@ -53,16 +69,27 @@ import {
|
||||
} from 'recharts'
|
||||
|
||||
export default function DashboardPage() {
|
||||
// ========================================
|
||||
// 1. 기본 상태 관리
|
||||
// ========================================
|
||||
const router = useRouter()
|
||||
const { user } = useAuthStore()
|
||||
const { filters } = useFilterStore()
|
||||
|
||||
// 농장 정보
|
||||
const [farmNo, setFarmNo] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 대시보드 통계 데이터
|
||||
const [stats, setStats] = useState<DashboardStatsDto | null>(null)
|
||||
const [farmRanking, setFarmRanking] = useState<FarmRegionRankingDto | null>(null)
|
||||
const [mptStats, setMptStats] = useState<MptStatisticsDto | null>(null)
|
||||
|
||||
// 모바일 감지 (반응형)
|
||||
// ========================================
|
||||
// 2. 모바일 감지 및 필터 관련 상태
|
||||
// ========================================
|
||||
|
||||
// 모바일 감지 (640px 이하)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 640)
|
||||
@@ -71,12 +98,16 @@ export default function DashboardPage() {
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
// 필터에서 대표 형질: 고정된 형질 중 selectedTraits 순서상 맨 위 > '도체중'
|
||||
// 고정되지 않은 형질은 순서가 맨 위여도 반영 안 됨
|
||||
/**
|
||||
* 대표 형질 선택 로직
|
||||
* @description
|
||||
* - 고정된(pinnedTraits) 형질 중 selectedTraits 순서상 첫 번째
|
||||
* - 고정되지 않은 형질은 무시
|
||||
* - 없으면 기본값 '도체중'
|
||||
*/
|
||||
const primaryTrait = (() => {
|
||||
const pinnedTraits = filters.pinnedTraits || []
|
||||
const selectedTraits = filters.selectedTraits || []
|
||||
// selectedTraits 순서대로 순회하면서 고정된 형질 찾기
|
||||
for (const trait of selectedTraits) {
|
||||
if (pinnedTraits.includes(trait)) {
|
||||
return trait
|
||||
@@ -85,7 +116,7 @@ export default function DashboardPage() {
|
||||
return '도체중'
|
||||
})()
|
||||
|
||||
// 연도별 육종가 추이 관련 state
|
||||
// 연도별 육종가 추이 차트용 형질 선택 (localStorage 연동)
|
||||
const [selectedTrait, setSelectedTrait] = useState<string>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('dashboard_trait') || primaryTrait
|
||||
@@ -95,34 +126,44 @@ export default function DashboardPage() {
|
||||
const [traitTrendData, setTraitTrendData] = useState<YearlyTraitTrendDto | null>(null)
|
||||
const [traitTrendLoading, setTraitTrendLoading] = useState(false)
|
||||
|
||||
// 보은군 내 농가 위치 차트 분포기준 (선발지수 or 개별 형질)
|
||||
// 필터 활성 시 'overall', 비활성 시 대표 형질
|
||||
/**
|
||||
* 농가 위치 차트 분포 기준
|
||||
* @description
|
||||
* - 필터 활성: 'overall' (전체 선발지수, 35개 형질 평균)
|
||||
* - 필터 비활성: primaryTrait (대표 형질)
|
||||
*/
|
||||
const [distributionBasis, setDistributionBasis] = useState<string>(() => {
|
||||
return filters.isActive ? 'overall' : primaryTrait
|
||||
})
|
||||
|
||||
// 필터 변경 시 기본값 업데이트
|
||||
// 필터 비활성화 시 'overall' → 대표 형질로 변경
|
||||
useEffect(() => {
|
||||
if (!filters.isActive && distributionBasis === 'overall') {
|
||||
setDistributionBasis(primaryTrait)
|
||||
}
|
||||
}, [filters.isActive, distributionBasis, primaryTrait])
|
||||
|
||||
// 대표 형질(고정 또는 첫 번째)이 변경되면 selectedTrait도 업데이트
|
||||
// 대표 형질 변경 시 연동
|
||||
useEffect(() => {
|
||||
// 대표 형질로 변경
|
||||
setSelectedTrait(primaryTrait)
|
||||
// distributionBasis가 overall이 아니면 대표 형질로 변경
|
||||
if (distributionBasis !== 'overall') {
|
||||
setDistributionBasis(primaryTrait)
|
||||
}
|
||||
}, [primaryTrait])
|
||||
|
||||
// 모든 형질 목록 (평탄화)
|
||||
// 모든 형질 목록 (카테고리별 평탄화)
|
||||
const allTraits = Object.entries(TRAIT_CATEGORIES).flatMap(([cat, traits]) =>
|
||||
traits.map(t => ({ category: cat, trait: t }))
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 3. 데이터 로드 (useEffect)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 농장 정보 로드
|
||||
* @description 사용자의 첫 번째 농장 정보를 가져옴
|
||||
*/
|
||||
useEffect(() => {
|
||||
const fetchFarm = async () => {
|
||||
setLoading(true)
|
||||
@@ -148,9 +189,17 @@ export default function DashboardPage() {
|
||||
// user가 없으면 loading 상태 유지 (AuthGuard에서 처리)
|
||||
}, [user])
|
||||
|
||||
/**
|
||||
* 대시보드 통계 데이터 로드
|
||||
* @description
|
||||
* - getDashboardStats: 총 검사 개체, 친자감별, 연도별 추이
|
||||
* - getFarmRegionRanking: 보은군 내 농가 순위 (필터 가중치 적용)
|
||||
* - getMptStatistics: 번식능력검사 통계
|
||||
*/
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (!farmNo) return
|
||||
|
||||
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성
|
||||
const traitConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0
|
||||
? filters.selectedTraits.map(traitNm => ({
|
||||
@@ -158,6 +207,7 @@ export default function DashboardPage() {
|
||||
weight: (filters.traitWeights as Record<string, number>)[traitNm] || 1
|
||||
}))
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const [statsData, rankingData, mptStatsData] = await Promise.all([
|
||||
genomeApi.getDashboardStats(farmNo),
|
||||
@@ -174,7 +224,10 @@ export default function DashboardPage() {
|
||||
fetchStats()
|
||||
}, [farmNo, filters.isActive, filters.selectedTraits, filters.traitWeights])
|
||||
|
||||
// 연도별 형질 추이 데이터 로드
|
||||
/**
|
||||
* 연도별 형질 추이 데이터 로드
|
||||
* @description 선택된 형질의 최근 3년 육종가 추이
|
||||
*/
|
||||
useEffect(() => {
|
||||
const fetchTraitTrend = async () => {
|
||||
if (!farmNo || !selectedTrait) return
|
||||
@@ -194,32 +247,44 @@ export default function DashboardPage() {
|
||||
fetchTraitTrend()
|
||||
}, [farmNo, selectedTrait])
|
||||
|
||||
// localStorage에 선택 저장
|
||||
/**
|
||||
* 선택된 형질을 localStorage에 저장
|
||||
* @description 페이지 새로고침 시에도 선택 유지
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('dashboard_trait', selectedTrait)
|
||||
}
|
||||
}, [selectedTrait])
|
||||
|
||||
// 계산된 값들
|
||||
// ========================================
|
||||
// 4. 계산된 값들 (파생 데이터)
|
||||
// ========================================
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const lastYear = currentYear - 1
|
||||
const last3Years = [currentYear - 2, currentYear - 1, currentYear]
|
||||
|
||||
// 올해/작년 데이터
|
||||
// 올해/작년 통계
|
||||
const thisYearStats = stats?.yearlyStats?.find(s => s.year === currentYear)
|
||||
const lastYearStats = stats?.yearlyStats?.find(s => s.year === lastYear)
|
||||
|
||||
// 전년 대비 변화량 계산
|
||||
// 전년 대비 검사 개체 수 변화
|
||||
const totalChange = (thisYearStats?.totalRequests || 0) - (lastYearStats?.totalRequests || 0)
|
||||
|
||||
// 분석 완료율 (분석 완료 / 총 의뢰)
|
||||
const completionRate = stats?.summary.totalRequests
|
||||
? Math.round((stats.summary.analyzedCount / stats.summary.totalRequests) * 100)
|
||||
: 0
|
||||
const lastYearCompletionRate = lastYearStats?.analyzeRate || 0
|
||||
const completionChange = completionRate - lastYearCompletionRate
|
||||
|
||||
// 친자 일치율: 분석 완료 / (분석 완료 + 분석 불가)
|
||||
/**
|
||||
* 친자 일치율 계산
|
||||
* @description
|
||||
* - 분자: 분석 완료 (부모 일치)
|
||||
* - 분모: 분석 완료 + 부 불일치 + 모 불일치 + 모 이력제부재
|
||||
*/
|
||||
const totalAnalyzed = stats?.paternityStats
|
||||
? (stats.paternityStats.analysisComplete + stats.paternityStats.sireMismatch + stats.paternityStats.damMismatch + stats.paternityStats.damNoRecord)
|
||||
: 0
|
||||
@@ -229,7 +294,7 @@ export default function DashboardPage() {
|
||||
const lastYearPaternityRate = lastYearStats?.sireMatchRate || 0
|
||||
const paternityChange = paternityMatchRate - lastYearPaternityRate
|
||||
|
||||
// 친자감별 파이차트 (상호 배타적 분류)
|
||||
// 친자감별 도넛 차트 데이터 (값이 0인 항목 제외)
|
||||
const paternityPieData = stats?.paternityStats ? [
|
||||
{ name: '분석 완료', value: stats.paternityStats.analysisComplete, color: '#1F3A8F' },
|
||||
{ name: '부 불일치', value: stats.paternityStats.sireMismatch, color: '#ef4444' },
|
||||
@@ -237,7 +302,12 @@ export default function DashboardPage() {
|
||||
{ name: '모 이력제부재', value: stats.paternityStats.damNoRecord, color: '#eab308' },
|
||||
].filter(d => d.value > 0) : []
|
||||
|
||||
// 변화량 표시 컴포넌트
|
||||
/**
|
||||
* 변화량 표시 컴포넌트
|
||||
* @description 전년 대비 증감을 화살표로 표시
|
||||
* @param value - 변화량 (양수: 증가, 음수: 감소)
|
||||
* @param suffix - 단위 (%, 두 등)
|
||||
*/
|
||||
const ChangeIndicator = ({ value, suffix = '' }: { value: number, suffix?: string }) => {
|
||||
if (value === 0) return null
|
||||
const isPositive = value > 0
|
||||
@@ -249,8 +319,25 @@ export default function DashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 농가 위치 차트용 데이터 (분포기준에 따라 다른 값 사용)
|
||||
// ========================================
|
||||
// 5. 농가 위치 차트 데이터 계산 (복잡한 로직)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 보은군 내 농가 위치 차트 데이터
|
||||
* @description
|
||||
* - 정규분포 히스토그램 생성
|
||||
* - 우리농가 vs 보은군 평균 비교
|
||||
* - 분포 기준: 전체 선발지수 or 개별 형질
|
||||
*
|
||||
* @logic
|
||||
* 1. 보은군 평균을 기준점(0)으로 설정
|
||||
* 2. 우리농가 점수 = 보은군 대비 차이 (±값)
|
||||
* 3. 정규분포 곡선 생성 (PDF 기반)
|
||||
* 4. X축 범위 자동 계산 (데이터 범위에 맞춤)
|
||||
*/
|
||||
const farmPositionData = useMemo(() => {
|
||||
// 데이터 없을 때 기본값
|
||||
if (!farmRanking) return {
|
||||
histogramData: [],
|
||||
xAxisRange: { min: -2.5, max: 2.5 },
|
||||
@@ -264,9 +351,9 @@ export default function DashboardPage() {
|
||||
percentile: null as number | null
|
||||
}
|
||||
|
||||
let farmScore = 0
|
||||
let regionScore = 0
|
||||
let originalFarmScore = 0
|
||||
let farmScore = 0 // 차트용: 보은군 대비 상대값
|
||||
let regionScore = 0 // 차트용: 보은군 = 0 (기준점)
|
||||
let originalFarmScore = 0 // 툴팁용: 실제 EPD/EBV 값
|
||||
let originalRegionScore = 0
|
||||
let label = '전체 선발지수'
|
||||
let rank: number | null = null
|
||||
@@ -274,8 +361,8 @@ export default function DashboardPage() {
|
||||
let percentile: number | null = null
|
||||
|
||||
if (distributionBasis === 'overall') {
|
||||
// 전체 선발지수 (35개 형질 평균)
|
||||
// 보은군 평균을 기준(0)으로 하고, 우리농가는 보은군 대비 차이로 표시
|
||||
// ===== 전체 선발지수 모드 =====
|
||||
// 35개 형질 평균 EBV (표준화 육종가)
|
||||
const rawFarmScore = farmRanking.farmAvgScore ?? 0
|
||||
const rawRegionScore = farmRanking.regionAvgScore ?? 0
|
||||
farmScore = rawFarmScore - rawRegionScore // 보은군 대비 차이
|
||||
@@ -286,21 +373,25 @@ export default function DashboardPage() {
|
||||
rank = farmRanking.farmRankInRegion
|
||||
percentile = farmRanking.percentile
|
||||
} else {
|
||||
// 개별 형질 선택 시 - traitAverages에서 해당 형질 찾기
|
||||
// ===== 개별 형질 모드 =====
|
||||
const traitData = stats?.traitAverages?.find(t => t.traitName === distributionBasis)
|
||||
if (traitData) {
|
||||
const farmEpd = traitData.avgEpd ?? 0
|
||||
const regionEpd = traitData.regionAvgEpd ?? 0
|
||||
let diff = farmEpd - regionEpd // 보은군 대비 차이
|
||||
|
||||
// 등지방두께 등 낮을수록 좋은 형질은 부호 반전
|
||||
// (농가가 보은군보다 낮으면 실제로는 더 좋은 것이므로 양수로 표시)
|
||||
/**
|
||||
* 낮을수록 좋은 형질 처리
|
||||
* @example 등지방두께: 낮으면 좋음 → 부호 반전
|
||||
* - 농가 EPD < 보은군 EPD → diff는 음수지만 실제로는 더 좋음
|
||||
* - 따라서 부호를 반전시켜 양수로 표시
|
||||
*/
|
||||
if (NEGATIVE_TRAITS.includes(distributionBasis)) {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
farmScore = diff
|
||||
regionScore = 0 // 보은군 = 기준점 (0)
|
||||
regionScore = 0
|
||||
originalFarmScore = farmEpd
|
||||
originalRegionScore = regionEpd
|
||||
label = distributionBasis
|
||||
@@ -310,12 +401,16 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* X축 범위 자동 계산
|
||||
* @description
|
||||
* - 데이터의 최대값에 맞춰 X축 범위를 동적으로 조정
|
||||
* - 범위가 작으면 0.5 단위, 크면 2/10/20 단위로 반올림
|
||||
*/
|
||||
const absMax = Math.max(Math.abs(farmScore), 0.1)
|
||||
// 데이터 범위에 따라 적절한 X축 범위 계산
|
||||
// absMax의 1.5배를 범위로 하고, 깔끔한 숫자로 반올림
|
||||
const rawRange = absMax * 1.5
|
||||
let range: number
|
||||
let step: number
|
||||
const rawRange = absMax * 1.5 // 여유 공간 확보 (1.5배)
|
||||
let range: number // X축 범위 (-range ~ +range)
|
||||
let step: number // 구간 간격
|
||||
|
||||
if (rawRange <= 3) {
|
||||
// 표준화 육종가 스케일 (전체 선발지수)
|
||||
@@ -332,11 +427,17 @@ export default function DashboardPage() {
|
||||
step = range / 5
|
||||
}
|
||||
|
||||
// 정규분포 비율 계산 (구간 개수에 맞춤)
|
||||
/**
|
||||
* 정규분포 히스토그램 생성
|
||||
* @description
|
||||
* - PDF (확률밀도함수)를 사용해 정규분포 곡선 생성
|
||||
* - 보은군 평균(0)을 중심으로 대칭 분포
|
||||
* - sigma = range/3 (±3σ에 99.7% 포함)
|
||||
*/
|
||||
const numBins = Math.round((range * 2) / step)
|
||||
const bins = []
|
||||
|
||||
// 정규분포 PDF 기반으로 각 구간의 비율 계산
|
||||
// 정규분포 PDF 함수
|
||||
const normalPDF = (x: number, sigma: number = range / 3) => {
|
||||
return Math.exp(-0.5 * Math.pow(x / sigma, 2)) / (sigma * Math.sqrt(2 * Math.PI))
|
||||
}
|
||||
@@ -345,6 +446,7 @@ export default function DashboardPage() {
|
||||
let totalPercent = 0
|
||||
const tempBins = []
|
||||
|
||||
// 각 구간의 비율 계산
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const min = -range + i * step
|
||||
const max = min + step
|
||||
@@ -354,7 +456,7 @@ export default function DashboardPage() {
|
||||
tempBins.push({ min, max, midPoint, percent })
|
||||
}
|
||||
|
||||
// 비율 정규화
|
||||
// 비율 정규화 (총합이 100%가 되도록)
|
||||
for (const bin of tempBins) {
|
||||
const normalizedPercent = (bin.percent / totalPercent) * 100
|
||||
bins.push({
|
||||
@@ -362,7 +464,7 @@ export default function DashboardPage() {
|
||||
min: bin.min,
|
||||
max: bin.max,
|
||||
midPoint: bin.midPoint,
|
||||
count: Math.round(total * normalizedPercent / 100),
|
||||
count: Math.round(total * normalizedPercent / 100), // 농가 수로 환산
|
||||
percent: normalizedPercent
|
||||
})
|
||||
}
|
||||
@@ -381,6 +483,10 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, [farmRanking, stats, distributionBasis])
|
||||
|
||||
// ========================================
|
||||
// 6. 렌더링
|
||||
// ========================================
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
@@ -388,6 +494,7 @@ export default function DashboardPage() {
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20 min-h-screen">
|
||||
{/* 로딩 상태 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
@@ -396,6 +503,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : !farmNo ? (
|
||||
/* 농장 정보 없음 */
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center space-y-3 p-8 bg-white rounded-2xl shadow-sm border">
|
||||
<AlertCircle className="w-14 h-14 text-slate-300 mx-auto" />
|
||||
@@ -405,7 +513,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ========== 1. 핵심 KPI 카드 (2개) ========== */}
|
||||
{/* ========================================
|
||||
1. 핵심 KPI 카드 영역
|
||||
- 총 검사 개체 수 (유전체/유전자/번식능력)
|
||||
- 친자감별 결과 (도넛 차트)
|
||||
======================================== */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-sm:gap-3">
|
||||
{/* 총 검사 개체 수 (합집합) */}
|
||||
<div
|
||||
@@ -520,7 +632,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 1-2. 번식능력검사 현황 ========== */}
|
||||
{/* ========================================
|
||||
2. MPT (번식능력검사) 현황
|
||||
- 에너지 균형 / 단백질 상태 / 간 건강 / 미네랄 균형
|
||||
- 안전/주의 구분
|
||||
======================================== */}
|
||||
{mptStats && mptStats.totalMptCows > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -614,9 +730,13 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 2. 좌측(농가위치+순위) + 우측(연도별 육종가) - 높이 맞춤 ========== */}
|
||||
{/* ========================================
|
||||
3. 메인 차트 영역 (좌우 2열)
|
||||
- 좌: 보은군 내 농가 위치 (정규분포 차트)
|
||||
- 우: 연도별 육종가 추이 (막대 차트)
|
||||
======================================== */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-sm:gap-4">
|
||||
{/* 좌측 영역: 보은군 내 농가 위치 + 순위 통합 */}
|
||||
{/* === 좌측: 보은군 내 농가 위치 차트 === */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 max-sm:flex-col max-sm:items-start max-sm:gap-2">
|
||||
<div className="flex items-center gap-3 max-sm:gap-2">
|
||||
@@ -931,7 +1051,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측 영역: 연도별 육종가 추이 */}
|
||||
{/* === 우측: 연도별 육종가 추이 차트 === */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 max-sm:flex-col max-sm:items-start max-sm:gap-2">
|
||||
<h3 className="font-semibold text-slate-900 text-lg max-sm:text-base">연도별 육종가 추이</h3>
|
||||
@@ -940,7 +1060,7 @@ export default function DashboardPage() {
|
||||
<span className="flex items-center gap-1.5 max-sm:gap-1 font-medium"><span className="w-3.5 h-3.5 max-sm:w-2.5 max-sm:h-2.5 rounded bg-slate-400"></span> 보은군</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 형질 선택 드롭다운 */}
|
||||
{/* 형질 선택 드롭다운 (카테고리별 그룹화) */}
|
||||
<div className="mb-4">
|
||||
<Select value={selectedTrait} onValueChange={setSelectedTrait}>
|
||||
<SelectTrigger className="w-full h-14 text-sm font-medium">
|
||||
@@ -1147,12 +1267,24 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 3. 카테고리별 보은군 대비 비교 - 전체 너비 ========== */}
|
||||
{/* ========================================
|
||||
4. 카테고리별 보은군 대비 비교
|
||||
- 레이더 차트 (좌측): 5개 카테고리 시각화
|
||||
- 바 차트 (우측): 카테고리별 상세 육종가
|
||||
======================================== */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-900 text-lg max-sm:text-base mb-4 max-sm:mb-3">보은군 대비 카테고리별 육종가 평균</h3>
|
||||
{(stats?.summary.genomeCowCount || 0) > 0 && stats?.traitAverages && stats.traitAverages.length > 0 ? (
|
||||
(() => {
|
||||
// 5개 카테고리 분류 (성장/생산/체형/무게/비율)
|
||||
const categories = ['성장', '생산', '체형', '무게', '비율']
|
||||
|
||||
/**
|
||||
* 카테고리별 평균 계산
|
||||
* @description
|
||||
* - 각 카테고리에 속한 형질들의 EPD 평균
|
||||
* - 백분위(percentile)도 평균 계산
|
||||
*/
|
||||
const categoryData = categories.map(cat => {
|
||||
const traits = stats.traitAverages.filter(t => t.category === cat)
|
||||
const avgEpd = traits.length > 0
|
||||
@@ -1168,17 +1300,30 @@ export default function DashboardPage() {
|
||||
traitCount: traits.length
|
||||
}
|
||||
})
|
||||
|
||||
// 바 차트 최대 길이 계산용
|
||||
const maxAbs = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 1)
|
||||
|
||||
// 레이더 차트용 설정
|
||||
/**
|
||||
* 레이더 차트 좌표 계산
|
||||
* @description
|
||||
* - 중심점: (140, 150)
|
||||
* - 반지름: 95px
|
||||
* - 5개 카테고리를 정오각형으로 배치
|
||||
*/
|
||||
const centerX = 140
|
||||
const centerY = 150
|
||||
const maxRadius = 95
|
||||
const angleStep = (2 * Math.PI) / categories.length
|
||||
const startAngle = -Math.PI / 2
|
||||
const angleStep = (2 * Math.PI) / categories.length // 72도씩
|
||||
const startAngle = -Math.PI / 2 // 12시 방향부터 시작
|
||||
|
||||
// 농가 데이터 다각형 좌표 (EPD 기반)
|
||||
// 육종가 범위에 맞게 스케일 조정
|
||||
/**
|
||||
* 농가 데이터 다각형 좌표 계산
|
||||
* @description
|
||||
* - 보은군 평균(0) = 반지름 50%
|
||||
* - ±epdScale = 반지름 0%/100%
|
||||
* - EPD 값을 반지름으로 변환
|
||||
*/
|
||||
const epdScale = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 10)
|
||||
const farmPoints = categoryData.map((d, i) => {
|
||||
const angle = startAngle + i * angleStep
|
||||
@@ -1188,32 +1333,43 @@ export default function DashboardPage() {
|
||||
return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }
|
||||
})
|
||||
|
||||
// 보은군 평균 (50% 지점 = EBV 0 기준)
|
||||
/**
|
||||
* 보은군 평균 다각형 (기준선)
|
||||
* @description
|
||||
* - 항상 50% 위치 (EBV = 0)
|
||||
* - 점선으로 표시
|
||||
*/
|
||||
const regionPoints = categories.map((_, i) => {
|
||||
const angle = startAngle + i * angleStep
|
||||
const radius = 0.5 * maxRadius
|
||||
const radius = 0.5 * maxRadius // 50% 고정
|
||||
return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }
|
||||
})
|
||||
|
||||
const farmPolygon = farmPoints.map(p => `${p.x},${p.y}`).join(' ')
|
||||
const regionPolygon = regionPoints.map(p => `${p.x},${p.y}`).join(' ')
|
||||
|
||||
// 호버된 포인트 인덱스를 위한 로컬 컴포넌트
|
||||
/**
|
||||
* 레이더 차트 SVG 컴포넌트
|
||||
* @description
|
||||
* - 호버/클릭으로 카테고리별 상세 정보 표시
|
||||
* - 모바일에서는 터치로 토글
|
||||
* - 툴팁은 차트 중앙에 고정 표시
|
||||
*/
|
||||
const RadarChart = () => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
const [clickedIndex, setClickedIndex] = useState<number | null>(null)
|
||||
|
||||
// 실제 표시할 인덱스 (클릭된 것 우선, 없으면 호버된 것)
|
||||
// 실제 표시할 인덱스 (클릭 우선, 없으면 호버)
|
||||
const activeIndex = clickedIndex !== null ? clickedIndex : hoveredIndex
|
||||
|
||||
// 클릭/터치 핸들러: 토글 방식
|
||||
// 클릭/터치 핸들러: 토글 방식 (같은 거 다시 누르면 닫힘)
|
||||
const handleClick = (e: React.MouseEvent | React.TouchEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setClickedIndex(prev => prev === index ? null : index)
|
||||
}
|
||||
|
||||
// 호버 핸들러: 클릭된 상태가 아닐 때만 동작
|
||||
// 호버 핸들러: 클릭 상태가 아닐 때만 동작
|
||||
const handleMouseEnter = (index: number) => {
|
||||
if (clickedIndex === null) {
|
||||
setHoveredIndex(index)
|
||||
|
||||
@@ -1,455 +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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Trophy, ChevronLeft, TrendingUp, Award, Star } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||
|
||||
export default function TopCowsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// 더미 데이터 - 실제로는 백엔드에서 가져와야 함
|
||||
const topCows = [
|
||||
{
|
||||
rank: 1,
|
||||
cowNo: '001122334401',
|
||||
name: 'KOR 001122334401',
|
||||
score: 85.2,
|
||||
grade: 'A',
|
||||
birthDate: '2021-03-15',
|
||||
age: '3년 8개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 92,
|
||||
marbling: 88,
|
||||
eyeMuscleArea: 90,
|
||||
backfatThickness: 82,
|
||||
bodyConformation: 95
|
||||
},
|
||||
strengths: ['체형', '도체중', '등심단면적'],
|
||||
weaknesses: ['등지방두께'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 485,
|
||||
marblingScore: 7,
|
||||
eyeMuscleArea: 95
|
||||
},
|
||||
recommendations: '체형이 매우 우수한 개체입니다. 등지방두께 개선을 위한 KPN 선택을 권장합니다.'
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
cowNo: '001122334402',
|
||||
name: 'KOR 001122334402',
|
||||
score: 82.7,
|
||||
grade: 'A',
|
||||
birthDate: '2020-11-20',
|
||||
age: '4년 0개월',
|
||||
lactationCount: 3,
|
||||
traits: {
|
||||
carcassWeight: 88,
|
||||
marbling: 94,
|
||||
eyeMuscleArea: 86,
|
||||
backfatThickness: 85,
|
||||
bodyConformation: 90
|
||||
},
|
||||
strengths: ['근내지방도', '체형', '도체중'],
|
||||
weaknesses: ['등심단면적'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 465,
|
||||
marblingScore: 8,
|
||||
eyeMuscleArea: 88
|
||||
},
|
||||
recommendations: '근내지방도가 탁월한 개체입니다. 등심단면적 개선을 위한 교배 전략이 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
cowNo: '001122334403',
|
||||
name: 'KOR 001122334403',
|
||||
score: 79.1,
|
||||
grade: 'A',
|
||||
birthDate: '2021-05-10',
|
||||
age: '3년 6개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 90,
|
||||
marbling: 85,
|
||||
eyeMuscleArea: 88,
|
||||
backfatThickness: 75,
|
||||
bodyConformation: 87
|
||||
},
|
||||
strengths: ['도체중', '등심단면적'],
|
||||
weaknesses: ['등지방두께', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 495,
|
||||
marblingScore: 6,
|
||||
eyeMuscleArea: 92
|
||||
},
|
||||
recommendations: '도체중이 우수한 개체입니다. 등지방두께와 체형 개선에 집중할 필요가 있습니다.'
|
||||
},
|
||||
{
|
||||
rank: 4,
|
||||
cowNo: '001122334404',
|
||||
name: 'KOR 001122334404',
|
||||
score: 76.5,
|
||||
grade: 'A',
|
||||
birthDate: '2020-08-25',
|
||||
age: '4년 3개월',
|
||||
lactationCount: 3,
|
||||
traits: {
|
||||
carcassWeight: 84,
|
||||
marbling: 82,
|
||||
eyeMuscleArea: 85,
|
||||
backfatThickness: 90,
|
||||
bodyConformation: 88
|
||||
},
|
||||
strengths: ['등지방두께', '체형'],
|
||||
weaknesses: ['근내지방도'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 455,
|
||||
marblingScore: 5,
|
||||
eyeMuscleArea: 87
|
||||
},
|
||||
recommendations: '등지방두께가 매우 우수한 개체입니다. 근내지방도 개선을 위한 KPN 선택이 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 5,
|
||||
cowNo: '001122334405',
|
||||
name: 'KOR 001122334405',
|
||||
score: 73.8,
|
||||
grade: 'A',
|
||||
birthDate: '2021-01-18',
|
||||
age: '3년 10개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 86,
|
||||
marbling: 80,
|
||||
eyeMuscleArea: 83,
|
||||
backfatThickness: 88,
|
||||
bodyConformation: 85
|
||||
},
|
||||
strengths: ['등지방두께', '도체중'],
|
||||
weaknesses: ['근내지방도', '등심단면적'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 470,
|
||||
marblingScore: 5,
|
||||
eyeMuscleArea: 85
|
||||
},
|
||||
recommendations: '균형 잡힌 개체입니다. 근내지방도 향상을 위한 교배가 권장됩니다.'
|
||||
},
|
||||
{
|
||||
rank: 6,
|
||||
cowNo: '001122334406',
|
||||
name: 'KOR 001122334406',
|
||||
score: 71.2,
|
||||
grade: 'B',
|
||||
birthDate: '2020-12-05',
|
||||
age: '3년 11개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 82,
|
||||
marbling: 78,
|
||||
eyeMuscleArea: 81,
|
||||
backfatThickness: 84,
|
||||
bodyConformation: 83
|
||||
},
|
||||
strengths: ['등지방두께', '도체중'],
|
||||
weaknesses: ['근내지방도', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 445,
|
||||
marblingScore: 4,
|
||||
eyeMuscleArea: 82
|
||||
},
|
||||
recommendations: '전반적으로 평균 이상인 개체입니다. 근내지방도와 체형 개선이 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 7,
|
||||
cowNo: '001122334407',
|
||||
name: 'KOR 001122334407',
|
||||
score: 68.9,
|
||||
grade: 'B',
|
||||
birthDate: '2021-06-22',
|
||||
age: '3년 5개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 80,
|
||||
marbling: 76,
|
||||
eyeMuscleArea: 79,
|
||||
backfatThickness: 82,
|
||||
bodyConformation: 81
|
||||
},
|
||||
strengths: ['등지방두께'],
|
||||
weaknesses: ['근내지방도', '등심단면적', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 430,
|
||||
marblingScore: 4,
|
||||
eyeMuscleArea: 80
|
||||
},
|
||||
recommendations: '개선 여지가 많은 개체입니다. 근내지방도와 체형 강화가 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 8,
|
||||
cowNo: '001122334408',
|
||||
name: 'KOR 001122334408',
|
||||
score: 65.5,
|
||||
grade: 'B',
|
||||
birthDate: '2020-09-30',
|
||||
age: '4년 2개월',
|
||||
lactationCount: 3,
|
||||
traits: {
|
||||
carcassWeight: 78,
|
||||
marbling: 74,
|
||||
eyeMuscleArea: 77,
|
||||
backfatThickness: 80,
|
||||
bodyConformation: 79
|
||||
},
|
||||
strengths: ['등지방두께'],
|
||||
weaknesses: ['근내지방도', '등심단면적', '도체중', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 415,
|
||||
marblingScore: 3,
|
||||
eyeMuscleArea: 78
|
||||
},
|
||||
recommendations: '전반적인 개선이 필요한 개체입니다. 종합적인 개량 전략을 수립하세요.'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 lg:p-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">우수 개체 순위</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
유전체 점수 기준 전체 개체 순위
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">전체 개체</div>
|
||||
<div className="text-3xl font-bold">{topCows.length}두</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">A등급</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{topCows.filter(cow => cow.grade === 'A').length}두
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">평균 점수</div>
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{(topCows.reduce((sum, cow) => sum + cow.score, 0) / topCows.length).toFixed(1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">최고 점수</div>
|
||||
<div className="text-3xl font-bold text-emerald-600">
|
||||
{Math.max(...topCows.map(cow => cow.score)).toFixed(1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 개체 상세 리스트 */}
|
||||
<div className="space-y-4">
|
||||
{topCows.map((cow, idx) => (
|
||||
<Card key={idx} className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50/30 border-b">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-full flex items-center justify-center text-xl font-bold shadow-md ${
|
||||
cow.rank === 1 ? 'bg-gradient-to-br from-yellow-400 to-yellow-500 text-white' :
|
||||
cow.rank === 2 ? 'bg-gradient-to-br from-slate-300 to-slate-400 text-white' :
|
||||
cow.rank === 3 ? 'bg-gradient-to-br from-amber-600 to-amber-700 text-white' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{cow.rank}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
{cow.name}
|
||||
<Badge className={
|
||||
cow.grade === 'A' ? 'bg-blue-100 text-blue-700 border-blue-200' :
|
||||
'bg-slate-100 text-slate-700 border-slate-200'
|
||||
}>
|
||||
{cow.grade}등급
|
||||
</Badge>
|
||||
{cow.rank <= 3 && (
|
||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
개체번호: {cow.cowNo} · 나이: {cow.age} · 산차: {cow.lactationCount}산
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-blue-600">{cow.score}</div>
|
||||
<div className="text-sm text-muted-foreground">점</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 좌측: 형질 점수 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-blue-600" />
|
||||
형질별 점수
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(cow.traits).map(([trait, score]) => {
|
||||
const traitNames: Record<string, string> = {
|
||||
carcassWeight: '도체중',
|
||||
marbling: '근내지방도',
|
||||
eyeMuscleArea: '등심단면적',
|
||||
backfatThickness: '등지방두께',
|
||||
bodyConformation: '체형'
|
||||
}
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'bg-blue-600'
|
||||
if (score >= 80) return 'bg-blue-500'
|
||||
if (score >= 70) return 'bg-blue-400'
|
||||
return 'bg-slate-400'
|
||||
}
|
||||
return (
|
||||
<div key={trait} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-700">{traitNames[trait]}</span>
|
||||
<span className="font-bold text-slate-900">{score}점</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`${getScoreColor(score)} h-2.5 rounded-full transition-all`}
|
||||
style={{ width: `${score}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 성능 데이터 및 분석 */}
|
||||
<div className="space-y-4">
|
||||
{/* 최근 성적 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-600" />
|
||||
예상 도체 성적
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-emerald-50 p-3 rounded-lg border border-emerald-100">
|
||||
<div className="text-xs text-emerald-700 mb-1">도체중</div>
|
||||
<div className="text-lg font-bold text-emerald-600">
|
||||
{cow.recentPerformance.carcassWeight}
|
||||
</div>
|
||||
<div className="text-xs text-emerald-600">kg</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-100">
|
||||
<div className="text-xs text-blue-700 mb-1">근내지방</div>
|
||||
<div className="text-lg font-bold text-blue-600">
|
||||
{cow.recentPerformance.marblingScore}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">번</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-3 rounded-lg border border-purple-100">
|
||||
<div className="text-xs text-purple-700 mb-1">등심단면적</div>
|
||||
<div className="text-lg font-bold text-purple-600">
|
||||
{cow.recentPerformance.eyeMuscleArea}
|
||||
</div>
|
||||
<div className="text-xs text-purple-600">cm²</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 강점/약점 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-900 mb-2">강점 / 약점</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs text-emerald-700 font-semibold mt-0.5">강점:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cow.strengths.map((strength, i) => (
|
||||
<Badge key={i} className="bg-emerald-100 text-emerald-700 border-emerald-200 text-xs">
|
||||
{strength}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs text-amber-700 font-semibold mt-0.5">약점:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cow.weaknesses.map((weakness, i) => (
|
||||
<Badge key={i} className="bg-amber-100 text-amber-700 border-amber-200 text-xs">
|
||||
{weakness}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권장사항 */}
|
||||
<div className="bg-blue-50/50 p-3 rounded-lg border border-blue-100">
|
||||
<h4 className="text-xs font-semibold text-blue-900 mb-1">💡 권장사항</h4>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">
|
||||
{cow.recommendations}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 */}
|
||||
<div className="mt-6 pt-4 border-t flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => router.push(`/cow/${cow.cowNo}`)}
|
||||
className="flex-1"
|
||||
>
|
||||
개체 상세정보
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/recommended-kpn?cowNo=${cow.cowNo}`)}
|
||||
className="flex-1"
|
||||
>
|
||||
추천 KPN 보기
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// ============================================
|
||||
// 차트 컴포넌트
|
||||
// ============================================
|
||||
export { MptGaugeBar } from './mpt-gauge-bar';
|
||||
@@ -1,261 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* MPT 혈액대사검사 게이지/바 차트
|
||||
* 정상 범위 대비 현재 값의 위치를 시각적으로 표시
|
||||
*/
|
||||
|
||||
interface MptGaugeBarProps {
|
||||
name: string
|
||||
value: number
|
||||
unit: string
|
||||
lowerLimit: number | null
|
||||
upperLimit: number | null
|
||||
category: string
|
||||
regionAverage?: number // 보은군 평균 (선택)
|
||||
}
|
||||
|
||||
export function MptGaugeBar({
|
||||
name,
|
||||
value,
|
||||
unit,
|
||||
lowerLimit,
|
||||
upperLimit,
|
||||
category,
|
||||
regionAverage
|
||||
}: MptGaugeBarProps) {
|
||||
// 상태 계산
|
||||
const getStatus = (): 'low' | 'normal' | 'high' => {
|
||||
if (upperLimit !== null && value > upperLimit) return 'high'
|
||||
if (lowerLimit !== null && value < lowerLimit) return 'low'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
const status = getStatus()
|
||||
|
||||
// 범위와 값 기반 시각화 계산
|
||||
const calculateVisualization = () => {
|
||||
if (lowerLimit === null || upperLimit === null) {
|
||||
// 범위 정보 없음 - 단순 값 표시
|
||||
return { position: 50, hasRange: false }
|
||||
}
|
||||
|
||||
// 모든 값 수집 (현재값, 보은군평균, 정상범위)
|
||||
const allValues = [value, lowerLimit, upperLimit]
|
||||
if (regionAverage !== undefined) {
|
||||
allValues.push(regionAverage)
|
||||
}
|
||||
|
||||
const dataMin = Math.min(...allValues)
|
||||
const dataMax = Math.max(...allValues)
|
||||
const dataRange = dataMax - dataMin
|
||||
|
||||
// 데이터 범위의 20% 패딩 추가
|
||||
const padding = dataRange * 0.2
|
||||
const chartMin = dataMin - padding
|
||||
const chartMax = dataMax + padding
|
||||
const chartRange = chartMax - chartMin
|
||||
|
||||
// 값의 위치 계산 (0-100%)
|
||||
let position = ((value - chartMin) / chartRange) * 100
|
||||
position = Math.max(0, Math.min(100, position)) // 0-100% 범위로 제한
|
||||
|
||||
// 정상범위 시작/끝 위치
|
||||
const normalStart = ((lowerLimit - chartMin) / chartRange) * 100
|
||||
const normalEnd = ((upperLimit - chartMin) / chartRange) * 100
|
||||
|
||||
return {
|
||||
position,
|
||||
normalStart,
|
||||
normalEnd,
|
||||
hasRange: true,
|
||||
chartMin,
|
||||
chartMax,
|
||||
chartRange,
|
||||
}
|
||||
}
|
||||
|
||||
const viz = calculateVisualization()
|
||||
|
||||
// 카테고리별 색상 (제거 - 사용 안 함)
|
||||
|
||||
// 상태별 색상 (부드러운 톤)
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'high': return {
|
||||
bg: 'bg-gradient-to-r from-red-400 to-red-500',
|
||||
text: 'text-red-700',
|
||||
badgeBg: 'bg-red-50',
|
||||
badgeBorder: 'border-red-200',
|
||||
barBg: 'linear-gradient(to right, #f87171, #ef4444)' // red-400 to red-500
|
||||
}
|
||||
case 'low': return {
|
||||
bg: 'bg-gradient-to-r from-blue-400 to-blue-500',
|
||||
text: 'text-blue-700',
|
||||
badgeBg: 'bg-blue-50',
|
||||
badgeBorder: 'border-blue-200',
|
||||
barBg: 'linear-gradient(to right, #60a5fa, #3b82f6)' // blue-400 to blue-500
|
||||
}
|
||||
case 'normal': return {
|
||||
bg: 'bg-gradient-to-r from-green-400 to-green-500',
|
||||
text: 'text-green-700',
|
||||
badgeBg: 'bg-green-50',
|
||||
badgeBorder: 'border-green-200',
|
||||
barBg: 'linear-gradient(to right, #4ade80, #22c55e)' // green-400 to green-500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'high': return '높음 ↑'
|
||||
case 'low': return '낮음 ↓'
|
||||
case 'normal': return '정상'
|
||||
}
|
||||
}
|
||||
|
||||
const statusColors = getStatusColor()
|
||||
|
||||
return (
|
||||
<div className="p-3 md:p-3.5 rounded-xl border-2 border-slate-200 bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* 항목명과 현재값 */}
|
||||
<div className="flex items-center justify-between mb-2 md:mb-2.5">
|
||||
<span className="text-xs md:text-sm font-bold text-slate-800">{name}</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-lg md:text-xl font-bold ${statusColors.text}`}>
|
||||
{value.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-xs md:text-sm font-semibold text-slate-600">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 게이지 바 */}
|
||||
{viz.hasRange ? (
|
||||
<div className="mb-2 md:mb-2.5">
|
||||
{/* 현재값 및 보은군 평균 표시 (바 위) */}
|
||||
<div className="relative h-10 md:h-11 mb-1">
|
||||
{/* 보은군 평균 표시 */}
|
||||
{regionAverage !== undefined && viz.hasRange && (() => {
|
||||
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
|
||||
return (
|
||||
<div
|
||||
className="absolute -translate-x-1/2 transition-all duration-500"
|
||||
style={{ left: `${Math.max(0, Math.min(100, regionPosition))}%`, top: '0px' }}
|
||||
>
|
||||
<div className="px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-semibold whitespace-nowrap bg-slate-100 text-slate-700 border border-slate-300 shadow-sm">
|
||||
보은군: {regionAverage.toFixed(1)}
|
||||
</div>
|
||||
{/* 화살표 */}
|
||||
<div
|
||||
className="w-0 h-0 mx-auto"
|
||||
style={{
|
||||
borderLeft: '4px solid transparent',
|
||||
borderRight: '4px solid transparent',
|
||||
borderTop: '4px solid #94a3b8',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 현재값 표시 */}
|
||||
<div
|
||||
className="absolute -translate-x-1/2 transition-all duration-500"
|
||||
style={{ left: `${viz.position}%`, top: regionAverage !== undefined ? '24px' : '0px' }}
|
||||
>
|
||||
<div className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold whitespace-nowrap shadow-sm border ${
|
||||
status === 'high' ? 'bg-red-500 text-white border-red-600' :
|
||||
status === 'low' ? 'bg-blue-500 text-white border-blue-600' :
|
||||
'bg-green-500 text-white border-green-600'
|
||||
}`}>
|
||||
{value.toFixed(1)}
|
||||
{regionAverage !== undefined && Math.abs(value - regionAverage) > 0.1 && (
|
||||
<span className="ml-0.5">
|
||||
({value > regionAverage ? '+' : ''}{(value - regionAverage).toFixed(1)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 화살표 */}
|
||||
<div
|
||||
className="w-0 h-0 mx-auto"
|
||||
style={{
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderTop: `5px solid ${
|
||||
status === 'high' ? '#ef4444' :
|
||||
status === 'low' ? '#3b82f6' : '#22c55e'
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
|
||||
{/* 정상 범위 영역 */}
|
||||
<div
|
||||
className="absolute h-full bg-green-100/60"
|
||||
style={{
|
||||
left: `${viz.normalStart}%`,
|
||||
width: `${viz.normalEnd! - viz.normalStart!}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 보은군 평균 인디케이터 (회색 실선) */}
|
||||
{regionAverage !== undefined && viz.hasRange && (() => {
|
||||
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 h-full w-[3px] transition-all duration-500 z-[9]"
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, regionPosition))}%`,
|
||||
background: '#94a3b8',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 현재 값 인디케이터 */}
|
||||
<div
|
||||
className="absolute top-0 h-full w-1.5 md:w-2 transition-all duration-500 shadow-md z-10 rounded-full"
|
||||
style={{
|
||||
left: `${viz.position}%`,
|
||||
background: statusColors.barBg,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정상 범위 수치 표기 (바 양옆) */}
|
||||
<div className="flex items-center justify-between mt-1.5 md:mt-2 px-0.5">
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[10px] md:text-xs text-slate-500 font-semibold">최소</span>
|
||||
<span className="text-xs md:text-sm font-bold text-slate-700">{lowerLimit}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[10px] md:text-xs text-slate-500 font-semibold">최대</span>
|
||||
<span className="text-xs md:text-sm font-bold text-slate-700">{upperLimit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2">
|
||||
<div className="h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500`}
|
||||
style={{ width: '100%', background: statusColors.barBg }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<span className={`px-2 md:px-2.5 py-1 md:py-1.5 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { User, CheckCircle2, BarChart3 } from "lucide-react"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { GenomeTrait as GenomeTraitType } from "@/types/genome.types"
|
||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend, Tooltip as RechartsTooltip } from 'recharts'
|
||||
|
||||
interface CowCompareModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
compareCowsData: { cow: CowDetail; genome: GenomeTraitType[] }[]
|
||||
transformGenomeData: (genomeData: GenomeTraitType[]) => any[]
|
||||
CATEGORIES: string[]
|
||||
TRAIT_COLORS: string[]
|
||||
}
|
||||
|
||||
export function CowCompareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
compareCowsData,
|
||||
transformGenomeData,
|
||||
CATEGORIES,
|
||||
TRAIT_COLORS
|
||||
}: CowCompareModalProps) {
|
||||
if (!isOpen || compareCowsData.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white w-full max-w-6xl max-h-[90vh] rounded-lg overflow-hidden flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary" />
|
||||
개체 유전체 비교
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{compareCowsData.length}개 개체 비교
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onClose} variant="ghost" size="sm">
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 비교 내용 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* 개체 카드들 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{compareCowsData.map((cowData, idx) => {
|
||||
const genomeTraits = transformGenomeData(cowData.genome)
|
||||
const avgBreedVal = genomeTraits.length > 0
|
||||
? genomeTraits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / genomeTraits.length
|
||||
: 0
|
||||
const avgPercentile = genomeTraits.length > 0
|
||||
? genomeTraits.reduce((sum: number, t: any) => sum + t.percentile, 0) / genomeTraits.length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={cowData.cow.pkCowNo} className={idx === 0 ? 'border-2 border-primary' : ''}>
|
||||
<CardHeader className={idx === 0 ? 'bg-primary/5' : ''}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{cowData.cow.cowId || cowData.cow.pkCowNo}</CardTitle>
|
||||
{cowData.cow.cowId && (
|
||||
<CardDescription>{cowData.cow.cowId}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{idx === 0 && (
|
||||
<Badge className="bg-primary text-white">현재 개체</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-center p-3 bg-muted/30 rounded-lg">
|
||||
<div className="text-xs text-muted-foreground mb-1">종합 육종가</div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{avgBreedVal > 0 ? '+' : ''}{avgBreedVal.toFixed(2)}σ
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
상위 {(100 - avgPercentile).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">생년월일</span>
|
||||
<span className="font-semibold">
|
||||
{cowData.cow.cowBirthDt
|
||||
? new Date(cowData.cow.cowBirthDt).toLocaleDateString('ko-KR')
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">나이</span>
|
||||
<span className="font-semibold">
|
||||
{cowData.cow.age ? `${cowData.cow.age}세` : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">평가 형질 수</span>
|
||||
<span className="font-semibold">{genomeTraits.length}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 카테고리별 비교 차트 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>카테고리별 육종가 비교</CardTitle>
|
||||
<CardDescription>각 개체의 카테고리별 평균 표준화육종가</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart>
|
||||
<PolarGrid stroke="#e2e8f0" />
|
||||
<PolarAngleAxis
|
||||
dataKey="category"
|
||||
tick={{ fill: '#64748b', fontSize: 12 }}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[-1, 2]}
|
||||
tick={{ fill: '#64748b', fontSize: 10 }}
|
||||
/>
|
||||
<RechartsTooltip />
|
||||
<Legend />
|
||||
{compareCowsData.map((cowData, idx) => {
|
||||
const genomeTraits = transformGenomeData(cowData.genome)
|
||||
const categoryData = CATEGORIES.map(cat => {
|
||||
const traits = genomeTraits.filter((t: any) => t.category === cat)
|
||||
const avgBreedVal = traits.length > 0
|
||||
? traits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / traits.length
|
||||
: 0
|
||||
return {
|
||||
category: cat,
|
||||
value: avgBreedVal
|
||||
}
|
||||
})
|
||||
|
||||
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
|
||||
return (
|
||||
<Radar
|
||||
key={cowData.cow.pkCowNo}
|
||||
name={idx === 0 ? `${cowLabel} (현재)` : cowLabel}
|
||||
dataKey="value"
|
||||
stroke={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
|
||||
fill={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
|
||||
fillOpacity={idx === 0 ? 0.6 : 0.3}
|
||||
strokeWidth={idx === 0 ? 3 : 2}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 형질별 비교 테이블 */}
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>주요 형질 비교 (Top 10)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b-2">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold sticky left-0 bg-muted/50">형질명</th>
|
||||
{compareCowsData.map((cowData, idx) => {
|
||||
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
|
||||
return (
|
||||
<th key={cowData.cow.pkCowNo} className="px-3 py-2 text-center font-semibold">
|
||||
{idx === 0 ? `${cowLabel}\n(현재)` : cowLabel}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{transformGenomeData(compareCowsData[0].genome).slice(0, 10).map((trait: any) => (
|
||||
<tr key={trait.id} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-medium sticky left-0 bg-white">
|
||||
{trait.name}
|
||||
</td>
|
||||
{compareCowsData.map((cowData) => {
|
||||
const genomeTraits = transformGenomeData(cowData.genome)
|
||||
const matchTrait = genomeTraits.find((t: any) => t.name === trait.name)
|
||||
return (
|
||||
<td key={cowData.cow.pkCowNo} className="px-3 py-2 text-center">
|
||||
{matchTrait ? (
|
||||
<div>
|
||||
<div className={`font-bold ${matchTrait.breedVal > 0 ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
{matchTrait.breedVal > 0 ? '+' : ''}{matchTrait.breedVal.toFixed(2)}σ
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{matchTrait.percentile.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-border bg-muted/30 flex justify-end">
|
||||
<Button onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { User, CheckCircle2 } from "lucide-react"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
|
||||
interface CowSelectSheetProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
farmCows: CowDetail[]
|
||||
selectedCowsForCompare: number[]
|
||||
toggleCowForCompare: (cowNo: number) => void
|
||||
onCompare: () => void
|
||||
onClearSelection: () => void
|
||||
}
|
||||
|
||||
export function CowSelectSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
farmCows,
|
||||
selectedCowsForCompare,
|
||||
toggleCowForCompare,
|
||||
onCompare,
|
||||
onClearSelection
|
||||
}: CowSelectSheetProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center md:justify-center">
|
||||
<div className="bg-white w-full md:max-w-3xl md:max-h-[80vh] md:rounded-lg overflow-hidden flex flex-col max-h-[90vh] rounded-t-2xl md:rounded-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
농장 내 개체 비교
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
비교할 개체를 선택하세요 ({selectedCowsForCompare.length}/{farmCows.length})
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onClose} variant="ghost" size="sm">
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 개체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{farmCows.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<User className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>같은 농장에 다른 개체가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{farmCows.map((farmCow) => {
|
||||
const isSelected = selectedCowsForCompare.includes(farmCow.pkCowNo)
|
||||
return (
|
||||
<div
|
||||
key={farmCow.pkCowNo}
|
||||
onClick={() => toggleCowForCompare(farmCow.pkCowNo)}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 체크박스 */}
|
||||
<div className="flex items-center pt-1">
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckCircle2 className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개체 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-foreground">{farmCow.cowId || farmCow.pkCowNo}</h4>
|
||||
{farmCow.cowId && (
|
||||
<p className="text-sm text-muted-foreground">{farmCow.cowId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-3 gap-2 text-xs mt-2">
|
||||
<div>
|
||||
<div className="text-muted-foreground">생년월일</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{farmCow.cowBirthDt
|
||||
? new Date(farmCow.cowBirthDt).toLocaleDateString('ko-KR', {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">나이</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{farmCow.age ? `${farmCow.age}세` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">성별</div>
|
||||
<div className="font-semibold text-foreground">
|
||||
{farmCow.cowSex === 'F' ? '암' : farmCow.cowSex === 'M' ? '수' : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="p-4 border-t border-border bg-muted/30 flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedCowsForCompare.length}개 개체 선택됨
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onClearSelection}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={selectedCowsForCompare.length === 0}
|
||||
>
|
||||
선택 해제
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCompare}
|
||||
className="gap-2"
|
||||
disabled={selectedCowsForCompare.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
비교하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Search, Loader2 } from "lucide-react"
|
||||
import { geneApi } from "@/lib/api/gene.api"
|
||||
|
||||
/**
|
||||
* 마커 데이터 타입 (API에서 받아오는 형식)
|
||||
*/
|
||||
interface MarkerData {
|
||||
pkMarkerNo: number
|
||||
markerNm: string
|
||||
markerDesc: string
|
||||
markerTypeCd: string
|
||||
relatedTrait: string
|
||||
favorableAllele: string
|
||||
useYn: string
|
||||
markerTypeInfo?: {
|
||||
pkTypeCd: string
|
||||
typeNm: string
|
||||
typeDesc: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 필터에서 사용할 간소화된 타입
|
||||
*/
|
||||
interface GeneOption {
|
||||
name: string
|
||||
description: string
|
||||
type: 'QTY' | 'QLT'
|
||||
relatedTrait: string
|
||||
}
|
||||
|
||||
interface GeneFilterModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedGenes: string[]
|
||||
onConfirm: (genes: string[]) => void
|
||||
}
|
||||
|
||||
export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }: GeneFilterModalProps) {
|
||||
const [tempSelectedGenes, setTempSelectedGenes] = useState<string[]>(selectedGenes)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'type'>('name')
|
||||
const [allMarkers, setAllMarkers] = useState<GeneOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// API에서 마커 목록 가져오기
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchMarkers()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// TODO: 백엔드 /gene/markers API 구현 후 활성화
|
||||
const fetchMarkers = async () => {
|
||||
// try {
|
||||
// setLoading(true)
|
||||
// setError(null)
|
||||
// const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
|
||||
|
||||
// // API 데이터를 GeneOption 형식으로 변환
|
||||
// const geneOptions: GeneOption[] = markers.map(marker => ({
|
||||
// name: marker.markerNm,
|
||||
// description: marker.relatedTrait || marker.markerDesc || '',
|
||||
// type: marker.markerTypeCd as 'QTY' | 'QLT',
|
||||
// relatedTrait: marker.relatedTrait || ''
|
||||
// }))
|
||||
|
||||
// setAllMarkers(geneOptions)
|
||||
// } catch (err) {
|
||||
// console.error('Failed to fetch markers:', err)
|
||||
// setError('유전자 목록을 불러오는데 실패했습니다.')
|
||||
// } finally {
|
||||
// setLoading(false)
|
||||
// }
|
||||
}
|
||||
|
||||
// 육량형/육질형 필터링
|
||||
const quantityGenes = useMemo(() => {
|
||||
return allMarkers.filter(g => g.type === 'QTY').sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [allMarkers])
|
||||
|
||||
const qualityGenes = useMemo(() => {
|
||||
return allMarkers.filter(g => g.type === 'QLT').sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [allMarkers])
|
||||
|
||||
// 전체 유전자 목록 (정렬)
|
||||
const allGenes = useMemo(() => {
|
||||
return [...allMarkers].sort((a, b) => {
|
||||
if (sortBy === 'type') {
|
||||
if (a.type !== b.type) {
|
||||
return a.type.localeCompare(b.type)
|
||||
}
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}, [allMarkers, sortBy])
|
||||
|
||||
const filteredGenes = useMemo(() => {
|
||||
if (!searchQuery) return allGenes
|
||||
return allGenes.filter(gene =>
|
||||
gene.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
gene.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [allGenes, searchQuery])
|
||||
|
||||
const toggleGene = (geneName: string) => {
|
||||
setTempSelectedGenes(prev =>
|
||||
prev.includes(geneName)
|
||||
? prev.filter(g => g !== geneName)
|
||||
: [...prev, geneName]
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(tempSelectedGenes)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setTempSelectedGenes(selectedGenes)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>유전자 선택</DialogTitle>
|
||||
<DialogDescription>
|
||||
개체를 필터링할 유전자를 선택하세요. 시스템이 자동으로 중요도 순으로 정렬합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2">유전자 목록 불러오는 중...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={fetchMarkers}>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="quick" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="quick">타입별 선택 ({allMarkers.length}개)</TabsTrigger>
|
||||
<TabsTrigger value="all">전체 유전자 검색</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="quick" className="space-y-4">
|
||||
<Tabs defaultValue="quantity" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="quantity">육량형 ({quantityGenes.length}개)</TabsTrigger>
|
||||
<TabsTrigger value="quality">육질형 ({qualityGenes.length}개)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="quantity">
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
||||
<div className="space-y-3">
|
||||
{quantityGenes.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
육량형 유전자가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
quantityGenes.map((gene) => (
|
||||
<div key={gene.name} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`quick-${gene.name}`}
|
||||
checked={tempSelectedGenes.includes(gene.name)}
|
||||
onCheckedChange={() => toggleGene(gene.name)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`quick-${gene.name}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{gene.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quality">
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
||||
<div className="space-y-3">
|
||||
{qualityGenes.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
육질형 유전자가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
qualityGenes.map((gene) => (
|
||||
<div key={gene.name} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`quick-${gene.name}`}
|
||||
checked={tempSelectedGenes.includes(gene.name)}
|
||||
onCheckedChange={() => toggleGene(gene.name)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`quick-${gene.name}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{gene.name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="유전자명 또는 설명 검색..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSortBy(sortBy === 'name' ? 'type' : 'name')}
|
||||
>
|
||||
{sortBy === 'type' ? '타입순' : '이름순'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
||||
<div className="space-y-3">
|
||||
{filteredGenes.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
검색 결과가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
filteredGenes.map((gene) => (
|
||||
<div key={gene.name} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`all-${gene.name}`}
|
||||
checked={tempSelectedGenes.includes(gene.name)}
|
||||
onCheckedChange={() => toggleGene(gene.name)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`all-${gene.name}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{gene.name} <span className="text-xs text-muted-foreground">({gene.type === 'QTY' ? '육량형' : '육질형'})</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
선택된 유전자: {tempSelectedGenes.length}개
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>
|
||||
선택 완료
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Search, X, Filter, Sparkles } from "lucide-react"
|
||||
import { geneApi } from "@/lib/api/gene.api"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
interface GeneSearchDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedGenes: string[]
|
||||
onGenesChange: (genes: string[]) => void
|
||||
}
|
||||
|
||||
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
|
||||
const [allGenes, setAllGenes] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
|
||||
|
||||
// 모달 열릴 때 전체 유전자 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAllGenes()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// TODO: 백엔드 /gene/markers API 구현 후 활성화
|
||||
const loadAllGenes = async () => {
|
||||
// try {
|
||||
// setLoading(true)
|
||||
// const genes = await geneApi.getAllMarkers()
|
||||
// setAllGenes(genes)
|
||||
// } catch {
|
||||
// // 유전자 로드 실패 시 빈 배열 유지
|
||||
// } finally {
|
||||
// setLoading(false)
|
||||
// }
|
||||
}
|
||||
|
||||
// 검색 및 필터링
|
||||
const filteredGenes = allGenes.filter((gene) => {
|
||||
// 타입 필터
|
||||
if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
gene.markerNm.toLowerCase().includes(query) ||
|
||||
gene.markerDesc?.toLowerCase().includes(query) ||
|
||||
gene.relatedTrait?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const toggleGene = (markerNm: string) => {
|
||||
if (selectedGenes.includes(markerNm)) {
|
||||
onGenesChange(selectedGenes.filter(g => g !== markerNm))
|
||||
} else {
|
||||
onGenesChange([...selectedGenes, markerNm])
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
const newGenes = [...selectedGenes]
|
||||
filteredGenes.forEach(gene => {
|
||||
if (!newGenes.includes(gene.markerNm)) {
|
||||
newGenes.push(gene.markerNm)
|
||||
}
|
||||
})
|
||||
onGenesChange(newGenes)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
onGenesChange([])
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col p-0 gap-0">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="px-5 pt-5 pb-3 border-b flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold">유전자 검색 및 선택</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
전체 <span className="font-semibold text-foreground">{allGenes.length.toLocaleString()}</span>개 / 선택 <span className="font-semibold text-primary">{selectedGenes.length}</span>개
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="px-4 py-3 space-y-3 flex-shrink-0 bg-muted/20">
|
||||
{/* 검색바 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="유전자명, 설명, 관련 형질로 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9 h-10 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 hover:bg-muted rounded-full p-1 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 탭 및 액션 버튼 */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Tabs value={filterType} onValueChange={(v) => setFilterType(v as any)} className="flex-1">
|
||||
<TabsList className="w-full grid grid-cols-3 h-9">
|
||||
<TabsTrigger value="ALL" className="text-xs">
|
||||
전체 <span className="ml-1 font-semibold">({allGenes.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="QTY" className="text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb]"></div>
|
||||
육량 <span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QTY').length})</span>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="QLT" className="text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-slate-400"></div>
|
||||
육질 <span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QLT').length})</span>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAllFiltered}
|
||||
disabled={filteredGenes.length === 0}
|
||||
className="h-8 text-xs px-3"
|
||||
>
|
||||
전체선택
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
disabled={selectedGenes.length === 0}
|
||||
className="h-8 text-xs px-3"
|
||||
>
|
||||
전체해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유전자 목록 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-slate-200 border-t-[#2563eb] mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground font-medium">유전자 데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredGenes.length > 0 ? (
|
||||
<ScrollArea className="h-full px-4">
|
||||
<div className="flex flex-wrap gap-1.5 py-3">
|
||||
{filteredGenes.map((gene) => {
|
||||
const isSelected = selectedGenes.includes(gene.markerNm)
|
||||
const isQuantity = gene.markerTypeCd === 'QTY'
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={gene.markerNm}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className={`cursor-pointer transition-colors text-xs h-7 px-2.5 ${
|
||||
isSelected
|
||||
? isQuantity
|
||||
? 'bg-[#2563eb] text-white hover:bg-[#2563eb]/90 border-[#2563eb]'
|
||||
: 'bg-slate-400 text-white hover:bg-slate-500 border-slate-400'
|
||||
: isQuantity
|
||||
? 'border-[#2563eb]/40 text-[#2563eb] hover:bg-[#2563eb]/5 hover:border-[#2563eb]'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50 hover:border-slate-400'
|
||||
}`}
|
||||
onClick={() => toggleGene(gene.markerNm)}
|
||||
title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`}
|
||||
>
|
||||
{gene.markerNm}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Filter className="h-16 w-16 mx-auto mb-4 opacity-40" />
|
||||
<p className="text-lg font-semibold">검색 결과가 없습니다</p>
|
||||
<p className="text-sm mt-2">다른 검색어나 필터를 시도해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="px-4 py-3 border-t flex justify-between items-center flex-shrink-0 bg-muted/20">
|
||||
<div className="text-sm">
|
||||
{searchQuery && (
|
||||
<span className="text-muted-foreground mr-3">
|
||||
검색: <span className="font-semibold text-foreground">{filteredGenes.length.toLocaleString()}</span>개
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
선택: <span className="font-bold text-primary text-base">{selectedGenes.length}</span>개
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 px-4">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)} className="h-9 px-4">
|
||||
완료
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { PieChart as PieChartIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
||||
|
||||
interface DistributionData {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
range: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface GenomeDistributionDonutProps {
|
||||
farmNo: number | null
|
||||
}
|
||||
|
||||
export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) {
|
||||
const [data, setData] = useState<DistributionData[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [
|
||||
{ traitNm: '도체중', weight: 0.25 },
|
||||
{ traitNm: '근내지방도', weight: 0.25 },
|
||||
{ traitNm: '등심단면적', weight: 0.25 },
|
||||
{ traitNm: '등지방두께', weight: 0.25 },
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = response.data || response
|
||||
const items = result.items || []
|
||||
setTotalCount(items.length)
|
||||
|
||||
const distribution = {
|
||||
top: 0, // 0σ 이상
|
||||
middle: 0, // -1.0σ ~ 0σ
|
||||
bottom: 0 // -1.0σ 이하
|
||||
}
|
||||
|
||||
items.forEach((item: any) => {
|
||||
const score = item.sortValue || 0
|
||||
if (score >= 0) distribution.top++
|
||||
else if (score >= -1.0) distribution.middle++
|
||||
else distribution.bottom++
|
||||
})
|
||||
|
||||
setData([
|
||||
{ name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' },
|
||||
{ name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' },
|
||||
{ name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' },
|
||||
].filter(d => d.value > 0))
|
||||
|
||||
} catch (error) {
|
||||
console.error('분포 데이터 로드 실패:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [farmNo])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[280px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center shadow-md shadow-violet-500/20">
|
||||
<PieChartIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">우리 소들의 등급</h3>
|
||||
<p className="text-[10px] text-slate-500">총 {totalCount}두 분포</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">{totalCount}두</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 */}
|
||||
<div className="p-5">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-[180px] h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={54}
|
||||
outerRadius={86}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="white" strokeWidth={2} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0].payload
|
||||
return (
|
||||
<div className="bg-slate-900 px-4 py-3 rounded-xl shadow-xl border border-slate-700">
|
||||
<p className="text-white font-bold mb-2">{item.name}</p>
|
||||
<p className="text-slate-200 text-sm">{item.description}</p>
|
||||
<div className="mt-2 pt-2 border-t border-slate-700">
|
||||
<p className="text-slate-300 text-sm">{item.value}두 ({Math.round(item.value / totalCount * 100)}%)</p>
|
||||
<p className="text-slate-400 text-xs mt-1">{item.range}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* 중앙 텍스트 */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold text-slate-900">{totalCount}</span>
|
||||
<span className="text-xs text-slate-500 mt-1">전체</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="w-full mt-5 pt-4 border-t border-slate-100">
|
||||
<div className="space-y-2.5">
|
||||
{data.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-100 hover:bg-slate-100/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 rounded-full flex-shrink-0 shadow-sm" style={{ backgroundColor: item.color }}></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-900">{item.name}</span>
|
||||
<span className="text-[10px] text-slate-500">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-base font-bold text-slate-900">{item.value}<span className="text-xs text-slate-500 font-normal ml-0.5">두</span></p>
|
||||
<p className="text-[10px] text-slate-500">{Math.round(item.value / totalCount * 100)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-4 text-center leading-relaxed">
|
||||
σ(시그마)는 유전능력 수준을 나타내요<br/>
|
||||
0보다 클수록 우수해요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Target } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
|
||||
|
||||
interface TraitScore {
|
||||
trait: string
|
||||
diff: number
|
||||
myFarm: number
|
||||
region: number
|
||||
}
|
||||
|
||||
interface GenomeRadarChartProps {
|
||||
farmNo: number | null
|
||||
}
|
||||
|
||||
export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) {
|
||||
const [data, setData] = useState<TraitScore[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const traits = [
|
||||
{ name: '도체중', key: '도체중' },
|
||||
{ name: '근내지방', key: '근내지방도' },
|
||||
{ name: '등심단면적', key: '등심단면적' },
|
||||
{ name: '등지방', key: '등지방두께' },
|
||||
{ name: '12개월체중', key: '12개월령체중' },
|
||||
]
|
||||
|
||||
const results: TraitScore[] = []
|
||||
|
||||
for (const trait of traits) {
|
||||
try {
|
||||
const farmResponse = await apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const globalResponse = await apiClient.post('/cow/ranking/global', {
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const farmResult = farmResponse.data || farmResponse
|
||||
const globalResult = globalResponse.data || globalResponse
|
||||
|
||||
const farmScores = farmResult.items?.map((item: any) => {
|
||||
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
|
||||
return traitDetail?.value ?? item.sortValue ?? 0
|
||||
}) || []
|
||||
const farmAvgScore = farmScores.length > 0
|
||||
? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length
|
||||
: 0
|
||||
|
||||
const globalScores = globalResult.items?.map((item: any) => {
|
||||
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
|
||||
return traitDetail?.value ?? item.sortValue ?? 0
|
||||
}) || []
|
||||
const regionAvgScore = globalScores.length > 0
|
||||
? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length
|
||||
: 0
|
||||
|
||||
const diff = farmAvgScore - regionAvgScore
|
||||
|
||||
results.push({
|
||||
trait: trait.name,
|
||||
diff: parseFloat(diff.toFixed(2)),
|
||||
myFarm: parseFloat(farmAvgScore.toFixed(2)),
|
||||
region: parseFloat(regionAvgScore.toFixed(2))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`형질 ${trait.name} 로드 실패:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setData(results)
|
||||
} catch (error) {
|
||||
console.error('레이더 차트 데이터 로드 실패:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [farmNo])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[280px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const validDiffs = data.filter(d => !isNaN(d.diff))
|
||||
const avgDiff = validDiffs.length > 0
|
||||
? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center shadow-md shadow-sky-500/20">
|
||||
<Target className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">카테고리별 결과</h3>
|
||||
<p className="text-[10px] text-slate-500">주요 형질 평균 비교</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${avgDiff >= 0
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: 'bg-red-50 text-red-600 border-red-200'
|
||||
}`}>
|
||||
평균 {avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 */}
|
||||
<div className="p-5">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-blue-50/30 rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<RadarChart data={data} margin={{ top: 30, right: 40, bottom: 30, left: 40 }}>
|
||||
<defs>
|
||||
<linearGradient id="radarGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#1482B0" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#1482B0" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<PolarGrid
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
<PolarAngleAxis
|
||||
dataKey="trait"
|
||||
tick={{ fontSize: 13, fill: '#334155', fontWeight: 600 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[-1.5, 1.5]}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickCount={4}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Radar
|
||||
name="보은군 평균"
|
||||
dataKey={() => 0}
|
||||
stroke="#94a3b8"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
<Radar
|
||||
name="내농장"
|
||||
dataKey="diff"
|
||||
stroke="#1482B0"
|
||||
fill="url(#radarGradient)"
|
||||
strokeWidth={3}
|
||||
dot={{
|
||||
fill: '#fff',
|
||||
strokeWidth: 3,
|
||||
stroke: '#1482B0',
|
||||
r: 6,
|
||||
strokeOpacity: 1
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0]?.payload
|
||||
const diff = item?.diff ?? 0
|
||||
const myFarm = item?.myFarm ?? 0
|
||||
const region = item?.region ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 px-4 py-3 rounded-xl text-sm shadow-xl border border-slate-700">
|
||||
<p className="text-white font-bold mb-2">{item?.trait}</p>
|
||||
<div className="space-y-1 text-slate-300 text-xs">
|
||||
<p>내농장: <span className="text-[#1482B0] font-semibold">{myFarm > 0 ? '+' : ''}{myFarm}σ</span></p>
|
||||
<p>보은군: <span className="text-slate-400">{region > 0 ? '+' : ''}{region}σ</span></p>
|
||||
</div>
|
||||
<div className={`mt-2 pt-2 border-t border-slate-700 font-bold ${diff >= 0.3 ? 'text-emerald-400' :
|
||||
diff <= -0.3 ? 'text-amber-400' :
|
||||
'text-slate-300'
|
||||
}`}>
|
||||
{diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-0.5 bg-slate-400 opacity-60" style={{ borderTop: '2px dashed #94a3b8' }}></div>
|
||||
<span className="text-xs text-slate-600">보은군 평균</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-white border-3 border-[#1482B0] shadow-sm"></div>
|
||||
<span className="text-xs text-slate-900 font-semibold">내 농장</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 형질별 수치 */}
|
||||
<div className="grid grid-cols-5 gap-2 mt-4 pt-4 border-t border-slate-200">
|
||||
{data.map((item, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<p className="text-[10px] text-slate-500 mb-1 truncate font-medium">{item.trait}</p>
|
||||
<p className={`text-sm font-bold ${item.diff >= 0.3 ? 'text-emerald-600' :
|
||||
item.diff <= -0.3 ? 'text-amber-600' :
|
||||
'text-slate-700'
|
||||
}`}>
|
||||
{item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import apiClient from "@/lib/api-client"
|
||||
|
||||
interface GenomeData {
|
||||
trait: string
|
||||
score: number
|
||||
type: string
|
||||
}
|
||||
|
||||
interface GenomeStrengthsWeaknessesProps {
|
||||
farmNo?: number | null
|
||||
}
|
||||
|
||||
export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) {
|
||||
const [allMetrics, setAllMetrics] = useState<GenomeData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTraitScores = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const traits = [
|
||||
{ name: '도체중', key: '도체중' },
|
||||
{ name: '근내지방도', key: '근내지방도' },
|
||||
{ name: '등심단면적', key: '등심단면적' },
|
||||
{ name: '등지방두께', key: '등지방두께' },
|
||||
{ name: '12개월령체중', key: '12개월령체중' },
|
||||
{ name: '체고', key: '체고' },
|
||||
]
|
||||
|
||||
const traitScores: GenomeData[] = []
|
||||
|
||||
for (const trait of traits) {
|
||||
try {
|
||||
const rankingRequest = {
|
||||
filterOptions: { farmNo: farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/cow/ranking', rankingRequest)
|
||||
const rankingResult = response.data || response
|
||||
|
||||
const scores = rankingResult.items?.map((item: any) => item.sortValue) || []
|
||||
const avgScore = scores.length > 0
|
||||
? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length
|
||||
: 0
|
||||
|
||||
traitScores.push({
|
||||
trait: trait.name,
|
||||
score: parseFloat(avgScore.toFixed(2)),
|
||||
type: '유전체'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setAllMetrics(traitScores)
|
||||
} catch (error) {
|
||||
console.error('형질 점수 로드 실패:', error)
|
||||
setAllMetrics([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTraitScores()
|
||||
}, [farmNo])
|
||||
|
||||
const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3)
|
||||
const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[140px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 강점 */}
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
|
||||
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
|
||||
<TrendingUp className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900">이 부분이 강해요</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">보은군보다 우수한 형질</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold border border-emerald-200 shadow-sm">TOP 3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{strengths.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{strengths.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-4 bg-gradient-to-r from-emerald-50 to-transparent rounded-xl hover:from-emerald-100 hover:to-emerald-50 transition-all duration-200 border-2 border-emerald-100 group-hover:border-emerald-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
|
||||
idx === 0 ? 'bg-gradient-to-br from-emerald-500 to-emerald-600 text-white shadow-emerald-500/30' :
|
||||
idx === 1 ? 'bg-emerald-200 text-emerald-800 border-2 border-emerald-300' :
|
||||
'bg-emerald-100 text-emerald-700 border-2 border-emerald-200'
|
||||
}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-emerald-200 shadow-sm">
|
||||
<span className="text-lg font-bold text-emerald-600">
|
||||
{item.score > 0 ? '+' : ''}{item.score}
|
||||
</span>
|
||||
<span className="text-xs text-emerald-500 font-semibold">σ</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 약점 */}
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
|
||||
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-amber-50 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
|
||||
<TrendingDown className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900">더 좋아질 수 있어요</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">개선하면 좋을 형질</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold border border-amber-200 shadow-sm">BOTTOM 3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{weaknesses.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{weaknesses.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-4 bg-gradient-to-r from-amber-50 to-transparent rounded-xl hover:from-amber-100 hover:to-amber-50 transition-all duration-200 border-2 border-amber-100 group-hover:border-amber-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
|
||||
idx === 0 ? 'bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-amber-500/30' :
|
||||
idx === 1 ? 'bg-amber-200 text-amber-800 border-2 border-amber-300' :
|
||||
'bg-amber-100 text-amber-700 border-2 border-amber-200'
|
||||
}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-amber-200 shadow-sm">
|
||||
<span className="text-lg font-bold text-amber-600">
|
||||
{item.score > 0 ? '+' : ''}{item.score}
|
||||
</span>
|
||||
<span className="text-xs text-amber-500 font-semibold">σ</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import apiClient from "@/lib/api-client"
|
||||
|
||||
interface TraitData {
|
||||
trait: string
|
||||
regional: number
|
||||
myFarm: number
|
||||
}
|
||||
|
||||
interface GenomeTraitsTableProps {
|
||||
farmNo?: number | null
|
||||
}
|
||||
|
||||
export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) {
|
||||
const [traitData, setTraitData] = useState<TraitData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTraitData = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const traits = [
|
||||
{ name: '12개월령체중', key: '12개월령체중' },
|
||||
{ name: '도체중', key: '도체중' },
|
||||
{ name: '근내지방도', key: '근내지방도' },
|
||||
{ name: '등심단면적', key: '등심단면적' },
|
||||
{ name: '등지방두께', key: '등지방두께' },
|
||||
]
|
||||
|
||||
const results: TraitData[] = []
|
||||
|
||||
for (const trait of traits) {
|
||||
try {
|
||||
const farmResponse = await apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo: farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const globalResponse = await apiClient.post('/cow/ranking/global', {
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const farmResult = farmResponse.data || farmResponse
|
||||
const globalResult = globalResponse.data || globalResponse
|
||||
|
||||
const farmScores = farmResult.items?.map((item: any) => item.sortValue) || []
|
||||
const farmAvg = farmScores.length > 0
|
||||
? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length
|
||||
: 0
|
||||
|
||||
const globalScores = globalResult.items?.map((item: any) => item.sortValue) || []
|
||||
const regionalAvg = globalScores.length > 0
|
||||
? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length
|
||||
: 0
|
||||
|
||||
results.push({
|
||||
trait: trait.name,
|
||||
myFarm: parseFloat(farmAvg.toFixed(2)),
|
||||
regional: parseFloat(regionalAvg.toFixed(2))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setTraitData(results)
|
||||
} catch (error) {
|
||||
console.error('[형질 테이블] 전체 데이터 로드 실패:', error)
|
||||
setTraitData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTraitData()
|
||||
}, [farmNo])
|
||||
|
||||
const getTraitShortName = (name: string) => {
|
||||
const shortNames: Record<string, string> = {
|
||||
'12개월령체중': '12개월령체중',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'근내지방도': '근내지방도',
|
||||
'도체중': '도체중'
|
||||
}
|
||||
return shortNames[name] || name
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[180px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1482B0] to-[#0d5f82] flex items-center justify-center shadow-md shadow-[#1482B0]/20">
|
||||
<BarChart3 className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">형질별 점수</h3>
|
||||
<p className="text-[10px] text-slate-500">보은군과 비교한 수치에요</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">5개 형질</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="p-5">
|
||||
{traitData.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{traitData.map((item, idx) => {
|
||||
const diff = item.myFarm - item.regional
|
||||
const isPositive = diff >= 0
|
||||
// σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100)
|
||||
const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100))
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
{/* 형질명 + 차이 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-800">
|
||||
{getTraitShortName(item.trait)}
|
||||
</span>
|
||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-lg ${diff >= 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' :
|
||||
diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' :
|
||||
'bg-slate-50 text-slate-700 border border-slate-200'
|
||||
}`}>
|
||||
{diff > 0 ? '+' : ''}{diff.toFixed(1)}σ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 비교 바 */}
|
||||
<div className="relative h-6 bg-slate-100 rounded-lg overflow-hidden shadow-inner">
|
||||
{/* 보은군 바 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-slate-300/80 rounded-lg transition-all duration-500"
|
||||
style={{ width: `${toPercent(item.regional)}%` }}
|
||||
/>
|
||||
{/* 내농장 바 */}
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full rounded-lg transition-all duration-500 shadow-sm ${isPositive
|
||||
? 'bg-gradient-to-r from-[#1482B0] via-[#1482B0] to-[#0d5f82]'
|
||||
: 'bg-gradient-to-r from-slate-400 to-slate-500'
|
||||
}`}
|
||||
style={{ width: `${toPercent(item.myFarm)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#1482B0] shadow-sm"></div>
|
||||
<span className="text-slate-600 font-medium">
|
||||
내농장 <span className="font-bold text-slate-900">{item.myFarm > 0 ? '+' : ''}{item.myFarm}σ</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 shadow-sm"></div>
|
||||
<span className="text-slate-500 font-medium">
|
||||
보은군 {item.regional > 0 ? '+' : ''}{item.regional}σ
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +1,143 @@
|
||||
/**
|
||||
* 인증 관련 타입 정의
|
||||
* 백엔드 UserModel Entity 및 Auth DTOs 기준으로 작성
|
||||
* ========================================
|
||||
* 인증(Auth) 관련 타입 정의
|
||||
* ========================================
|
||||
*
|
||||
* @description
|
||||
* - 백엔드 UserModel Entity 및 Auth DTOs와 1:1 매핑
|
||||
* - 회원가입, 로그인, 프로필 관리 포함
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// 1. 사용자 기본 정보
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 사용자 정보
|
||||
* 백엔드 UserModel Entity와 일치
|
||||
*
|
||||
* @backend UserModel Entity
|
||||
* @description 시스템 사용자 기본 정보
|
||||
* @usage
|
||||
* - 로그인 후 세션 정보
|
||||
* - 사용자 프로필 조회
|
||||
*/
|
||||
export interface UserDto {
|
||||
pkUserNo: number; // 내부 PK (자동증가)
|
||||
userId: string; // 로그인 ID
|
||||
userName: string; // 이름
|
||||
userPhone?: string; // 핸드폰번호
|
||||
userEmail?: string; // 이메일 (인증용)
|
||||
userRole: 'USER' | 'ADMIN'; // 권한 (USER: 일반, ADMIN: 관리자)
|
||||
delDt?: string; // 삭제일시 (Soft Delete)
|
||||
// === 기본 키 ===
|
||||
pkUserNo: number // 사용자 내부 번호 (Primary Key, 자동증가)
|
||||
userId: string // 로그인 ID (아이디)
|
||||
|
||||
// === 사용자 정보 ===
|
||||
userName: string // 이름
|
||||
userPhone?: string // 핸드폰 번호
|
||||
userEmail?: string // 이메일 (인증용)
|
||||
|
||||
// === 권한 ===
|
||||
userRole: 'USER' | 'ADMIN' // 권한 (USER: 일반 사용자, ADMIN: 관리자)
|
||||
|
||||
// === 시스템 필드 ===
|
||||
delDt?: string // 삭제일시 (Soft Delete)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 2. 회원가입 관련 타입
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 회원가입 DTO
|
||||
* 백엔드 SignupDto와 일치
|
||||
* 회원가입 요청
|
||||
*
|
||||
* @backend SignupDto
|
||||
* @description 신규 사용자 등록 정보
|
||||
* @usage POST /auth/signup 요청 바디
|
||||
*/
|
||||
export interface SignupDto {
|
||||
userSe: 'FARM' | 'CNSLT' | 'ORGAN'; // 사용자 구분 (FARM/CNSLT/ORGAN)
|
||||
userInstName?: string; // 농장명/기관명
|
||||
userId: string; // 사용자 ID (4자 이상)
|
||||
userPassword: string; // 비밀번호 (6자 이상)
|
||||
userName: string; // 이름
|
||||
userPhone: string; // 휴대폰 번호
|
||||
userBirth?: string; // 생년월일
|
||||
userEmail: string; // 이메일
|
||||
userAddress?: string; // 주소
|
||||
userBizNo?: string; // 사업자등록번호
|
||||
// === 사용자 구분 ===
|
||||
userSe: 'FARM' | 'CNSLT' | 'ORGAN' // 구분 (농가/컨설턴트/기관)
|
||||
userInstName?: string // 농장명 또는 기관명
|
||||
|
||||
// === 계정 정보 ===
|
||||
userId: string // 사용자 ID (4자 이상)
|
||||
userPassword: string // 비밀번호 (6자 이상)
|
||||
|
||||
// === 개인 정보 ===
|
||||
userName: string // 이름
|
||||
userPhone: string // 휴대폰 번호
|
||||
userEmail: string // 이메일
|
||||
userBirth?: string // 생년월일
|
||||
userAddress?: string // 주소
|
||||
|
||||
// === 사업자 정보 ===
|
||||
userBizNo?: string // 사업자등록번호
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 DTO
|
||||
* 백엔드 LoginDto와 일치
|
||||
*/
|
||||
export interface LoginDto {
|
||||
userId: string; // 사용자 ID (로그인 ID)
|
||||
userPassword: string; // 비밀번호
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 응답 DTO
|
||||
* 백엔드 LoginResponseDto와 일치
|
||||
*/
|
||||
export interface LoginResponseDto {
|
||||
message: string;
|
||||
accessToken?: string;
|
||||
user: {
|
||||
pkUserNo: number;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail?: string;
|
||||
userRole: 'USER' | 'ADMIN';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 응답 DTO
|
||||
* 백엔드 SignupResponseDto와 일치
|
||||
*/
|
||||
export interface SignupResponseDto {
|
||||
message: string;
|
||||
redirectUrl: string;
|
||||
userId?: string;
|
||||
userNo?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 프로필 DTO (프론트엔드 확장)
|
||||
*/
|
||||
export interface UserProfileDto extends UserDto {
|
||||
farmCount?: number; // 보유 농장 수
|
||||
cowCount?: number; // 보유 개체 수
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로필 수정 DTO
|
||||
*/
|
||||
export interface UpdateProfileDto {
|
||||
userName?: string; // 이름 수정
|
||||
userPhone?: string; // 전화번호 수정
|
||||
userEmail?: string; // 이메일 수정
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 데이터 (클라이언트 전용)
|
||||
* 회원가입 폼 데이터
|
||||
*
|
||||
* @description
|
||||
* - 클라이언트 전용 (백엔드 전송 전 변환 필요)
|
||||
* - 이메일 분리 입력 등 UI 편의 필드 포함
|
||||
*
|
||||
* @usage 회원가입 페이지 폼
|
||||
*/
|
||||
export interface SignupFormData {
|
||||
userSe: string;
|
||||
userId: string;
|
||||
userPassword: string;
|
||||
confirmPassword: string;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
userEmail: string;
|
||||
emailId: string;
|
||||
emailDomain: string;
|
||||
customDomain?: string;
|
||||
userInstName?: string;
|
||||
userBirth?: string;
|
||||
userAddress?: string;
|
||||
userBizNo?: string;
|
||||
userSe: string // 사용자 구분
|
||||
userId: string // 사용자 ID
|
||||
userPassword: string // 비밀번호
|
||||
confirmPassword: string // 비밀번호 확인
|
||||
userName: string // 이름
|
||||
userPhone: string // 휴대폰 번호
|
||||
userEmail: string // 이메일 전체
|
||||
emailId: string // 이메일 ID 부분
|
||||
emailDomain: string // 이메일 도메인 부분
|
||||
customDomain?: string // 직접 입력 도메인
|
||||
userInstName?: string // 농장명/기관명
|
||||
userBirth?: string // 생년월일
|
||||
userAddress?: string // 주소
|
||||
userBizNo?: string // 사업자등록번호
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 3. 로그인 관련 타입
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 로그인 요청
|
||||
*
|
||||
* @backend LoginDto
|
||||
* @description 로그인 인증 정보
|
||||
* @usage POST /auth/login 요청 바디
|
||||
*/
|
||||
export interface LoginDto {
|
||||
userId: string // 사용자 ID
|
||||
userPassword: string // 비밀번호
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 응답 DTO (통합)
|
||||
* 로그인/회원가입 응답에 사용
|
||||
* 인증 응답 (통합)
|
||||
*
|
||||
* @description
|
||||
* - 로그인/회원가입 공통 응답 형식
|
||||
* - 프론트엔드에서 확장하여 사용
|
||||
*/
|
||||
export interface AuthResponseDto {
|
||||
message?: string;
|
||||
accessToken: string;
|
||||
user: UserDto;
|
||||
message?: string // 결과 메시지
|
||||
accessToken: string // JWT 액세스 토큰
|
||||
user: UserDto // 사용자 정보
|
||||
}
|
||||
|
||||
// 타입 alias (호환성)
|
||||
export type User = UserDto;
|
||||
export type AuthResponse = LoginResponseDto;
|
||||
// ========================================
|
||||
// 4. 프로필 관련 타입
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 사용자 프로필
|
||||
*
|
||||
* @description
|
||||
* - UserDto를 확장하여 통계 정보 추가
|
||||
* - 프론트엔드에서 확장
|
||||
*
|
||||
* @usage 마이페이지 프로필 표시
|
||||
*/
|
||||
export interface UserProfileDto extends UserDto {
|
||||
farmCount?: number // 보유 농장 수
|
||||
cowCount?: number // 보유 개체 수
|
||||
}
|
||||
|
||||
@@ -1,134 +1,108 @@
|
||||
/**
|
||||
* ========================================
|
||||
* 개체(Cow) 관련 타입 정의
|
||||
* 백엔드 CowModel Entity 기준으로 작성
|
||||
* ========================================
|
||||
*
|
||||
* @description
|
||||
* - 실제 사용되는 타입만 정의
|
||||
* - 백엔드 CowModel Entity와 1:1 매핑
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// 1. 개체 기본 정보
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 개체 기본 정보
|
||||
* 백엔드 CowModel Entity와 일치
|
||||
*
|
||||
* @backend CowModel Entity
|
||||
* @description 한우 개체 1마리의 기본 정보
|
||||
*/
|
||||
export interface CowDto {
|
||||
pkCowNo: number; // 내부 PK (자동증가)
|
||||
cowId: string; // 개체식별번호 (KOR 또는 KPN) - 필수
|
||||
cowSex?: string; // 성별 (M/F)
|
||||
cowBirthDt?: string; // 생년월일
|
||||
sireKpn?: string; // 부(씨수소) KPN번호
|
||||
damCowId?: string; // 모(어미소) 개체식별번호 (KOR)
|
||||
fkFarmNo?: number; // 농장번호 FK
|
||||
cowStatus?: string; // 개체상태
|
||||
delDt?: string; // 삭제일시 (Soft Delete)
|
||||
anlysDt?: string; // 분석일자
|
||||
unavailableReason?: string; // 분석불가 사유
|
||||
hasMpt?: boolean; // 번식능력검사(MPT) 여부
|
||||
mptTestDt?: string; // MPT 검사일
|
||||
mptMonthAge?: number; // MPT 검사일 기준 월령
|
||||
// === 기본 키 ===
|
||||
pkCowNo: number // 개체 내부 번호 (Primary Key)
|
||||
cowId: string // 개체식별번호 (KOR 또는 KPN)
|
||||
|
||||
// Relations
|
||||
// === 개체 정보 ===
|
||||
cowSex?: string // 성별 (M/F)
|
||||
cowBirthDt?: string // 생년월일 (YYYY-MM-DD)
|
||||
cowStatus?: string // 개체 상태
|
||||
|
||||
// === 혈통 정보 ===
|
||||
sireKpn?: string // 부(씨수소) KPN 번호
|
||||
damCowId?: string // 모(어미소) 개체식별번호
|
||||
|
||||
// === 연결 정보 ===
|
||||
fkFarmNo?: number // 농장번호 (Foreign Key)
|
||||
|
||||
// === 분석 정보 ===
|
||||
anlysDt?: string // 분석일자
|
||||
unavailableReason?: string // 분석 불가 사유
|
||||
|
||||
// === 번식능력검사(MPT) 정보 ===
|
||||
hasMpt?: boolean // MPT 검사 여부
|
||||
mptTestDt?: string // MPT 검사일
|
||||
mptMonthAge?: number // MPT 검사 월령
|
||||
|
||||
// === 시스템 필드 ===
|
||||
delDt?: string // 삭제일시
|
||||
|
||||
// === 관계 데이터 ===
|
||||
farm?: {
|
||||
pkFarmNo: number;
|
||||
farmNm?: string;
|
||||
farmAddr?: string;
|
||||
}; // 농장 정보 (조인)
|
||||
pkFarmNo: number
|
||||
farmNm?: string
|
||||
farmAddr?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 목록 응답
|
||||
*/
|
||||
export interface CowListResponseDto {
|
||||
totalCount: number; // 전체 개체 수
|
||||
cows: CowDto[]; // 개체 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 상세 응답 (프론트엔드 확장)
|
||||
* 개체 상세 정보
|
||||
*
|
||||
* @description CowDto 확장 + 계산 필드 + 분석 정보
|
||||
*/
|
||||
export interface CowDetailResponseDto extends CowDto {
|
||||
// 계산된 필드 (프론트엔드에서 계산)
|
||||
age?: number; // 나이 (년)
|
||||
cowShortNo?: string; // 개체 요약번호 (4자리, cowId에서 추출)
|
||||
// === 계산 필드 ===
|
||||
age?: number // 나이 (년 단위)
|
||||
cowShortNo?: string // 개체 요약번호 (뒷 4자리)
|
||||
|
||||
// 추가 분석 정보 (백엔드 별도 API에서 조회)
|
||||
genomeScore?: number; // 유전체 점수
|
||||
farmRank?: number; // 농장 내 순위
|
||||
totalCows?: number; // 농장 총 개체 수
|
||||
inbreedingCoef?: number; // 근친계수 (0.0~1.0)
|
||||
calvingCount?: number; // 분만회차
|
||||
// === 유전체 분석 정보 ===
|
||||
genomeScore?: number // 유전체 종합 점수
|
||||
farmRank?: number // 농장 내 순위
|
||||
totalCows?: number // 농장 총 개체 수
|
||||
inbreedingCoef?: number // 근친계수 (0.0~1.0)
|
||||
calvingCount?: number // 분만 회차
|
||||
|
||||
// 데이터 상태 (백엔드에서 조회)
|
||||
// === 데이터 상태 ===
|
||||
dataStatus?: {
|
||||
hasGenomeData: boolean; // 유전체 데이터 존재 여부
|
||||
hasGeneData: boolean; // 유전자 데이터 존재 여부
|
||||
};
|
||||
hasGenomeData: boolean
|
||||
hasGeneData: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 검색 DTO
|
||||
*/
|
||||
export interface CowSearchDto {
|
||||
keyword?: string; // 검색 키워드 (개체번호, 이름 등)
|
||||
farmNo?: number; // 농장 번호로 필터링
|
||||
gender?: 'M' | 'F'; // 성별로 필터링
|
||||
minBirthDate?: string; // 최소 생년월일 (YYYY-MM-DD)
|
||||
maxBirthDate?: string; // 최대 생년월일 (YYYY-MM-DD)
|
||||
}
|
||||
// ========================================
|
||||
// 2. 확장 타입 (순위 포함)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 개체 생성 DTO
|
||||
* 유전자 정보 포함 개체
|
||||
*
|
||||
* @description 개체 목록에서 사용 (유전자 + 순위 정보)
|
||||
*/
|
||||
export interface CreateCowDto {
|
||||
cowId: string; // 개체식별번호 (필수)
|
||||
cowSex: 'M' | 'F'; // 성별 (필수)
|
||||
cowBirthDt?: string; // 생년월일 (YYYY-MM-DD)
|
||||
fkFarmNo: number; // 농장 번호 (필수)
|
||||
sireKpn?: string; // 부(씨수소) KPN번호
|
||||
damCowId?: string; // 모(어미소) 개체식별번호
|
||||
cowStatus?: string; // 개체상태
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 수정 DTO
|
||||
*/
|
||||
export interface UpdateCowDto {
|
||||
cowBirthDt?: string; // 생년월일 수정 (YYYY-MM-DD)
|
||||
sireKpn?: string; // 부(씨수소) KPN번호 수정
|
||||
damCowId?: string; // 모(어미소) 개체식별번호 수정
|
||||
cowStatus?: string; // 개체상태 수정
|
||||
}
|
||||
|
||||
// 타입 alias (호환성)
|
||||
export type Cow = CowDto;
|
||||
export type CowList = CowListResponseDto;
|
||||
export type CowDetail = CowDetailResponseDto;
|
||||
|
||||
/**
|
||||
* 형질 데이터 (육종가/형질값)
|
||||
*/
|
||||
export interface TraitData {
|
||||
breedVal: number | null
|
||||
traitVal: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 정보를 포함한 개체 (개체 목록용)
|
||||
*/
|
||||
export interface CowWithGenes extends Cow {
|
||||
export interface CowWithGenes extends CowDto {
|
||||
genes?: { name: string; genotype: string }[]
|
||||
traits?: Record<string, TraitData | number>
|
||||
traits?: Record<string, { breedVal: number | null; traitVal: number | null } | number>
|
||||
rank?: number
|
||||
genomeScore?: number
|
||||
cowShortNo?: string
|
||||
anlysDt?: string
|
||||
unavailableReason?: string
|
||||
hasMpt?: boolean
|
||||
mptTestDt?: string
|
||||
mptMonthAge?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranking API 응답 아이템
|
||||
* 순위 API 응답 항목
|
||||
*
|
||||
* @description /cow/ranking API 응답의 개별 항목
|
||||
*/
|
||||
export interface RankingItem {
|
||||
entity: Cow & {
|
||||
entity: CowDto & {
|
||||
genes?: Record<string, number>
|
||||
calvingCount?: number
|
||||
bcs?: number
|
||||
@@ -141,9 +115,23 @@ export interface RankingItem {
|
||||
mptTestDt?: string
|
||||
mptMonthAge?: number
|
||||
}
|
||||
rank: number
|
||||
sortValue: number
|
||||
rank: number // 순위
|
||||
sortValue: number // 정렬 기준값
|
||||
ranking?: {
|
||||
traits?: { traitName: string; traitEbv: number | null; traitVal: number | null }[]
|
||||
traits?: {
|
||||
traitName: string
|
||||
traitEbv: number | null
|
||||
traitVal: number | null
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 3. 타입 별칭
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @description 실제 사용 중인 별칭
|
||||
*/
|
||||
export type Cow = CowDto
|
||||
export type CowDetail = CowDetailResponseDto
|
||||
|
||||
@@ -1,128 +1,95 @@
|
||||
/**
|
||||
* 전역 필터 타입 정의 /
|
||||
* ========================================
|
||||
* 전역 필터 타입 정의
|
||||
* ========================================
|
||||
*
|
||||
* @description
|
||||
* - 사용자별 맞춤 필터 설정
|
||||
* - 유전자/형질 선택 및 가중치 설정
|
||||
* - 모든 페이지에 공통 적용
|
||||
*/
|
||||
|
||||
/**
|
||||
* 분석 지표 (유전자/유전능력)
|
||||
*/
|
||||
export type AnalysisIndex = "GENE" | "ABILITY";
|
||||
|
||||
/**
|
||||
* 유전자 정보
|
||||
*/
|
||||
export interface GeneInfo {
|
||||
name: string;
|
||||
category: "QUANTITY" | "QUALITY";
|
||||
description: string;
|
||||
importance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 형질 정보
|
||||
*/
|
||||
export interface TraitInfo {
|
||||
name: string;
|
||||
category: "GROWTH" | "ECONOMIC" | "BODY" | "WEIGHT" | "RATE";
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 필터 설정
|
||||
* 사용자가 로그인해서 설정하면 모든 페이지에서 이 필터가 적용됨
|
||||
*
|
||||
* @description
|
||||
* - 사용자별 맞춤 필터 설정
|
||||
* - Zustand 스토어에 저장
|
||||
* - 개체 목록 필터링 및 선발지수 계산에 사용
|
||||
*/
|
||||
export interface GlobalFilterSettings {
|
||||
// 분석 지표
|
||||
analysisIndex?: AnalysisIndex;
|
||||
// === 분석 지표 ===
|
||||
analysisIndex?: 'GENE' | 'ABILITY' // 유전자(GENE) 또는 유전능력(ABILITY) 기반
|
||||
|
||||
// 선택된 유전자 목록 (유전자 기반)
|
||||
selectedGenes: string[];
|
||||
// === 유전자 선택 (GENE 모드) ===
|
||||
selectedGenes: string[] // 선택된 유전자 목록
|
||||
pinnedGenes?: string[] // 고정 유전자 (최대 5개)
|
||||
|
||||
// 고정 유전자 (최대 5개, 항상 맨 앞 표시)
|
||||
pinnedGenes?: string[];
|
||||
|
||||
// 선택된 형질 목록
|
||||
selectedTraits?: string[];
|
||||
|
||||
// 고정 형질 (최대 5개, 항상 맨 앞 표시)
|
||||
pinnedTraits?: string[];
|
||||
|
||||
// 형질 가중치 (유전체 기반)
|
||||
// ======== DB의 trait_nm과 일치해야 함==============
|
||||
// JavaScript/TypeScript 문법 규칙 :
|
||||
// "12개월령체중": number; // 필수 - 숫자로 시작
|
||||
// "my-key": number; // 필수 - 하이픈(특수문자)
|
||||
// "my key": number; // 필수 - 공백 포함
|
||||
// === 형질 선택 (ABILITY 모드) ===
|
||||
selectedTraits?: string[] // 선택된 형질 목록
|
||||
pinnedTraits?: string[] // 고정 형질 (최대 5개)
|
||||
|
||||
// === 형질 가중치 ===
|
||||
// 35개 형질 (DB의 trait_nm과 일치)
|
||||
traitWeights: {
|
||||
// 성장형질 (1개)
|
||||
"12개월령체중": number; // 따옴표
|
||||
"12개월령체중": number
|
||||
|
||||
// 경제형질 (4개)
|
||||
도체중: number;
|
||||
등심단면적: number;
|
||||
등지방두께: number;
|
||||
근내지방도: number;
|
||||
도체중: number
|
||||
등심단면적: number
|
||||
등지방두께: number
|
||||
근내지방도: number
|
||||
|
||||
// 체형형질 (10개)
|
||||
체고: number;
|
||||
십자: number; // 십자부고 → 십자
|
||||
체장: number;
|
||||
흉심: number;
|
||||
흉폭: number;
|
||||
고장: number; // 요각장 → 고장
|
||||
요각폭: number;
|
||||
좌골폭: number;
|
||||
곤폭: number; // 좌골단폭 → 곤폭
|
||||
흉위: number;
|
||||
체고: number
|
||||
십자: number
|
||||
체장: number
|
||||
흉심: number
|
||||
흉폭: number
|
||||
고장: number
|
||||
요각폭: number
|
||||
좌골폭: number
|
||||
곤폭: number
|
||||
흉위: number
|
||||
|
||||
// 부위별무게 (10개) - DB 형질명과 일치 (무게와 비율은 영문 붙여서 구분)
|
||||
안심weight: number;
|
||||
등심weight: number;
|
||||
채끝weight: number;
|
||||
목심weight: number;
|
||||
앞다리weight: number;
|
||||
우둔weight: number;
|
||||
설도weight: number;
|
||||
사태weight: number;
|
||||
양지weight: number;
|
||||
갈비weight: number;
|
||||
// 부위별무게 (10개)
|
||||
안심weight: number
|
||||
등심weight: number
|
||||
채끝weight: number
|
||||
목심weight: number
|
||||
앞다리weight: number
|
||||
우둔weight: number
|
||||
설도weight: number
|
||||
사태weight: number
|
||||
양지weight: number
|
||||
갈비weight: number
|
||||
|
||||
// 부위별비율 (10개) - DB 형질명과 일치 (무게와 비율은 영문 붙여서 구분)
|
||||
안심rate: number;
|
||||
등심rate: number;
|
||||
채끝rate: number;
|
||||
목심rate: number;
|
||||
앞다리rate: number;
|
||||
우둔rate: number;
|
||||
설도rate: number;
|
||||
사태rate: number;
|
||||
양지rate: number;
|
||||
갈비rate: number;
|
||||
};
|
||||
// 부위별비율 (10개)
|
||||
안심rate: number
|
||||
등심rate: number
|
||||
채끝rate: number
|
||||
목심rate: number
|
||||
앞다리rate: number
|
||||
우둔rate: number
|
||||
설도rate: number
|
||||
사태rate: number
|
||||
양지rate: number
|
||||
갈비rate: number
|
||||
}
|
||||
|
||||
// 근친도 임계값 (%)
|
||||
inbreedingThreshold: number;
|
||||
|
||||
// 필터 활성화 여부
|
||||
isActive: boolean;
|
||||
|
||||
// 마지막 업데이트 시간
|
||||
updtDt: Date;
|
||||
// === 기타 설정 ===
|
||||
inbreedingThreshold: number // 근친도 임계값 (%)
|
||||
isActive: boolean // 필터 활성화 여부
|
||||
updtDt: Date // 마지막 업데이트 시간
|
||||
}
|
||||
|
||||
/**
|
||||
* ====================================================================================================
|
||||
* 기본 필터 초기값 설정
|
||||
* 사용자가 해당 형질 선택하지 않았을때 필터의 초기 값 0 세팅
|
||||
* ====================================================================================================
|
||||
* const [filterSettings, setFilterSettings] = useState(DEFAULT_FILTER_SETTINGS);
|
||||
* function resetFilter() {
|
||||
setFilterSettings(DEFAULT_FILTER_SETTINGS); // 초기화
|
||||
}
|
||||
if (!user.filterSettings) {
|
||||
user.filterSettings = DEFAULT_FILTER_SETTINGS; // 기본값 적용
|
||||
}
|
||||
* 기본 필터 초기값
|
||||
*
|
||||
* @description
|
||||
* - 사용자가 필터를 설정하지 않았을 때 기본값
|
||||
* - 기본 7개 형질 선택 (도체중, 등심단면적, 등지방두께, 근내지방도, 등심중량, 체장, 체고)
|
||||
*/
|
||||
export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
|
||||
analysisIndex: "GENE",
|
||||
@@ -131,16 +98,16 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
|
||||
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "등심weight", "체장", "체고"],
|
||||
pinnedTraits: [],
|
||||
traitWeights: {
|
||||
// 성장형질 (점수: 1 ~ 10, 미선택 시 0)
|
||||
// 성장형질
|
||||
"12개월령체중": 0,
|
||||
|
||||
// 경제형질 (점수: 1 ~ 10, 미선택 시 0)
|
||||
// 경제형질 (기본 선택: 가중치 1)
|
||||
도체중: 1,
|
||||
등심단면적: 1,
|
||||
등지방두께: 1,
|
||||
근내지방도: 1,
|
||||
|
||||
// 체형형질 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치
|
||||
// 체형형질
|
||||
체고: 1,
|
||||
십자: 0,
|
||||
체장: 1,
|
||||
@@ -152,7 +119,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
|
||||
곤폭: 0,
|
||||
흉위: 0,
|
||||
|
||||
// 부위별무게 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치
|
||||
// 부위별무게
|
||||
안심weight: 0,
|
||||
등심weight: 1,
|
||||
채끝weight: 0,
|
||||
@@ -164,7 +131,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
|
||||
양지weight: 0,
|
||||
갈비weight: 0,
|
||||
|
||||
// 부위별비율 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치
|
||||
// 부위별비율
|
||||
안심rate: 0,
|
||||
등심rate: 0,
|
||||
채끝rate: 0,
|
||||
@@ -176,35 +143,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
|
||||
양지rate: 0,
|
||||
갈비rate: 0,
|
||||
},
|
||||
inbreedingThreshold: 0, // 근친도 기본값 0
|
||||
isActive: true, // 기본 7개 형질이 선택되어 있으므로 활성화
|
||||
// 기본 각각 1점으로 세팅
|
||||
inbreedingThreshold: 0,
|
||||
isActive: true,
|
||||
updtDt: new Date(),
|
||||
};
|
||||
|
||||
/**
|
||||
* 주요 유전자 7개 (육량형 3개 + 육질형 4개)
|
||||
* 나머지 유전자는 "전체 보기" 검색 버튼을 통해 선택 가능
|
||||
*/
|
||||
export const MAJOR_GENES: GeneInfo[] = [
|
||||
// 육량형 3개
|
||||
{ name: "PLAG1", category: "QUANTITY", description: "성장 및 체중 증가에 영향", importance: 20 },
|
||||
{ name: "NCAPG2", category: "QUANTITY", description: "체구 크기와 성장 속도", importance: 19 },
|
||||
{ name: "BTB", category: "QUANTITY", description: "도체 크기", importance: 18 },
|
||||
|
||||
// 육질형 4개
|
||||
{ name: "NT5E", category: "QUALITY", description: "근내지방도", importance: 20 },
|
||||
{ name: "SCD", category: "QUALITY", description: "지방산 불포화도", importance: 19 },
|
||||
{ name: "FASN", category: "QUALITY", description: "지방 합성", importance: 18 },
|
||||
{ name: "CAPN1", category: "QUALITY", description: "육질 연도에 영향", importance: 17 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 경제형질 4개
|
||||
*/
|
||||
export const ECONOMIC_TRAITS: TraitInfo[] = [
|
||||
{ name: "도체중", category: "ECONOMIC", description: "도체중" },
|
||||
{ name: "등심단면적", category: "ECONOMIC", description: "등심단면적" },
|
||||
{ name: "등지방두께", category: "ECONOMIC", description: "등지방두께" },
|
||||
{ name: "근내지방도", category: "ECONOMIC", description: "근내지방도" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,145 +1,70 @@
|
||||
/**
|
||||
* 유전체 관련 타입 정의
|
||||
* 백엔드 API 응답 형식 기준으로 작성
|
||||
*/
|
||||
|
||||
/**
|
||||
* 유전체 분석 의뢰 정보
|
||||
* 백엔드 GenomeRequestModel Entity와 일치
|
||||
*/
|
||||
export interface GenomeRequestDto {
|
||||
pkRequestNo: number; // No (PK)
|
||||
fkFarmNo?: number; // 농장번호 FK
|
||||
fkCowNo?: number; // 개체번호 FK
|
||||
cowRemarks?: string; // 개체 비고
|
||||
requestDt?: string; // 접수일자
|
||||
snpTest?: string; // SNP 검사
|
||||
msTest?: string; // MS 검사
|
||||
sampleAmount?: string; // 모근량
|
||||
sampleRemarks?: string; // 모근 비고
|
||||
|
||||
// 칩 분석 정보
|
||||
chipNo?: string; // 분석 Chip 번호
|
||||
chipType?: string; // 분석 칩 종류
|
||||
chipInfo?: string; // 칩정보
|
||||
chipRemarks?: string; // 칩 비고
|
||||
chipSireName?: string; // 칩분석 아비명
|
||||
chipDamName?: string; // 칩분석 어미명
|
||||
chipReportDt?: string; // 칩분석 보고일자
|
||||
|
||||
// MS 검사 결과
|
||||
msResultStatus?: string; // MS 감정결과
|
||||
msFatherEstimate?: string; // MS 추정부
|
||||
msReportDt?: string; // MS 보고일자
|
||||
|
||||
delDt?: string; // 삭제일시 (Soft Delete)
|
||||
}
|
||||
|
||||
/**
|
||||
* 형질 정보 (API 응답용)
|
||||
*/
|
||||
export interface TraitInfo {
|
||||
traitNm: string; // 형질명
|
||||
traitCtgry?: string; // 형질 카테고리
|
||||
traitDesc?: string; // 형질 설명
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전체 개체 형질 상세 (API 응답용 - genomeCows 배열 항목)
|
||||
*/
|
||||
export interface GenomeCow {
|
||||
traitVal?: number; // 형질 측정값
|
||||
breedVal?: number; // EBV (추정육종가)
|
||||
percentile?: number; // 백분위 순위
|
||||
traitName?: string; // 형질명 (평평한 구조)
|
||||
traitCategory?: string; // 형질 카테고리
|
||||
traitDesc?: string; // 형질 설명
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전체 형질 정보 (API 응답용)
|
||||
* 백엔드 findByCowId 응답 형식과 일치
|
||||
* ========================================
|
||||
* 유전체 분석 관련 타입 정의
|
||||
* ========================================
|
||||
*
|
||||
* NOTE: genome_trait 테이블이 삭제되어 genome_trait_detail이 직접 genome_request와 연결됨
|
||||
* @description
|
||||
* - 실제 사용되는 타입만 정의
|
||||
* - 백엔드 findByCowId API 응답 형식
|
||||
*/
|
||||
|
||||
/**
|
||||
* 형질별 육종가 정보
|
||||
*
|
||||
* @backend GenomeTraitDto.genomeCows 배열 항목
|
||||
* @description 개체의 형질별 유전체 분석 결과
|
||||
*
|
||||
* @usage
|
||||
* - 개체 상세 페이지 형질 데이터 표시
|
||||
* - 형질 비교 차트 컴포넌트
|
||||
*/
|
||||
export interface GenomeCowTraitDto {
|
||||
traitName?: string // 형질명 (예: "도체중", "등심단면적")
|
||||
breedVal?: number // EBV: 표준화 육종가 (σ 단위)
|
||||
traitVal?: number // EPD: 육종가 원본값
|
||||
percentile?: number // 백분위 순위 (0-100, 낮을수록 상위)
|
||||
traitCategory?: string // 형질 카테고리
|
||||
traitDesc?: string // 형질 설명
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전체 형질 정보
|
||||
*
|
||||
* @backend findByCowId API 응답
|
||||
* @description 개체의 유전체 분석 결과 (의뢰 정보 + 35개 형질 데이터)
|
||||
*
|
||||
* @usage
|
||||
* - genome.api.findByCowNo() 응답
|
||||
* - 개체 상세 페이지에서 형질 데이터 표시
|
||||
*/
|
||||
export interface GenomeTraitDto {
|
||||
fkRequestNo?: number; // 의뢰번호 FK
|
||||
// === 연결 정보 ===
|
||||
fkRequestNo?: number // 분석 의뢰번호
|
||||
|
||||
// API 응답 필드
|
||||
request?: GenomeRequestDto; // 분석 의뢰 정보
|
||||
trait?: any; // 형질 기본 정보
|
||||
genomeCows?: GenomeCow[]; // 형질별 상세 데이터 (EBV, 백분위 등)
|
||||
// === 관계 데이터 ===
|
||||
request?: { // 분석 의뢰 기본 정보
|
||||
pkRequestNo?: number
|
||||
requestDt?: string
|
||||
chipSireName?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 계산된 필드 (프론트엔드용)
|
||||
anlysDt?: string; // 분석일자
|
||||
trait?: any // 형질 메타데이터 (legacy)
|
||||
|
||||
delDt?: string; // 삭제일시 (Soft Delete)
|
||||
[key: string]: any;
|
||||
genomeCows?: GenomeCowTraitDto[] // 형질별 육종가/백분위 배열 (35개)
|
||||
|
||||
// === 계산 필드 ===
|
||||
anlysDt?: string // 분석일자
|
||||
|
||||
// === 시스템 필드 ===
|
||||
delDt?: string // 삭제일시
|
||||
[key: string]: any // 추가 동적 필드
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전체 형질 상세 정보
|
||||
* 백엔드 GenomeTraitDetailModel Entity와 일치
|
||||
* GenomeTrait 별칭
|
||||
*
|
||||
* NOTE: fk_trait_no가 fk_request_no로 변경됨 (genome_trait 테이블 삭제로 인해)
|
||||
* @description 실제 사용 중 (genome.api.ts, cow/[cowNo]/page.tsx 등)
|
||||
*/
|
||||
export interface GenomeTraitDetailDto {
|
||||
pkTraitDetailNo: number; // 형질상세번호 PK
|
||||
fkRequestNo: number; // 의뢰번호 FK (변경됨: fkTraitNo → fkRequestNo)
|
||||
cowId?: string; // 개체식별번호 (KOR...) - 추가됨
|
||||
traitName?: string; // 형질명 (예: "12개월령체중", "도체중", "등심단면적")
|
||||
traitVal?: number; // 실측값
|
||||
traitEbv?: number; // 표준화육종가 (EBV: Estimated Breeding Value)
|
||||
traitPercentile?: number; // 백분위수 (전국 대비 순위)
|
||||
delDt?: string; // 삭제일시 (Soft Delete)
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전능력 평가 요청
|
||||
*/
|
||||
export interface GeneticAbilityRequest {
|
||||
selectedTraits?: {
|
||||
[traitName: string]: number; // 형질명: 가중치
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전능력 평가 응답 (프론트엔드 확장)
|
||||
* 형질별 표준화육종가 기반 평가
|
||||
*/
|
||||
export interface GeneticAbilityDto {
|
||||
// 종합 평가
|
||||
overallScore?: number; // 종합 점수 (표준화 점수)
|
||||
grade?: 'A' | 'B' | 'C' | 'D' | 'E'; // 등급
|
||||
farmRank?: number; // 농장 내 순위
|
||||
totalCows?: number; // 농장 전체 개체 수
|
||||
|
||||
// 35개 형질 데이터 (형질명: 표준화육종가)
|
||||
traits?: Record<string, number>;
|
||||
|
||||
// 경제형질 (4개) - 표준화육종가 (백엔드 응답 필드명)
|
||||
carcassWeight_breedVal?: number; // 도체중
|
||||
eyeMuscleArea_breedVal?: number; // 등심단면적
|
||||
backfatThickness_breedVal?: number; // 등지방두께
|
||||
marbling_breedVal?: number; // 근내지방도
|
||||
|
||||
// 체형형질 (10개) - 표준화육종가 (백엔드 응답 필드명)
|
||||
bodyHeight_breedVal?: number; // 체고
|
||||
crossHeight_breedVal?: number; // 십자
|
||||
bodyLength_breedVal?: number; // 체장
|
||||
chestDepth_breedVal?: number; // 흉심
|
||||
chestWidth_breedVal?: number; // 흉폭
|
||||
hipLength_breedVal?: number; // 고장
|
||||
hipWidth_breedVal?: number; // 요각폭
|
||||
sitBoneWidth_breedVal?: number; // 곤폭
|
||||
ischiumWidth_breedVal?: number; // 좌골폭
|
||||
chestGirth_breedVal?: number; // 흉위
|
||||
}
|
||||
|
||||
// 타입 alias (호환성)
|
||||
export type GenomeRequest = GenomeRequestDto;
|
||||
export type GenomeTrait = GenomeTraitDto;
|
||||
export type GenomeTraitDetail = GenomeTraitDetailDto;
|
||||
export type GeneticAbility = GeneticAbilityDto;
|
||||
export type GenomeTrait = GenomeTraitDto
|
||||
|
||||
@@ -1,209 +1,101 @@
|
||||
/**
|
||||
* 랭킹 관련 타입 정의
|
||||
* 백엔드 ranking.interface.ts와 동기화
|
||||
* ========================================
|
||||
* 랭킹(Ranking) 관련 타입 정의
|
||||
* ========================================
|
||||
*
|
||||
* @description
|
||||
* - 실제 사용되는 타입만 정의
|
||||
* - POST /cow/ranking API 요청/응답
|
||||
*/
|
||||
|
||||
/**
|
||||
* 랭킹 기준 타입
|
||||
*/
|
||||
export enum RankingCriteriaType {
|
||||
GENE = 'GENE', // 유전자 기반 랭킹
|
||||
GENOME = 'GENOME', // 유전체 형질 기반 랭킹
|
||||
CONCEPTION_RATE = 'CONCEPTION_RATE', // 수태율 랭킹
|
||||
BCS = 'BCS', // BCS 랭킹
|
||||
MPT = 'MPT', // 혈액대사검사 랭킹
|
||||
INBREEDING = 'INBREEDING', // 근친도 기반 랭킹
|
||||
COW_PURPOSE = 'COW_PURPOSE', // 암소 용도별 추천
|
||||
COMPOSITE = 'COMPOSITE', // 복합 평가
|
||||
}
|
||||
// ========================================
|
||||
// 1. 랭킹 조건
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 정렬 방향
|
||||
*/
|
||||
export enum RankingOrder {
|
||||
ASC = 'ASC', // 오름차순
|
||||
DESC = 'DESC', // 내림차순
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 기반 랭킹 조건
|
||||
*/
|
||||
export interface GeneRankingCondition {
|
||||
markerNm: string; // 유전자 마커명
|
||||
order?: RankingOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전체 형질 기반 랭킹 조건
|
||||
* 형질 기반 랭킹 조건
|
||||
*
|
||||
* @description 선발지수 계산용 형질별 가중치
|
||||
* @example { traitNm: "도체중", weight: 2.0 }
|
||||
*/
|
||||
export interface TraitRankingCondition {
|
||||
traitNm: string; // 형질 이름
|
||||
weight?: number; // 가중치
|
||||
traitNm: string // 형질명 (예: "도체중", "등심단면적")
|
||||
weight?: number // 가중치 (기본값: 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 수태율 기반 랭킹 조건
|
||||
* 필터 조건
|
||||
*
|
||||
* @description WHERE 조건 (현재 사용 안 함, 향후 확장용)
|
||||
*/
|
||||
export interface ConceptionRateCondition {
|
||||
order?: RankingOrder;
|
||||
export interface FilterCondition {
|
||||
field: string // 컬럼명
|
||||
operator: string // 연산자 (eq, gt, lt 등)
|
||||
value: any // 비교값
|
||||
}
|
||||
|
||||
/**
|
||||
* BCS 기반 랭킹 조건
|
||||
*/
|
||||
export interface BCSCondition {
|
||||
order?: RankingOrder;
|
||||
}
|
||||
// ========================================
|
||||
// 2. 랭킹 옵션
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* MPT (혈액대사검사) 기반 랭킹 조건
|
||||
* 필터 엔진 옵션
|
||||
*
|
||||
* @description 필터링 옵션 (현재 farmNo만 사용)
|
||||
* @usage filterOptions: { farmNo: 1 }
|
||||
*/
|
||||
export interface MptRankingCondition {
|
||||
criteria: string[]; // 평가할 MPT 항목
|
||||
normalRanges?: Record<string, { min: number; max: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 근친도 기반 랭킹 조건
|
||||
*/
|
||||
export interface InbreedingCondition {
|
||||
targetKpnNo?: string; // 특정 KPN과의 근친도 계산
|
||||
maxThreshold?: number; // 최대 허용 근친도
|
||||
order?: RankingOrder;
|
||||
export interface FilterEngineOptions {
|
||||
farmNo?: number // 농장 번호 필터
|
||||
filters?: FilterCondition[] // 추가 필터 조건 (향후 확장용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 옵션
|
||||
*
|
||||
* @description 순위 계산 설정
|
||||
* @usage
|
||||
* {
|
||||
* criteriaType: 'GENOME',
|
||||
* traitConditions: [{ traitNm: "도체중", weight: 2 }]
|
||||
* }
|
||||
*/
|
||||
export interface RankingOptions {
|
||||
criteriaType: RankingCriteriaType;
|
||||
geneConditions?: GeneRankingCondition[];
|
||||
traitConditions?: TraitRankingCondition[];
|
||||
conceptionRateCondition?: ConceptionRateCondition;
|
||||
bcsCondition?: BCSCondition;
|
||||
mptCondition?: MptRankingCondition;
|
||||
inbreedingCondition?: InbreedingCondition;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
criteriaType: 'GENE' | 'GENOME' // 유전자 또는 유전체 기반
|
||||
traitConditions?: TraitRankingCondition[] // 형질 조건 배열
|
||||
limit?: number // 결과 개수 제한 (향후 확장용)
|
||||
offset?: number // 시작 위치 (향후 확장용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 암소 용도별 추천 타입
|
||||
*/
|
||||
export enum CowRecommendationType {
|
||||
EMBRYO_DONOR = 'EMBRYO_DONOR', // 공란우
|
||||
EMBRYO_RECIPIENT = 'EMBRYO_RECIPIENT', // 수란우
|
||||
ARTIFICIAL_INSEMINATION = 'ARTIFICIAL_INSEMINATION', // 인공수정
|
||||
CULLING = 'CULLING', // 도태대상
|
||||
GENERAL = 'GENERAL', // 일반
|
||||
}
|
||||
// ========================================
|
||||
// 3. 랭킹 요청 DTO
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 랭킹 상세 정보
|
||||
*/
|
||||
export interface RankingDetail {
|
||||
code: string; // 평가 항목 명
|
||||
value: number; // 실체 측정 값
|
||||
weight?: number; // 가중치
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 결과 아이템
|
||||
*/
|
||||
export interface RankingResultItem<T> {
|
||||
entity: T; // 원본 엔티티
|
||||
rank: number; // 순위
|
||||
sortValue: number; // 정렬에 사용된 값
|
||||
details?: RankingDetail[];
|
||||
recommendationType?: CowRecommendationType;
|
||||
compositeScores?: {
|
||||
genomeScore: number;
|
||||
geneScore: number;
|
||||
inbreedingPercent: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 결과
|
||||
*/
|
||||
export interface RankingResult<T> {
|
||||
items: RankingResultItem<T>[];
|
||||
total: number;
|
||||
criteriaType: RankingCriteriaType;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 연산자 타입
|
||||
*/
|
||||
export type FilterOperator =
|
||||
| 'eq' // 같음
|
||||
| 'ne' // 같지 않음
|
||||
| 'gt' // 초과
|
||||
| 'gte' // 이상
|
||||
| 'lt' // 미만
|
||||
| 'lte' // 이하
|
||||
| 'like' // 포함 (문자열)
|
||||
| 'in' // 배열 내 포함
|
||||
| 'between'; // 범위
|
||||
|
||||
/**
|
||||
* 필터 조건
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
field: string; // 필터링할 컬럼명
|
||||
operator: FilterOperator; // 연산자
|
||||
value: any; // 비교 값 (between인 경우 [min, max] 배열)
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 방향
|
||||
*/
|
||||
export type SortOrder = 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* 정렬 옵션
|
||||
*/
|
||||
export interface SortOption {
|
||||
field: string; // 정렬할 컬럼명
|
||||
order: SortOrder; // 정렬 방향
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지네이션 옵션
|
||||
*/
|
||||
export interface PaginationOption {
|
||||
page: number; // 페이지 번호 (1부터 시작)
|
||||
limit: number; // 페이지당 아이템 수
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterEngine 옵션
|
||||
*/
|
||||
export interface FilterEngineOptions {
|
||||
filters?: FilterCondition[]; // 필터 조건 배열
|
||||
sorts?: SortOption[]; // 정렬 옵션 배열
|
||||
pagination?: PaginationOption; // 페이지네이션 옵션
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterEngine 실행 결과
|
||||
*/
|
||||
export interface FilterEngineResult<T> {
|
||||
data: T[]; // 조회된 데이터
|
||||
total: number; // 전체 데이터 개수 (페이지네이션 전)
|
||||
page?: number; // 현재 페이지
|
||||
limit?: number; // 페이지당 아이템 수
|
||||
totalPages?: number; // 전체 페이지 수
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 요청 DTO
|
||||
* 랭킹 요청
|
||||
*
|
||||
* @description POST /cow/ranking 요청 바디
|
||||
* @example
|
||||
* {
|
||||
* filterOptions: { farmNo: 1 },
|
||||
* rankingOptions: {
|
||||
* criteriaType: "GENOME",
|
||||
* traitConditions: [
|
||||
* { traitNm: "도체중", weight: 2 },
|
||||
* { traitNm: "근내지방도", weight: 3 }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface RankingRequestDto {
|
||||
filterOptions?: FilterEngineOptions;
|
||||
rankingOptions: RankingOptions;
|
||||
filterOptions?: FilterEngineOptions // 필터링 옵션
|
||||
rankingOptions: RankingOptions // 랭킹 계산 옵션
|
||||
}
|
||||
|
||||
// 타입 alias
|
||||
export type RankingRequest = RankingRequestDto;
|
||||
// ========================================
|
||||
// 4. 타입 별칭
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @description 실제 사용 중인 별칭
|
||||
*/
|
||||
export type RankingRequest = RankingRequestDto
|
||||
|
||||
Reference in New Issue
Block a user