update_cow_detail_page
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -74,19 +74,53 @@ export function CategoryEvaluationCard({
|
||||
// 차트에 표시할 형질 목록 (커스텀 가능)
|
||||
const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS])
|
||||
|
||||
// 활성화된 형질 목록 (차트에 표시할 형질)
|
||||
const [activeTraits, setActiveTraits] = useState<Set<string>>(new Set([...DEFAULT_TRAITS]))
|
||||
|
||||
// 형질 추가 모달/드로어 상태
|
||||
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
|
||||
|
||||
// 선택된 형질 (터치/클릭 시 정보 표시용)
|
||||
const [selectedTraitName, setSelectedTraitName] = useState<string | null>(null)
|
||||
|
||||
// 차트 로딩 상태
|
||||
const [isChartLoading, setIsChartLoading] = useState(false)
|
||||
|
||||
// 모바일 여부 확인
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)")
|
||||
|
||||
// 형질 활성화/비활성화 토글
|
||||
const toggleTraitActive = (traitName: string) => {
|
||||
setActiveTraits(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(traitName)) {
|
||||
// 비활성화 시 제한 없음 (2개 이하일 때 차트 비활성화로 처리)
|
||||
newSet.delete(traitName)
|
||||
} else {
|
||||
newSet.add(traitName)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
// 차트 데이터 변경 시 로딩 처리
|
||||
useEffect(() => {
|
||||
setIsChartLoading(true)
|
||||
const timer = setTimeout(() => {
|
||||
setIsChartLoading(false)
|
||||
}, 300) // 차트 렌더링 시뮬레이션
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeTraits])
|
||||
|
||||
// 형질 제거
|
||||
const removeTrait = (traitName: string) => {
|
||||
if (chartTraits.length > 3) { // 최소 3개는 유지
|
||||
setChartTraits(prev => prev.filter(t => t !== traitName))
|
||||
setActiveTraits(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(traitName)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,16 +128,21 @@ export function CategoryEvaluationCard({
|
||||
const addTrait = (traitName: string) => {
|
||||
if (chartTraits.length < 7 && !chartTraits.includes(traitName)) { // 최대 7개
|
||||
setChartTraits(prev => [...prev, traitName])
|
||||
setActiveTraits(prev => new Set([...prev, traitName]))
|
||||
}
|
||||
}
|
||||
|
||||
// 기본값으로 초기화
|
||||
const resetToDefault = () => {
|
||||
setChartTraits([...DEFAULT_TRAITS])
|
||||
setActiveTraits(new Set([...DEFAULT_TRAITS]))
|
||||
}
|
||||
|
||||
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
|
||||
const traitChartData = chartTraits.map(traitName => {
|
||||
// 폴리곤 차트용 데이터 생성 (활성화된 형질만 포함) - 보은군, 농가, 이 개체 비교
|
||||
const traitChartData = useMemo(() => {
|
||||
return chartTraits
|
||||
.filter(traitName => activeTraits.has(traitName))
|
||||
.map(traitName => {
|
||||
const trait = allTraits.find((t: GenomeCowTraitDto) => t.traitName === traitName)
|
||||
|
||||
// 형질별 평균 데이터에서 해당 형질 찾기
|
||||
@@ -131,17 +170,50 @@ export function CategoryEvaluationCard({
|
||||
diff: trait?.breedVal ?? 0,
|
||||
hasData: !!trait
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [chartTraits, activeTraits, allTraits, traitComparisonAverages])
|
||||
|
||||
// 가장 높은 형질 찾기 (이 개체 기준)
|
||||
const bestTraitName = traitChartData.reduce((best, current) =>
|
||||
current.breedVal > best.breedVal ? current : best
|
||||
, traitChartData[0])?.shortName
|
||||
|
||||
// 동적 스케일 계산 (모든 값의 최대 절대값 기준)
|
||||
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
|
||||
const maxAbsValue = Math.max(...allValues.map(Math.abs), 0.3) // 최소 0.3
|
||||
const dynamicDomain = Math.ceil(maxAbsValue * 1.2 * 10) / 10 // 20% 여유
|
||||
// 동적 스케일 계산 (실제 데이터 범위를 기반으로, min/max 각각에 5% 여유분만 추가)
|
||||
// useMemo를 사용하는 이유: traitChartData가 변경될 때만 재계산하여 성능 최적화
|
||||
// - traitChartData는 activeTraits, chartTraits, allTraits, traitComparisonAverages에 의존
|
||||
// - 이 값들이 변경될 때마다 스케일을 다시 계산해야 함
|
||||
// - useMemo를 사용하면 의존성이 변경되지 않으면 이전 계산 결과를 재사용
|
||||
const dynamicDomain = useMemo(() => {
|
||||
if (traitChartData.length === 0) return [-0.3, 0.3]
|
||||
|
||||
// 모든 값 수집 (breedVal, regionVal, farmVal)
|
||||
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
|
||||
|
||||
// 실제 데이터의 최소값과 최대값 찾기
|
||||
const minValue = Math.min(...allValues)
|
||||
const maxValue = Math.max(...allValues)
|
||||
|
||||
// 데이터 범위 계산
|
||||
const dataRange = maxValue - minValue
|
||||
|
||||
// 데이터 범위가 너무 작으면 최소 범위 보장 (0.3)
|
||||
const effectiveRange = Math.max(dataRange, 0.3)
|
||||
|
||||
// min/max 각각에 범위의 10%만큼 여유분 추가 (대칭 처리하지 않음)
|
||||
const padding = effectiveRange * 0.10
|
||||
let domainMin = minValue - padding
|
||||
let domainMax = maxValue + padding
|
||||
|
||||
// 소수점 첫째자리까지 반올림
|
||||
domainMin = Math.floor(domainMin * 10) / 10
|
||||
domainMax = Math.ceil(domainMax * 10) / 10
|
||||
|
||||
return [domainMin, domainMax]
|
||||
}, [traitChartData])
|
||||
|
||||
// 활성화된 형질 개수
|
||||
const activeTraitsCount = activeTraits.size
|
||||
const hasEnoughTraits = activeTraitsCount >= 3
|
||||
|
||||
// 형질 이름으로 원본 형질명 찾기 (shortName -> name)
|
||||
const findTraitNameByShortName = (shortName: string) => {
|
||||
@@ -189,7 +261,7 @@ export function CategoryEvaluationCard({
|
||||
y={0}
|
||||
dy={5}
|
||||
textAnchor="middle"
|
||||
fontSize={15}
|
||||
fontSize={isDesktop ? 17 : 15}
|
||||
fontWeight={isSelected ? 700 : 600}
|
||||
fill={isSelected ? '#ffffff' : '#334155'}
|
||||
>
|
||||
@@ -227,11 +299,12 @@ export function CategoryEvaluationCard({
|
||||
}`}
|
||||
>
|
||||
{getTraitDisplayName(trait)}
|
||||
{traitData && traitData.breedVal !== undefined && (
|
||||
{/* 육종가(EBV) 값 표시 (주석 처리) */}
|
||||
{/* {traitData && traitData.breedVal !== undefined && (
|
||||
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
|
||||
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
|
||||
</span>
|
||||
)}
|
||||
)} */}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -282,38 +355,52 @@ export function CategoryEvaluationCard({
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
{/* 2열 레이아웃: 왼쪽 차트 + 오른쪽 카드 */}
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="p-4 lg:p-6 lg:pb-0">
|
||||
{/* 형질 선택 칩 영역 */}
|
||||
<div className="mb-4 lg:mb-6">
|
||||
<div className="flex items-center justify-between mb-2 lg:mb-3">
|
||||
<span className="text-sm lg:text-base font-medium text-muted-foreground">비교 형질을 선택해주세요</span>
|
||||
<button
|
||||
onClick={() => setIsTraitSelectorOpen(true)}
|
||||
className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="mb-4 lg:mb-2">
|
||||
<div className="flex items-center justify-between mb-2 lg:mb-0">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="text-lg lg:text-base font-medium text-muted-foreground">비교 형질을 선택해주세요 :</div>
|
||||
<div className="flex flex-wrap gap-1.5 lg:gap-2">
|
||||
{chartTraits.map(trait => {
|
||||
const isActive = activeTraits.has(trait)
|
||||
return (
|
||||
<div
|
||||
key={trait}
|
||||
className={`inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 rounded-full text-sm lg:text-base font-medium transition-all cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-primary/10 text-primary opacity-50'
|
||||
}`}
|
||||
onClick={() => toggleTraitActive(trait)}
|
||||
>
|
||||
<span className="text-md font-bold">{getTraitDisplayName(trait)}</span>
|
||||
{chartTraits.length > 3 && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeTrait(trait)
|
||||
setActiveTraits(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(trait)
|
||||
return newSet
|
||||
})
|
||||
}}
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3 lg:w-4 lg:h-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setIsTraitSelectorOpen(true)} className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors">
|
||||
<Pencil className="w-4 h-4 lg:w-5 lg:h-5" />
|
||||
편집
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 lg:gap-2">
|
||||
{chartTraits.map(trait => (
|
||||
<span
|
||||
key={trait}
|
||||
className="inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 bg-primary/10 text-primary rounded-full text-sm lg:text-base font-medium group"
|
||||
>
|
||||
{getTraitDisplayName(trait)}
|
||||
{chartTraits.length > 3 && (
|
||||
<button
|
||||
onClick={() => removeTrait(trait)}
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3 lg:w-4 lg:h-4" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
{/* 형질 편집 모달 - 웹: Dialog, 모바일: Drawer */}
|
||||
{isDesktop ? (
|
||||
@@ -342,18 +429,47 @@ export function CategoryEvaluationCard({
|
||||
<div className={`flex flex-col ${hideTraitCards ? '' : 'lg:flex-row'} gap-6`}>
|
||||
{/* 폴리곤 차트 */}
|
||||
<div className={hideTraitCards ? 'w-full' : 'lg:w-1/2'}>
|
||||
<div className="bg-muted/20 rounded-xl h-full">
|
||||
<div className="bg-muted/20 rounded-xl h-full relative">
|
||||
<div className={hideTraitCards ? 'h-[95vw] max-h-[520px] sm:h-[420px]' : 'h-[95vw] max-h-[520px] sm:h-[440px]'}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={traitChartData} margin={{ top: 40, right: 45, bottom: 40, left: 45 }}>
|
||||
{/* 범례 - 좌측 상단 */}
|
||||
<div className="absolute top-2 left-2 z-20 flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#10b981' }}></div>
|
||||
<span className="text-lg sm:text-base font-medium text-muted-foreground">보은군 평균</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
|
||||
<span className="text-lg sm:text-base font-medium text-muted-foreground">농가 평균</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1482B0' }}></div>
|
||||
<span className="text-lg sm:text-base font-medium text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 상태 또는 최소 형질 개수 미달 */}
|
||||
{(isChartLoading || !hasEnoughTraits) ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
|
||||
{isChartLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"></div>
|
||||
<p className="text-sm text-muted-foreground font-medium">차트 데이터 로딩 중...</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-lg text-muted-foreground font-bold">비교 형질 3개 이상 선택해주세요.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={traitChartData} margin={{ top: 40, right: 0, bottom: 0, left: 0 }}>
|
||||
<PolarGrid
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[-dynamicDomain, dynamicDomain]}
|
||||
tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }}
|
||||
domain={dynamicDomain}
|
||||
tick={{ fontSize: isDesktop ? 16 : 15, fill: '#64748b', fontWeight: 700 }}
|
||||
tickCount={5}
|
||||
axisLine={false}
|
||||
/>
|
||||
@@ -362,8 +478,9 @@ export function CategoryEvaluationCard({
|
||||
name="보은군 평균"
|
||||
dataKey="regionVal"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.2}
|
||||
// fill="#10b981"
|
||||
// fillOpacity={0.2}
|
||||
fill="transparent"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
@@ -372,8 +489,9 @@ export function CategoryEvaluationCard({
|
||||
name="농가 평균"
|
||||
dataKey="farmVal"
|
||||
stroke="#1F3A8F"
|
||||
fill="#1F3A8F"
|
||||
fillOpacity={0.3}
|
||||
// fill="#1F3A8F"
|
||||
// fillOpacity={0.3}
|
||||
fill="transparent"
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
/>
|
||||
@@ -382,8 +500,9 @@ export function CategoryEvaluationCard({
|
||||
name={formatCowNo(cowNo)}
|
||||
dataKey="breedVal"
|
||||
stroke="#1482B0"
|
||||
fill="#1482B0"
|
||||
fillOpacity={0.35}
|
||||
// fill="#1482B0"
|
||||
// fillOpacity={0.35}
|
||||
fill="transparent"
|
||||
strokeWidth={isDesktop ? 3 : 2}
|
||||
dot={{
|
||||
fill: '#1482B0',
|
||||
@@ -399,6 +518,7 @@ export function CategoryEvaluationCard({
|
||||
tickLine={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
animationDuration={0}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0]?.payload
|
||||
@@ -408,8 +528,8 @@ export function CategoryEvaluationCard({
|
||||
|
||||
return (
|
||||
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
|
||||
<p className="text-white font-bold mb-2">{item?.name}</p>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<p className="text-white font-bold mb-2 text-lg">{item?.name}</p>
|
||||
<div className="space-y-1.5 text-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
|
||||
@@ -440,22 +560,7 @@ export function CategoryEvaluationCard({
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center gap-4 sm:gap-6 py-3 border-t border-border">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#10b981' }}></div>
|
||||
<span className="text-sm text-muted-foreground">보은군 평균</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
|
||||
<span className="text-sm text-muted-foreground">농가 평균</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#1482B0' }}></div>
|
||||
<span className="text-sm font-medium text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */}
|
||||
|
||||
@@ -273,8 +273,8 @@ export function NormalDistributionChart({
|
||||
// 전체 선발지수: selectionIndexHistogram 사용
|
||||
if (chartFilterTrait === 'overall' && selectionIndexHistogram.length > 0) {
|
||||
const bins = selectionIndexHistogram.map(item => item.bin - cowScore)
|
||||
// 내 개체(0)도 범위에 포함
|
||||
const allValues = [...bins, 0]
|
||||
// 내 개체(0), 농가 평균, 보은군 평균도 범위에 포함
|
||||
const allValues = [...bins, 0, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
|
||||
const minData = Math.min(...allValues)
|
||||
const maxData = Math.max(...allValues)
|
||||
|
||||
@@ -309,11 +309,19 @@ export function NormalDistributionChart({
|
||||
// 형질별: traitRankData.histogram 사용
|
||||
if (chartFilterTrait !== 'overall' && traitRankData?.histogram && traitRankData.histogram.length > 0) {
|
||||
const bins = traitRankData.histogram.map(item => item.bin - cowScore)
|
||||
// 내 개체(0)도 범위에 포함
|
||||
const allValues = [...bins, 0]
|
||||
// 내 개체(0), 농가 평균, 보은군 평균도 범위에 포함
|
||||
const allValues = [...bins, 0, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
|
||||
const minData = Math.min(...allValues)
|
||||
const maxData = Math.max(...allValues)
|
||||
|
||||
console.log(`[${chartFilterTrait}] X축 범위 계산:`, {
|
||||
bins: `${bins[0].toFixed(2)} ~ ${bins[bins.length-1].toFixed(2)}`,
|
||||
내개체: 0,
|
||||
농가평균위치: chartDisplayValues.farmScore.toFixed(2),
|
||||
보은군평균위치: chartDisplayValues.regionScore.toFixed(2),
|
||||
allValues범위: `${minData.toFixed(2)} ~ ${maxData.toFixed(2)}`,
|
||||
})
|
||||
|
||||
// 데이터의 중심점 계산
|
||||
const center = (minData + maxData) / 2
|
||||
// 데이터 범위에 20% 여유 추가
|
||||
@@ -507,17 +515,17 @@ export function NormalDistributionChart({
|
||||
return bins
|
||||
}, [xAxisConfig, chartFilterTrait, traitRankData, chartDisplayValues.originalScore, selectionIndexHistogram])
|
||||
|
||||
// Y축 범위 (실제 데이터에 맞게 조정)
|
||||
const maxPercent = useMemo(() => {
|
||||
if (histogramData.length === 0) return 40
|
||||
// Y축 범위 (실제 데이터에 맞게 조정 - 개체수 기준)
|
||||
const maxCount = useMemo(() => {
|
||||
if (histogramData.length === 0) return 100
|
||||
|
||||
const maxValue = Math.max(...histogramData.map(d => d.percent || 0))
|
||||
const maxValue = Math.max(...histogramData.map(d => ('count' in d ? d.count : 0) || 0))
|
||||
|
||||
// 실제 최대값에 20% 여유만 추가 (너무 빡빡하지 않게)
|
||||
const calculatedMax = Math.ceil(maxValue * 1.2)
|
||||
|
||||
// 최소 5% 보장 (데이터가 너무 작을 때만)
|
||||
return Math.max(5, calculatedMax)
|
||||
// 최소 10개체 보장 (데이터가 너무 작을 때만)
|
||||
return Math.max(10, calculatedMax)
|
||||
}, [histogramData])
|
||||
|
||||
|
||||
@@ -708,7 +716,7 @@ export function NormalDistributionChart({
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 농가 내 순위 */}
|
||||
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
||||
<span className="text-sm text-muted-foreground mb-2 font-medium">농가 내 순위</span>
|
||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">농가 내 순위</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||
@@ -738,7 +746,7 @@ export function NormalDistributionChart({
|
||||
|
||||
{/* 보은군 내 순위 */}
|
||||
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-300 shadow-sm">
|
||||
<span className="text-sm text-muted-foreground mb-2 font-medium">보은군 내 순위</span>
|
||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">보은군 내 순위</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||
@@ -768,7 +776,7 @@ export function NormalDistributionChart({
|
||||
|
||||
{/* 농가 평균 대비 */}
|
||||
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalFarmScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
|
||||
<span className="text-sm text-muted-foreground mb-2 font-medium">농가 평균 대비</span>
|
||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">농가 평균 대비</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||
@@ -788,7 +796,7 @@ export function NormalDistributionChart({
|
||||
|
||||
{/* 보은군 평균 대비 */}
|
||||
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalRegionScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
|
||||
<span className="text-sm text-muted-foreground mb-2 font-medium">보은군 평균 대비</span>
|
||||
<span className="text-2xl text-muted-foreground mb-2 font-medium">보은군 평균 대비</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
{traitRankLoading && chartFilterTrait !== 'overall' ? (
|
||||
<span className="text-2xl font-bold text-muted-foreground">...</span>
|
||||
@@ -857,21 +865,25 @@ export function NormalDistributionChart({
|
||||
type="number"
|
||||
domain={[xAxisConfig.min, xAxisConfig.max]}
|
||||
ticks={xTicks}
|
||||
tick={{ fontSize: isMobileView ? 11 : 13, fill: '#64748b', fontWeight: 600 }}
|
||||
tick={{ fontSize: isMobileView ? 16 : 18, fill: '#64748b', fontWeight: 700 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }}
|
||||
tickFormatter={(value) => {
|
||||
if (value === 0) return '내 개체'
|
||||
if (value === 0) {
|
||||
// cowNo의 뒤에서 5번째부터 2번째까지 4자리 추출 (예: KOR002203259861 -> 5986)
|
||||
const shortId = cowNo ? cowNo.slice(-5, -1) : ''
|
||||
return shortId || '0'
|
||||
}
|
||||
return value > 0 ? `+${value}` : `${value}`
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={{ stroke: '#cbd5e1', strokeWidth: 1 }}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: isMobileView ? 10 : 11, fill: '#64748b' }}
|
||||
width={isMobileView ? 35 : 45}
|
||||
domain={[0, Math.ceil(maxPercent)]}
|
||||
tickFormatter={(value) => `${Math.round(value)}%`}
|
||||
tick={{ fontSize: isMobileView ? 15 : 17, fill: '#64748b', fontWeight: 700 }}
|
||||
width={isMobileView ? 45 : 60}
|
||||
domain={[0, Math.ceil(maxCount)]}
|
||||
tickFormatter={(value) => `${Math.round(value)}`}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
@@ -908,7 +920,7 @@ export function NormalDistributionChart({
|
||||
{/* 실제 데이터 분포 (Area 그래프 + 점 표시) */}
|
||||
<Area
|
||||
type="linear"
|
||||
dataKey="percent"
|
||||
dataKey="count"
|
||||
stroke="#64748b"
|
||||
strokeWidth={2.5}
|
||||
fill="url(#areaFillGradient)"
|
||||
@@ -1063,7 +1075,7 @@ export function NormalDistributionChart({
|
||||
fontSize={isMobile ? 13 : 15}
|
||||
fontWeight={600}
|
||||
>
|
||||
내 개체
|
||||
{cowNo ? cowNo.slice(-5, -1) : '0'}
|
||||
</text>
|
||||
<text
|
||||
x={clamp(cowX, cowBadgeW / 2)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useEffect, useState } from 'react'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DEFAULT_TRAITS, NEGATIVE_TRAITS, getTraitDisplayName } from "@/constants/traits"
|
||||
import { GenomeCowTraitDto } from "@/types/genome.types"
|
||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||
|
||||
// 카테고리별 배지 스타일 (진한 톤)
|
||||
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
|
||||
@@ -23,13 +24,14 @@ interface TraitDistributionChartsProps {
|
||||
regionAvgZ: number
|
||||
farmAvgZ: number
|
||||
cowName?: string
|
||||
cowNo?: string // API 호출용 개체번호
|
||||
totalCowCount?: number
|
||||
selectedTraits?: GenomeCowTraitDto[]
|
||||
traitWeights?: Record<string, number>
|
||||
}
|
||||
|
||||
// 리스트 뷰 컴포넌트
|
||||
function TraitListView({ traits, cowName }: {
|
||||
// 테이블 뷰 컴포넌트 (데스크탑)
|
||||
function TraitTableView({ traits, traitRanks }: {
|
||||
traits: Array<{
|
||||
traitName?: string;
|
||||
shortName: string;
|
||||
@@ -39,62 +41,69 @@ function TraitListView({ traits, cowName }: {
|
||||
traitVal?: number;
|
||||
hasData?: boolean;
|
||||
}>;
|
||||
cowName: string
|
||||
traitRanks: Record<string, TraitRankDto>
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md">
|
||||
<Card className="hidden lg:block bg-white border border-border rounded-xl overflow-hidden shadow-md">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<table className="w-full text-[1.5rem]">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border bg-muted/70">
|
||||
<th className="px-3 sm:px-5 py-4 text-center text-sm sm:text-base font-bold text-foreground">형질명</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">카테고리</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">육종가</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">전국 백분위</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground">유전형질</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left font-semibold text-foreground">유전체 육종가</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left font-semibold text-foreground">전국 백분위</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left font-semibold text-foreground">농가 내 순위</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left font-semibold text-foreground">보은군 내 순위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{traits.map((trait, idx) => (
|
||||
<tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 sm:px-5 py-4 text-center">
|
||||
<span className="text-sm sm:text-lg font-semibold text-foreground">{trait.shortName}</span>
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
{trait.traitCategory && (
|
||||
<span
|
||||
className={`inline-flex items-center text-xs sm:text-sm font-bold px-3 sm:px-4 py-1.5 rounded-full whitespace-nowrap border-2 ${CATEGORY_STYLES[trait.traitCategory]?.bg || 'bg-slate-50'} ${CATEGORY_STYLES[trait.traitCategory]?.text || 'text-slate-700'} ${CATEGORY_STYLES[trait.traitCategory]?.border || 'border-slate-200'}`}
|
||||
>
|
||||
{trait.traitCategory}
|
||||
{traits.map((trait, idx) => {
|
||||
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
|
||||
return (
|
||||
<tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 sm:px-5 py-4 text-center">
|
||||
<span className="font-medium text-foreground">{trait.shortName}</span>
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold ${(() => {
|
||||
const value = trait.traitVal ?? 0
|
||||
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
|
||||
if (value === 0) return 'text-muted-foreground'
|
||||
if (isNegativeTrait) {
|
||||
return value < 0 ? 'text-green-600' : 'text-red-600'
|
||||
}
|
||||
return value > 0 ? 'text-green-600' : 'text-red-600'
|
||||
})()}`}>
|
||||
{trait.traitVal !== undefined ? (
|
||||
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<span className="font-bold text-foreground">
|
||||
상위 {(trait.percentile || 0).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base sm:text-xl font-bold ${(() => {
|
||||
const value = trait.traitVal ?? 0
|
||||
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
|
||||
// 등지방두께: 음수가 좋음(녹색), 양수가 나쁨(빨간색)
|
||||
// 나머지: 양수가 좋음(녹색), 음수가 나쁨(빨간색)
|
||||
if (value === 0) return 'text-muted-foreground'
|
||||
if (isNegativeTrait) {
|
||||
return value < 0 ? 'text-green-600' : 'text-red-600'
|
||||
}
|
||||
return value > 0 ? 'text-green-600' : 'text-red-600'
|
||||
})()}`}>
|
||||
{trait.traitVal !== undefined ? (
|
||||
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<span className="font-bold text-foreground">
|
||||
{rankData?.farmRank && rankData.farmTotal ? (
|
||||
`${rankData.farmRank}위/${rankData.farmTotal}두`
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<span className="text-base sm:text-xl font-bold text-foreground">
|
||||
상위 {(trait.percentile || 0).toFixed(0)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<span className="font-bold text-foreground">
|
||||
{rankData?.regionRank && rankData.regionTotal ? (
|
||||
`${rankData.regionRank}위/${rankData.regionTotal}두`
|
||||
) : '-'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -103,12 +112,96 @@ function TraitListView({ traits, cowName }: {
|
||||
)
|
||||
}
|
||||
|
||||
// 카드 뷰 컴포넌트 (모바일)
|
||||
function TraitCardView({ traits, traitRanks }: {
|
||||
traits: Array<{
|
||||
traitName?: string;
|
||||
shortName: string;
|
||||
breedVal: number;
|
||||
percentile?: number;
|
||||
traitCategory?: string;
|
||||
traitVal?: number;
|
||||
hasData?: boolean;
|
||||
}>;
|
||||
traitRanks: Record<string, TraitRankDto>
|
||||
}) {
|
||||
return (
|
||||
<div className="lg:hidden space-y-3">
|
||||
{traits.map((trait, idx) => {
|
||||
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
|
||||
const value = trait.traitVal ?? 0
|
||||
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
|
||||
const valueColor = (() => {
|
||||
if (value === 0) return 'text-muted-foreground'
|
||||
if (isNegativeTrait) {
|
||||
return value < 0 ? 'text-green-600' : 'text-red-600'
|
||||
}
|
||||
return value > 0 ? 'text-green-600' : 'text-red-600'
|
||||
})()
|
||||
|
||||
return (
|
||||
<Card key={trait.traitName || idx} className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* 형질명 */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<span className="text-sm font-medium text-muted-foreground">유전형질</span>
|
||||
<span className="text-base font-bold text-foreground">{trait.shortName}</span>
|
||||
</div>
|
||||
|
||||
{/* 유전체 육종가 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">유전체 육종가</span>
|
||||
<span className={`text-base font-bold ${valueColor}`}>
|
||||
{trait.traitVal !== undefined ? (
|
||||
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 전국 백분위 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">전국 백분위</span>
|
||||
<span className="text-base font-bold text-foreground">
|
||||
상위 {(trait.percentile || 0).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 농가 내 순위 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">농가 내 순위</span>
|
||||
<span className="text-base font-bold text-foreground">
|
||||
{rankData?.farmRank && rankData.farmTotal ? (
|
||||
`${rankData.farmRank}위/${rankData.farmTotal}두`
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 보은군 내 순위 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">보은군 내 순위</span>
|
||||
<span className="text-base font-bold text-foreground">
|
||||
{rankData?.regionRank && rankData.regionTotal ? (
|
||||
`${rankData.regionRank}위/${rankData.regionTotal}두`
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 메인 컴포넌트
|
||||
export function TraitDistributionCharts({
|
||||
allTraits,
|
||||
regionAvgZ,
|
||||
farmAvgZ,
|
||||
cowName = '개체',
|
||||
cowNo,
|
||||
totalCowCount = 100,
|
||||
selectedTraits = [],
|
||||
traitWeights = {}
|
||||
@@ -153,6 +246,53 @@ export function TraitDistributionCharts({
|
||||
})
|
||||
}, [allTraits, selectedTraits, traitWeights])
|
||||
|
||||
// 표시할 형질명 목록 (순위 조회용)
|
||||
const traitNames = useMemo(() => {
|
||||
return displayTraits
|
||||
.filter(trait => trait.traitName && trait.hasData)
|
||||
.map(trait => trait.traitName!)
|
||||
.sort() // 정렬하여 안정적인 키 생성
|
||||
}, [displayTraits])
|
||||
|
||||
// 형질명 목록의 안정적인 키 (dependency용)
|
||||
const traitNamesKey = useMemo(() => {
|
||||
return traitNames.join(',')
|
||||
}, [traitNames])
|
||||
|
||||
// 각 형질의 순위 정보 가져오기
|
||||
const [traitRanks, setTraitRanks] = useState<Record<string, TraitRankDto>>({})
|
||||
const [loadingRanks, setLoadingRanks] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cowNo || traitNames.length === 0) return
|
||||
|
||||
const fetchRanks = async () => {
|
||||
setLoadingRanks(true)
|
||||
try {
|
||||
const rankPromises = traitNames.map(traitName =>
|
||||
genomeApi.getTraitRank(cowNo, traitName)
|
||||
.then(rank => ({ traitName, rank }))
|
||||
.catch(() => null)
|
||||
)
|
||||
|
||||
const results = await Promise.all(rankPromises)
|
||||
const ranksMap: Record<string, TraitRankDto> = {}
|
||||
results.forEach(result => {
|
||||
if (result) {
|
||||
ranksMap[result.traitName] = result.rank
|
||||
}
|
||||
})
|
||||
setTraitRanks(ranksMap)
|
||||
} catch (error) {
|
||||
console.error('순위 정보 로드 실패:', error)
|
||||
} finally {
|
||||
setLoadingRanks(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchRanks()
|
||||
}, [cowNo, traitNamesKey])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 헤더 */}
|
||||
@@ -166,8 +306,11 @@ export function TraitDistributionCharts({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리스트 뷰 */}
|
||||
<TraitListView traits={displayTraits} cowName={displayCowNumber} />
|
||||
{/* 테이블 뷰 (데스크탑) */}
|
||||
<TraitTableView traits={displayTraits} traitRanks={traitRanks} />
|
||||
|
||||
{/* 카드 뷰 (모바일) */}
|
||||
<TraitCardView traits={displayTraits} traitRanks={traitRanks} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 개체 정보 섹션 */}
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">개체 정보</h3>
|
||||
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">개체 정보</h3>
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{/* 데스크탑: 가로 그리드 */}
|
||||
@@ -161,7 +161,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
{/* 검사 정보 */}
|
||||
{selectedMpt && (
|
||||
<>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">검사 정보</h3>
|
||||
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">검사 정보</h3>
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
||||
@@ -245,22 +245,22 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
{/* 혈액화학검사 결과 테이블 - selectedMpt가 있을 때만 표시 */}
|
||||
{selectedMpt ? (
|
||||
<>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">혈액화학검사 결과</h3>
|
||||
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground">혈액화학검사 결과</h3>
|
||||
|
||||
{/* 데스크탑: 테이블 */}
|
||||
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<table className="w-full text-[1.5rem]">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border">
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}>카테고리</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground" style={{ width: '18%' }}>검사항목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '15%' }}>측정값</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}>하한값</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}>상한값</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '15%' }}>단위</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '16%' }}>상태</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}>카테고리</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-muted-foreground" style={{ width: '18%' }}>검사항목</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}>측정값</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}>하한값</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}>상한값</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}>단위</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '16%' }}>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -275,14 +275,14 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
{itemIdx === 0 && (
|
||||
<td
|
||||
rowSpan={category.items.length}
|
||||
className={`px-4 py-3 text-sm font-semibold text-foreground ${category.color} align-middle text-center`}
|
||||
className={`px-4 py-3 font-semibold text-foreground ${category.color} align-middle text-center`}
|
||||
>
|
||||
{category.name}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-sm font-medium text-foreground">{ref?.name || itemKey}</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">{ref?.name || itemKey}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`text-lg font-bold ${
|
||||
<span className={`font-bold ${
|
||||
status === 'safe' ? 'text-green-600' :
|
||||
status === 'caution' ? 'text-amber-600' :
|
||||
'text-muted-foreground'
|
||||
@@ -290,12 +290,12 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.lowerLimit ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.upperLimit ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.unit || '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.lowerLimit ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.upperLimit ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.unit || '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{value !== null && value !== undefined ? (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold ${
|
||||
<span className={`inline-flex items-center px-4 py-1 rounded-full font-semibold ${
|
||||
status === 'safe' ? 'bg-green-100 text-green-700' :
|
||||
status === 'caution' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
|
||||
@@ -733,10 +733,10 @@ function MyCowContent() {
|
||||
setItemsPerPage(parseInt(value, 10))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] sm:w-[140px] h-12 sm:h-14 text-lg sm:text-xl font-medium">
|
||||
<SelectTrigger className="cnt_per_list w-[120px] sm:w-[140px] h-12 sm:h-14 text-lg sm:text-xl font-medium">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="cnt_per_list">
|
||||
<SelectContent className="cnt_per_list_opts">
|
||||
<SelectItem value="50" className="text-lg sm:text-xl">50두</SelectItem>
|
||||
<SelectItem value="100" className="text-lg sm:text-xl">100두</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -814,7 +814,7 @@ function MyCowContent() {
|
||||
{/* 데스크톱 테이블 뷰 */}
|
||||
{(
|
||||
<div className="hidden md:block border rounded-lg overflow-hidden">
|
||||
<div ref={tableScrollRef} className="overflow-y-auto" style={{ height: 'calc(100vh - 405px)', maxHeight: 'calc(100vh - 350px)' }}>
|
||||
<div ref={tableScrollRef} className="cows_list_table overflow-y-auto" style={{ height: 'calc(100vh - 410px)', maxHeight: 'calc(100vh - 350px)' }}>
|
||||
<table className="w-full">
|
||||
<thead className="border-b sticky top-0 z-1">
|
||||
{/* <thead className="border-b"> */}
|
||||
@@ -1067,9 +1067,19 @@ function MyCowContent() {
|
||||
{cow.genomeScore.toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold">
|
||||
분석불가
|
||||
</Badge>
|
||||
|
||||
cow.anlysDt ? (
|
||||
<Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold">
|
||||
분석불가
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="text-sm px-3 py-1.5 bg-slate-300 text-white border-0 font-semibold">
|
||||
검사결과없음
|
||||
</Badge>
|
||||
)
|
||||
// <Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold">
|
||||
// 분석불가
|
||||
// </Badge>
|
||||
)}
|
||||
</td>
|
||||
{selectedDisplayGenes.length > 0 && (
|
||||
@@ -1293,9 +1303,15 @@ function MyCowContent() {
|
||||
{cow.genomeScore.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
cow.anlysDt ? (
|
||||
<Badge className="text-xs px-2 py-1 bg-slate-500 text-white border-0 font-medium">
|
||||
분석불가
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="text-xs px-2 py-1 bg-slate-300 text-white border-0 font-medium">
|
||||
검사결과없음
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem; /* 16px */
|
||||
font-size: 1.0rem; /* 16px */
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user