This commit is contained in:
NYD
2026-01-06 17:26:25 +09:00
24 changed files with 678 additions and 4015 deletions

View File

@@ -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>
)
}

View File

@@ -26,46 +26,8 @@ import {
ResponsiveContainer ResponsiveContainer
} from 'recharts' } from 'recharts'
import { useMediaQuery } from "@/hooks/use-media-query" import { useMediaQuery } from "@/hooks/use-media-query"
import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES } from "@/constants/traits" import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES, getTraitDisplayName, TRAIT_DISPLAY_NAMES } from "@/constants/traits"
import { GenomeCowTraitDto } from "@/types/genome.types"
// 형질명 표시 (전체 이름)
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': '갈비비율',
}
interface CategoryStat { interface CategoryStat {
category: string category: string
@@ -74,22 +36,13 @@ interface CategoryStat {
count: number count: number
} }
interface TraitData {
id?: number
traitName?: string // 형질명
traitCategory?: string // 카테고리
breedVal?: number // 표준화육종가 (σ 단위)
percentile?: number
traitVal?: number // EPD (예상후대차이) 원래 값
}
interface CategoryEvaluationCardProps { interface CategoryEvaluationCardProps {
categoryStats: CategoryStat[] categoryStats: CategoryStat[]
comparisonAverages: ComparisonAveragesDto | null comparisonAverages: ComparisonAveragesDto | null
traitComparisonAverages?: TraitComparisonAveragesDto | null // 형질별 평균 비교 데이터 (폴리곤 차트용) traitComparisonAverages?: TraitComparisonAveragesDto | null // 형질별 평균 비교 데이터 (폴리곤 차트용)
regionAvgZ: number regionAvgZ: number
farmAvgZ: number farmAvgZ: number
allTraits?: TraitData[] allTraits?: GenomeCowTraitDto[]
cowNo?: string cowNo?: string
hideTraitCards?: boolean // 형질 카드 숨김 여부 hideTraitCards?: boolean // 형질 카드 숨김 여부
} }
@@ -151,7 +104,7 @@ export function CategoryEvaluationCard({
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교 // 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
const traitChartData = chartTraits.map(traitName => { 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) const traitAvgRegion = traitComparisonAverages?.region?.find(t => t.traitName === traitName)
@@ -166,7 +119,7 @@ export function CategoryEvaluationCard({
return { return {
name: traitName, name: traitName,
shortName: TRAIT_SHORT_NAMES[traitName] || traitName, shortName: getTraitDisplayName(traitName),
breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ) breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ)
epd: trait?.traitVal ?? 0, // 이 개체 EPD (육종가) epd: trait?.traitVal ?? 0, // 이 개체 EPD (육종가)
regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가) regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가)
@@ -192,7 +145,7 @@ export function CategoryEvaluationCard({
// 형질 이름으로 원본 형질명 찾기 (shortName -> name) // 형질 이름으로 원본 형질명 찾기 (shortName -> name)
const findTraitNameByShortName = (shortName: string) => { 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 return entry ? entry[0] : shortName
} }
@@ -262,7 +215,7 @@ export function CategoryEvaluationCard({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{traits.map(trait => { {traits.map(trait => {
const isSelected = chartTraits.includes(trait) const isSelected = chartTraits.includes(trait)
const traitData = allTraits.find((t: TraitData) => t.traitName === trait) const traitData = allTraits.find((t: GenomeCowTraitDto) => t.traitName === trait)
return ( return (
<button <button
key={trait} key={trait}
@@ -273,8 +226,8 @@ export function CategoryEvaluationCard({
: 'bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50' : 'bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50'
}`} }`}
> >
{TRAIT_SHORT_NAMES[trait] || trait} {getTraitDisplayName(trait)}
{traitData && ( {traitData && traitData.breedVal !== undefined && (
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}> <span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)}) ({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
</span> </span>
@@ -348,7 +301,7 @@ export function CategoryEvaluationCard({
key={trait} 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" 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 && ( {chartTraits.length > 3 && (
<button <button
onClick={() => removeTrait(trait)} onClick={() => removeTrait(trait)}

View File

@@ -21,6 +21,8 @@ interface GenomicTrait {
breedVal: number breedVal: number
percentile: number percentile: number
actualValue: number actualValue: number
description?: string // 형질 설명
unit?: string // 단위 (kg, cm 등)
} }
interface CategoryTraitGridProps { interface CategoryTraitGridProps {

View File

@@ -579,8 +579,8 @@ export function GenomeIntegratedComparison({
: null : null
// 표시할 값 결정 // 표시할 값 결정
const displayScore = isTraitMode && selectedTrait ? selectedTrait.breedVal : overallScore const displayScore = isTraitMode && selectedTrait ? (selectedTrait.breedVal ?? 0) : overallScore
const displayPercentile = isTraitMode && selectedTrait ? selectedTrait.percentile : (selectionIndex?.percentile || 50) const displayPercentile = isTraitMode && selectedTrait ? (selectedTrait.percentile ?? 50) : (selectionIndex?.percentile || 50)
// 형질 모드일 때는 API에서 가져온 평균값 사용, 없으면 traitComparison 사용 // 형질 모드일 때는 API에서 가져온 평균값 사용, 없으면 traitComparison 사용
const displayFarmAvg = isTraitMode const displayFarmAvg = isTraitMode
? (traitRank?.farmAvgEbv ?? traitComparison?.myFarm ?? 0) ? (traitRank?.farmAvgEbv ?? traitComparison?.myFarm ?? 0)

View File

@@ -2,24 +2,22 @@
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { useEffect, useMemo, useState } from 'react'
import { import {
Area, Area,
CartesianGrid,
ComposedChart, ComposedChart,
Customized, Customized,
ReferenceLine, ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
Tooltip,
XAxis, XAxis,
YAxis YAxis
} from 'recharts' } 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> = { const CATEGORY_COLORS: Record<string, string> = {
'성장': '#3b82f6', // 블루 '성장': '#3b82f6', // 블루
'생산': '#f59e0b', // 앰버 '생산': '#f59e0b', // 앰버
@@ -28,71 +26,25 @@ const CATEGORY_COLORS: Record<string, string> = {
'비율': '#ec4899' // 핑크 '비율': '#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 { interface GenomicTrait {
id?: number id?: number
traitName?: string traitName?: string // 형질명 (예: 도체중, 등지방두께)
traitCategory?: string traitCategory?: string // 형질 카테고리 (성장/생산/체형/무게/비율)
breedVal?: number breedVal?: number // 육종가 값
percentile?: number percentile?: number // 백분위 순위
traitVal?: number traitVal?: number // 형질 값 (EPD)
} }
// 형질별 비교 데이터 타입 /** 형질별 농가/보은군 비교 데이터 */
interface TraitComparison { interface TraitComparison {
trait: string trait: string // 형질명
shortName: string shortName: string // 짧은 형질명 (차트 표시용)
myFarm: number // 농가 평균 myFarm: number // 농가 평균
region: number // 보은군 평균 region: number // 보은군 평균
diff: number // 차이 diff: number // 농가와 보은군 간 차이
} }
interface NormalDistributionChartProps { interface NormalDistributionChartProps {

View File

@@ -2,18 +2,8 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { DEFAULT_TRAITS, NEGATIVE_TRAITS } from "@/constants/traits" import { DEFAULT_TRAITS, NEGATIVE_TRAITS, getTraitDisplayName } from "@/constants/traits"
import { GenomeCowTraitDto } from "@/types/genome.types"
// 형질명 표시 (전체 이름)
const TRAIT_SHORT_NAMES: Record<string, string> = {
'도체중': '도체중',
'등심단면적': '등심단면적',
'등지방두께': '등지방두께',
'근내지방도': '근내지방도',
'체장': '체장',
'체고': '체고',
'등심weight': '등심중량'
}
// 카테고리별 배지 스타일 (진한 톤) // 카테고리별 배지 스타일 (진한 톤)
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = { 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' }, '비율': { 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 { interface TraitDistributionChartsProps {
allTraits: TraitData[] allTraits: GenomeCowTraitDto[]
regionAvgZ: number regionAvgZ: number
farmAvgZ: number farmAvgZ: number
cowName?: string cowName?: string
totalCowCount?: number totalCowCount?: number
selectedTraits?: TraitData[] selectedTraits?: GenomeCowTraitDto[]
traitWeights?: Record<string, number> 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 ( return (
<Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md"> <Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md">
<CardContent className="p-0"> <CardContent className="p-0">
@@ -136,7 +128,7 @@ export function TraitDistributionCharts({
const weight = traitWeights[trait.traitName || ''] || 1 const weight = traitWeights[trait.traitName || ''] || 1
return { return {
traitName: trait.traitName, traitName: trait.traitName,
shortName: TRAIT_SHORT_NAMES[trait.traitName || ''] || trait.traitName, shortName: getTraitDisplayName(trait.traitName || ''),
breedVal: (trait.breedVal || 0) * weight, breedVal: (trait.breedVal || 0) * weight,
percentile: trait.percentile, percentile: trait.percentile,
traitCategory: trait.traitCategory, traitCategory: trait.traitCategory,
@@ -151,7 +143,7 @@ export function TraitDistributionCharts({
const weight = traitWeights[traitName] || 1 const weight = traitWeights[traitName] || 1
return { return {
traitName: traitName, traitName: traitName,
shortName: TRAIT_SHORT_NAMES[traitName] || traitName, shortName: getTraitDisplayName(traitName),
breedVal: (trait?.breedVal ?? 0) * weight, breedVal: (trait?.breedVal ?? 0) * weight,
percentile: trait?.percentile ?? 50, percentile: trait?.percentile ?? 50,
traitCategory: trait?.traitCategory, traitCategory: trait?.traitCategory,

View File

@@ -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"
}
]

View File

@@ -1,3 +1,19 @@
/**
* ========================================
* 대시보드 메인 페이지
* ========================================
*
* @description
* 농장의 유전체/유전자/MPT 검사 현황을 한눈에 보여주는 대시보드
*
* @features
* 1. 총 검사 개체 수 (유전체/유전자/번식능력 구분)
* 2. 친자감별 결과 (도넛 차트)
* 3. MPT 검사 현황 (에너지/단백질/간/미네랄)
* 4. 보은군 내 농가 위치 (정규분포 차트)
* 5. 연도별 육종가 추이 (막대 차트)
* 6. 카테고리별 보은군 대비 비교 (레이더 + 바 차트)
*/
'use client' 'use client'
import { AppSidebar } from "@/components/layout/app-sidebar" import { AppSidebar } from "@/components/layout/app-sidebar"
@@ -53,16 +69,27 @@ import {
} from 'recharts' } from 'recharts'
export default function DashboardPage() { export default function DashboardPage() {
// ========================================
// 1. 기본 상태 관리
// ========================================
const router = useRouter() const router = useRouter()
const { user } = useAuthStore() const { user } = useAuthStore()
const { filters } = useFilterStore() const { filters } = useFilterStore()
// 농장 정보
const [farmNo, setFarmNo] = useState<number | null>(null) const [farmNo, setFarmNo] = useState<number | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// 대시보드 통계 데이터
const [stats, setStats] = useState<DashboardStatsDto | null>(null) const [stats, setStats] = useState<DashboardStatsDto | null>(null)
const [farmRanking, setFarmRanking] = useState<FarmRegionRankingDto | null>(null) const [farmRanking, setFarmRanking] = useState<FarmRegionRankingDto | null>(null)
const [mptStats, setMptStats] = useState<MptStatisticsDto | null>(null) const [mptStats, setMptStats] = useState<MptStatisticsDto | null>(null)
// 모바일 감지 (반응형) // ========================================
// 2. 모바일 감지 및 필터 관련 상태
// ========================================
// 모바일 감지 (640px 이하)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
useEffect(() => { useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640) const checkMobile = () => setIsMobile(window.innerWidth < 640)
@@ -71,12 +98,16 @@ export default function DashboardPage() {
return () => window.removeEventListener('resize', checkMobile) return () => window.removeEventListener('resize', checkMobile)
}, []) }, [])
// 필터에서 대표 형질: 고정된 형질 중 selectedTraits 순서상 맨 위 > '도체중' /**
// 고정되지 않은 형질은 순서가 맨 위여도 반영 안 됨 * 대표 형질 선택 로직
* @description
* - 고정된(pinnedTraits) 형질 중 selectedTraits 순서상 첫 번째
* - 고정되지 않은 형질은 무시
* - 없으면 기본값 '도체중'
*/
const primaryTrait = (() => { const primaryTrait = (() => {
const pinnedTraits = filters.pinnedTraits || [] const pinnedTraits = filters.pinnedTraits || []
const selectedTraits = filters.selectedTraits || [] const selectedTraits = filters.selectedTraits || []
// selectedTraits 순서대로 순회하면서 고정된 형질 찾기
for (const trait of selectedTraits) { for (const trait of selectedTraits) {
if (pinnedTraits.includes(trait)) { if (pinnedTraits.includes(trait)) {
return trait return trait
@@ -85,7 +116,7 @@ export default function DashboardPage() {
return '도체중' return '도체중'
})() })()
// 연도별 육종가 추이 관련 state // 연도별 육종가 추이 차트용 형질 선택 (localStorage 연동)
const [selectedTrait, setSelectedTrait] = useState<string>(() => { const [selectedTrait, setSelectedTrait] = useState<string>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('dashboard_trait') || primaryTrait return localStorage.getItem('dashboard_trait') || primaryTrait
@@ -95,34 +126,44 @@ export default function DashboardPage() {
const [traitTrendData, setTraitTrendData] = useState<YearlyTraitTrendDto | null>(null) const [traitTrendData, setTraitTrendData] = useState<YearlyTraitTrendDto | null>(null)
const [traitTrendLoading, setTraitTrendLoading] = useState(false) const [traitTrendLoading, setTraitTrendLoading] = useState(false)
// 보은군 내 농가 위치 차트 분포기준 (선발지수 or 개별 형질) /**
// 필터 활성 시 'overall', 비활성 시 대표 형질 * 농가 위치 차트 분포 기준
* @description
* - 필터 활성: 'overall' (전체 선발지수, 35개 형질 평균)
* - 필터 비활성: primaryTrait (대표 형질)
*/
const [distributionBasis, setDistributionBasis] = useState<string>(() => { const [distributionBasis, setDistributionBasis] = useState<string>(() => {
return filters.isActive ? 'overall' : primaryTrait return filters.isActive ? 'overall' : primaryTrait
}) })
// 필터 변경 시 기본값 업데이트 // 필터 비활성화 시 'overall' → 대표 형질로 변경
useEffect(() => { useEffect(() => {
if (!filters.isActive && distributionBasis === 'overall') { if (!filters.isActive && distributionBasis === 'overall') {
setDistributionBasis(primaryTrait) setDistributionBasis(primaryTrait)
} }
}, [filters.isActive, distributionBasis, primaryTrait]) }, [filters.isActive, distributionBasis, primaryTrait])
// 대표 형질(고정 또는 첫 번째)이 변경되면 selectedTrait도 업데이트 // 대표 형질 변경 시 연동
useEffect(() => { useEffect(() => {
// 대표 형질로 변경
setSelectedTrait(primaryTrait) setSelectedTrait(primaryTrait)
// distributionBasis가 overall이 아니면 대표 형질로 변경
if (distributionBasis !== 'overall') { if (distributionBasis !== 'overall') {
setDistributionBasis(primaryTrait) setDistributionBasis(primaryTrait)
} }
}, [primaryTrait]) }, [primaryTrait])
// 모든 형질 목록 (평탄화) // 모든 형질 목록 (카테고리별 평탄화)
const allTraits = Object.entries(TRAIT_CATEGORIES).flatMap(([cat, traits]) => const allTraits = Object.entries(TRAIT_CATEGORIES).flatMap(([cat, traits]) =>
traits.map(t => ({ category: cat, trait: t })) traits.map(t => ({ category: cat, trait: t }))
) )
// ========================================
// 3. 데이터 로드 (useEffect)
// ========================================
/**
* 농장 정보 로드
* @description 사용자의 첫 번째 농장 정보를 가져옴
*/
useEffect(() => { useEffect(() => {
const fetchFarm = async () => { const fetchFarm = async () => {
setLoading(true) setLoading(true)
@@ -148,9 +189,17 @@ export default function DashboardPage() {
// user가 없으면 loading 상태 유지 (AuthGuard에서 처리) // user가 없으면 loading 상태 유지 (AuthGuard에서 처리)
}, [user]) }, [user])
/**
* 대시보드 통계 데이터 로드
* @description
* - getDashboardStats: 총 검사 개체, 친자감별, 연도별 추이
* - getFarmRegionRanking: 보은군 내 농가 순위 (필터 가중치 적용)
* - getMptStatistics: 번식능력검사 통계
*/
useEffect(() => { useEffect(() => {
const fetchStats = async () => { const fetchStats = async () => {
if (!farmNo) return if (!farmNo) return
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 // 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성
const traitConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0 const traitConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0
? filters.selectedTraits.map(traitNm => ({ ? filters.selectedTraits.map(traitNm => ({
@@ -158,6 +207,7 @@ export default function DashboardPage() {
weight: (filters.traitWeights as Record<string, number>)[traitNm] || 1 weight: (filters.traitWeights as Record<string, number>)[traitNm] || 1
})) }))
: undefined : undefined
try { try {
const [statsData, rankingData, mptStatsData] = await Promise.all([ const [statsData, rankingData, mptStatsData] = await Promise.all([
genomeApi.getDashboardStats(farmNo), genomeApi.getDashboardStats(farmNo),
@@ -174,7 +224,10 @@ export default function DashboardPage() {
fetchStats() fetchStats()
}, [farmNo, filters.isActive, filters.selectedTraits, filters.traitWeights]) }, [farmNo, filters.isActive, filters.selectedTraits, filters.traitWeights])
// 연도별 형질 추이 데이터 로드 /**
* 연도별 형질 추이 데이터 로드
* @description 선택된 형질의 최근 3년 육종가 추이
*/
useEffect(() => { useEffect(() => {
const fetchTraitTrend = async () => { const fetchTraitTrend = async () => {
if (!farmNo || !selectedTrait) return if (!farmNo || !selectedTrait) return
@@ -194,32 +247,44 @@ export default function DashboardPage() {
fetchTraitTrend() fetchTraitTrend()
}, [farmNo, selectedTrait]) }, [farmNo, selectedTrait])
// localStorage에 선택 저장 /**
* 선택된 형질을 localStorage에 저장
* @description 페이지 새로고침 시에도 선택 유지
*/
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem('dashboard_trait', selectedTrait) localStorage.setItem('dashboard_trait', selectedTrait)
} }
}, [selectedTrait]) }, [selectedTrait])
// 계산된 값들 // ========================================
// 4. 계산된 값들 (파생 데이터)
// ========================================
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const lastYear = currentYear - 1 const lastYear = currentYear - 1
const last3Years = [currentYear - 2, currentYear - 1, currentYear] const last3Years = [currentYear - 2, currentYear - 1, currentYear]
// 올해/작년 데이터 // 올해/작년 통계
const thisYearStats = stats?.yearlyStats?.find(s => s.year === currentYear) const thisYearStats = stats?.yearlyStats?.find(s => s.year === currentYear)
const lastYearStats = stats?.yearlyStats?.find(s => s.year === lastYear) const lastYearStats = stats?.yearlyStats?.find(s => s.year === lastYear)
// 전년 대비 변화량 계산 // 전년 대비 검사 개체 수 변화
const totalChange = (thisYearStats?.totalRequests || 0) - (lastYearStats?.totalRequests || 0) const totalChange = (thisYearStats?.totalRequests || 0) - (lastYearStats?.totalRequests || 0)
// 분석 완료율 (분석 완료 / 총 의뢰)
const completionRate = stats?.summary.totalRequests const completionRate = stats?.summary.totalRequests
? Math.round((stats.summary.analyzedCount / stats.summary.totalRequests) * 100) ? Math.round((stats.summary.analyzedCount / stats.summary.totalRequests) * 100)
: 0 : 0
const lastYearCompletionRate = lastYearStats?.analyzeRate || 0 const lastYearCompletionRate = lastYearStats?.analyzeRate || 0
const completionChange = completionRate - lastYearCompletionRate const completionChange = completionRate - lastYearCompletionRate
// 친자 일치율: 분석 완료 / (분석 완료 + 분석 불가) /**
* 친자 일치율 계산
* @description
* - 분자: 분석 완료 (부모 일치)
* - 분모: 분석 완료 + 부 불일치 + 모 불일치 + 모 이력제부재
*/
const totalAnalyzed = stats?.paternityStats const totalAnalyzed = stats?.paternityStats
? (stats.paternityStats.analysisComplete + stats.paternityStats.sireMismatch + stats.paternityStats.damMismatch + stats.paternityStats.damNoRecord) ? (stats.paternityStats.analysisComplete + stats.paternityStats.sireMismatch + stats.paternityStats.damMismatch + stats.paternityStats.damNoRecord)
: 0 : 0
@@ -229,7 +294,7 @@ export default function DashboardPage() {
const lastYearPaternityRate = lastYearStats?.sireMatchRate || 0 const lastYearPaternityRate = lastYearStats?.sireMatchRate || 0
const paternityChange = paternityMatchRate - lastYearPaternityRate const paternityChange = paternityMatchRate - lastYearPaternityRate
// 친자감별 파이차트 (상호 배타적 분류) // 친자감별 도넛 차트 데이터 (값이 0인 항목 제외)
const paternityPieData = stats?.paternityStats ? [ const paternityPieData = stats?.paternityStats ? [
{ name: '분석 완료', value: stats.paternityStats.analysisComplete, color: '#1F3A8F' }, { name: '분석 완료', value: stats.paternityStats.analysisComplete, color: '#1F3A8F' },
{ name: '부 불일치', value: stats.paternityStats.sireMismatch, color: '#ef4444' }, { name: '부 불일치', value: stats.paternityStats.sireMismatch, color: '#ef4444' },
@@ -237,7 +302,12 @@ export default function DashboardPage() {
{ name: '모 이력제부재', value: stats.paternityStats.damNoRecord, color: '#eab308' }, { name: '모 이력제부재', value: stats.paternityStats.damNoRecord, color: '#eab308' },
].filter(d => d.value > 0) : [] ].filter(d => d.value > 0) : []
// 변화량 표시 컴포넌트 /**
* 변화량 표시 컴포넌트
* @description 전년 대비 증감을 화살표로 표시
* @param value - 변화량 (양수: 증가, 음수: 감소)
* @param suffix - 단위 (%, 두 등)
*/
const ChangeIndicator = ({ value, suffix = '' }: { value: number, suffix?: string }) => { const ChangeIndicator = ({ value, suffix = '' }: { value: number, suffix?: string }) => {
if (value === 0) return null if (value === 0) return null
const isPositive = value > 0 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(() => { const farmPositionData = useMemo(() => {
// 데이터 없을 때 기본값
if (!farmRanking) return { if (!farmRanking) return {
histogramData: [], histogramData: [],
xAxisRange: { min: -2.5, max: 2.5 }, xAxisRange: { min: -2.5, max: 2.5 },
@@ -264,9 +351,9 @@ export default function DashboardPage() {
percentile: null as number | null percentile: null as number | null
} }
let farmScore = 0 let farmScore = 0 // 차트용: 보은군 대비 상대값
let regionScore = 0 let regionScore = 0 // 차트용: 보은군 = 0 (기준점)
let originalFarmScore = 0 let originalFarmScore = 0 // 툴팁용: 실제 EPD/EBV 값
let originalRegionScore = 0 let originalRegionScore = 0
let label = '전체 선발지수' let label = '전체 선발지수'
let rank: number | null = null let rank: number | null = null
@@ -274,8 +361,8 @@ export default function DashboardPage() {
let percentile: number | null = null let percentile: number | null = null
if (distributionBasis === 'overall') { if (distributionBasis === 'overall') {
// 전체 선발지수 (35개 형질 평균) // ===== 전체 선발지수 모드 =====
// 보은군 평균을 기준(0)으로 하고, 우리농가는 보은군 대비 차이로 표시 // 35개 형질 평균 EBV (표준화 육종가)
const rawFarmScore = farmRanking.farmAvgScore ?? 0 const rawFarmScore = farmRanking.farmAvgScore ?? 0
const rawRegionScore = farmRanking.regionAvgScore ?? 0 const rawRegionScore = farmRanking.regionAvgScore ?? 0
farmScore = rawFarmScore - rawRegionScore // 보은군 대비 차이 farmScore = rawFarmScore - rawRegionScore // 보은군 대비 차이
@@ -286,21 +373,25 @@ export default function DashboardPage() {
rank = farmRanking.farmRankInRegion rank = farmRanking.farmRankInRegion
percentile = farmRanking.percentile percentile = farmRanking.percentile
} else { } else {
// 개별 형질 선택 시 - traitAverages에서 해당 형질 찾기 // ===== 개별 형질 모드 =====
const traitData = stats?.traitAverages?.find(t => t.traitName === distributionBasis) const traitData = stats?.traitAverages?.find(t => t.traitName === distributionBasis)
if (traitData) { if (traitData) {
const farmEpd = traitData.avgEpd ?? 0 const farmEpd = traitData.avgEpd ?? 0
const regionEpd = traitData.regionAvgEpd ?? 0 const regionEpd = traitData.regionAvgEpd ?? 0
let diff = farmEpd - regionEpd // 보은군 대비 차이 let diff = farmEpd - regionEpd // 보은군 대비 차이
// 등지방두께 등 낮을수록 좋은 형질은 부호 반전 /**
// (농가가 보은군보다 낮으면 실제로는 더 좋은 것이므로 양수로 표시) * 낮을수록 좋은 형질 처리
* @example 등지방두께: 낮으면 좋음 → 부호 반전
* - 농가 EPD < 보은군 EPD → diff는 음수지만 실제로는 더 좋음
* - 따라서 부호를 반전시켜 양수로 표시
*/
if (NEGATIVE_TRAITS.includes(distributionBasis)) { if (NEGATIVE_TRAITS.includes(distributionBasis)) {
diff = -diff diff = -diff
} }
farmScore = diff farmScore = diff
regionScore = 0 // 보은군 = 기준점 (0) regionScore = 0
originalFarmScore = farmEpd originalFarmScore = farmEpd
originalRegionScore = regionEpd originalRegionScore = regionEpd
label = distributionBasis 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) const absMax = Math.max(Math.abs(farmScore), 0.1)
// 데이터 범위에 따라 적절한 X축 범위 계산 const rawRange = absMax * 1.5 // 여유 공간 확보 (1.5배)
// absMax의 1.5배를 범위로 하고, 깔끔한 숫자로 반올림 let range: number // X축 범위 (-range ~ +range)
const rawRange = absMax * 1.5 let step: number // 구간 간격
let range: number
let step: number
if (rawRange <= 3) { if (rawRange <= 3) {
// 표준화 육종가 스케일 (전체 선발지수) // 표준화 육종가 스케일 (전체 선발지수)
@@ -332,11 +427,17 @@ export default function DashboardPage() {
step = range / 5 step = range / 5
} }
// 정규분포 비율 계산 (구간 개수에 맞춤) /**
* 정규분포 히스토그램 생성
* @description
* - PDF (확률밀도함수)를 사용해 정규분포 곡선 생성
* - 보은군 평균(0)을 중심으로 대칭 분포
* - sigma = range/3 (±3σ에 99.7% 포함)
*/
const numBins = Math.round((range * 2) / step) const numBins = Math.round((range * 2) / step)
const bins = [] const bins = []
// 정규분포 PDF 기반으로 각 구간의 비율 계산 // 정규분포 PDF 함수
const normalPDF = (x: number, sigma: number = range / 3) => { const normalPDF = (x: number, sigma: number = range / 3) => {
return Math.exp(-0.5 * Math.pow(x / sigma, 2)) / (sigma * Math.sqrt(2 * Math.PI)) 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 let totalPercent = 0
const tempBins = [] const tempBins = []
// 각 구간의 비율 계산
for (let i = 0; i < numBins; i++) { for (let i = 0; i < numBins; i++) {
const min = -range + i * step const min = -range + i * step
const max = min + step const max = min + step
@@ -354,7 +456,7 @@ export default function DashboardPage() {
tempBins.push({ min, max, midPoint, percent }) tempBins.push({ min, max, midPoint, percent })
} }
// 비율 정규화 // 비율 정규화 (총합이 100%가 되도록)
for (const bin of tempBins) { for (const bin of tempBins) {
const normalizedPercent = (bin.percent / totalPercent) * 100 const normalizedPercent = (bin.percent / totalPercent) * 100
bins.push({ bins.push({
@@ -362,7 +464,7 @@ export default function DashboardPage() {
min: bin.min, min: bin.min,
max: bin.max, max: bin.max,
midPoint: bin.midPoint, midPoint: bin.midPoint,
count: Math.round(total * normalizedPercent / 100), count: Math.round(total * normalizedPercent / 100), // 농가 수로 환산
percent: normalizedPercent percent: normalizedPercent
}) })
} }
@@ -381,6 +483,10 @@ export default function DashboardPage() {
} }
}, [farmRanking, stats, distributionBasis]) }, [farmRanking, stats, distributionBasis])
// ========================================
// 6. 렌더링
// ========================================
return ( return (
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider>
@@ -388,6 +494,7 @@ export default function DashboardPage() {
<SidebarInset> <SidebarInset>
<SiteHeader /> <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"> <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 ? ( {loading ? (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<div className="text-center"> <div className="text-center">
@@ -396,6 +503,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
) : !farmNo ? ( ) : !farmNo ? (
/* 농장 정보 없음 */
<div className="flex items-center justify-center h-96"> <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"> <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" /> <AlertCircle className="w-14 h-14 text-slate-300 mx-auto" />
@@ -405,7 +513,11 @@ export default function DashboardPage() {
</div> </div>
) : ( ) : (
<> <>
{/* ========== 1. 핵심 KPI 카드 (2개) ========== */} {/* ========================================
1. 핵심 KPI 카드 영역
- 총 검사 개체 수 (유전체/유전자/번식능력)
- 친자감별 결과 (도넛 차트)
======================================== */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-sm:gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-sm:gap-3">
{/* 총 검사 개체 수 (합집합) */} {/* 총 검사 개체 수 (합집합) */}
<div <div
@@ -520,7 +632,11 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* ========== 1-2. 번식능력검사 현황 ========== */} {/* ========================================
2. MPT (번식능력검사) 현황
- 에너지 균형 / 단백질 상태 / 간 건강 / 미네랄 균형
- 안전/주의 구분
======================================== */}
{mptStats && mptStats.totalMptCows > 0 && ( {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="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"> <div className="flex items-center justify-between mb-4">
@@ -614,9 +730,13 @@ export default function DashboardPage() {
</div> </div>
)} )}
{/* ========== 2. 좌측(농가위치+순위) + 우측(연도별 육종가) - 높이 맞춤 ========== */} {/* ========================================
3. 메인 차트 영역 (좌우 2열)
- 좌: 보은군 내 농가 위치 (정규분포 차트)
- 우: 연도별 육종가 추이 (막대 차트)
======================================== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-sm:gap-4"> <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="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 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"> <div className="flex items-center gap-3 max-sm:gap-2">
@@ -931,7 +1051,7 @@ export default function DashboardPage() {
)} )}
</div> </div>
{/* 우측 영역: 연도별 육종가 추이 */} {/* === 우측: 연도별 육종가 추이 차트 === */}
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm flex flex-col"> <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 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> <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> <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> </div>
{/* 형질 선택 드롭다운 */} {/* 형질 선택 드롭다운 (카테고리별 그룹화) */}
<div className="mb-4"> <div className="mb-4">
<Select value={selectedTrait} onValueChange={setSelectedTrait}> <Select value={selectedTrait} onValueChange={setSelectedTrait}>
<SelectTrigger className="w-full h-14 text-sm font-medium"> <SelectTrigger className="w-full h-14 text-sm font-medium">
@@ -1147,12 +1267,24 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* ========== 3. 카테고리별 보은군 대비 비교 - 전체 너비 ========== */} {/* ========================================
4. 카테고리별 보은군 대비 비교
- 레이더 차트 (좌측): 5개 카테고리 시각화
- 바 차트 (우측): 카테고리별 상세 육종가
======================================== */}
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm"> <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> <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 ? ( {(stats?.summary.genomeCowCount || 0) > 0 && stats?.traitAverages && stats.traitAverages.length > 0 ? (
(() => { (() => {
// 5개 카테고리 분류 (성장/생산/체형/무게/비율)
const categories = ['성장', '생산', '체형', '무게', '비율'] const categories = ['성장', '생산', '체형', '무게', '비율']
/**
* 카테고리별 평균 계산
* @description
* - 각 카테고리에 속한 형질들의 EPD 평균
* - 백분위(percentile)도 평균 계산
*/
const categoryData = categories.map(cat => { const categoryData = categories.map(cat => {
const traits = stats.traitAverages.filter(t => t.category === cat) const traits = stats.traitAverages.filter(t => t.category === cat)
const avgEpd = traits.length > 0 const avgEpd = traits.length > 0
@@ -1168,17 +1300,30 @@ export default function DashboardPage() {
traitCount: traits.length traitCount: traits.length
} }
}) })
// 바 차트 최대 길이 계산용
const maxAbs = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 1) const maxAbs = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 1)
// 레이더 차트용 설정 /**
* 레이더 차트 좌표 계산
* @description
* - 중심점: (140, 150)
* - 반지름: 95px
* - 5개 카테고리를 정오각형으로 배치
*/
const centerX = 140 const centerX = 140
const centerY = 150 const centerY = 150
const maxRadius = 95 const maxRadius = 95
const angleStep = (2 * Math.PI) / categories.length const angleStep = (2 * Math.PI) / categories.length // 72도씩
const startAngle = -Math.PI / 2 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 epdScale = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 10)
const farmPoints = categoryData.map((d, i) => { const farmPoints = categoryData.map((d, i) => {
const angle = startAngle + i * angleStep 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) } 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 regionPoints = categories.map((_, i) => {
const angle = startAngle + i * angleStep 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) } return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }
}) })
const farmPolygon = farmPoints.map(p => `${p.x},${p.y}`).join(' ') const farmPolygon = farmPoints.map(p => `${p.x},${p.y}`).join(' ')
const regionPolygon = regionPoints.map(p => `${p.x},${p.y}`).join(' ') const regionPolygon = regionPoints.map(p => `${p.x},${p.y}`).join(' ')
// 호버된 포인트 인덱스를 위한 로컬 컴포넌트 /**
* 레이더 차트 SVG 컴포넌트
* @description
* - 호버/클릭으로 카테고리별 상세 정보 표시
* - 모바일에서는 터치로 토글
* - 툴팁은 차트 중앙에 고정 표시
*/
const RadarChart = () => { const RadarChart = () => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null) const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const [clickedIndex, setClickedIndex] = useState<number | null>(null) const [clickedIndex, setClickedIndex] = useState<number | null>(null)
// 실제 표시할 인덱스 (클릭된 것 우선, 없으면 호버된 것) // 실제 표시할 인덱스 (클릭 우선, 없으면 호버)
const activeIndex = clickedIndex !== null ? clickedIndex : hoveredIndex const activeIndex = clickedIndex !== null ? clickedIndex : hoveredIndex
// 클릭/터치 핸들러: 토글 방식 // 클릭/터치 핸들러: 토글 방식 (같은 거 다시 누르면 닫힘)
const handleClick = (e: React.MouseEvent | React.TouchEvent, index: number) => { const handleClick = (e: React.MouseEvent | React.TouchEvent, index: number) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setClickedIndex(prev => prev === index ? null : index) setClickedIndex(prev => prev === index ? null : index)
} }
// 호버 핸들러: 클릭 상태가 아닐 때만 동작 // 호버 핸들러: 클릭 상태가 아닐 때만 동작
const handleMouseEnter = (index: number) => { const handleMouseEnter = (index: number) => {
if (clickedIndex === null) { if (clickedIndex === null) {
setHoveredIndex(index) setHoveredIndex(index)

View File

@@ -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>
)
}

View File

@@ -1,4 +0,0 @@
// ============================================
// 차트 컴포넌트
// ============================================
export { MptGaugeBar } from './mpt-gauge-bar';

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 { export interface UserDto {
pkUserNo: number; // 내부 PK (자동증가) // === 기본 키 ===
userId: string; // 로그인 ID pkUserNo: number // 사용자 내부 번호 (Primary Key, 자동증가)
userName: string; // 이름 userId: string // 로그인 ID (아이디)
userPhone?: string; // 핸드폰번호
userEmail?: string; // 이메일 (인증용) // === 사용자 정보 ===
userRole: 'USER' | 'ADMIN'; // 권한 (USER: 일반, ADMIN: 관리자) userName: string // 이름
delDt?: string; // 삭제일시 (Soft Delete) 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 { export interface SignupDto {
userSe: 'FARM' | 'CNSLT' | 'ORGAN'; // 사용자 구분 (FARM/CNSLT/ORGAN) // === 사용자 구분 ===
userInstName?: string; // 농장명/기관 userSe: 'FARM' | 'CNSLT' | 'ORGAN' // 구분 (농가/컨설턴트/기관)
userId: string; // 사용자 ID (4자 이상) userInstName?: string // 농장명 또는 기관명
userPassword: string; // 비밀번호 (6자 이상)
userName: string; // 이름 // === 계정 정보 ===
userPhone: string; // 휴대폰 번호 userId: string // 사용자 ID (4자 이상)
userBirth?: string; // 생년월일 userPassword: string // 비밀번호 (6자 이상)
userEmail: string; // 이메일
userAddress?: string; // 주소 // === 개인 정보 ===
userBizNo?: string; // 사업자등록번호 userName: string // 이름
userPhone: string // 휴대폰 번호
userEmail: string // 이메일
userBirth?: string // 생년월일
userAddress?: string // 주소
// === 사업자 정보 ===
userBizNo?: string // 사업자등록번호
} }
/** /**
* 로그인 DTO * 회원가입 폼 데이터
* 백엔드 LoginDto와 일치 *
*/ * @description
export interface LoginDto { * - 클라이언트 전용 (백엔드 전송 전 변환 필요)
userId: string; // 사용자 ID (로그인 ID) * - 이메일 분리 입력 등 UI 편의 필드 포함
userPassword: string; // 비밀번호 *
} * @usage 회원가입 페이지 폼
/**
* 로그인 응답 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; // 이메일 수정
}
/**
* 회원가입 폼 데이터 (클라이언트 전용)
*/ */
export interface SignupFormData { export interface SignupFormData {
userSe: string; userSe: string // 사용자 구분
userId: string; userId: string // 사용자 ID
userPassword: string; userPassword: string // 비밀번호
confirmPassword: string; confirmPassword: string // 비밀번호 확인
userName: string; userName: string // 이름
userPhone: string; userPhone: string // 휴대폰 번호
userEmail: string; userEmail: string // 이메일 전체
emailId: string; emailId: string // 이메일 ID 부분
emailDomain: string; emailDomain: string // 이메일 도메인 부분
customDomain?: string; customDomain?: string // 직접 입력 도메인
userInstName?: string; userInstName?: string // 농장명/기관명
userBirth?: string; userBirth?: string // 생년월일
userAddress?: string; userAddress?: string // 주소
userBizNo?: string; userBizNo?: string // 사업자등록번호
}
// ========================================
// 3. 로그인 관련 타입
// ========================================
/**
* 로그인 요청
*
* @backend LoginDto
* @description 로그인 인증 정보
* @usage POST /auth/login 요청 바디
*/
export interface LoginDto {
userId: string // 사용자 ID
userPassword: string // 비밀번호
} }
/** /**
* 인증 응답 DTO (통합) * 인증 응답 (통합)
* 로그인/회원가입 응답에 사용 *
* @description
* - 로그인/회원가입 공통 응답 형식
* - 프론트엔드에서 확장하여 사용
*/ */
export interface AuthResponseDto { export interface AuthResponseDto {
message?: string; message?: string // 결과 메시지
accessToken: string; accessToken: string // JWT 액세스 토큰
user: UserDto; user: UserDto // 사용자 정보
} }
// 타입 alias (호환성) // ========================================
export type User = UserDto; // 4. 프로필 관련 타입
export type AuthResponse = LoginResponseDto; // ========================================
/**
* 사용자 프로필
*
* @description
* - UserDto를 확장하여 통계 정보 추가
* - 프론트엔드에서 확장
*
* @usage 마이페이지 프로필 표시
*/
export interface UserProfileDto extends UserDto {
farmCount?: number // 보유 농장 수
cowCount?: number // 보유 개체 수
}

View File

@@ -1,134 +1,108 @@
/** /**
* ========================================
* 개체(Cow) 관련 타입 정의 * 개체(Cow) 관련 타입 정의
* 백엔드 CowModel Entity 기준으로 작성 * ========================================
*
* @description
* - 실제 사용되는 타입만 정의
* - 백엔드 CowModel Entity와 1:1 매핑
*/ */
// ========================================
// 1. 개체 기본 정보
// ========================================
/** /**
* 개체 기본 정보 * 개체 기본 정보
* 백엔드 CowModel Entity와 일치 *
* @backend CowModel Entity
* @description 한우 개체 1마리의 기본 정보
*/ */
export interface CowDto { export interface CowDto {
pkCowNo: number; // 내부 PK (자동증가) // === 기본 키 ===
cowId: string; // 개체식별번호 (KOR 또는 KPN) - 필수 pkCowNo: number // 개체 내부 번호 (Primary Key)
cowSex?: string; // 성별 (M/F) cowId: string // 개체식별번호 (KOR 또는 KPN)
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 검사일 기준 월령
// 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?: { farm?: {
pkFarmNo: number; pkFarmNo: number
farmNm?: string; farmNm?: string
farmAddr?: string; farmAddr?: string
}; // 농장 정보 (조인) }
} }
/** /**
* 개체 목록 응답 * 개체 상세 정보
*/ *
export interface CowListResponseDto { * @description CowDto 확장 + 계산 필드 + 분석 정보
totalCount: number; // 전체 개체 수
cows: CowDto[]; // 개체 목록
}
/**
* 개체 상세 응답 (프론트엔드 확장)
*/ */
export interface CowDetailResponseDto extends CowDto { export interface CowDetailResponseDto extends CowDto {
// 계산 필드 (프론트엔드에서 계산) // === 계산 필드 ===
age?: number; // 나이 (년) age?: number // 나이 (년 단위)
cowShortNo?: string; // 개체 요약번호 (4자리, cowId에서 추출) cowShortNo?: string // 개체 요약번호 (4자리)
// 추가 분석 정보 (백엔드 별도 API에서 조회) // === 유전체 분석 정보 ===
genomeScore?: number; // 유전체 점수 genomeScore?: number // 유전체 종합 점수
farmRank?: number; // 농장 내 순위 farmRank?: number // 농장 내 순위
totalCows?: number; // 농장 총 개체 수 totalCows?: number // 농장 총 개체 수
inbreedingCoef?: number; // 근친계수 (0.0~1.0) inbreedingCoef?: number // 근친계수 (0.0~1.0)
calvingCount?: number; // 분만회차 calvingCount?: number // 분만 회차
// 데이터 상태 (백엔드에서 조회) // === 데이터 상태 ===
dataStatus?: { dataStatus?: {
hasGenomeData: boolean; // 유전체 데이터 존재 여부 hasGenomeData: boolean
hasGeneData: boolean; // 유전자 데이터 존재 여부 hasGeneData: boolean
}; }
} }
/** // ========================================
* 개체 검색 DTO // 2. 확장 타입 (순위 포함)
*/ // ========================================
export interface CowSearchDto {
keyword?: string; // 검색 키워드 (개체번호, 이름 등)
farmNo?: number; // 농장 번호로 필터링
gender?: 'M' | 'F'; // 성별로 필터링
minBirthDate?: string; // 최소 생년월일 (YYYY-MM-DD)
maxBirthDate?: string; // 최대 생년월일 (YYYY-MM-DD)
}
/** /**
* 개체 생성 DTO * 유전자 정보 포함 개체
*
* @description 개체 목록에서 사용 (유전자 + 순위 정보)
*/ */
export interface CreateCowDto { export interface CowWithGenes extends CowDto {
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 {
genes?: { name: string; genotype: string }[] genes?: { name: string; genotype: string }[]
traits?: Record<string, TraitData | number> traits?: Record<string, { breedVal: number | null; traitVal: number | null } | number>
rank?: number rank?: number
genomeScore?: number genomeScore?: number
cowShortNo?: string cowShortNo?: string
anlysDt?: string
unavailableReason?: string
hasMpt?: boolean
mptTestDt?: string
mptMonthAge?: number
} }
/** /**
* Ranking API 응답 아이템 * 순위 API 응답 항목
*
* @description /cow/ranking API 응답의 개별 항목
*/ */
export interface RankingItem { export interface RankingItem {
entity: Cow & { entity: CowDto & {
genes?: Record<string, number> genes?: Record<string, number>
calvingCount?: number calvingCount?: number
bcs?: number bcs?: number
@@ -141,9 +115,23 @@ export interface RankingItem {
mptTestDt?: string mptTestDt?: string
mptMonthAge?: number mptMonthAge?: number
} }
rank: number rank: number // 순위
sortValue: number sortValue: number // 정렬 기준값
ranking?: { 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

View File

@@ -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 { export interface GlobalFilterSettings {
// 분석 지표 // === 분석 지표 ===
analysisIndex?: AnalysisIndex; analysisIndex?: 'GENE' | 'ABILITY' // 유전자(GENE) 또는 유전능력(ABILITY) 기반
// 선택된 유전자 목록 (유전자 기반) // === 유전자 선택 (GENE 모드) ===
selectedGenes: string[]; selectedGenes: string[] // 선택된 유전자 목록
pinnedGenes?: string[] // 고정 유전자 (최대 5개)
// 고정 유전자 (최대 5개, 항상 맨 앞 표시) // === 형질 선택 (ABILITY 모드) ===
pinnedGenes?: string[]; selectedTraits?: string[] // 선택된 형질 목록
pinnedTraits?: string[] // 고정 형질 (최대 5개)
// 선택된 형질 목록
selectedTraits?: string[];
// 고정 형질 (최대 5개, 항상 맨 앞 표시)
pinnedTraits?: string[];
// 형질 가중치 (유전체 기반)
// ======== DB의 trait_nm과 일치해야 함==============
// JavaScript/TypeScript 문법 규칙 :
// "12개월령체중": number; // 필수 - 숫자로 시작
// "my-key": number; // 필수 - 하이픈(특수문자)
// "my key": number; // 필수 - 공백 포함
// === 형질 가중치 ===
// 35개 형질 (DB의 trait_nm과 일치)
traitWeights: { traitWeights: {
// 성장형질 (1개) // 성장형질 (1개)
"12개월령체중": number; // 따옴표 "12개월령체중": number
// 경제형질 (4개) // 경제형질 (4개)
도체중: number; 도체중: number
등심단면적: number; 등심단면적: number
등지방두께: number; 등지방두께: number
근내지방도: number; 근내지방도: number
// 체형형질 (10개) // 체형형질 (10개)
체고: number; 체고: number
십자: number; // 십자부고 → 십자 십자: number
체장: number; 체장: number
흉심: number; 흉심: number
흉폭: number; 흉폭: number
고장: number; // 요각장 → 고장 고장: number
요각폭: number; 요각폭: number
좌골폭: number; 좌골폭: number
곤폭: number; // 좌골단폭 → 곤폭 곤폭: number
흉위: number; 흉위: number
// 부위별무게 (10개) - DB 형질명과 일치 (무게와 비율은 영문 붙여서 구분) // 부위별무게 (10개)
안심weight: number; 안심weight: number
등심weight: number; 등심weight: number
채끝weight: number; 채끝weight: number
목심weight: number; 목심weight: number
앞다리weight: number; 앞다리weight: number
우둔weight: number; 우둔weight: number
설도weight: number; 설도weight: number
사태weight: number; 사태weight: number
양지weight: number; 양지weight: number
갈비weight: number; 갈비weight: number
// 부위별비율 (10개) - DB 형질명과 일치 (무게와 비율은 영문 붙여서 구분) // 부위별비율 (10개)
안심rate: number; 안심rate: number
등심rate: number; 등심rate: number
채끝rate: number; 채끝rate: number
목심rate: number; 목심rate: number
앞다리rate: number; 앞다리rate: number
우둔rate: number; 우둔rate: number
설도rate: number; 설도rate: number
사태rate: number; 사태rate: number
양지rate: number; 양지rate: number
갈비rate: number; 갈비rate: number
}; }
// 근친도 임계값 (%) // === 기타 설정 ===
inbreedingThreshold: number; inbreedingThreshold: number // 근친도 임계값 (%)
isActive: boolean // 필터 활성화 여부
// 필터 활성화 여부 updtDt: Date // 마지막 업데이트 시간
isActive: boolean;
// 마지막 업데이트 시간
updtDt: Date;
} }
/** /**
* ==================================================================================================== * 기본 필터 초기값
* 기본 필터 초기값 설정 *
* 사용자가 해당 형질 선택하지 않았을때 필터의 초기 값 0 세팅 * @description
* ==================================================================================================== * - 사용자가 필터를 설정하지 않았을 때 기본값
* const [filterSettings, setFilterSettings] = useState(DEFAULT_FILTER_SETTINGS); * - 기본 7개 형질 선택 (도체중, 등심단면적, 등지방두께, 근내지방도, 등심중량, 체장, 체고)
* function resetFilter() {
setFilterSettings(DEFAULT_FILTER_SETTINGS); // 초기화
}
if (!user.filterSettings) {
user.filterSettings = DEFAULT_FILTER_SETTINGS; // 기본값 적용
}
*/ */
export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = { export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
analysisIndex: "GENE", analysisIndex: "GENE",
@@ -131,16 +98,16 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "등심weight", "체장", "체고"], selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "등심weight", "체장", "체고"],
pinnedTraits: [], pinnedTraits: [],
traitWeights: { traitWeights: {
// 성장형질 (점수: 1 ~ 10, 미선택 시 0) // 성장형질
"12개월령체중": 0, "12개월령체중": 0,
// 경제형질 (점수: 1 ~ 10, 미선택 시 0) // 경제형질 (기본 선택: 가중치 1)
도체중: 1, 도체중: 1,
등심단면적: 1, 등심단면적: 1,
등지방두께: 1, 등지방두께: 1,
근내지방도: 1, 근내지방도: 1,
// 체형형질 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치 // 체형형질
체고: 1, 체고: 1,
십자: 0, 십자: 0,
체장: 1, 체장: 1,
@@ -152,7 +119,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
곤폭: 0, 곤폭: 0,
흉위: 0, 흉위: 0,
// 부위별무게 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치 // 부위별무게
안심weight: 0, 안심weight: 0,
등심weight: 1, 등심weight: 1,
채끝weight: 0, 채끝weight: 0,
@@ -164,7 +131,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
양지weight: 0, 양지weight: 0,
갈비weight: 0, 갈비weight: 0,
// 부위별비율 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치 // 부위별비율
안심rate: 0, 안심rate: 0,
등심rate: 0, 등심rate: 0,
채끝rate: 0, 채끝rate: 0,
@@ -176,35 +143,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
양지rate: 0, 양지rate: 0,
갈비rate: 0, 갈비rate: 0,
}, },
inbreedingThreshold: 0, // 근친도 기본값 0 inbreedingThreshold: 0,
isActive: true, // 기본 7개 형질이 선택되어 있으므로 활성화 isActive: true,
// 기본 각각 1점으로 세팅
updtDt: new Date(), 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: "근내지방도" },
];

View File

@@ -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 { export interface GenomeTraitDto {
fkRequestNo?: number; // 의뢰번호 FK // === 연결 정보 ===
fkRequestNo?: number // 분석 의뢰번호
// API 응답 필드 // === 관계 데이터 ===
request?: GenomeRequestDto; // 분석 의뢰 정보 request?: { // 분석 의뢰 기본 정보
trait?: any; // 형질 기본 정보 pkRequestNo?: number
genomeCows?: GenomeCow[]; // 형질별 상세 데이터 (EBV, 백분위 등) requestDt?: string
chipSireName?: string
[key: string]: any
}
// 계산된 필드 (프론트엔드용) trait?: any // 형질 메타데이터 (legacy)
anlysDt?: string; // 분석일자
delDt?: string; // 삭제일시 (Soft Delete) genomeCows?: GenomeCowTraitDto[] // 형질별 육종가/백분위 배열 (35개)
[key: string]: any;
// === 계산 필드 ===
anlysDt?: string // 분석일자
// === 시스템 필드 ===
delDt?: string // 삭제일시
[key: string]: any // 추가 동적 필드
} }
/** /**
* 유전체 형질 상세 정보 * GenomeTrait 별칭
* 백엔드 GenomeTraitDetailModel Entity와 일치
* *
* NOTE: fk_trait_no가 fk_request_no로 변경됨 (genome_trait 테이블 삭제로 인해) * @description 실제 사용 중 (genome.api.ts, cow/[cowNo]/page.tsx 등)
*/ */
export interface GenomeTraitDetailDto { export type GenomeTrait = GenomeTraitDto
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;

View File

@@ -1,209 +1,101 @@
/** /**
* 랭킹 관련 타입 정의 * ========================================
* 백엔드 ranking.interface.ts와 동기화 * 랭킹(Ranking) 관련 타입 정의
* ========================================
*
* @description
* - 실제 사용되는 타입만 정의
* - POST /cow/ranking API 요청/응답
*/ */
/** // ========================================
* 랭킹 기준 타입 // 1. 랭킹 조건
*/ // ========================================
export enum RankingCriteriaType {
GENE = 'GENE', // 유전자 기반 랭킹
GENOME = 'GENOME', // 유전체 형질 기반 랭킹
CONCEPTION_RATE = 'CONCEPTION_RATE', // 수태율 랭킹
BCS = 'BCS', // BCS 랭킹
MPT = 'MPT', // 혈액대사검사 랭킹
INBREEDING = 'INBREEDING', // 근친도 기반 랭킹
COW_PURPOSE = 'COW_PURPOSE', // 암소 용도별 추천
COMPOSITE = 'COMPOSITE', // 복합 평가
}
/** /**
* 정렬 방향 * 형질 기반 랭킹 조건
*/ *
export enum RankingOrder { * @description 선발지수 계산용 형질별 가중치
ASC = 'ASC', // 오름차순 * @example { traitNm: "도체중", weight: 2.0 }
DESC = 'DESC', // 내림차순
}
/**
* 유전자 기반 랭킹 조건
*/
export interface GeneRankingCondition {
markerNm: string; // 유전자 마커명
order?: RankingOrder;
}
/**
* 유전체 형질 기반 랭킹 조건
*/ */
export interface TraitRankingCondition { export interface TraitRankingCondition {
traitNm: string; // 형질 이름 traitNm: string // 형질명 (예: "도체중", "등심단면적")
weight?: number; // 가중치 weight?: number // 가중치 (기본값: 1)
} }
/** /**
* 수태율 기반 랭킹 조건 * 필터 조건
*
* @description WHERE 조건 (현재 사용 안 함, 향후 확장용)
*/ */
export interface ConceptionRateCondition { export interface FilterCondition {
order?: RankingOrder; field: string // 컬럼명
operator: string // 연산자 (eq, gt, lt 등)
value: any // 비교값
} }
/** // ========================================
* BCS 기반 랭킹 조건 // 2. 랭킹 옵션
*/ // ========================================
export interface BCSCondition {
order?: RankingOrder;
}
/** /**
* MPT (혈액대사검사) 기반 랭킹 조건 * 필터 엔진 옵션
*
* @description 필터링 옵션 (현재 farmNo만 사용)
* @usage filterOptions: { farmNo: 1 }
*/ */
export interface MptRankingCondition { export interface FilterEngineOptions {
criteria: string[]; // 평가할 MPT 항목 farmNo?: number // 농장 번호 필터
normalRanges?: Record<string, { min: number; max: number }>; filters?: FilterCondition[] // 추가 필터 조건 (향후 확장용)
}
/**
* 근친도 기반 랭킹 조건
*/
export interface InbreedingCondition {
targetKpnNo?: string; // 특정 KPN과의 근친도 계산
maxThreshold?: number; // 최대 허용 근친도
order?: RankingOrder;
} }
/** /**
* 랭킹 옵션 * 랭킹 옵션
*
* @description 순위 계산 설정
* @usage
* {
* criteriaType: 'GENOME',
* traitConditions: [{ traitNm: "도체중", weight: 2 }]
* }
*/ */
export interface RankingOptions { export interface RankingOptions {
criteriaType: RankingCriteriaType; criteriaType: 'GENE' | 'GENOME' // 유전자 또는 유전체 기반
geneConditions?: GeneRankingCondition[]; traitConditions?: TraitRankingCondition[] // 형질 조건 배열
traitConditions?: TraitRankingCondition[]; limit?: number // 결과 개수 제한 (향후 확장용)
conceptionRateCondition?: ConceptionRateCondition; offset?: number // 시작 위치 (향후 확장용)
bcsCondition?: BCSCondition;
mptCondition?: MptRankingCondition;
inbreedingCondition?: InbreedingCondition;
limit?: number;
offset?: number;
} }
/** // ========================================
* 암소 용도별 추천 타입 // 3. 랭킹 요청 DTO
*/ // ========================================
export enum CowRecommendationType {
EMBRYO_DONOR = 'EMBRYO_DONOR', // 공란우
EMBRYO_RECIPIENT = 'EMBRYO_RECIPIENT', // 수란우
ARTIFICIAL_INSEMINATION = 'ARTIFICIAL_INSEMINATION', // 인공수정
CULLING = 'CULLING', // 도태대상
GENERAL = 'GENERAL', // 일반
}
/** /**
* 랭킹 상세 정보 * 랭킹 요청
*/ *
export interface RankingDetail { * @description POST /cow/ranking 요청 바디
code: string; // 평가 항목 명 * @example
value: number; // 실체 측정 값 * {
weight?: number; // 가중치 * filterOptions: { farmNo: 1 },
} * rankingOptions: {
* criteriaType: "GENOME",
/** * traitConditions: [
* 랭킹 결과 아이템 * { traitNm: "도체중", weight: 2 },
*/ * { traitNm: "근내지방도", weight: 3 }
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
*/ */
export interface RankingRequestDto { export interface RankingRequestDto {
filterOptions?: FilterEngineOptions; filterOptions?: FilterEngineOptions // 필터링 옵션
rankingOptions: RankingOptions; rankingOptions: RankingOptions // 랭킹 계산 옵션
} }
// 타입 alias // ========================================
export type RankingRequest = RankingRequestDto; // 4. 타입 별칭
// ========================================
/**
* @description 실제 사용 중인 별칭
*/
export type RankingRequest = RankingRequestDto