주석수정 및 코드정리
This commit is contained in:
@@ -1,44 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ArrowLeft } from "lucide-react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
|
|
||||||
interface CowHeaderProps {
|
|
||||||
from?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CowHeader({ from }: CowHeaderProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (from === 'ranking') {
|
|
||||||
router.push('/ranking')
|
|
||||||
} else if (from === 'list') {
|
|
||||||
router.push('/list')
|
|
||||||
} else {
|
|
||||||
router.push('/cow')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 뒤로가기 버튼 */}
|
|
||||||
<Button
|
|
||||||
onClick={handleBack}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-muted -ml-2 gap-1.5"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
<span className="text-sm">목록으로</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 페이지 헤더 카드 */}
|
|
||||||
<div className="rounded-lg p-6 border bg-slate-50">
|
|
||||||
<h1 className="text-2xl font-bold mb-2">개체 상세 정보</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">개체의 기본 정보와 분석 현황을 확인할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
YAxis
|
YAxis
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
|
|
||||||
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
|
// 형질 카테고리별 색상 매핑
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
'성장': '#3b82f6', // 블루
|
'성장': '#3b82f6', // 블루
|
||||||
'생산': '#f59e0b', // 앰버
|
'생산': '#f59e0b', // 앰버
|
||||||
@@ -26,71 +26,25 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
'비율': '#ec4899' // 핑크
|
'비율': '#ec4899' // 핑크
|
||||||
}
|
}
|
||||||
|
|
||||||
// 형질 비교용 색상 배열
|
|
||||||
const TRAIT_COLORS = [
|
|
||||||
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
|
|
||||||
'#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6',
|
|
||||||
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
|
|
||||||
'#f43f5e', '#fb923c', '#fbbf24', '#a3e635', '#4ade80',
|
|
||||||
'#2dd4bf', '#22d3ee', '#38bdf8', '#60a5fa', '#818cf8',
|
|
||||||
'#a78bfa', '#c084fc', '#e879f9', '#f472b6', '#fb7185',
|
|
||||||
'#fdba74', '#fcd34d', '#bef264', '#86efac', '#5eead4'
|
|
||||||
]
|
|
||||||
|
|
||||||
// 정규분포 CDF (누적분포함수) - σ값을 백분위로 변환
|
|
||||||
// 표준정규분포에서 z값 이하의 확률을 반환 (0~1)
|
|
||||||
function normalCDF(z: number): number {
|
|
||||||
// Abramowitz and Stegun 근사법 (오차 < 7.5×10^-8)
|
|
||||||
const a1 = 0.254829592
|
|
||||||
const a2 = -0.284496736
|
|
||||||
const a3 = 1.421413741
|
|
||||||
const a4 = -1.453152027
|
|
||||||
const a5 = 1.061405429
|
|
||||||
const p = 0.3275911
|
|
||||||
|
|
||||||
const sign = z < 0 ? -1 : 1
|
|
||||||
z = Math.abs(z)
|
|
||||||
|
|
||||||
const t = 1.0 / (1.0 + p * z)
|
|
||||||
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-z * z / 2)
|
|
||||||
|
|
||||||
return 0.5 * (1.0 + sign * y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// σ값을 상위 백분위(%)로 변환 (예: +1σ → 상위 15.87%)
|
|
||||||
function sigmaToPercentile(sigma: number): number {
|
|
||||||
// CDF는 "이하" 확률이므로, 상위 %는 (1 - CDF) * 100
|
|
||||||
const percentile = (1 - normalCDF(sigma)) * 100
|
|
||||||
return Math.max(1, Math.min(99, percentile))
|
|
||||||
}
|
|
||||||
|
|
||||||
// σ 값을 등급으로 변환
|
|
||||||
function getGradeFromSigma(sigmaValue: number): { grade: string; color: string; bg: string } {
|
|
||||||
if (sigmaValue >= 1) {
|
|
||||||
return { grade: '우수', color: 'text-green-600', bg: 'bg-green-50' }
|
|
||||||
} else if (sigmaValue >= -1) {
|
|
||||||
return { grade: '보통', color: 'text-gray-600', bg: 'bg-gray-100' }
|
|
||||||
} else {
|
|
||||||
return { grade: '개선필요', color: 'text-orange-600', bg: 'bg-orange-50' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** 유전체 형질 데이터 타입 */
|
||||||
interface GenomicTrait {
|
interface GenomicTrait {
|
||||||
id?: number
|
id?: number
|
||||||
traitName?: string
|
traitName?: string // 형질명 (예: 도체중, 등지방두께)
|
||||||
traitCategory?: string
|
traitCategory?: string // 형질 카테고리 (성장/생산/체형/무게/비율)
|
||||||
breedVal?: number
|
breedVal?: number // 육종가 값
|
||||||
percentile?: number
|
percentile?: number // 백분위 순위
|
||||||
traitVal?: number
|
traitVal?: number // 형질 값 (EPD)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 형질별 비교 데이터 타입
|
/** 형질별 농가/보은군 비교 데이터 */
|
||||||
interface TraitComparison {
|
interface TraitComparison {
|
||||||
trait: string
|
trait: string // 형질명
|
||||||
shortName: string
|
shortName: string // 짧은 형질명 (차트 표시용)
|
||||||
myFarm: number // 농가 평균
|
myFarm: number // 농가 평균 값
|
||||||
region: number // 보은군 평균
|
region: number // 보은군 평균 값
|
||||||
diff: number // 차이
|
diff: number // 농가와 보은군 간 차이
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NormalDistributionChartProps {
|
interface NormalDistributionChartProps {
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { User, CheckCircle2, BarChart3 } from "lucide-react"
|
|
||||||
import { CowDetail } from "@/types/cow.types"
|
|
||||||
import { GenomeTrait as GenomeTraitType } from "@/types/genome.types"
|
|
||||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend, Tooltip as RechartsTooltip } from 'recharts'
|
|
||||||
|
|
||||||
interface CowCompareModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
compareCowsData: { cow: CowDetail; genome: GenomeTraitType[] }[]
|
|
||||||
transformGenomeData: (genomeData: GenomeTraitType[]) => any[]
|
|
||||||
CATEGORIES: string[]
|
|
||||||
TRAIT_COLORS: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CowCompareModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
compareCowsData,
|
|
||||||
transformGenomeData,
|
|
||||||
CATEGORIES,
|
|
||||||
TRAIT_COLORS
|
|
||||||
}: CowCompareModalProps) {
|
|
||||||
if (!isOpen || compareCowsData.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white w-full max-w-6xl max-h-[90vh] rounded-lg overflow-hidden flex flex-col">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
<BarChart3 className="w-5 h-5 text-primary" />
|
|
||||||
개체 유전체 비교
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{compareCowsData.length}개 개체 비교
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={onClose} variant="ghost" size="sm">
|
|
||||||
닫기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 비교 내용 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{/* 개체 카드들 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
|
||||||
{compareCowsData.map((cowData, idx) => {
|
|
||||||
const genomeTraits = transformGenomeData(cowData.genome)
|
|
||||||
const avgBreedVal = genomeTraits.length > 0
|
|
||||||
? genomeTraits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / genomeTraits.length
|
|
||||||
: 0
|
|
||||||
const avgPercentile = genomeTraits.length > 0
|
|
||||||
? genomeTraits.reduce((sum: number, t: any) => sum + t.percentile, 0) / genomeTraits.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={cowData.cow.pkCowNo} className={idx === 0 ? 'border-2 border-primary' : ''}>
|
|
||||||
<CardHeader className={idx === 0 ? 'bg-primary/5' : ''}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">{cowData.cow.cowId || cowData.cow.pkCowNo}</CardTitle>
|
|
||||||
{cowData.cow.cowId && (
|
|
||||||
<CardDescription>{cowData.cow.cowId}</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{idx === 0 && (
|
|
||||||
<Badge className="bg-primary text-white">현재 개체</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-center p-3 bg-muted/30 rounded-lg">
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">종합 육종가</div>
|
|
||||||
<div className="text-2xl font-bold text-primary">
|
|
||||||
{avgBreedVal > 0 ? '+' : ''}{avgBreedVal.toFixed(2)}σ
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
상위 {(100 - avgPercentile).toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">생년월일</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{cowData.cow.cowBirthDt
|
|
||||||
? new Date(cowData.cow.cowBirthDt).toLocaleDateString('ko-KR')
|
|
||||||
: 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">나이</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{cowData.cow.age ? `${cowData.cow.age}세` : 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">평가 형질 수</span>
|
|
||||||
<span className="font-semibold">{genomeTraits.length}개</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 카테고리별 비교 차트 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>카테고리별 육종가 비교</CardTitle>
|
|
||||||
<CardDescription>각 개체의 카테고리별 평균 표준화육종가</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-96">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<RadarChart>
|
|
||||||
<PolarGrid stroke="#e2e8f0" />
|
|
||||||
<PolarAngleAxis
|
|
||||||
dataKey="category"
|
|
||||||
tick={{ fill: '#64748b', fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<PolarRadiusAxis
|
|
||||||
angle={90}
|
|
||||||
domain={[-1, 2]}
|
|
||||||
tick={{ fill: '#64748b', fontSize: 10 }}
|
|
||||||
/>
|
|
||||||
<RechartsTooltip />
|
|
||||||
<Legend />
|
|
||||||
{compareCowsData.map((cowData, idx) => {
|
|
||||||
const genomeTraits = transformGenomeData(cowData.genome)
|
|
||||||
const categoryData = CATEGORIES.map(cat => {
|
|
||||||
const traits = genomeTraits.filter((t: any) => t.category === cat)
|
|
||||||
const avgBreedVal = traits.length > 0
|
|
||||||
? traits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / traits.length
|
|
||||||
: 0
|
|
||||||
return {
|
|
||||||
category: cat,
|
|
||||||
value: avgBreedVal
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
|
|
||||||
return (
|
|
||||||
<Radar
|
|
||||||
key={cowData.cow.pkCowNo}
|
|
||||||
name={idx === 0 ? `${cowLabel} (현재)` : cowLabel}
|
|
||||||
dataKey="value"
|
|
||||||
stroke={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
|
|
||||||
fill={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
|
|
||||||
fillOpacity={idx === 0 ? 0.6 : 0.3}
|
|
||||||
strokeWidth={idx === 0 ? 3 : 2}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</RadarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 형질별 비교 테이블 */}
|
|
||||||
<Card className="mt-4">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>주요 형질 비교 (Top 10)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted/50 border-b-2">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold sticky left-0 bg-muted/50">형질명</th>
|
|
||||||
{compareCowsData.map((cowData, idx) => {
|
|
||||||
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
|
|
||||||
return (
|
|
||||||
<th key={cowData.cow.pkCowNo} className="px-3 py-2 text-center font-semibold">
|
|
||||||
{idx === 0 ? `${cowLabel}\n(현재)` : cowLabel}
|
|
||||||
</th>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{transformGenomeData(compareCowsData[0].genome).slice(0, 10).map((trait: any) => (
|
|
||||||
<tr key={trait.id} className="hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-2 font-medium sticky left-0 bg-white">
|
|
||||||
{trait.name}
|
|
||||||
</td>
|
|
||||||
{compareCowsData.map((cowData) => {
|
|
||||||
const genomeTraits = transformGenomeData(cowData.genome)
|
|
||||||
const matchTrait = genomeTraits.find((t: any) => t.name === trait.name)
|
|
||||||
return (
|
|
||||||
<td key={cowData.cow.pkCowNo} className="px-3 py-2 text-center">
|
|
||||||
{matchTrait ? (
|
|
||||||
<div>
|
|
||||||
<div className={`font-bold ${matchTrait.breedVal > 0 ? 'text-primary' : 'text-muted-foreground'}`}>
|
|
||||||
{matchTrait.breedVal > 0 ? '+' : ''}{matchTrait.breedVal.toFixed(2)}σ
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{matchTrait.percentile.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">N/A</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 */}
|
|
||||||
<div className="p-4 border-t border-border bg-muted/30 flex justify-end">
|
|
||||||
<Button onClick={onClose}>
|
|
||||||
닫기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { User, CheckCircle2 } from "lucide-react"
|
|
||||||
import { CowDetail } from "@/types/cow.types"
|
|
||||||
|
|
||||||
interface CowSelectSheetProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
farmCows: CowDetail[]
|
|
||||||
selectedCowsForCompare: number[]
|
|
||||||
toggleCowForCompare: (cowNo: number) => void
|
|
||||||
onCompare: () => void
|
|
||||||
onClearSelection: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CowSelectSheet({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
farmCows,
|
|
||||||
selectedCowsForCompare,
|
|
||||||
toggleCowForCompare,
|
|
||||||
onCompare,
|
|
||||||
onClearSelection
|
|
||||||
}: CowSelectSheetProps) {
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center md:justify-center">
|
|
||||||
<div className="bg-white w-full md:max-w-3xl md:max-h-[80vh] md:rounded-lg overflow-hidden flex flex-col max-h-[90vh] rounded-t-2xl md:rounded-2xl">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
<User className="w-5 h-5 text-primary" />
|
|
||||||
농장 내 개체 비교
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
비교할 개체를 선택하세요 ({selectedCowsForCompare.length}/{farmCows.length})
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={onClose} variant="ghost" size="sm">
|
|
||||||
닫기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 개체 목록 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{farmCows.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<User className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<p>같은 농장에 다른 개체가 없습니다</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{farmCows.map((farmCow) => {
|
|
||||||
const isSelected = selectedCowsForCompare.includes(farmCow.pkCowNo)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={farmCow.pkCowNo}
|
|
||||||
onClick={() => toggleCowForCompare(farmCow.pkCowNo)}
|
|
||||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
|
||||||
isSelected
|
|
||||||
? 'border-primary bg-primary/5'
|
|
||||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{/* 체크박스 */}
|
|
||||||
<div className="flex items-center pt-1">
|
|
||||||
<div
|
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-primary border-primary'
|
|
||||||
: 'border-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSelected && (
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 개체 정보 */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-bold text-foreground">{farmCow.cowId || farmCow.pkCowNo}</h4>
|
|
||||||
{farmCow.cowId && (
|
|
||||||
<p className="text-sm text-muted-foreground">{farmCow.cowId}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 상세 정보 */}
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs mt-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground">생년월일</div>
|
|
||||||
<div className="font-semibold text-foreground">
|
|
||||||
{farmCow.cowBirthDt
|
|
||||||
? new Date(farmCow.cowBirthDt).toLocaleDateString('ko-KR', {
|
|
||||||
year: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
})
|
|
||||||
: 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground">나이</div>
|
|
||||||
<div className="font-semibold text-foreground">
|
|
||||||
{farmCow.age ? `${farmCow.age}세` : 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground">성별</div>
|
|
||||||
<div className="font-semibold text-foreground">
|
|
||||||
{farmCow.cowSex === 'F' ? '암' : farmCow.cowSex === 'M' ? '수' : 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 */}
|
|
||||||
<div className="p-4 border-t border-border bg-muted/30 flex items-center justify-between">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{selectedCowsForCompare.length}개 개체 선택됨
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={onClearSelection}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={selectedCowsForCompare.length === 0}
|
|
||||||
>
|
|
||||||
선택 해제
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onCompare}
|
|
||||||
className="gap-2"
|
|
||||||
disabled={selectedCowsForCompare.length === 0}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
비교하기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from "react"
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Search, Loader2 } from "lucide-react"
|
|
||||||
import { geneApi } from "@/lib/api/gene.api"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마커 데이터 타입 (API에서 받아오는 형식)
|
|
||||||
*/
|
|
||||||
interface MarkerData {
|
|
||||||
pkMarkerNo: number
|
|
||||||
markerNm: string
|
|
||||||
markerDesc: string
|
|
||||||
markerTypeCd: string
|
|
||||||
relatedTrait: string
|
|
||||||
favorableAllele: string
|
|
||||||
useYn: string
|
|
||||||
markerTypeInfo?: {
|
|
||||||
pkTypeCd: string
|
|
||||||
typeNm: string
|
|
||||||
typeDesc: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유전자 필터에서 사용할 간소화된 타입
|
|
||||||
*/
|
|
||||||
interface GeneOption {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
type: 'QTY' | 'QLT'
|
|
||||||
relatedTrait: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeneFilterModalProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
selectedGenes: string[]
|
|
||||||
onConfirm: (genes: string[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }: GeneFilterModalProps) {
|
|
||||||
const [tempSelectedGenes, setTempSelectedGenes] = useState<string[]>(selectedGenes)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'type'>('name')
|
|
||||||
const [allMarkers, setAllMarkers] = useState<GeneOption[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// API에서 마커 목록 가져오기
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
fetchMarkers()
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
// TODO: 백엔드 /gene/markers API 구현 후 활성화
|
|
||||||
const fetchMarkers = async () => {
|
|
||||||
// try {
|
|
||||||
// setLoading(true)
|
|
||||||
// setError(null)
|
|
||||||
// const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
|
|
||||||
|
|
||||||
// // API 데이터를 GeneOption 형식으로 변환
|
|
||||||
// const geneOptions: GeneOption[] = markers.map(marker => ({
|
|
||||||
// name: marker.markerNm,
|
|
||||||
// description: marker.relatedTrait || marker.markerDesc || '',
|
|
||||||
// type: marker.markerTypeCd as 'QTY' | 'QLT',
|
|
||||||
// relatedTrait: marker.relatedTrait || ''
|
|
||||||
// }))
|
|
||||||
|
|
||||||
// setAllMarkers(geneOptions)
|
|
||||||
// } catch (err) {
|
|
||||||
// console.error('Failed to fetch markers:', err)
|
|
||||||
// setError('유전자 목록을 불러오는데 실패했습니다.')
|
|
||||||
// } finally {
|
|
||||||
// setLoading(false)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 육량형/육질형 필터링
|
|
||||||
const quantityGenes = useMemo(() => {
|
|
||||||
return allMarkers.filter(g => g.type === 'QTY').sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
}, [allMarkers])
|
|
||||||
|
|
||||||
const qualityGenes = useMemo(() => {
|
|
||||||
return allMarkers.filter(g => g.type === 'QLT').sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
}, [allMarkers])
|
|
||||||
|
|
||||||
// 전체 유전자 목록 (정렬)
|
|
||||||
const allGenes = useMemo(() => {
|
|
||||||
return [...allMarkers].sort((a, b) => {
|
|
||||||
if (sortBy === 'type') {
|
|
||||||
if (a.type !== b.type) {
|
|
||||||
return a.type.localeCompare(b.type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
}, [allMarkers, sortBy])
|
|
||||||
|
|
||||||
const filteredGenes = useMemo(() => {
|
|
||||||
if (!searchQuery) return allGenes
|
|
||||||
return allGenes.filter(gene =>
|
|
||||||
gene.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
gene.description.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
}, [allGenes, searchQuery])
|
|
||||||
|
|
||||||
const toggleGene = (geneName: string) => {
|
|
||||||
setTempSelectedGenes(prev =>
|
|
||||||
prev.includes(geneName)
|
|
||||||
? prev.filter(g => g !== geneName)
|
|
||||||
: [...prev, geneName]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
onConfirm(tempSelectedGenes)
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setTempSelectedGenes(selectedGenes)
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>유전자 선택</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
개체를 필터링할 유전자를 선택하세요. 시스템이 자동으로 중요도 순으로 정렬합니다.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<span className="ml-2">유전자 목록 불러오는 중...</span>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-center py-8 text-destructive">
|
|
||||||
<p>{error}</p>
|
|
||||||
<Button variant="outline" className="mt-4" onClick={fetchMarkers}>
|
|
||||||
다시 시도
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Tabs defaultValue="quick" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="quick">타입별 선택 ({allMarkers.length}개)</TabsTrigger>
|
|
||||||
<TabsTrigger value="all">전체 유전자 검색</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="quick" className="space-y-4">
|
|
||||||
<Tabs defaultValue="quantity" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="quantity">육량형 ({quantityGenes.length}개)</TabsTrigger>
|
|
||||||
<TabsTrigger value="quality">육질형 ({qualityGenes.length}개)</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="quantity">
|
|
||||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{quantityGenes.length === 0 ? (
|
|
||||||
<p className="text-center text-muted-foreground py-8">
|
|
||||||
육량형 유전자가 없습니다.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
quantityGenes.map((gene) => (
|
|
||||||
<div key={gene.name} className="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id={`quick-${gene.name}`}
|
|
||||||
checked={tempSelectedGenes.includes(gene.name)}
|
|
||||||
onCheckedChange={() => toggleGene(gene.name)}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label
|
|
||||||
htmlFor={`quick-${gene.name}`}
|
|
||||||
className="text-sm font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
{gene.name}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="quality">
|
|
||||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{qualityGenes.length === 0 ? (
|
|
||||||
<p className="text-center text-muted-foreground py-8">
|
|
||||||
육질형 유전자가 없습니다.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
qualityGenes.map((gene) => (
|
|
||||||
<div key={gene.name} className="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id={`quick-${gene.name}`}
|
|
||||||
checked={tempSelectedGenes.includes(gene.name)}
|
|
||||||
onCheckedChange={() => toggleGene(gene.name)}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label
|
|
||||||
htmlFor={`quick-${gene.name}`}
|
|
||||||
className="text-sm font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
{gene.name}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="all" className="space-y-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="유전자명 또는 설명 검색..."
|
|
||||||
className="pl-9"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setSortBy(sortBy === 'name' ? 'type' : 'name')}
|
|
||||||
>
|
|
||||||
{sortBy === 'type' ? '타입순' : '이름순'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filteredGenes.length === 0 ? (
|
|
||||||
<p className="text-center text-muted-foreground py-8">
|
|
||||||
검색 결과가 없습니다.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
filteredGenes.map((gene) => (
|
|
||||||
<div key={gene.name} className="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id={`all-${gene.name}`}
|
|
||||||
checked={tempSelectedGenes.includes(gene.name)}
|
|
||||||
onCheckedChange={() => toggleGene(gene.name)}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label
|
|
||||||
htmlFor={`all-${gene.name}`}
|
|
||||||
className="text-sm font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
{gene.name} <span className="text-xs text-muted-foreground">({gene.type === 'QTY' ? '육량형' : '육질형'})</span>
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{gene.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
선택된 유전자: {tempSelectedGenes.length}개
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleConfirm}>
|
|
||||||
선택 완료
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Search, X, Filter, Sparkles } from "lucide-react"
|
|
||||||
import { geneApi } from "@/lib/api/gene.api"
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
|
|
||||||
interface GeneSearchDrawerProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
selectedGenes: string[]
|
|
||||||
onGenesChange: (genes: string[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
|
|
||||||
const [allGenes, setAllGenes] = useState<any[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
|
|
||||||
|
|
||||||
// 모달 열릴 때 전체 유전자 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
loadAllGenes()
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
// TODO: 백엔드 /gene/markers API 구현 후 활성화
|
|
||||||
const loadAllGenes = async () => {
|
|
||||||
// try {
|
|
||||||
// setLoading(true)
|
|
||||||
// const genes = await geneApi.getAllMarkers()
|
|
||||||
// setAllGenes(genes)
|
|
||||||
// } catch {
|
|
||||||
// // 유전자 로드 실패 시 빈 배열 유지
|
|
||||||
// } finally {
|
|
||||||
// setLoading(false)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검색 및 필터링
|
|
||||||
const filteredGenes = allGenes.filter((gene) => {
|
|
||||||
// 타입 필터
|
|
||||||
if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검색어 필터
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase()
|
|
||||||
return (
|
|
||||||
gene.markerNm.toLowerCase().includes(query) ||
|
|
||||||
gene.markerDesc?.toLowerCase().includes(query) ||
|
|
||||||
gene.relatedTrait?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleGene = (markerNm: string) => {
|
|
||||||
if (selectedGenes.includes(markerNm)) {
|
|
||||||
onGenesChange(selectedGenes.filter(g => g !== markerNm))
|
|
||||||
} else {
|
|
||||||
onGenesChange([...selectedGenes, markerNm])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAllFiltered = () => {
|
|
||||||
const newGenes = [...selectedGenes]
|
|
||||||
filteredGenes.forEach(gene => {
|
|
||||||
if (!newGenes.includes(gene.markerNm)) {
|
|
||||||
newGenes.push(gene.markerNm)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
onGenesChange(newGenes)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
onGenesChange([])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col p-0 gap-0">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<DialogHeader className="px-5 pt-5 pb-3 border-b flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<DialogTitle className="text-lg font-bold">유전자 검색 및 선택</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
전체 <span className="font-semibold text-foreground">{allGenes.length.toLocaleString()}</span>개 / 선택 <span className="font-semibold text-primary">{selectedGenes.length}</span>개
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* 검색 및 필터 */}
|
|
||||||
<div className="px-4 py-3 space-y-3 flex-shrink-0 bg-muted/20">
|
|
||||||
{/* 검색바 */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="유전자명, 설명, 관련 형질로 검색..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9 pr-9 h-10 text-sm bg-background"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 hover:bg-muted rounded-full p-1 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필터 탭 및 액션 버튼 */}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Tabs value={filterType} onValueChange={(v) => setFilterType(v as any)} className="flex-1">
|
|
||||||
<TabsList className="w-full grid grid-cols-3 h-9">
|
|
||||||
<TabsTrigger value="ALL" className="text-xs">
|
|
||||||
전체 <span className="ml-1 font-semibold">({allGenes.length})</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="QTY" className="text-xs">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb]"></div>
|
|
||||||
육량 <span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QTY').length})</span>
|
|
||||||
</div>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="QLT" className="text-xs">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-slate-400"></div>
|
|
||||||
육질 <span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QLT').length})</span>
|
|
||||||
</div>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={selectAllFiltered}
|
|
||||||
disabled={filteredGenes.length === 0}
|
|
||||||
className="h-8 text-xs px-3"
|
|
||||||
>
|
|
||||||
전체선택
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearAll}
|
|
||||||
disabled={selectedGenes.length === 0}
|
|
||||||
className="h-8 text-xs px-3"
|
|
||||||
>
|
|
||||||
전체해제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 유전자 목록 */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-slate-200 border-t-[#2563eb] mx-auto mb-4"></div>
|
|
||||||
<p className="text-muted-foreground font-medium">유전자 데이터 로딩 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : filteredGenes.length > 0 ? (
|
|
||||||
<ScrollArea className="h-full px-4">
|
|
||||||
<div className="flex flex-wrap gap-1.5 py-3">
|
|
||||||
{filteredGenes.map((gene) => {
|
|
||||||
const isSelected = selectedGenes.includes(gene.markerNm)
|
|
||||||
const isQuantity = gene.markerTypeCd === 'QTY'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
key={gene.markerNm}
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
className={`cursor-pointer transition-colors text-xs h-7 px-2.5 ${
|
|
||||||
isSelected
|
|
||||||
? isQuantity
|
|
||||||
? 'bg-[#2563eb] text-white hover:bg-[#2563eb]/90 border-[#2563eb]'
|
|
||||||
: 'bg-slate-400 text-white hover:bg-slate-500 border-slate-400'
|
|
||||||
: isQuantity
|
|
||||||
? 'border-[#2563eb]/40 text-[#2563eb] hover:bg-[#2563eb]/5 hover:border-[#2563eb]'
|
|
||||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50 hover:border-slate-400'
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleGene(gene.markerNm)}
|
|
||||||
title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`}
|
|
||||||
>
|
|
||||||
{gene.markerNm}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center text-muted-foreground">
|
|
||||||
<Filter className="h-16 w-16 mx-auto mb-4 opacity-40" />
|
|
||||||
<p className="text-lg font-semibold">검색 결과가 없습니다</p>
|
|
||||||
<p className="text-sm mt-2">다른 검색어나 필터를 시도해보세요</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 하단 버튼 */}
|
|
||||||
<div className="px-4 py-3 border-t flex justify-between items-center flex-shrink-0 bg-muted/20">
|
|
||||||
<div className="text-sm">
|
|
||||||
{searchQuery && (
|
|
||||||
<span className="text-muted-foreground mr-3">
|
|
||||||
검색: <span className="font-semibold text-foreground">{filteredGenes.length.toLocaleString()}</span>개
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
선택: <span className="font-bold text-primary text-base">{selectedGenes.length}</span>개
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 px-4">
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => onOpenChange(false)} className="h-9 px-4">
|
|
||||||
완료
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { PieChart as PieChartIcon } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import apiClient from "@/lib/api-client"
|
|
||||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
|
||||||
|
|
||||||
interface DistributionData {
|
|
||||||
name: string
|
|
||||||
value: number
|
|
||||||
color: string
|
|
||||||
range: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenomeDistributionDonutProps {
|
|
||||||
farmNo: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) {
|
|
||||||
const [data, setData] = useState<DistributionData[]>([])
|
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
if (!farmNo) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiClient.post('/cow/ranking', {
|
|
||||||
filterOptions: { farmNo },
|
|
||||||
rankingOptions: {
|
|
||||||
criteriaType: 'GENOME',
|
|
||||||
traitConditions: [
|
|
||||||
{ traitNm: '도체중', weight: 0.25 },
|
|
||||||
{ traitNm: '근내지방도', weight: 0.25 },
|
|
||||||
{ traitNm: '등심단면적', weight: 0.25 },
|
|
||||||
{ traitNm: '등지방두께', weight: 0.25 },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = response.data || response
|
|
||||||
const items = result.items || []
|
|
||||||
setTotalCount(items.length)
|
|
||||||
|
|
||||||
const distribution = {
|
|
||||||
top: 0, // 0σ 이상
|
|
||||||
middle: 0, // -1.0σ ~ 0σ
|
|
||||||
bottom: 0 // -1.0σ 이하
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach((item: any) => {
|
|
||||||
const score = item.sortValue || 0
|
|
||||||
if (score >= 0) distribution.top++
|
|
||||||
else if (score >= -1.0) distribution.middle++
|
|
||||||
else distribution.bottom++
|
|
||||||
})
|
|
||||||
|
|
||||||
setData([
|
|
||||||
{ name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' },
|
|
||||||
{ name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' },
|
|
||||||
{ name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' },
|
|
||||||
].filter(d => d.value > 0))
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('분포 데이터 로드 실패:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
}, [farmNo])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
|
||||||
<div className="flex items-center justify-center h-[280px]">
|
|
||||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="px-4 py-3 border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center shadow-md shadow-violet-500/20">
|
|
||||||
<PieChartIcon className="w-4 h-4 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-slate-900">우리 소들의 등급</h3>
|
|
||||||
<p className="text-[10px] text-slate-500">총 {totalCount}두 분포</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">{totalCount}두</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 차트 */}
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="relative w-[180px] h-[180px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={data}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={54}
|
|
||||||
outerRadius={86}
|
|
||||||
paddingAngle={3}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{data.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="white" strokeWidth={2} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const item = payload[0].payload
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-900 px-4 py-3 rounded-xl shadow-xl border border-slate-700">
|
|
||||||
<p className="text-white font-bold mb-2">{item.name}</p>
|
|
||||||
<p className="text-slate-200 text-sm">{item.description}</p>
|
|
||||||
<div className="mt-2 pt-2 border-t border-slate-700">
|
|
||||||
<p className="text-slate-300 text-sm">{item.value}두 ({Math.round(item.value / totalCount * 100)}%)</p>
|
|
||||||
<p className="text-slate-400 text-xs mt-1">{item.range}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
{/* 중앙 텍스트 */}
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-3xl font-bold text-slate-900">{totalCount}</span>
|
|
||||||
<span className="text-xs text-slate-500 mt-1">전체</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 범례 */}
|
|
||||||
<div className="w-full mt-5 pt-4 border-t border-slate-100">
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{data.map((item, idx) => (
|
|
||||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-100 hover:bg-slate-100/50 transition-colors">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-4 h-4 rounded-full flex-shrink-0 shadow-sm" style={{ backgroundColor: item.color }}></div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">{item.name}</span>
|
|
||||||
<span className="text-[10px] text-slate-500">{item.description}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-base font-bold text-slate-900">{item.value}<span className="text-xs text-slate-500 font-normal ml-0.5">두</span></p>
|
|
||||||
<p className="text-[10px] text-slate-500">{Math.round(item.value / totalCount * 100)}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-4 text-center leading-relaxed">
|
|
||||||
σ(시그마)는 유전능력 수준을 나타내요<br/>
|
|
||||||
0보다 클수록 우수해요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Target } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import apiClient from "@/lib/api-client"
|
|
||||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
|
|
||||||
|
|
||||||
interface TraitScore {
|
|
||||||
trait: string
|
|
||||||
diff: number
|
|
||||||
myFarm: number
|
|
||||||
region: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenomeRadarChartProps {
|
|
||||||
farmNo: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) {
|
|
||||||
const [data, setData] = useState<TraitScore[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
if (!farmNo) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const traits = [
|
|
||||||
{ name: '도체중', key: '도체중' },
|
|
||||||
{ name: '근내지방', key: '근내지방도' },
|
|
||||||
{ name: '등심단면적', key: '등심단면적' },
|
|
||||||
{ name: '등지방', key: '등지방두께' },
|
|
||||||
{ name: '12개월체중', key: '12개월령체중' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const results: TraitScore[] = []
|
|
||||||
|
|
||||||
for (const trait of traits) {
|
|
||||||
try {
|
|
||||||
const farmResponse = await apiClient.post('/cow/ranking', {
|
|
||||||
filterOptions: { farmNo },
|
|
||||||
rankingOptions: {
|
|
||||||
criteriaType: 'GENOME',
|
|
||||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const globalResponse = await apiClient.post('/cow/ranking/global', {
|
|
||||||
rankingOptions: {
|
|
||||||
criteriaType: 'GENOME',
|
|
||||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const farmResult = farmResponse.data || farmResponse
|
|
||||||
const globalResult = globalResponse.data || globalResponse
|
|
||||||
|
|
||||||
const farmScores = farmResult.items?.map((item: any) => {
|
|
||||||
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
|
|
||||||
return traitDetail?.value ?? item.sortValue ?? 0
|
|
||||||
}) || []
|
|
||||||
const farmAvgScore = farmScores.length > 0
|
|
||||||
? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const globalScores = globalResult.items?.map((item: any) => {
|
|
||||||
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
|
|
||||||
return traitDetail?.value ?? item.sortValue ?? 0
|
|
||||||
}) || []
|
|
||||||
const regionAvgScore = globalScores.length > 0
|
|
||||||
? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const diff = farmAvgScore - regionAvgScore
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
trait: trait.name,
|
|
||||||
diff: parseFloat(diff.toFixed(2)),
|
|
||||||
myFarm: parseFloat(farmAvgScore.toFixed(2)),
|
|
||||||
region: parseFloat(regionAvgScore.toFixed(2))
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`형질 ${trait.name} 로드 실패:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(results)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('레이더 차트 데이터 로드 실패:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
}, [farmNo])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
|
||||||
<div className="flex items-center justify-center h-[280px]">
|
|
||||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const validDiffs = data.filter(d => !isNaN(d.diff))
|
|
||||||
const avgDiff = validDiffs.length > 0
|
|
||||||
? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="px-4 py-3 border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center shadow-md shadow-sky-500/20">
|
|
||||||
<Target className="w-4 h-4 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-slate-900">카테고리별 결과</h3>
|
|
||||||
<p className="text-[10px] text-slate-500">주요 형질 평균 비교</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${avgDiff >= 0
|
|
||||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
|
||||||
: 'bg-red-50 text-red-600 border-red-200'
|
|
||||||
}`}>
|
|
||||||
평균 {avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 차트 */}
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="bg-gradient-to-br from-slate-50 to-blue-50/30 rounded-xl p-4">
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<RadarChart data={data} margin={{ top: 30, right: 40, bottom: 30, left: 40 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="radarGradient" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#1482B0" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#1482B0" stopOpacity={0.1} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<PolarGrid
|
|
||||||
stroke="#cbd5e1"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeOpacity={0.5}
|
|
||||||
/>
|
|
||||||
<PolarAngleAxis
|
|
||||||
dataKey="trait"
|
|
||||||
tick={{ fontSize: 13, fill: '#334155', fontWeight: 600 }}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<PolarRadiusAxis
|
|
||||||
angle={90}
|
|
||||||
domain={[-1.5, 1.5]}
|
|
||||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
|
||||||
tickCount={4}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<Radar
|
|
||||||
name="보은군 평균"
|
|
||||||
dataKey={() => 0}
|
|
||||||
stroke="#94a3b8"
|
|
||||||
fill="none"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="5 3"
|
|
||||||
strokeOpacity={0.6}
|
|
||||||
/>
|
|
||||||
<Radar
|
|
||||||
name="내농장"
|
|
||||||
dataKey="diff"
|
|
||||||
stroke="#1482B0"
|
|
||||||
fill="url(#radarGradient)"
|
|
||||||
strokeWidth={3}
|
|
||||||
dot={{
|
|
||||||
fill: '#fff',
|
|
||||||
strokeWidth: 3,
|
|
||||||
stroke: '#1482B0',
|
|
||||||
r: 6,
|
|
||||||
strokeOpacity: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const item = payload[0]?.payload
|
|
||||||
const diff = item?.diff ?? 0
|
|
||||||
const myFarm = item?.myFarm ?? 0
|
|
||||||
const region = item?.region ?? 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-900 px-4 py-3 rounded-xl text-sm shadow-xl border border-slate-700">
|
|
||||||
<p className="text-white font-bold mb-2">{item?.trait}</p>
|
|
||||||
<div className="space-y-1 text-slate-300 text-xs">
|
|
||||||
<p>내농장: <span className="text-[#1482B0] font-semibold">{myFarm > 0 ? '+' : ''}{myFarm}σ</span></p>
|
|
||||||
<p>보은군: <span className="text-slate-400">{region > 0 ? '+' : ''}{region}σ</span></p>
|
|
||||||
</div>
|
|
||||||
<div className={`mt-2 pt-2 border-t border-slate-700 font-bold ${diff >= 0.3 ? 'text-emerald-400' :
|
|
||||||
diff <= -0.3 ? 'text-amber-400' :
|
|
||||||
'text-slate-300'
|
|
||||||
}`}>
|
|
||||||
{diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</RadarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 범례 */}
|
|
||||||
<div className="flex items-center justify-center gap-6 mt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-5 h-0.5 bg-slate-400 opacity-60" style={{ borderTop: '2px dashed #94a3b8' }}></div>
|
|
||||||
<span className="text-xs text-slate-600">보은군 평균</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded-full bg-white border-3 border-[#1482B0] shadow-sm"></div>
|
|
||||||
<span className="text-xs text-slate-900 font-semibold">내 농장</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 형질별 수치 */}
|
|
||||||
<div className="grid grid-cols-5 gap-2 mt-4 pt-4 border-t border-slate-200">
|
|
||||||
{data.map((item, idx) => (
|
|
||||||
<div key={idx} className="text-center">
|
|
||||||
<p className="text-[10px] text-slate-500 mb-1 truncate font-medium">{item.trait}</p>
|
|
||||||
<p className={`text-sm font-bold ${item.diff >= 0.3 ? 'text-emerald-600' :
|
|
||||||
item.diff <= -0.3 ? 'text-amber-600' :
|
|
||||||
'text-slate-700'
|
|
||||||
}`}>
|
|
||||||
{item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import apiClient from "@/lib/api-client"
|
|
||||||
|
|
||||||
interface GenomeData {
|
|
||||||
trait: string
|
|
||||||
score: number
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenomeStrengthsWeaknessesProps {
|
|
||||||
farmNo?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) {
|
|
||||||
const [allMetrics, setAllMetrics] = useState<GenomeData[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTraitScores = async () => {
|
|
||||||
if (!farmNo) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const traits = [
|
|
||||||
{ name: '도체중', key: '도체중' },
|
|
||||||
{ name: '근내지방도', key: '근내지방도' },
|
|
||||||
{ name: '등심단면적', key: '등심단면적' },
|
|
||||||
{ name: '등지방두께', key: '등지방두께' },
|
|
||||||
{ name: '12개월령체중', key: '12개월령체중' },
|
|
||||||
{ name: '체고', key: '체고' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const traitScores: GenomeData[] = []
|
|
||||||
|
|
||||||
for (const trait of traits) {
|
|
||||||
try {
|
|
||||||
const rankingRequest = {
|
|
||||||
filterOptions: { farmNo: farmNo },
|
|
||||||
rankingOptions: {
|
|
||||||
criteriaType: 'GENOME',
|
|
||||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiClient.post('/cow/ranking', rankingRequest)
|
|
||||||
const rankingResult = response.data || response
|
|
||||||
|
|
||||||
const scores = rankingResult.items?.map((item: any) => item.sortValue) || []
|
|
||||||
const avgScore = scores.length > 0
|
|
||||||
? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
traitScores.push({
|
|
||||||
trait: trait.name,
|
|
||||||
score: parseFloat(avgScore.toFixed(2)),
|
|
||||||
type: '유전체'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAllMetrics(traitScores)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('형질 점수 로드 실패:', error)
|
|
||||||
setAllMetrics([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchTraitScores()
|
|
||||||
}, [farmNo])
|
|
||||||
|
|
||||||
const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3)
|
|
||||||
const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3)
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<div key={i} className="bg-white rounded-xl border border-slate-100 p-4">
|
|
||||||
<div className="flex items-center justify-center h-[140px]">
|
|
||||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* 강점 */}
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
|
|
||||||
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
|
|
||||||
>
|
|
||||||
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
|
|
||||||
<TrendingUp className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-slate-900">이 부분이 강해요</h3>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">보은군보다 우수한 형질</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold border border-emerald-200 shadow-sm">TOP 3</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
{strengths.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
|
||||||
데이터가 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{strengths.map((item, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="flex items-center justify-between p-4 bg-gradient-to-r from-emerald-50 to-transparent rounded-xl hover:from-emerald-100 hover:to-emerald-50 transition-all duration-200 border-2 border-emerald-100 group-hover:border-emerald-200 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
|
|
||||||
idx === 0 ? 'bg-gradient-to-br from-emerald-500 to-emerald-600 text-white shadow-emerald-500/30' :
|
|
||||||
idx === 1 ? 'bg-emerald-200 text-emerald-800 border-2 border-emerald-300' :
|
|
||||||
'bg-emerald-100 text-emerald-700 border-2 border-emerald-200'
|
|
||||||
}`}>
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-emerald-200 shadow-sm">
|
|
||||||
<span className="text-lg font-bold text-emerald-600">
|
|
||||||
{item.score > 0 ? '+' : ''}{item.score}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-emerald-500 font-semibold">σ</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 약점 */}
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
|
|
||||||
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
|
|
||||||
>
|
|
||||||
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-amber-50 to-transparent">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
|
|
||||||
<TrendingDown className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-slate-900">더 좋아질 수 있어요</h3>
|
|
||||||
<p className="text-xs text-slate-500 mt-0.5">개선하면 좋을 형질</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold border border-amber-200 shadow-sm">BOTTOM 3</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
{weaknesses.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
|
||||||
데이터가 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{weaknesses.map((item, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="flex items-center justify-between p-4 bg-gradient-to-r from-amber-50 to-transparent rounded-xl hover:from-amber-100 hover:to-amber-50 transition-all duration-200 border-2 border-amber-100 group-hover:border-amber-200 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
|
|
||||||
idx === 0 ? 'bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-amber-500/30' :
|
|
||||||
idx === 1 ? 'bg-amber-200 text-amber-800 border-2 border-amber-300' :
|
|
||||||
'bg-amber-100 text-amber-700 border-2 border-amber-200'
|
|
||||||
}`}>
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-amber-200 shadow-sm">
|
|
||||||
<span className="text-lg font-bold text-amber-600">
|
|
||||||
{item.score > 0 ? '+' : ''}{item.score}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-amber-500 font-semibold">σ</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { BarChart3 } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import apiClient from "@/lib/api-client"
|
|
||||||
|
|
||||||
interface TraitData {
|
|
||||||
trait: string
|
|
||||||
regional: number
|
|
||||||
myFarm: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenomeTraitsTableProps {
|
|
||||||
farmNo?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) {
|
|
||||||
const [traitData, setTraitData] = useState<TraitData[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTraitData = async () => {
|
|
||||||
if (!farmNo) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const traits = [
|
|
||||||
{ name: '12개월령체중', key: '12개월령체중' },
|
|
||||||
{ name: '도체중', key: '도체중' },
|
|
||||||
{ name: '근내지방도', key: '근내지방도' },
|
|
||||||
{ name: '등심단면적', key: '등심단면적' },
|
|
||||||
{ name: '등지방두께', key: '등지방두께' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const results: TraitData[] = []
|
|
||||||
|
|
||||||
for (const trait of traits) {
|
|
||||||
try {
|
|
||||||
const farmResponse = await apiClient.post('/cow/ranking', {
|
|
||||||
filterOptions: { farmNo: farmNo },
|
|
||||||
rankingOptions: {
|
|
||||||
criteriaType: 'GENOME',
|
|
||||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const globalResponse = await apiClient.post('/cow/ranking/global', {
|
|
||||||
rankingOptions: {
|
|
||||||
criteriaType: 'GENOME',
|
|
||||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const farmResult = farmResponse.data || farmResponse
|
|
||||||
const globalResult = globalResponse.data || globalResponse
|
|
||||||
|
|
||||||
const farmScores = farmResult.items?.map((item: any) => item.sortValue) || []
|
|
||||||
const farmAvg = farmScores.length > 0
|
|
||||||
? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const globalScores = globalResult.items?.map((item: any) => item.sortValue) || []
|
|
||||||
const regionalAvg = globalScores.length > 0
|
|
||||||
? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length
|
|
||||||
: 0
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
trait: trait.name,
|
|
||||||
myFarm: parseFloat(farmAvg.toFixed(2)),
|
|
||||||
regional: parseFloat(regionalAvg.toFixed(2))
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTraitData(results)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[형질 테이블] 전체 데이터 로드 실패:', error)
|
|
||||||
setTraitData([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchTraitData()
|
|
||||||
}, [farmNo])
|
|
||||||
|
|
||||||
const getTraitShortName = (name: string) => {
|
|
||||||
const shortNames: Record<string, string> = {
|
|
||||||
'12개월령체중': '12개월령체중',
|
|
||||||
'등심단면적': '등심단면적',
|
|
||||||
'등지방두께': '등지방두께',
|
|
||||||
'근내지방도': '근내지방도',
|
|
||||||
'도체중': '도체중'
|
|
||||||
}
|
|
||||||
return shortNames[name] || name
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
|
||||||
<div className="flex items-center justify-center h-[180px]">
|
|
||||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="px-4 py-3 border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1482B0] to-[#0d5f82] flex items-center justify-center shadow-md shadow-[#1482B0]/20">
|
|
||||||
<BarChart3 className="w-4 h-4 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-slate-900">형질별 점수</h3>
|
|
||||||
<p className="text-[10px] text-slate-500">보은군과 비교한 수치에요</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">5개 형질</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 콘텐츠 */}
|
|
||||||
<div className="p-5">
|
|
||||||
{traitData.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
|
||||||
데이터가 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{traitData.map((item, idx) => {
|
|
||||||
const diff = item.myFarm - item.regional
|
|
||||||
const isPositive = diff >= 0
|
|
||||||
// σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100)
|
|
||||||
const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={idx} className="space-y-1.5">
|
|
||||||
{/* 형질명 + 차이 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold text-slate-800">
|
|
||||||
{getTraitShortName(item.trait)}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-lg ${diff >= 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' :
|
|
||||||
diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' :
|
|
||||||
'bg-slate-50 text-slate-700 border border-slate-200'
|
|
||||||
}`}>
|
|
||||||
{diff > 0 ? '+' : ''}{diff.toFixed(1)}σ
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 비교 바 */}
|
|
||||||
<div className="relative h-6 bg-slate-100 rounded-lg overflow-hidden shadow-inner">
|
|
||||||
{/* 보은군 바 */}
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 h-full bg-slate-300/80 rounded-lg transition-all duration-500"
|
|
||||||
style={{ width: `${toPercent(item.regional)}%` }}
|
|
||||||
/>
|
|
||||||
{/* 내농장 바 */}
|
|
||||||
<div
|
|
||||||
className={`absolute top-0 left-0 h-full rounded-lg transition-all duration-500 shadow-sm ${isPositive
|
|
||||||
? 'bg-gradient-to-r from-[#1482B0] via-[#1482B0] to-[#0d5f82]'
|
|
||||||
: 'bg-gradient-to-r from-slate-400 to-slate-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${toPercent(item.myFarm)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 라벨 */}
|
|
||||||
<div className="flex items-center justify-between text-[11px]">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-[#1482B0] shadow-sm"></div>
|
|
||||||
<span className="text-slate-600 font-medium">
|
|
||||||
내농장 <span className="font-bold text-slate-900">{item.myFarm > 0 ? '+' : ''}{item.myFarm}σ</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 shadow-sm"></div>
|
|
||||||
<span className="text-slate-500 font-medium">
|
|
||||||
보은군 {item.regional > 0 ? '+' : ''}{item.regional}σ
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user