주석수정 및 코드정리

This commit is contained in:
2025-12-31 09:56:54 +09:00
parent 42cb317354
commit 838b279eb5
10 changed files with 13 additions and 1882 deletions

View File

@@ -1,44 +0,0 @@
'use client'
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation"
interface CowHeaderProps {
from?: string | null
}
export function CowHeader({ from }: CowHeaderProps) {
const router = useRouter()
const handleBack = () => {
if (from === 'ranking') {
router.push('/ranking')
} else if (from === 'list') {
router.push('/list')
} else {
router.push('/cow')
}
}
return (
<div className="space-y-4">
{/* 뒤로가기 버튼 */}
<Button
onClick={handleBack}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground hover:bg-muted -ml-2 gap-1.5"
>
<ArrowLeft className="h-4 w-4" />
<span className="text-sm"></span>
</Button>
{/* 페이지 헤더 카드 */}
<div className="rounded-lg p-6 border bg-slate-50">
<h1 className="text-2xl font-bold mb-2"> </h1>
<p className="text-sm text-muted-foreground"> .</p>
</div>
</div>
)
}

View File

@@ -17,7 +17,7 @@ import {
YAxis
} from 'recharts'
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
// 형질 카테고리 색상 매핑
const CATEGORY_COLORS: Record<string, string> = {
'성장': '#3b82f6', // 블루
'생산': '#f59e0b', // 앰버
@@ -26,71 +26,25 @@ const CATEGORY_COLORS: Record<string, string> = {
'비율': '#ec4899' // 핑크
}
// 형질 비교용 색상 배열
const TRAIT_COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
'#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6',
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
'#f43f5e', '#fb923c', '#fbbf24', '#a3e635', '#4ade80',
'#2dd4bf', '#22d3ee', '#38bdf8', '#60a5fa', '#818cf8',
'#a78bfa', '#c084fc', '#e879f9', '#f472b6', '#fb7185',
'#fdba74', '#fcd34d', '#bef264', '#86efac', '#5eead4'
]
// 정규분포 CDF (누적분포함수) - σ값을 백분위로 변환
// 표준정규분포에서 z값 이하의 확률을 반환 (0~1)
function normalCDF(z: number): number {
// Abramowitz and Stegun 근사법 (오차 < 7.5×10^-8)
const a1 = 0.254829592
const a2 = -0.284496736
const a3 = 1.421413741
const a4 = -1.453152027
const a5 = 1.061405429
const p = 0.3275911
const sign = z < 0 ? -1 : 1
z = Math.abs(z)
const t = 1.0 / (1.0 + p * z)
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-z * z / 2)
return 0.5 * (1.0 + sign * y)
}
// σ값을 상위 백분위(%)로 변환 (예: +1σ → 상위 15.87%)
function sigmaToPercentile(sigma: number): number {
// CDF는 "이하" 확률이므로, 상위 %는 (1 - CDF) * 100
const percentile = (1 - normalCDF(sigma)) * 100
return Math.max(1, Math.min(99, percentile))
}
// σ 값을 등급으로 변환
function getGradeFromSigma(sigmaValue: number): { grade: string; color: string; bg: string } {
if (sigmaValue >= 1) {
return { grade: '우수', color: 'text-green-600', bg: 'bg-green-50' }
} else if (sigmaValue >= -1) {
return { grade: '보통', color: 'text-gray-600', bg: 'bg-gray-100' }
} else {
return { grade: '개선필요', color: 'text-orange-600', bg: 'bg-orange-50' }
}
}
/** 유전체 형질 데이터 타입 */
interface GenomicTrait {
id?: number
traitName?: string
traitCategory?: string
breedVal?: number
percentile?: number
traitVal?: number
traitName?: string // 형질명 (예: 도체중, 등지방두께)
traitCategory?: string // 형질 카테고리 (성장/생산/체형/무게/비율)
breedVal?: number // 육종가 값
percentile?: number // 백분위 순위
traitVal?: number // 형질 값 (EPD)
}
// 형질별 비교 데이터 타입
/** 형질별 농가/보은군 비교 데이터 */
interface TraitComparison {
trait: string
shortName: string
myFarm: number // 농가 평균
region: number // 보은군 평균
diff: number // 차이
trait: string // 형질명
shortName: string // 짧은 형질명 (차트 표시용)
myFarm: number // 농가 평균
region: number // 보은군 평균
diff: number // 농가와 보은군 간 차이
}
interface NormalDistributionChartProps {

View File

@@ -1,235 +0,0 @@
'use client'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { User, CheckCircle2, BarChart3 } from "lucide-react"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait as GenomeTraitType } from "@/types/genome.types"
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend, Tooltip as RechartsTooltip } from 'recharts'
interface CowCompareModalProps {
isOpen: boolean
onClose: () => void
compareCowsData: { cow: CowDetail; genome: GenomeTraitType[] }[]
transformGenomeData: (genomeData: GenomeTraitType[]) => any[]
CATEGORIES: string[]
TRAIT_COLORS: string[]
}
export function CowCompareModal({
isOpen,
onClose,
compareCowsData,
transformGenomeData,
CATEGORIES,
TRAIT_COLORS
}: CowCompareModalProps) {
if (!isOpen || compareCowsData.length === 0) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white w-full max-w-6xl max-h-[90vh] rounded-lg overflow-hidden flex flex-col">
{/* 헤더 */}
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" />
</h2>
<p className="text-sm text-muted-foreground mt-1">
{compareCowsData.length}
</p>
</div>
<Button onClick={onClose} variant="ghost" size="sm">
</Button>
</div>
{/* 비교 내용 */}
<div className="flex-1 overflow-y-auto p-4">
{/* 개체 카드들 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{compareCowsData.map((cowData, idx) => {
const genomeTraits = transformGenomeData(cowData.genome)
const avgBreedVal = genomeTraits.length > 0
? genomeTraits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / genomeTraits.length
: 0
const avgPercentile = genomeTraits.length > 0
? genomeTraits.reduce((sum: number, t: any) => sum + t.percentile, 0) / genomeTraits.length
: 0
return (
<Card key={cowData.cow.pkCowNo} className={idx === 0 ? 'border-2 border-primary' : ''}>
<CardHeader className={idx === 0 ? 'bg-primary/5' : ''}>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{cowData.cow.cowId || cowData.cow.pkCowNo}</CardTitle>
{cowData.cow.cowId && (
<CardDescription>{cowData.cow.cowId}</CardDescription>
)}
</div>
{idx === 0 && (
<Badge className="bg-primary text-white"> </Badge>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
<div className="space-y-3">
<div className="text-center p-3 bg-muted/30 rounded-lg">
<div className="text-xs text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold text-primary">
{avgBreedVal > 0 ? '+' : ''}{avgBreedVal.toFixed(2)}σ
</div>
<div className="text-xs text-muted-foreground mt-1">
{(100 - avgPercentile).toFixed(1)}%
</div>
</div>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-semibold">
{cowData.cow.cowBirthDt
? new Date(cowData.cow.cowBirthDt).toLocaleDateString('ko-KR')
: 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-semibold">
{cowData.cow.age ? `${cowData.cow.age}` : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<span className="font-semibold">{genomeTraits.length}</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* 카테고리별 비교 차트 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<RadarChart>
<PolarGrid stroke="#e2e8f0" />
<PolarAngleAxis
dataKey="category"
tick={{ fill: '#64748b', fontSize: 12 }}
/>
<PolarRadiusAxis
angle={90}
domain={[-1, 2]}
tick={{ fill: '#64748b', fontSize: 10 }}
/>
<RechartsTooltip />
<Legend />
{compareCowsData.map((cowData, idx) => {
const genomeTraits = transformGenomeData(cowData.genome)
const categoryData = CATEGORIES.map(cat => {
const traits = genomeTraits.filter((t: any) => t.category === cat)
const avgBreedVal = traits.length > 0
? traits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / traits.length
: 0
return {
category: cat,
value: avgBreedVal
}
})
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
return (
<Radar
key={cowData.cow.pkCowNo}
name={idx === 0 ? `${cowLabel} (현재)` : cowLabel}
dataKey="value"
stroke={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
fill={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
fillOpacity={idx === 0 ? 0.6 : 0.3}
strokeWidth={idx === 0 ? 3 : 2}
/>
)
})}
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* 형질별 비교 테이블 */}
<Card className="mt-4">
<CardHeader>
<CardTitle> (Top 10)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b-2">
<tr>
<th className="px-3 py-2 text-left font-semibold sticky left-0 bg-muted/50"></th>
{compareCowsData.map((cowData, idx) => {
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
return (
<th key={cowData.cow.pkCowNo} className="px-3 py-2 text-center font-semibold">
{idx === 0 ? `${cowLabel}\n(현재)` : cowLabel}
</th>
)
})}
</tr>
</thead>
<tbody className="divide-y">
{transformGenomeData(compareCowsData[0].genome).slice(0, 10).map((trait: any) => (
<tr key={trait.id} className="hover:bg-muted/30">
<td className="px-3 py-2 font-medium sticky left-0 bg-white">
{trait.name}
</td>
{compareCowsData.map((cowData) => {
const genomeTraits = transformGenomeData(cowData.genome)
const matchTrait = genomeTraits.find((t: any) => t.name === trait.name)
return (
<td key={cowData.cow.pkCowNo} className="px-3 py-2 text-center">
{matchTrait ? (
<div>
<div className={`font-bold ${matchTrait.breedVal > 0 ? 'text-primary' : 'text-muted-foreground'}`}>
{matchTrait.breedVal > 0 ? '+' : ''}{matchTrait.breedVal.toFixed(2)}σ
</div>
<div className="text-xs text-muted-foreground">
{matchTrait.percentile.toFixed(1)}%
</div>
</div>
) : (
<span className="text-muted-foreground">N/A</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
{/* 푸터 */}
<div className="p-4 border-t border-border bg-muted/30 flex justify-end">
<Button onClick={onClose}>
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,159 +0,0 @@
'use client'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { User, CheckCircle2 } from "lucide-react"
import { CowDetail } from "@/types/cow.types"
interface CowSelectSheetProps {
isOpen: boolean
onClose: () => void
farmCows: CowDetail[]
selectedCowsForCompare: number[]
toggleCowForCompare: (cowNo: number) => void
onCompare: () => void
onClearSelection: () => void
}
export function CowSelectSheet({
isOpen,
onClose,
farmCows,
selectedCowsForCompare,
toggleCowForCompare,
onCompare,
onClearSelection
}: CowSelectSheetProps) {
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center md:justify-center">
<div className="bg-white w-full md:max-w-3xl md:max-h-[80vh] md:rounded-lg overflow-hidden flex flex-col max-h-[90vh] rounded-t-2xl md:rounded-2xl">
{/* 헤더 */}
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<User className="w-5 h-5 text-primary" />
</h2>
<p className="text-sm text-muted-foreground mt-1">
({selectedCowsForCompare.length}/{farmCows.length})
</p>
</div>
<Button onClick={onClose} variant="ghost" size="sm">
</Button>
</div>
{/* 개체 목록 */}
<div className="flex-1 overflow-y-auto p-4">
{farmCows.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p> </p>
</div>
) : (
<div className="space-y-2">
{farmCows.map((farmCow) => {
const isSelected = selectedCowsForCompare.includes(farmCow.pkCowNo)
return (
<div
key={farmCow.pkCowNo}
onClick={() => toggleCowForCompare(farmCow.pkCowNo)}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
}`}
>
<div className="flex items-start gap-3">
{/* 체크박스 */}
<div className="flex items-center pt-1">
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary'
: 'border-muted-foreground'
}`}
>
{isSelected && (
<CheckCircle2 className="w-4 h-4 text-white" />
)}
</div>
</div>
{/* 개체 정보 */}
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-bold text-foreground">{farmCow.cowId || farmCow.pkCowNo}</h4>
{farmCow.cowId && (
<p className="text-sm text-muted-foreground">{farmCow.cowId}</p>
)}
</div>
</div>
{/* 상세 정보 */}
<div className="grid grid-cols-3 gap-2 text-xs mt-2">
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.cowBirthDt
? new Date(farmCow.cowBirthDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
})
: 'N/A'}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.age ? `${farmCow.age}` : 'N/A'}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.cowSex === 'F' ? '암' : farmCow.cowSex === 'M' ? '수' : 'N/A'}
</div>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* 푸터 */}
<div className="p-4 border-t border-border bg-muted/30 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedCowsForCompare.length}
</div>
<div className="flex gap-2">
<Button
onClick={onClearSelection}
variant="ghost"
size="sm"
disabled={selectedCowsForCompare.length === 0}
>
</Button>
<Button
onClick={onCompare}
className="gap-2"
disabled={selectedCowsForCompare.length === 0}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,301 +0,0 @@
'use client'
import { useState, useMemo, useEffect } from "react"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Search, Loader2 } from "lucide-react"
import { geneApi } from "@/lib/api/gene.api"
/**
* 마커 데이터 타입 (API에서 받아오는 형식)
*/
interface MarkerData {
pkMarkerNo: number
markerNm: string
markerDesc: string
markerTypeCd: string
relatedTrait: string
favorableAllele: string
useYn: string
markerTypeInfo?: {
pkTypeCd: string
typeNm: string
typeDesc: string
}
}
/**
* 유전자 필터에서 사용할 간소화된 타입
*/
interface GeneOption {
name: string
description: string
type: 'QTY' | 'QLT'
relatedTrait: string
}
interface GeneFilterModalProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedGenes: string[]
onConfirm: (genes: string[]) => void
}
export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }: GeneFilterModalProps) {
const [tempSelectedGenes, setTempSelectedGenes] = useState<string[]>(selectedGenes)
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'type'>('name')
const [allMarkers, setAllMarkers] = useState<GeneOption[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// API에서 마커 목록 가져오기
useEffect(() => {
if (open) {
fetchMarkers()
}
}, [open])
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const fetchMarkers = async () => {
// try {
// setLoading(true)
// setError(null)
// const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
// // API 데이터를 GeneOption 형식으로 변환
// const geneOptions: GeneOption[] = markers.map(marker => ({
// name: marker.markerNm,
// description: marker.relatedTrait || marker.markerDesc || '',
// type: marker.markerTypeCd as 'QTY' | 'QLT',
// relatedTrait: marker.relatedTrait || ''
// }))
// setAllMarkers(geneOptions)
// } catch (err) {
// console.error('Failed to fetch markers:', err)
// setError('유전자 목록을 불러오는데 실패했습니다.')
// } finally {
// setLoading(false)
// }
}
// 육량형/육질형 필터링
const quantityGenes = useMemo(() => {
return allMarkers.filter(g => g.type === 'QTY').sort((a, b) => a.name.localeCompare(b.name))
}, [allMarkers])
const qualityGenes = useMemo(() => {
return allMarkers.filter(g => g.type === 'QLT').sort((a, b) => a.name.localeCompare(b.name))
}, [allMarkers])
// 전체 유전자 목록 (정렬)
const allGenes = useMemo(() => {
return [...allMarkers].sort((a, b) => {
if (sortBy === 'type') {
if (a.type !== b.type) {
return a.type.localeCompare(b.type)
}
}
return a.name.localeCompare(b.name)
})
}, [allMarkers, sortBy])
const filteredGenes = useMemo(() => {
if (!searchQuery) return allGenes
return allGenes.filter(gene =>
gene.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
gene.description.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [allGenes, searchQuery])
const toggleGene = (geneName: string) => {
setTempSelectedGenes(prev =>
prev.includes(geneName)
? prev.filter(g => g !== geneName)
: [...prev, geneName]
)
}
const handleConfirm = () => {
onConfirm(tempSelectedGenes)
onOpenChange(false)
}
const handleCancel = () => {
setTempSelectedGenes(selectedGenes)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2"> ...</span>
</div>
) : error ? (
<div className="text-center py-8 text-destructive">
<p>{error}</p>
<Button variant="outline" className="mt-4" onClick={fetchMarkers}>
</Button>
</div>
) : (
<Tabs defaultValue="quick" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="quick"> ({allMarkers.length})</TabsTrigger>
<TabsTrigger value="all"> </TabsTrigger>
</TabsList>
<TabsContent value="quick" className="space-y-4">
<Tabs defaultValue="quantity" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="quantity"> ({quantityGenes.length})</TabsTrigger>
<TabsTrigger value="quality"> ({qualityGenes.length})</TabsTrigger>
</TabsList>
<TabsContent value="quantity">
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{quantityGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
quantityGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`quick-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`quick-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name}
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="quality">
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{qualityGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
qualityGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`quick-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`quick-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name}
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="all" className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="유전자명 또는 설명 검색..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={() => setSortBy(sortBy === 'name' ? 'type' : 'name')}
>
{sortBy === 'type' ? '타입순' : '이름순'}
</Button>
</div>
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{filteredGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
filteredGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`all-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`all-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name} <span className="text-xs text-muted-foreground">({gene.type === 'QTY' ? '육량형' : '육질형'})</span>
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
)}
<div className="text-sm text-muted-foreground">
: {tempSelectedGenes.length}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,246 +0,0 @@
'use client'
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Search, X, Filter, Sparkles } from "lucide-react"
import { geneApi } from "@/lib/api/gene.api"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface GeneSearchDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedGenes: string[]
onGenesChange: (genes: string[]) => void
}
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
const [allGenes, setAllGenes] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
// 모달 열릴 때 전체 유전자 로드
useEffect(() => {
if (open) {
loadAllGenes()
}
}, [open])
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const loadAllGenes = async () => {
// try {
// setLoading(true)
// const genes = await geneApi.getAllMarkers()
// setAllGenes(genes)
// } catch {
// // 유전자 로드 실패 시 빈 배열 유지
// } finally {
// setLoading(false)
// }
}
// 검색 및 필터링
const filteredGenes = allGenes.filter((gene) => {
// 타입 필터
if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) {
return false
}
// 검색어 필터
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
gene.markerNm.toLowerCase().includes(query) ||
gene.markerDesc?.toLowerCase().includes(query) ||
gene.relatedTrait?.toLowerCase().includes(query)
)
}
return true
})
const toggleGene = (markerNm: string) => {
if (selectedGenes.includes(markerNm)) {
onGenesChange(selectedGenes.filter(g => g !== markerNm))
} else {
onGenesChange([...selectedGenes, markerNm])
}
}
const selectAllFiltered = () => {
const newGenes = [...selectedGenes]
filteredGenes.forEach(gene => {
if (!newGenes.includes(gene.markerNm)) {
newGenes.push(gene.markerNm)
}
})
onGenesChange(newGenes)
}
const clearAll = () => {
onGenesChange([])
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col p-0 gap-0">
{/* 헤더 */}
<DialogHeader className="px-5 pt-5 pb-3 border-b flex-shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-1.5 bg-primary/10 rounded-lg">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div>
<DialogTitle className="text-lg font-bold"> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
<span className="font-semibold text-foreground">{allGenes.length.toLocaleString()}</span> / <span className="font-semibold text-primary">{selectedGenes.length}</span>
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* 검색 및 필터 */}
<div className="px-4 py-3 space-y-3 flex-shrink-0 bg-muted/20">
{/* 검색바 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="유전자명, 설명, 관련 형질로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9 h-10 text-sm bg-background"
autoFocus
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 hover:bg-muted rounded-full p-1 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* 필터 탭 및 액션 버튼 */}
<div className="flex items-center justify-between gap-3">
<Tabs value={filterType} onValueChange={(v) => setFilterType(v as any)} className="flex-1">
<TabsList className="w-full grid grid-cols-3 h-9">
<TabsTrigger value="ALL" className="text-xs">
<span className="ml-1 font-semibold">({allGenes.length})</span>
</TabsTrigger>
<TabsTrigger value="QTY" className="text-xs">
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb]"></div>
<span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QTY').length})</span>
</div>
</TabsTrigger>
<TabsTrigger value="QLT" className="text-xs">
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-slate-400"></div>
<span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QLT').length})</span>
</div>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllFiltered}
disabled={filteredGenes.length === 0}
className="h-8 text-xs px-3"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={clearAll}
disabled={selectedGenes.length === 0}
className="h-8 text-xs px-3"
>
</Button>
</div>
</div>
</div>
{/* 유전자 목록 */}
<div className="flex-1 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-2 border-slate-200 border-t-[#2563eb] mx-auto mb-4"></div>
<p className="text-muted-foreground font-medium"> ...</p>
</div>
</div>
) : filteredGenes.length > 0 ? (
<ScrollArea className="h-full px-4">
<div className="flex flex-wrap gap-1.5 py-3">
{filteredGenes.map((gene) => {
const isSelected = selectedGenes.includes(gene.markerNm)
const isQuantity = gene.markerTypeCd === 'QTY'
return (
<Badge
key={gene.markerNm}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer transition-colors text-xs h-7 px-2.5 ${
isSelected
? isQuantity
? 'bg-[#2563eb] text-white hover:bg-[#2563eb]/90 border-[#2563eb]'
: 'bg-slate-400 text-white hover:bg-slate-500 border-slate-400'
: isQuantity
? 'border-[#2563eb]/40 text-[#2563eb] hover:bg-[#2563eb]/5 hover:border-[#2563eb]'
: 'border-slate-300 text-slate-600 hover:bg-slate-50 hover:border-slate-400'
}`}
onClick={() => toggleGene(gene.markerNm)}
title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`}
>
{gene.markerNm}
</Badge>
)
})}
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center text-muted-foreground">
<Filter className="h-16 w-16 mx-auto mb-4 opacity-40" />
<p className="text-lg font-semibold"> </p>
<p className="text-sm mt-2"> </p>
</div>
</div>
)}
</div>
{/* 하단 버튼 */}
<div className="px-4 py-3 border-t flex justify-between items-center flex-shrink-0 bg-muted/20">
<div className="text-sm">
{searchQuery && (
<span className="text-muted-foreground mr-3">
: <span className="font-semibold text-foreground">{filteredGenes.length.toLocaleString()}</span>
</span>
)}
<span className="text-muted-foreground">
: <span className="font-bold text-primary text-base">{selectedGenes.length}</span>
</span>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 px-4">
</Button>
<Button onClick={() => onOpenChange(false)} className="h-9 px-4">
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,181 +0,0 @@
'use client'
import { PieChart as PieChartIcon } from "lucide-react"
import { useEffect, useState } from "react"
import apiClient from "@/lib/api-client"
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
interface DistributionData {
name: string
value: number
color: string
range: string
description: string
}
interface GenomeDistributionDonutProps {
farmNo: number | null
}
export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) {
const [data, setData] = useState<DistributionData[]>([])
const [totalCount, setTotalCount] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const response = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [
{ traitNm: '도체중', weight: 0.25 },
{ traitNm: '근내지방도', weight: 0.25 },
{ traitNm: '등심단면적', weight: 0.25 },
{ traitNm: '등지방두께', weight: 0.25 },
]
}
})
const result = response.data || response
const items = result.items || []
setTotalCount(items.length)
const distribution = {
top: 0, // 0σ 이상
middle: 0, // -1.0σ ~ 0σ
bottom: 0 // -1.0σ 이하
}
items.forEach((item: any) => {
const score = item.sortValue || 0
if (score >= 0) distribution.top++
else if (score >= -1.0) distribution.middle++
else distribution.bottom++
})
setData([
{ name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' },
{ name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' },
{ name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' },
].filter(d => d.value > 0))
} catch (error) {
console.error('분포 데이터 로드 실패:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[280px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center shadow-md shadow-violet-500/20">
<PieChartIcon className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> {totalCount} </p>
</div>
</div>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">{totalCount}</span>
</div>
</div>
{/* 차트 */}
<div className="p-5">
<div className="flex flex-col items-center">
<div className="relative w-[180px] h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={54}
outerRadius={86}
paddingAngle={3}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="white" strokeWidth={2} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0].payload
return (
<div className="bg-slate-900 px-4 py-3 rounded-xl shadow-xl border border-slate-700">
<p className="text-white font-bold mb-2">{item.name}</p>
<p className="text-slate-200 text-sm">{item.description}</p>
<div className="mt-2 pt-2 border-t border-slate-700">
<p className="text-slate-300 text-sm">{item.value} ({Math.round(item.value / totalCount * 100)}%)</p>
<p className="text-slate-400 text-xs mt-1">{item.range}</p>
</div>
</div>
)
}
return null
}}
/>
</PieChart>
</ResponsiveContainer>
{/* 중앙 텍스트 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-slate-900">{totalCount}</span>
<span className="text-xs text-slate-500 mt-1"></span>
</div>
</div>
{/* 범례 */}
<div className="w-full mt-5 pt-4 border-t border-slate-100">
<div className="space-y-2.5">
{data.map((item, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-100 hover:bg-slate-100/50 transition-colors">
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-full flex-shrink-0 shadow-sm" style={{ backgroundColor: item.color }}></div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900">{item.name}</span>
<span className="text-[10px] text-slate-500">{item.description}</span>
</div>
</div>
<div className="text-right">
<p className="text-base font-bold text-slate-900">{item.value}<span className="text-xs text-slate-500 font-normal ml-0.5"></span></p>
<p className="text-[10px] text-slate-500">{Math.round(item.value / totalCount * 100)}%</p>
</div>
</div>
))}
</div>
<p className="text-[10px] text-slate-400 mt-4 text-center leading-relaxed">
σ() <br/>
0
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,250 +0,0 @@
'use client'
import { Target } from "lucide-react"
import { useEffect, useState } from "react"
import apiClient from "@/lib/api-client"
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
interface TraitScore {
trait: string
diff: number
myFarm: number
region: number
}
interface GenomeRadarChartProps {
farmNo: number | null
}
export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) {
const [data, setData] = useState<TraitScore[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '도체중', key: '도체중' },
{ name: '근내지방', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방', key: '등지방두께' },
{ name: '12개월체중', key: '12개월령체중' },
]
const results: TraitScore[] = []
for (const trait of traits) {
try {
const farmResponse = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const globalResponse = await apiClient.post('/cow/ranking/global', {
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const farmResult = farmResponse.data || farmResponse
const globalResult = globalResponse.data || globalResponse
const farmScores = farmResult.items?.map((item: any) => {
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
return traitDetail?.value ?? item.sortValue ?? 0
}) || []
const farmAvgScore = farmScores.length > 0
? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length
: 0
const globalScores = globalResult.items?.map((item: any) => {
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
return traitDetail?.value ?? item.sortValue ?? 0
}) || []
const regionAvgScore = globalScores.length > 0
? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length
: 0
const diff = farmAvgScore - regionAvgScore
results.push({
trait: trait.name,
diff: parseFloat(diff.toFixed(2)),
myFarm: parseFloat(farmAvgScore.toFixed(2)),
region: parseFloat(regionAvgScore.toFixed(2))
})
} catch (error) {
console.error(`형질 ${trait.name} 로드 실패:`, error)
}
}
setData(results)
} catch (error) {
console.error('레이더 차트 데이터 로드 실패:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[280px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
const validDiffs = data.filter(d => !isNaN(d.diff))
const avgDiff = validDiffs.length > 0
? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length
: 0
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center shadow-md shadow-sky-500/20">
<Target className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> </p>
</div>
</div>
<div className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${avgDiff >= 0
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: 'bg-red-50 text-red-600 border-red-200'
}`}>
{avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ
</div>
</div>
</div>
{/* 차트 */}
<div className="p-5">
<div className="bg-gradient-to-br from-slate-50 to-blue-50/30 rounded-xl p-4">
<ResponsiveContainer width="100%" height={240}>
<RadarChart data={data} margin={{ top: 30, right: 40, bottom: 30, left: 40 }}>
<defs>
<linearGradient id="radarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1482B0" stopOpacity={0.4} />
<stop offset="100%" stopColor="#1482B0" stopOpacity={0.1} />
</linearGradient>
</defs>
<PolarGrid
stroke="#cbd5e1"
strokeWidth={1.5}
strokeOpacity={0.5}
/>
<PolarAngleAxis
dataKey="trait"
tick={{ fontSize: 13, fill: '#334155', fontWeight: 600 }}
tickLine={false}
/>
<PolarRadiusAxis
angle={90}
domain={[-1.5, 1.5]}
tick={{ fontSize: 10, fill: '#64748b' }}
tickCount={4}
axisLine={false}
/>
<Radar
name="보은군 평균"
dataKey={() => 0}
stroke="#94a3b8"
fill="none"
strokeWidth={2}
strokeDasharray="5 3"
strokeOpacity={0.6}
/>
<Radar
name="내농장"
dataKey="diff"
stroke="#1482B0"
fill="url(#radarGradient)"
strokeWidth={3}
dot={{
fill: '#fff',
strokeWidth: 3,
stroke: '#1482B0',
r: 6,
strokeOpacity: 1
}}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0]?.payload
const diff = item?.diff ?? 0
const myFarm = item?.myFarm ?? 0
const region = item?.region ?? 0
return (
<div className="bg-slate-900 px-4 py-3 rounded-xl text-sm shadow-xl border border-slate-700">
<p className="text-white font-bold mb-2">{item?.trait}</p>
<div className="space-y-1 text-slate-300 text-xs">
<p>: <span className="text-[#1482B0] font-semibold">{myFarm > 0 ? '+' : ''}{myFarm}σ</span></p>
<p>: <span className="text-slate-400">{region > 0 ? '+' : ''}{region}σ</span></p>
</div>
<div className={`mt-2 pt-2 border-t border-slate-700 font-bold ${diff >= 0.3 ? 'text-emerald-400' :
diff <= -0.3 ? 'text-amber-400' :
'text-slate-300'
}`}>
{diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ
</div>
</div>
)
}
return null
}}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{/* 범례 */}
<div className="flex items-center justify-center gap-6 mt-4 pb-3">
<div className="flex items-center gap-2">
<div className="w-5 h-0.5 bg-slate-400 opacity-60" style={{ borderTop: '2px dashed #94a3b8' }}></div>
<span className="text-xs text-slate-600"> </span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-white border-3 border-[#1482B0] shadow-sm"></div>
<span className="text-xs text-slate-900 font-semibold"> </span>
</div>
</div>
{/* 형질별 수치 */}
<div className="grid grid-cols-5 gap-2 mt-4 pt-4 border-t border-slate-200">
{data.map((item, idx) => (
<div key={idx} className="text-center">
<p className="text-[10px] text-slate-500 mb-1 truncate font-medium">{item.trait}</p>
<p className={`text-sm font-bold ${item.diff >= 0.3 ? 'text-emerald-600' :
item.diff <= -0.3 ? 'text-amber-600' :
'text-slate-700'
}`}>
{item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)}
</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,208 +0,0 @@
'use client'
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
import { useEffect, useState } from "react"
import apiClient from "@/lib/api-client"
interface GenomeData {
trait: string
score: number
type: string
}
interface GenomeStrengthsWeaknessesProps {
farmNo?: number | null
}
export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) {
const [allMetrics, setAllMetrics] = useState<GenomeData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchTraitScores = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '도체중', key: '도체중' },
{ name: '근내지방도', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방두께', key: '등지방두께' },
{ name: '12개월령체중', key: '12개월령체중' },
{ name: '체고', key: '체고' },
]
const traitScores: GenomeData[] = []
for (const trait of traits) {
try {
const rankingRequest = {
filterOptions: { farmNo: farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
}
const response = await apiClient.post('/cow/ranking', rankingRequest)
const rankingResult = response.data || response
const scores = rankingResult.items?.map((item: any) => item.sortValue) || []
const avgScore = scores.length > 0
? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length
: 0
traitScores.push({
trait: trait.name,
score: parseFloat(avgScore.toFixed(2)),
type: '유전체'
})
} catch (error) {
console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error)
}
}
setAllMetrics(traitScores)
} catch (error) {
console.error('형질 점수 로드 실패:', error)
setAllMetrics([])
} finally {
setLoading(false)
}
}
fetchTraitScores()
}, [farmNo])
const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3)
const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3)
if (loading) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{[1, 2].map((i) => (
<div key={i} className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[140px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
))}
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 강점 */}
<div
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
>
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900"> </h3>
<p className="text-xs text-slate-500 mt-0.5"> </p>
</div>
</div>
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold border border-emerald-200 shadow-sm">TOP 3</span>
</div>
</div>
<div className="p-5">
{strengths.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-3">
{strengths.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between p-4 bg-gradient-to-r from-emerald-50 to-transparent rounded-xl hover:from-emerald-100 hover:to-emerald-50 transition-all duration-200 border-2 border-emerald-100 group-hover:border-emerald-200 shadow-sm"
>
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
idx === 0 ? 'bg-gradient-to-br from-emerald-500 to-emerald-600 text-white shadow-emerald-500/30' :
idx === 1 ? 'bg-emerald-200 text-emerald-800 border-2 border-emerald-300' :
'bg-emerald-100 text-emerald-700 border-2 border-emerald-200'
}`}>
{idx + 1}
</span>
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
</div>
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-emerald-200 shadow-sm">
<span className="text-lg font-bold text-emerald-600">
{item.score > 0 ? '+' : ''}{item.score}
</span>
<span className="text-xs text-emerald-500 font-semibold">σ</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 약점 */}
<div
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
>
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-amber-50 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
<TrendingDown className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900"> </h3>
<p className="text-xs text-slate-500 mt-0.5"> </p>
</div>
</div>
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold border border-amber-200 shadow-sm">BOTTOM 3</span>
</div>
</div>
<div className="p-5">
{weaknesses.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-3">
{weaknesses.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between p-4 bg-gradient-to-r from-amber-50 to-transparent rounded-xl hover:from-amber-100 hover:to-amber-50 transition-all duration-200 border-2 border-amber-100 group-hover:border-amber-200 shadow-sm"
>
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
idx === 0 ? 'bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-amber-500/30' :
idx === 1 ? 'bg-amber-200 text-amber-800 border-2 border-amber-300' :
'bg-amber-100 text-amber-700 border-2 border-amber-200'
}`}>
{idx + 1}
</span>
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
</div>
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-amber-200 shadow-sm">
<span className="text-lg font-bold text-amber-600">
{item.score > 0 ? '+' : ''}{item.score}
</span>
<span className="text-xs text-amber-500 font-semibold">σ</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,199 +0,0 @@
'use client'
import { BarChart3 } from "lucide-react"
import { useEffect, useState } from "react"
import apiClient from "@/lib/api-client"
interface TraitData {
trait: string
regional: number
myFarm: number
}
interface GenomeTraitsTableProps {
farmNo?: number | null
}
export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) {
const [traitData, setTraitData] = useState<TraitData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchTraitData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '12개월령체중', key: '12개월령체중' },
{ name: '도체중', key: '도체중' },
{ name: '근내지방도', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방두께', key: '등지방두께' },
]
const results: TraitData[] = []
for (const trait of traits) {
try {
const farmResponse = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo: farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const globalResponse = await apiClient.post('/cow/ranking/global', {
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const farmResult = farmResponse.data || farmResponse
const globalResult = globalResponse.data || globalResponse
const farmScores = farmResult.items?.map((item: any) => item.sortValue) || []
const farmAvg = farmScores.length > 0
? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length
: 0
const globalScores = globalResult.items?.map((item: any) => item.sortValue) || []
const regionalAvg = globalScores.length > 0
? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length
: 0
results.push({
trait: trait.name,
myFarm: parseFloat(farmAvg.toFixed(2)),
regional: parseFloat(regionalAvg.toFixed(2))
})
} catch (error) {
console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error)
}
}
setTraitData(results)
} catch (error) {
console.error('[형질 테이블] 전체 데이터 로드 실패:', error)
setTraitData([])
} finally {
setLoading(false)
}
}
fetchTraitData()
}, [farmNo])
const getTraitShortName = (name: string) => {
const shortNames: Record<string, string> = {
'12개월령체중': '12개월령체중',
'등심단면적': '등심단면적',
'등지방두께': '등지방두께',
'근내지방도': '근내지방도',
'도체중': '도체중'
}
return shortNames[name] || name
}
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[180px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1482B0] to-[#0d5f82] flex items-center justify-center shadow-md shadow-[#1482B0]/20">
<BarChart3 className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> </p>
</div>
</div>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">5 </span>
</div>
</div>
{/* 콘텐츠 */}
<div className="p-5">
{traitData.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-4">
{traitData.map((item, idx) => {
const diff = item.myFarm - item.regional
const isPositive = diff >= 0
// σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100)
const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100))
return (
<div key={idx} className="space-y-1.5">
{/* 형질명 + 차이 */}
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-800">
{getTraitShortName(item.trait)}
</span>
<span className={`text-xs font-bold px-2.5 py-1 rounded-lg ${diff >= 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' :
diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' :
'bg-slate-50 text-slate-700 border border-slate-200'
}`}>
{diff > 0 ? '+' : ''}{diff.toFixed(1)}σ
</span>
</div>
{/* 비교 바 */}
<div className="relative h-6 bg-slate-100 rounded-lg overflow-hidden shadow-inner">
{/* 보은군 바 */}
<div
className="absolute top-0 left-0 h-full bg-slate-300/80 rounded-lg transition-all duration-500"
style={{ width: `${toPercent(item.regional)}%` }}
/>
{/* 내농장 바 */}
<div
className={`absolute top-0 left-0 h-full rounded-lg transition-all duration-500 shadow-sm ${isPositive
? 'bg-gradient-to-r from-[#1482B0] via-[#1482B0] to-[#0d5f82]'
: 'bg-gradient-to-r from-slate-400 to-slate-500'
}`}
style={{ width: `${toPercent(item.myFarm)}%` }}
/>
</div>
{/* 라벨 */}
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[#1482B0] shadow-sm"></div>
<span className="text-slate-600 font-medium">
<span className="font-bold text-slate-900">{item.myFarm > 0 ? '+' : ''}{item.myFarm}σ</span>
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 shadow-sm"></div>
<span className="text-slate-500 font-medium">
{item.regional > 0 ? '+' : ''}{item.regional}σ
</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}