update_cow_detail_page

This commit is contained in:
NYD
2026-01-08 16:04:01 +09:00
parent 9e5ffb2c15
commit f8ff86e4ea
9 changed files with 1594 additions and 1243 deletions

View File

@@ -1511,14 +1511,38 @@ export class GenomeService {
const maxEpd = Math.max(...epdValues.map(v => v.epd));
const range = maxEpd - minEpd;
// 구간 크기 결정 (전체 범위를 약 20-30개 구간으로 나눔)
const binSize = range > 0 ? Math.ceil(range / 25) : 1;
// rate 형질 여부 확인 (형질명에 'rate' 또는 'Rate' 포함)
const isRateTrait = traitName.toLowerCase().includes('rate');
// 구간 크기 결정
let binSize: number;
if (isRateTrait) {
// rate 형질: 소수점 binSize 사용 (더 촘촘한 구간)
binSize = range > 0 ? range / 25 : 0.1;
// 너무 작으면 최소값 보장
if (binSize < 0.1) binSize = 0.1;
// 소수점 둘째자리까지 반올림
binSize = Math.round(binSize * 100) / 100;
console.log(`📊 [${traitName}] rate 형질 히스토그램 생성:`, {
: `${minEpd.toFixed(2)} ~ ${maxEpd.toFixed(2)}`,
range: range.toFixed(2),
binSize: binSize.toFixed(2),
: '소수점'
});
} else {
// 일반 형질: 기존 로직 (정수 binSize)
binSize = range > 0 ? Math.ceil(range / 25) : 1;
}
// 구간별 집계
const binMap = new Map<number, { count: number; farmCount: number }>();
epdValues.forEach(({ epd, farmNo: scoreFarmNo }) => {
const binStart = Math.floor(epd / binSize) * binSize;
// rate 형질은 소수점 구간, 일반 형질은 정수 구간
const binStart = isRateTrait
? Math.round((Math.floor(epd / binSize) * binSize) * 100) / 100 // 소수점 둘째자리까지
: Math.floor(epd / binSize) * binSize;
const existing = binMap.get(binStart) || { count: 0, farmCount: 0 };
existing.count += 1;
if (scoreFarmNo === farmNo) {
@@ -1528,10 +1552,21 @@ export class GenomeService {
});
// Map을 배열로 변환 및 정렬
histogram.push(...Array.from(binMap.entries())
const sortedHistogram = Array.from(binMap.entries())
.map(([bin, data]) => ({ bin, count: data.count, farmCount: data.farmCount }))
.sort((a, b) => a.bin - b.bin)
);
.sort((a, b) => a.bin - b.bin);
histogram.push(...sortedHistogram);
// rate 형질일 때만 로그 출력
if (isRateTrait && sortedHistogram.length > 0) {
console.log(`📊 [${traitName}] 최종 히스토그램:`, {
구간수: sortedHistogram.length,
첫구간: sortedHistogram[0].bin,
마지막구간: sortedHistogram[sortedHistogram.length - 1].bin,
샘플: sortedHistogram.slice(0, 5).map(h => `${h.bin.toFixed(2)}(${h.count}마리)`)
});
}
}
}

View File

@@ -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)
// 형질별 평균 데이터에서 해당 형질 찾기
@@ -132,16 +171,49 @@ export function CategoryEvaluationCard({
hasData: !!trait
}
})
}, [chartTraits, activeTraits, allTraits, traitComparisonAverages])
// 가장 높은 형질 찾기 (이 개체 기준)
const bestTraitName = traitChartData.reduce((best, current) =>
current.breedVal > best.breedVal ? current : best
, traitChartData[0])?.shortName
// 동적 스케일 계산 (모든 값의 최대 절대값 기준)
// 동적 스케일 계산 (실제 데이터 범위를 기반으로, 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 maxAbsValue = Math.max(...allValues.map(Math.abs), 0.3) // 최소 0.3
const dynamicDomain = Math.ceil(maxAbsValue * 1.2 * 10) / 10 // 20% 여유
// 실제 데이터의 최소값과 최대값 찾기
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]'}>
{/* 범례 - 좌측 상단 */}
<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: 45, bottom: 40, left: 45 }}>
<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) 값으로 표시 */}

View File

@@ -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)}

View File

@@ -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,43 +41,35 @@ 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) => (
{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="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}
</span>
)}
<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={`text-base sm:text-xl font-bold ${(() => {
<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'
@@ -89,12 +83,27 @@ function TraitListView({ traits, cowName }: {
</div>
</td>
<td className="px-3 sm:px-5 py-4 text-left">
<span className="text-base sm:text-xl font-bold text-foreground">
<span className="font-bold text-foreground">
{(trait.percentile || 0).toFixed(0)}%
</span>
</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>
</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} />
</>
)
}

View File

@@ -90,7 +90,16 @@ export default function CowOverviewPage() {
const [geneDataLoaded, setGeneDataLoaded] = useState(false)
const [geneDataLoading, setGeneDataLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<string>('genome')
const [activeTab, setActiveTab] = useState<string>(() => {
// 목록에서 진입 시 초기화
if (from === 'list') return 'genome'
// 그 외에는 localStorage에서 복원
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(`cowDetailActiveTab_${cowNo}`)
return saved || 'genome'
}
return 'genome'
})
// 2. 검사 상태
const [hasGenomeData, setHasGenomeData] = useState(false)
@@ -142,14 +151,74 @@ export default function CowOverviewPage() {
})
// 7. 유전자 탭 필터/정렬
const [geneSearchInput, setGeneSearchInput] = useState('')
const [geneSearchKeyword, setGeneSearchKeyword] = useState('')
const [geneSearchInput, setGeneSearchInput] = useState(() => {
if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('geneSearchInput')
return saved || ''
}
return ''
})
const [geneSearchKeyword, setGeneSearchKeyword] = useState(() => {
if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('geneSearchKeyword')
return saved || ''
}
return ''
})
const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all')
const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all')
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
const [geneCurrentPage, setGeneCurrentPage] = useState(1)
const GENES_PER_PAGE = 50
// 무한 스크롤 페이지네이션
const [geneCurrentLoadedPage, setGeneCurrentLoadedPage] = useState(() => {
if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('geneCurrentLoadedPage')
return saved ? parseInt(saved, 10) : 1
}
return 1
})
const [genesPerPage, setGenesPerPage] = useState(() => {
if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('genesPerPage')
return saved ? parseInt(saved, 10) : 50
}
return 50
})
const [isLoadingMoreGenes, setIsLoadingMoreGenes] = useState(false)
// ========================================
// useEffect - localStorage 저장 (유전자 탭)
// ========================================
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('geneSearchInput', geneSearchInput)
}
}, [geneSearchInput])
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('geneSearchKeyword', geneSearchKeyword)
}
}, [geneSearchKeyword])
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('genesPerPage', genesPerPage.toString())
}
}, [genesPerPage])
// 검색어 또는 genesPerPage 변경 시 1페이지로 리셋
useEffect(() => {
setGeneCurrentLoadedPage(1)
}, [geneSearchKeyword, genesPerPage])
// activeTab 변경 시 localStorage 저장 (목록에서 진입 시 제외)
useEffect(() => {
if (typeof window !== 'undefined' && from !== 'list') {
localStorage.setItem(`cowDetailActiveTab_${cowNo}`, activeTab)
}
}, [activeTab, cowNo, from])
// ========================================
// useEffect - UI 이벤트
@@ -176,11 +245,18 @@ export default function CowOverviewPage() {
useEffect(() => {
const timer = setTimeout(() => {
setGeneSearchKeyword(geneSearchInput)
setGeneCurrentPage(1)
setGeneCurrentLoadedPage(1)
}, 300)
return () => clearTimeout(timer)
}, [geneSearchInput])
// 유전자 테이블 무한 스크롤: geneCurrentLoadedPage가 변경되면 localStorage에 저장
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('geneCurrentLoadedPage', geneCurrentLoadedPage.toString())
}
}, [geneCurrentLoadedPage])
// ========================================
// 헬퍼 함수
// ========================================
@@ -342,12 +418,14 @@ export default function CowOverviewPage() {
setHasReproductionData(false)
}
// 5. 탭 자동 선택
// 5. 탭 자동 선택 (목록에서 진입하거나 저장된 탭이 없을 때만)
if (from === 'list' || (typeof window !== 'undefined' && !localStorage.getItem(`cowDetailActiveTab_${cowNo}`))) {
if (genomeExists) {
setActiveTab('genome')
} else if (geneData && geneData.length > 0) {
setActiveTab('gene')
}
}
// 6. 비교 데이터 + 선발지수 조회
if (genomeDataResult.length > 0) {
@@ -481,6 +559,86 @@ export default function CowOverviewPage() {
// 정규분포 곡선 데이터 (전국/지역/농가 비교 차트)
const multiDistribution = useMemo(() => generateMultipleDistributions(0, 1, regionAvgZ, 1, farmAvgZ, 1), [regionAvgZ, farmAvgZ])
// 유전자 데이터 필터링 및 정렬 (useMemo로 최상위에서 관리)
const filteredAndSortedGeneData = useMemo(() => {
const filteredData = geneData.filter(gene => {
// 검색 필터
if (geneSearchKeyword) {
const keyword = geneSearchKeyword.toLowerCase()
const snpName = (gene.snpName || '').toLowerCase()
const chromosome = (gene.chromosome || '').toLowerCase()
const position = (gene.position || '').toLowerCase()
const snpType = (gene.snpType || '').toLowerCase()
const allele1 = (gene.allele1 || '').toLowerCase()
const allele2 = (gene.allele2 || '').toLowerCase()
const remarks = (gene.remarks || '').toLowerCase()
if (!snpName.includes(keyword) &&
!chromosome.includes(keyword) &&
!position.includes(keyword) &&
!snpType.includes(keyword) &&
!allele1.includes(keyword) &&
!allele2.includes(keyword) &&
!remarks.includes(keyword)) {
return false
}
}
// 유전자형 필터
if (genotypeFilter !== 'all') {
const isHomozygous = gene.allele1 === gene.allele2
if (genotypeFilter === 'homozygous' && !isHomozygous) return false
if (genotypeFilter === 'heterozygous' && isHomozygous) return false
}
return true
})
// 정렬
return [...filteredData].sort((a, b) => {
let aVal: string | number = ''
let bVal: string | number = ''
switch (geneSortBy) {
case 'snpName':
aVal = a.snpName || ''
bVal = b.snpName || ''
break
case 'chromosome':
aVal = parseInt(a.chromosome || '0') || 0
bVal = parseInt(b.chromosome || '0') || 0
break
case 'position':
aVal = parseInt(a.position || '0') || 0
bVal = parseInt(b.position || '0') || 0
break
case 'snpType':
aVal = a.snpType || ''
bVal = b.snpType || ''
break
case 'allele1':
aVal = a.allele1 || ''
bVal = b.allele1 || ''
break
case 'allele2':
aVal = a.allele2 || ''
bVal = b.allele2 || ''
break
case 'remarks':
aVal = a.remarks || ''
bVal = b.remarks || ''
break
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal
}
const strA = String(aVal)
const strB = String(bVal)
return geneSortOrder === 'asc'
? strA.localeCompare(strB)
: strB.localeCompare(strA)
})
}, [geneData, geneSearchKeyword, genotypeFilter, geneSortBy, geneSortOrder])
const toggleTraitSelection = (traitId: number) => {
setSelectedTraits(prev =>
prev.includes(traitId)
@@ -489,6 +647,24 @@ export default function CowOverviewPage() {
)
}
// 유전자 테이블 스크롤 핸들러 (간단하게 함수로만 정의)
const handleGeneTableScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget
const { scrollTop, scrollHeight, clientHeight } = target
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100
if (isNearBottom && !isLoadingMoreGenes) {
const totalPages = Math.ceil(filteredAndSortedGeneData.length / genesPerPage)
if (geneCurrentLoadedPage < totalPages) {
setIsLoadingMoreGenes(true)
setTimeout(() => {
setGeneCurrentLoadedPage(prev => prev + 1)
setIsLoadingMoreGenes(false)
}, 300)
}
}
}
if (loading) {
return (
<SidebarProvider>
@@ -513,9 +689,9 @@ export default function CowOverviewPage() {
<AppSidebar />
<SidebarInset>
<SiteHeader />
<main className="flex-1 overflow-y-auto bg-white min-h-screen">
<main className="flex-1 overflow-y-auto bg-white">
{/* 메인 컨테이너 여백 : p-6 */}
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6">
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6" style={{ paddingBottom: '0px' }}>
{/* 헤더: 뒤로가기 + 타이틀 + 다운로드 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-4">
@@ -547,13 +723,13 @@ export default function CowOverviewPage() {
{/* 탭 네비게이션 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full grid grid-cols-3 bg-transparent p-0 h-auto gap-0 rounded-none border-b border-border">
<TabsList className="tabs_nav_area w-full grid grid-cols-3 bg-transparent p-0 h-auto gap-0 rounded-none border-b border-border">
<TabsTrigger
value="genome"
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
>
<BarChart3 className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span>
<span className="font-bold text-sm lg:!text-[1.5rem]"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? '완료' : '미검사'}
</span>
@@ -563,7 +739,7 @@ export default function CowOverviewPage() {
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
>
<Dna className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span>
<span className="font-bold text-sm lg:!text-[1.5rem]"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? '완료' : '미검사'}
</span>
@@ -573,19 +749,21 @@ export default function CowOverviewPage() {
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
>
<Activity className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span>
<span className="font-bold text-sm lg:!text-[1.5rem]"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasReproductionData ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasReproductionData ? '완료' : '미검사'}
</span>
</TabsTrigger>
</TabsList>
{/* 탭 콘텐츠 영역 */}
<div className="tab_contents_area h-[calc(100vh-215px)] sm:h-[calc(100vh-260px)] lg:h-[calc(100vh-275px)] overflow-y-auto">
{/* 유전체 분석 탭 */}
<TabsContent value="genome" className="mt-6 space-y-6">
{hasGenomeData ? (
<>
{/* 개체 정보 섹션 */}
<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">
@@ -669,7 +847,7 @@ export default function CowOverviewPage() {
</Card>
{/* 친자확인 섹션 */}
<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">
@@ -730,7 +908,7 @@ export default function CowOverviewPage() {
{isValidGenomeAnalysis(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) ? (
<>
{/* 농가 및 보은군 내 개체 위치 */}
<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>
<div ref={distributionChartRef}>
<NormalDistributionChart
multiDistribution={multiDistribution}
@@ -771,7 +949,7 @@ export default function CowOverviewPage() {
</div>
{/* 유전체 형질별 육종가 비교 */}
<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>
<CategoryEvaluationCard
categoryStats={categoryStats}
comparisonAverages={comparisonAverages}
@@ -783,42 +961,48 @@ export default function CowOverviewPage() {
hideTraitCards={true}
/>
<h3 className="text-lg lg:text-xl font-bold text-foreground mt-6"> </h3>
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground mt-6"> </h3>
<TraitDistributionCharts
allTraits={GENOMIC_TRAITS}
regionAvgZ={regionAvgZ}
farmAvgZ={farmAvgZ}
cowName={cow?.cowId || cowNo}
cowNo={cow?.cowId || cowNo}
totalCowCount={totalCowCount}
selectedTraits={filterSelectedTraitData}
traitWeights={filters.traitWeights}
/>
<h3 className="text-lg lg:text-xl font-bold text-foreground mt-6"> </h3>
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground mt-6"> </h3>
<div className="analysis_info_notice bg-blue-50 border border-blue-200 rounded-xl p-4 sm:p-5 text-sm sm:text-base text-foreground leading-relaxed">
<p> '한우암소 유전체 분석 서비스' .</p>
<p>--- .</p>
<p> 6 , '25.8.1. ~ '26.1.31. .</p>
<p> (SNP) , .</p>
</div>
<Card className="bg-white border border-border rounded-xl overflow-hidden">
<CardContent className="p-0">
<div className="grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-border">
<div className="p-3 sm:p-4 flex justify-between sm:block">
<div className="text-xs font-medium text-muted-foreground sm:mb-1"></div>
<div className="text-sm font-semibold text-foreground">
<div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1"></div>
<div className="text-[1.3rem] font-semibold text-foreground">
{genomeData[0]?.request?.requestDt
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
: '-'}
</div>
</div>
<div className="p-3 sm:p-4 flex justify-between sm:block">
<div className="text-xs font-medium text-muted-foreground sm:mb-1"> </div>
<div className="text-sm font-semibold text-foreground">
<div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1"> </div>
<div className="text-[1.3rem] font-semibold text-foreground">
{genomeData[0]?.request?.chipReportDt
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
: '-'}
</div>
</div>
<div className="p-3 sm:p-4 flex justify-between sm:block">
<div className="text-xs font-medium text-muted-foreground sm:mb-1"> </div>
<div className="text-sm font-semibold text-foreground">
<div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1"> </div>
<div className="text-[1.3rem] font-semibold text-foreground">
{genomeData[0]?.request?.chipType || '-'}
</div>
</div>
@@ -828,7 +1012,7 @@ export default function CowOverviewPage() {
</>
) : (
<>
<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">
@@ -863,7 +1047,7 @@ export default function CowOverviewPage() {
) : (
<>
{/* 개체 정보 섹션 */}
<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">
@@ -939,7 +1123,7 @@ export default function CowOverviewPage() {
</Card>
{/* 친자확인 섹션 */}
<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">
@@ -1024,7 +1208,7 @@ export default function CowOverviewPage() {
) : hasGeneData ? (
<>
{/* 개체 정보 섹션 (유전체 탭과 동일) */}
<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">
@@ -1108,7 +1292,7 @@ export default function CowOverviewPage() {
</Card>
{/* 친자확인 결과 섹션 (유전체 탭과 동일) */}
<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">
@@ -1166,7 +1350,24 @@ export default function CowOverviewPage() {
</Card>
{/* 유전자 검색 및 필터 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground"> </h3>
<div className="flex items-center gap-2">
<Select value={genesPerPage.toString()} onValueChange={(value) => {
setGenesPerPage(parseInt(value, 10))
setGeneCurrentLoadedPage(1)
}}>
<SelectTrigger className="w-[90px] h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="1000">1000</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 유전자 탭 분기: 분석불가/정보없음만 차단, 불일치/이력제부재는 유전자 데이터 표시 */}
{!(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? (
@@ -1184,9 +1385,9 @@ export default function CowOverviewPage() {
</div>
{/* 필터 옵션들 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3">
{/* <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3"> */}
{/* 유전자 타입 필터 */}
<div className="flex items-center gap-2">
{/* <div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600 shrink-0">구분:</span>
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
<button
@@ -1217,10 +1418,10 @@ export default function CowOverviewPage() {
육질형
</button>
</div>
</div>
</div> */}
{/* 정렬 드롭다운 */}
<div className="flex items-center gap-2 sm:ml-auto">
{/* <div className="flex items-center gap-2 sm:ml-auto">
<Select
value={geneSortBy}
onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks') => setGeneSortBy(value)}
@@ -1250,257 +1451,80 @@ export default function CowOverviewPage() {
<SelectItem value="desc">내림차순</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div> */}
{/* </div> */}
</div>
{/* 유전자 테이블/카드 */}
{(() => {
const filteredData = geneData.filter(gene => {
// 검색 필터 (테이블의 모든 필드 검색)
if (geneSearchKeyword) {
const keyword = geneSearchKeyword.toLowerCase()
const snpName = (gene.snpName || '').toLowerCase()
const chromosome = (gene.chromosome || '').toLowerCase()
const position = (gene.position || '').toLowerCase()
const snpType = (gene.snpType || '').toLowerCase()
const allele1 = (gene.allele1 || '').toLowerCase()
const allele2 = (gene.allele2 || '').toLowerCase()
const remarks = (gene.remarks || '').toLowerCase()
if (!snpName.includes(keyword) &&
!chromosome.includes(keyword) &&
!position.includes(keyword) &&
!snpType.includes(keyword) &&
!allele1.includes(keyword) &&
!allele2.includes(keyword) &&
!remarks.includes(keyword)) {
return false
}
}
// 유전자형 필터
if (genotypeFilter !== 'all') {
const isHomozygous = gene.allele1 === gene.allele2
if (genotypeFilter === 'homozygous' && !isHomozygous) return false
if (genotypeFilter === 'heterozygous' && isHomozygous) return false
}
return true
})
// 정렬
const sortedData = [...filteredData].sort((a, b) => {
let aVal: string | number = ''
let bVal: string | number = ''
switch (geneSortBy) {
case 'snpName':
aVal = a.snpName || ''
bVal = b.snpName || ''
break
case 'chromosome':
aVal = parseInt(a.chromosome || '0') || 0
bVal = parseInt(b.chromosome || '0') || 0
break
case 'position':
aVal = parseInt(a.position || '0') || 0
bVal = parseInt(b.position || '0') || 0
break
case 'snpType':
aVal = a.snpType || ''
bVal = b.snpType || ''
break
case 'allele1':
aVal = a.allele1 || ''
bVal = b.allele1 || ''
break
case 'allele2':
aVal = a.allele2 || ''
bVal = b.allele2 || ''
break
case 'remarks':
aVal = a.remarks || ''
bVal = b.remarks || ''
break
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal
}
const strA = String(aVal)
const strB = String(bVal)
return geneSortOrder === 'asc'
? strA.localeCompare(strB)
: strB.localeCompare(strA)
})
// 페이지네이션 계산
const totalPages = Math.ceil(sortedData.length / GENES_PER_PAGE)
const startIndex = (geneCurrentPage - 1) * GENES_PER_PAGE
const endIndex = startIndex + GENES_PER_PAGE
const displayData = sortedData.length > 0
? sortedData.slice(startIndex, endIndex)
: Array(10).fill(null)
// 페이지네이션 UI 컴포넌트
const PaginationUI = () => {
if (sortedData.length <= GENES_PER_PAGE) return null
// 표시할 페이지 번호들 계산 (모바일: 3개 단순, 데스크탑: 5개 + 1/마지막 고정)
const getPageNumbers = () => {
const pages: (number | string)[] = []
const showPages = isMobile ? 3 : 5
const offset = isMobile ? 1 : 2
let start = Math.max(1, geneCurrentPage - offset)
let end = Math.min(totalPages, start + showPages - 1)
if (end - start < showPages - 1) {
start = Math.max(1, end - showPages + 1)
}
// 모바일: 현재 페이지 기준 앞뒤만 표시 (1, 마지막 고정 없음)
if (isMobile) {
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// 데스크탑: 1과 마지막 페이지 고정
if (start > 1) {
pages.push(1)
if (start > 2) pages.push('...')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < totalPages) {
if (end < totalPages - 1) pages.push('...')
pages.push(totalPages)
}
return pages
}
return (
<div className="px-3 sm:px-4 py-3 bg-muted/30 border-t flex flex-col sm:flex-row items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{sortedData.length.toLocaleString()} {startIndex + 1}-{Math.min(endIndex, sortedData.length)}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(1)}
disabled={geneCurrentPage === 1}
className="px-2.5 h-9 text-sm"
>
«
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(p => Math.max(1, p - 1))}
disabled={geneCurrentPage === 1}
className="px-2.5 h-9 text-sm"
>
</Button>
{getPageNumbers().map((page, idx) => (
typeof page === 'number' ? (
<Button
key={idx}
variant={geneCurrentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setGeneCurrentPage(page)}
className="px-2.5 min-w-[36px] h-9 text-sm"
>
{page}
</Button>
) : (
<span key={idx} className="px-1 text-sm text-muted-foreground">...</span>
)
))}
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={geneCurrentPage === totalPages}
className="px-2.5 h-9 text-sm"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(totalPages)}
disabled={geneCurrentPage === totalPages}
className="px-2.5 h-9 text-sm"
>
»
</Button>
</div>
</div>
)
}
// 무한 스크롤 계산
const totalItems = geneCurrentLoadedPage * genesPerPage
const displayData = filteredAndSortedGeneData.length > 0
? filteredAndSortedGeneData.slice(0, totalItems)
: []
return (
<>
{/* 데스크톱: 테이블 */}
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<div className="hidden lg:block mb-0">
<Card className="snp_result_table bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<div>
<table className="w-full table-fixed">
<thead className="bg-muted/50 border-b border-border">
<div onScroll={handleGeneTableScroll} className="overflow-y-auto" style={{ height: 'calc(100vh - 470px)', maxHeight: 'calc(100vh - 420px)' }}>
<table className="w-full table-fixed text-[1.5rem]">
<thead className="bg-slate-50 border-b border-border sticky top-0 z-1">
<tr>
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[22%]">SNP </th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[10%]"> </th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">Position</th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">SNP </th>
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[20%]"></th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground w-[22%]">SNP </th>
<th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[10%]"> </th>
<th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[11%]">Position</th>
<th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[11%]">SNP </th>
<th className="px-2 py-3 text-center font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-2 py-3 text-center font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground w-[20%]"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{displayData.map((gene, idx) => {
if (!gene) {
return (
{displayData.map((gene, idx) => (
<tr key={idx} className="hover:bg-muted/30">
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td>
<td className="px-4 py-3 text-center font-medium text-foreground">{gene.snpName || '-'}</td>
<td className="px-3 py-3 text-center text-foreground">{gene.chromosome || '-'}</td>
<td className="px-3 py-3 text-center text-foreground">{gene.position || '-'}</td>
<td className="px-3 py-3 text-center text-foreground">{gene.snpType || '-'}</td>
<td className="px-2 py-3 text-center text-foreground">{gene.allele1 || '-'}</td>
<td className="px-2 py-3 text-center text-foreground">{gene.allele2 || '-'}</td>
<td className="px-4 py-3 text-center text-muted-foreground">{gene.remarks || '-'}</td>
</tr>
)
}
return (
<tr key={idx} className="hover:bg-muted/30">
<td className="px-4 py-3 text-center text-base font-medium text-foreground">{gene.snpName || '-'}</td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.chromosome || '-'}</td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.position || '-'}</td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.snpType || '-'}</td>
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele1 || '-'}</td>
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele2 || '-'}</td>
<td className="px-4 py-3 text-center text-base text-muted-foreground">{gene.remarks || '-'}</td>
))}
{isLoadingMoreGenes && (
<tr>
<td colSpan={7} className="px-4 py-3 text-center text-sm text-muted-foreground">
...
</td>
</tr>
)
})}
)}
</tbody>
</table>
</div>
<PaginationUI />
</CardContent>
</Card>
{/* 현황 정보 표시 */}
<div className="flex items-center justify-center py-4 border-t">
<span className="text-base font-bold text-muted-foreground">
{filteredAndSortedGeneData.length > 0 ? (
<>
{filteredAndSortedGeneData.length.toLocaleString()} 1-{displayData.length.toLocaleString()}
{isLoadingMoreGenes && ' (로딩 중...)'}
</>
) : (
'데이터 없음'
)}
</span>
</div>
</div>
{/* 모바일: 카드 뷰 */}
<div className="lg:hidden space-y-3">
{displayData.map((gene, idx) => {
return (
<div className="lg:hidden">
<div onScroll={handleGeneTableScroll} className="space-y-3 overflow-y-auto" style={{ height: 'calc(100vh - 470px)', maxHeight: 'calc(100vh - 420px)' }}>
{displayData.map((gene, idx) => (
<Card key={idx} className="bg-white border border-border shadow-sm rounded-xl">
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between">
@@ -1533,11 +1557,26 @@ export default function CowOverviewPage() {
</div>
</CardContent>
</Card>
)
})}
))}
{isLoadingMoreGenes && (
<div className="text-center py-4 text-sm text-muted-foreground">
...
</div>
)}
</div>
{/* 현황 정보 표시 */}
<div className="flex items-center justify-center py-4 border-t">
<span className="text-sm font-bold text-muted-foreground">
{filteredAndSortedGeneData.length > 0 ? (
<>
{filteredAndSortedGeneData.length.toLocaleString()} 1-{displayData.length.toLocaleString()}
{isLoadingMoreGenes && ' (로딩 중...)'}
</>
) : (
'데이터 없음'
)}
</span>
</div>
<div className="lg:hidden">
<PaginationUI />
</div>
</>
)
@@ -1560,7 +1599,7 @@ export default function CowOverviewPage() {
) : (
<>
{/* 개체 정보 섹션 */}
<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">
@@ -1686,7 +1725,7 @@ export default function CowOverviewPage() {
</Card>
{/* 유전자 분석 결과 섹션 */}
<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-slate-100 border border-slate-300 rounded-2xl">
<CardContent className="p-8 text-center">
<Dna className="h-12 w-12 text-slate-400 mx-auto mb-4" />
@@ -1705,12 +1744,12 @@ export default function CowOverviewPage() {
)}
</TabsContent>
{/* 번식능력 탭 */}
<TabsContent value="reproduction" className="mt-6 space-y-6">
{/* 혈액화학검사(MPT) 테이블 */}
<MptTable cowShortNo={cowNo?.slice(-4)} cowNo={cowNo} farmNo={cow?.fkFarmNo} cow={cow} genomeRequest={genomeRequest} />
</TabsContent>
</div>
</Tabs>
</div>
</main>

View File

@@ -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'

View File

@@ -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>
) : (
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>

View File

@@ -110,7 +110,7 @@
}
.text-base {
font-size: 1rem; /* 16px */
font-size: 1.0rem; /* 16px */
line-height: 1.6;
}

View File

@@ -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
)}