From 42cb3173540ba20e16ea4fe4df22b193b44a34a8 Mon Sep 17 00:00:00 2001 From: chu eun ju Date: Wed, 31 Dec 2025 08:13:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BD=94=EB=93=9C=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/category-evaluation-card.tsx | 67 +- .../_components/category-trait-grid.tsx | 2 + .../genome-integrated-comparison.tsx | 4 +- .../_components/normal-distribution-chart.tsx | 10 +- .../_components/trait-distribution-charts.tsx | 44 +- frontend/src/app/dashboard/data.json | 614 ------------------ frontend/src/app/dashboard/page.tsx | 270 ++++++-- frontend/src/app/dashboard/top-cows/page.tsx | 455 ------------- frontend/src/components/charts/index.ts | 4 - .../src/components/charts/mpt-gauge-bar.tsx | 261 -------- frontend/src/types/auth.types.ts | 217 ++++--- frontend/src/types/cow.types.ts | 192 +++--- frontend/src/types/filter.types.ts | 217 +++---- frontend/src/types/genome.types.ts | 191 ++---- frontend/src/types/ranking.types.ts | 250 ++----- 15 files changed, 665 insertions(+), 2133 deletions(-) delete mode 100644 frontend/src/app/dashboard/data.json delete mode 100644 frontend/src/app/dashboard/top-cows/page.tsx delete mode 100644 frontend/src/components/charts/index.ts delete mode 100644 frontend/src/components/charts/mpt-gauge-bar.tsx diff --git a/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx b/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx index 1c89c2d..6511322 100644 --- a/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx +++ b/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx @@ -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 = { - '도체중': '도체중', - '등심단면적': '등심단면적', - '등지방두께': '등지방두께', - '근내지방도': '근내지방도', - '체장': '체장', - '체고': '체고', - '등심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({
{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 ( -
- - -
- {filteredGenes.length === 0 ? ( -

- 검색 결과가 없습니다. -

- ) : ( - filteredGenes.map((gene) => ( -
- toggleGene(gene.name)} - /> -
- -

{gene.description}

-
-
- )) - )} -
-
- - - )} - -
- 선택된 유전자: {tempSelectedGenes.length}개 -
- - - - - - - - ) -} diff --git a/frontend/src/components/genome/gene-search-modal.tsx b/frontend/src/components/genome/gene-search-modal.tsx deleted file mode 100644 index 206d052..0000000 --- a/frontend/src/components/genome/gene-search-modal.tsx +++ /dev/null @@ -1,246 +0,0 @@ -'use client' - -import { useState, useEffect } from "react" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Search, X, Filter, Sparkles } from "lucide-react" -import { geneApi } from "@/lib/api/gene.api" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" - -interface GeneSearchDrawerProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedGenes: string[] - onGenesChange: (genes: string[]) => void -} - -export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) { - const [allGenes, setAllGenes] = useState([]) - const [loading, setLoading] = useState(false) - const [searchQuery, setSearchQuery] = useState("") - const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL') - - // 모달 열릴 때 전체 유전자 로드 - useEffect(() => { - if (open) { - loadAllGenes() - } - }, [open]) - - // TODO: 백엔드 /gene/markers API 구현 후 활성화 - const loadAllGenes = async () => { - // try { - // setLoading(true) - // const genes = await geneApi.getAllMarkers() - // setAllGenes(genes) - // } catch { - // // 유전자 로드 실패 시 빈 배열 유지 - // } finally { - // setLoading(false) - // } - } - - // 검색 및 필터링 - const filteredGenes = allGenes.filter((gene) => { - // 타입 필터 - if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) { - return false - } - - // 검색어 필터 - if (searchQuery) { - const query = searchQuery.toLowerCase() - return ( - gene.markerNm.toLowerCase().includes(query) || - gene.markerDesc?.toLowerCase().includes(query) || - gene.relatedTrait?.toLowerCase().includes(query) - ) - } - - return true - }) - - const toggleGene = (markerNm: string) => { - if (selectedGenes.includes(markerNm)) { - onGenesChange(selectedGenes.filter(g => g !== markerNm)) - } else { - onGenesChange([...selectedGenes, markerNm]) - } - } - - const selectAllFiltered = () => { - const newGenes = [...selectedGenes] - filteredGenes.forEach(gene => { - if (!newGenes.includes(gene.markerNm)) { - newGenes.push(gene.markerNm) - } - }) - onGenesChange(newGenes) - } - - const clearAll = () => { - onGenesChange([]) - } - - return ( - - - {/* 헤더 */} - -
-
- -
-
- 유전자 검색 및 선택 - - 전체 {allGenes.length.toLocaleString()}개 / 선택 {selectedGenes.length}개 - -
-
-
- - {/* 검색 및 필터 */} -
- {/* 검색바 */} -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-9 h-10 text-sm bg-background" - autoFocus - /> - {searchQuery && ( - - )} -
- - {/* 필터 탭 및 액션 버튼 */} -
- setFilterType(v as any)} className="flex-1"> - - - 전체 ({allGenes.length}) - - -
-
- 육량 ({allGenes.filter(g => g.markerTypeCd === 'QTY').length}) -
-
- -
-
- 육질 ({allGenes.filter(g => g.markerTypeCd === 'QLT').length}) -
-
-
-
- -
- - -
-
-
- - {/* 유전자 목록 */} -
- {loading ? ( -
-
-
-

유전자 데이터 로딩 중...

-
-
- ) : filteredGenes.length > 0 ? ( - -
- {filteredGenes.map((gene) => { - const isSelected = selectedGenes.includes(gene.markerNm) - const isQuantity = gene.markerTypeCd === 'QTY' - - return ( - toggleGene(gene.markerNm)} - title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`} - > - {gene.markerNm} - - ) - })} -
-
- ) : ( -
-
- -

검색 결과가 없습니다

-

다른 검색어나 필터를 시도해보세요

-
-
- )} -
- - {/* 하단 버튼 */} -
-
- {searchQuery && ( - - 검색: {filteredGenes.length.toLocaleString()}개 - - )} - - 선택: {selectedGenes.length}개 - -
-
- - -
-
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-distribution-donut.tsx b/frontend/src/components/genome/genome-distribution-donut.tsx deleted file mode 100644 index bd8fe4b..0000000 --- a/frontend/src/components/genome/genome-distribution-donut.tsx +++ /dev/null @@ -1,181 +0,0 @@ -'use client' - -import { PieChart as PieChartIcon } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" -import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts' - -interface DistributionData { - name: string - value: number - color: string - range: string - description: string -} - -interface GenomeDistributionDonutProps { - farmNo: number | null -} - -export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) { - const [data, setData] = useState([]) - const [totalCount, setTotalCount] = useState(0) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchData = async () => { - if (!farmNo) { - setLoading(false) - return - } - - try { - const response = await apiClient.post('/cow/ranking', { - filterOptions: { farmNo }, - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [ - { traitNm: '도체중', weight: 0.25 }, - { traitNm: '근내지방도', weight: 0.25 }, - { traitNm: '등심단면적', weight: 0.25 }, - { traitNm: '등지방두께', weight: 0.25 }, - ] - } - }) - - const result = response.data || response - const items = result.items || [] - setTotalCount(items.length) - - const distribution = { - top: 0, // 0σ 이상 - middle: 0, // -1.0σ ~ 0σ - bottom: 0 // -1.0σ 이하 - } - - items.forEach((item: any) => { - const score = item.sortValue || 0 - if (score >= 0) distribution.top++ - else if (score >= -1.0) distribution.middle++ - else distribution.bottom++ - }) - - setData([ - { name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' }, - { name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' }, - { name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' }, - ].filter(d => d.value > 0)) - - } catch (error) { - console.error('분포 데이터 로드 실패:', error) - } finally { - setLoading(false) - } - } - - fetchData() - }, [farmNo]) - - if (loading) { - return ( -
-
-
-
-
- ) - } - - return ( -
- {/* 헤더 */} -
-
-
-
- -
-
-

우리 소들의 등급

-

총 {totalCount}두 분포

-
-
- {totalCount}두 -
-
- - {/* 차트 */} -
-
-
- - - - {data.map((entry, index) => ( - - ))} - - { - if (active && payload && payload.length) { - const item = payload[0].payload - return ( -
-

{item.name}

-

{item.description}

-
-

{item.value}두 ({Math.round(item.value / totalCount * 100)}%)

-

{item.range}

-
-
- ) - } - return null - }} - /> -
-
- {/* 중앙 텍스트 */} -
- {totalCount} - 전체 -
-
- - {/* 범례 */} -
-
- {data.map((item, idx) => ( -
-
-
-
- {item.name} - {item.description} -
-
-
-

{item.value}

-

{Math.round(item.value / totalCount * 100)}%

-
-
- ))} -
-

- σ(시그마)는 유전능력 수준을 나타내요
- 0보다 클수록 우수해요 -

-
-
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-radar-chart.tsx b/frontend/src/components/genome/genome-radar-chart.tsx deleted file mode 100644 index 10b2836..0000000 --- a/frontend/src/components/genome/genome-radar-chart.tsx +++ /dev/null @@ -1,250 +0,0 @@ -'use client' - -import { Target } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" -import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts' - -interface TraitScore { - trait: string - diff: number - myFarm: number - region: number -} - -interface GenomeRadarChartProps { - farmNo: number | null -} - -export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) { - const [data, setData] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchData = async () => { - if (!farmNo) { - setLoading(false) - return - } - - try { - const traits = [ - { name: '도체중', key: '도체중' }, - { name: '근내지방', key: '근내지방도' }, - { name: '등심단면적', key: '등심단면적' }, - { name: '등지방', key: '등지방두께' }, - { name: '12개월체중', key: '12개월령체중' }, - ] - - const results: TraitScore[] = [] - - for (const trait of traits) { - try { - const farmResponse = await apiClient.post('/cow/ranking', { - filterOptions: { farmNo }, - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [{ traitNm: trait.key, weight: 1.0 }] - } - }) - - const globalResponse = await apiClient.post('/cow/ranking/global', { - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [{ traitNm: trait.key, weight: 1.0 }] - } - }) - - const farmResult = farmResponse.data || farmResponse - const globalResult = globalResponse.data || globalResponse - - const farmScores = farmResult.items?.map((item: any) => { - const traitDetail = item.details?.find((d: any) => d.code === trait.key) - return traitDetail?.value ?? item.sortValue ?? 0 - }) || [] - const farmAvgScore = farmScores.length > 0 - ? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length - : 0 - - const globalScores = globalResult.items?.map((item: any) => { - const traitDetail = item.details?.find((d: any) => d.code === trait.key) - return traitDetail?.value ?? item.sortValue ?? 0 - }) || [] - const regionAvgScore = globalScores.length > 0 - ? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length - : 0 - - const diff = farmAvgScore - regionAvgScore - - results.push({ - trait: trait.name, - diff: parseFloat(diff.toFixed(2)), - myFarm: parseFloat(farmAvgScore.toFixed(2)), - region: parseFloat(regionAvgScore.toFixed(2)) - }) - } catch (error) { - console.error(`형질 ${trait.name} 로드 실패:`, error) - } - } - - setData(results) - } catch (error) { - console.error('레이더 차트 데이터 로드 실패:', error) - } finally { - setLoading(false) - } - } - - fetchData() - }, [farmNo]) - - if (loading) { - return ( -
-
-
-
-
- ) - } - - const validDiffs = data.filter(d => !isNaN(d.diff)) - const avgDiff = validDiffs.length > 0 - ? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length - : 0 - - return ( -
- {/* 헤더 */} -
-
-
-
- -
-
-

카테고리별 결과

-

주요 형질 평균 비교

-
-
-
= 0 - ? 'bg-emerald-50 text-emerald-700 border-emerald-200' - : 'bg-red-50 text-red-600 border-red-200' - }`}> - 평균 {avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ -
-
-
- - {/* 차트 */} -
-
- - - - - - - - - - - - 0} - stroke="#94a3b8" - fill="none" - strokeWidth={2} - strokeDasharray="5 3" - strokeOpacity={0.6} - /> - - { - if (active && payload && payload.length) { - const item = payload[0]?.payload - const diff = item?.diff ?? 0 - const myFarm = item?.myFarm ?? 0 - const region = item?.region ?? 0 - - return ( -
-

{item?.trait}

-
-

내농장: {myFarm > 0 ? '+' : ''}{myFarm}σ

-

보은군: {region > 0 ? '+' : ''}{region}σ

-
-
= 0.3 ? 'text-emerald-400' : - diff <= -0.3 ? 'text-amber-400' : - 'text-slate-300' - }`}> - {diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ -
-
- ) - } - return null - }} - /> -
-
-
- - {/* 범례 */} -
-
-
- 보은군 평균 -
-
-
- 내 농장 -
-
- - {/* 형질별 수치 */} -
- {data.map((item, idx) => ( -
-

{item.trait}

-

= 0.3 ? 'text-emerald-600' : - item.diff <= -0.3 ? 'text-amber-600' : - 'text-slate-700' - }`}> - {item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)} -

-
- ))} -
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-strengths-weaknesses.tsx b/frontend/src/components/genome/genome-strengths-weaknesses.tsx deleted file mode 100644 index ab99c48..0000000 --- a/frontend/src/components/genome/genome-strengths-weaknesses.tsx +++ /dev/null @@ -1,208 +0,0 @@ -'use client' - -import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" - -interface GenomeData { - trait: string - score: number - type: string -} - -interface GenomeStrengthsWeaknessesProps { - farmNo?: number | null -} - -export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) { - const [allMetrics, setAllMetrics] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchTraitScores = async () => { - if (!farmNo) { - setLoading(false) - return - } - - try { - const traits = [ - { name: '도체중', key: '도체중' }, - { name: '근내지방도', key: '근내지방도' }, - { name: '등심단면적', key: '등심단면적' }, - { name: '등지방두께', key: '등지방두께' }, - { name: '12개월령체중', key: '12개월령체중' }, - { name: '체고', key: '체고' }, - ] - - const traitScores: GenomeData[] = [] - - for (const trait of traits) { - try { - const rankingRequest = { - filterOptions: { farmNo: farmNo }, - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [{ traitNm: trait.key, weight: 1.0 }] - } - } - - const response = await apiClient.post('/cow/ranking', rankingRequest) - const rankingResult = response.data || response - - const scores = rankingResult.items?.map((item: any) => item.sortValue) || [] - const avgScore = scores.length > 0 - ? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length - : 0 - - traitScores.push({ - trait: trait.name, - score: parseFloat(avgScore.toFixed(2)), - type: '유전체' - }) - } catch (error) { - console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error) - } - } - - setAllMetrics(traitScores) - } catch (error) { - console.error('형질 점수 로드 실패:', error) - setAllMetrics([]) - } finally { - setLoading(false) - } - } - - fetchTraitScores() - }, [farmNo]) - - const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3) - const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3) - - if (loading) { - return ( -
- {[1, 2].map((i) => ( -
-
-
-
-
- ))} -
- ) - } - - return ( -
- {/* 강점 */} -
window.location.href = '/dashboard/strengths-weaknesses'} - > -
-
-
-
- -
-
-

이 부분이 강해요

-

보은군보다 우수한 형질

-
-
- TOP 3 -
-
-
- {strengths.length === 0 ? ( -
- 데이터가 없습니다. -
- ) : ( -
- {strengths.map((item, idx) => ( -
-
- - {idx + 1} - - {item.trait} -
-
- - {item.score > 0 ? '+' : ''}{item.score} - - σ -
-
- ))} -
- )} -
-
- - {/* 약점 */} -
window.location.href = '/dashboard/strengths-weaknesses'} - > -
-
-
-
- -
-
-

더 좋아질 수 있어요

-

개선하면 좋을 형질

-
-
- BOTTOM 3 -
-
-
- {weaknesses.length === 0 ? ( -
- 데이터가 없습니다. -
- ) : ( -
- {weaknesses.map((item, idx) => ( -
-
- - {idx + 1} - - {item.trait} -
-
- - {item.score > 0 ? '+' : ''}{item.score} - - σ -
-
- ))} -
- )} -
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-traits-table.tsx b/frontend/src/components/genome/genome-traits-table.tsx deleted file mode 100644 index dd52eb3..0000000 --- a/frontend/src/components/genome/genome-traits-table.tsx +++ /dev/null @@ -1,199 +0,0 @@ -'use client' - -import { BarChart3 } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" - -interface TraitData { - trait: string - regional: number - myFarm: number -} - -interface GenomeTraitsTableProps { - farmNo?: number | null -} - -export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) { - const [traitData, setTraitData] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchTraitData = async () => { - if (!farmNo) { - setLoading(false) - return - } - - try { - const traits = [ - { name: '12개월령체중', key: '12개월령체중' }, - { name: '도체중', key: '도체중' }, - { name: '근내지방도', key: '근내지방도' }, - { name: '등심단면적', key: '등심단면적' }, - { name: '등지방두께', key: '등지방두께' }, - ] - - const results: TraitData[] = [] - - for (const trait of traits) { - try { - const farmResponse = await apiClient.post('/cow/ranking', { - filterOptions: { farmNo: farmNo }, - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [{ traitNm: trait.key, weight: 1.0 }] - } - }) - - const globalResponse = await apiClient.post('/cow/ranking/global', { - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [{ traitNm: trait.key, weight: 1.0 }] - } - }) - - const farmResult = farmResponse.data || farmResponse - const globalResult = globalResponse.data || globalResponse - - const farmScores = farmResult.items?.map((item: any) => item.sortValue) || [] - const farmAvg = farmScores.length > 0 - ? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length - : 0 - - const globalScores = globalResult.items?.map((item: any) => item.sortValue) || [] - const regionalAvg = globalScores.length > 0 - ? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length - : 0 - - results.push({ - trait: trait.name, - myFarm: parseFloat(farmAvg.toFixed(2)), - regional: parseFloat(regionalAvg.toFixed(2)) - }) - } catch (error) { - console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error) - } - } - - setTraitData(results) - } catch (error) { - console.error('[형질 테이블] 전체 데이터 로드 실패:', error) - setTraitData([]) - } finally { - setLoading(false) - } - } - - fetchTraitData() - }, [farmNo]) - - const getTraitShortName = (name: string) => { - const shortNames: Record = { - '12개월령체중': '12개월령체중', - '등심단면적': '등심단면적', - '등지방두께': '등지방두께', - '근내지방도': '근내지방도', - '도체중': '도체중' - } - return shortNames[name] || name - } - - if (loading) { - return ( -
-
-
-
-
- ) - } - - return ( -
- {/* 헤더 */} -
-
-
-
- -
-
-

형질별 점수

-

보은군과 비교한 수치에요

-
-
- 5개 형질 -
-
- - {/* 콘텐츠 */} -
- {traitData.length === 0 ? ( -
- 데이터가 없습니다. -
- ) : ( -
- {traitData.map((item, idx) => { - const diff = item.myFarm - item.regional - const isPositive = diff >= 0 - // σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100) - const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100)) - - return ( -
- {/* 형질명 + 차이 */} -
- - {getTraitShortName(item.trait)} - - = 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' : - diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' : - 'bg-slate-50 text-slate-700 border border-slate-200' - }`}> - {diff > 0 ? '+' : ''}{diff.toFixed(1)}σ - -
- - {/* 비교 바 */} -
- {/* 보은군 바 */} -
- {/* 내농장 바 */} -
-
- - {/* 라벨 */} -
-
-
- - 내농장 {item.myFarm > 0 ? '+' : ''}{item.myFarm}σ - -
-
-
- - 보은군 {item.regional > 0 ? '+' : ''}{item.regional}σ - -
-
-
- ) - })} -
- )} -
-
- ) -}