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