번식 능력 검사 리스트 및 보고서 수정

This commit is contained in:
2025-12-22 19:52:38 +09:00
parent d3dda3d929
commit 1644fcf241
15 changed files with 916 additions and 407 deletions

View File

@@ -14,7 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { useMediaQuery } from "@/hooks/use-media-query"
import { useToast } from "@/hooks/use-toast"
import { ComparisonAveragesDto, cowApi, geneApi, GeneDetail, genomeApi, GenomeRequestDto, TraitComparisonAveragesDto } from "@/lib/api"
import { ComparisonAveragesDto, cowApi, geneApi, GeneDetail, genomeApi, GenomeRequestDto, TraitComparisonAveragesDto, mptApi } from "@/lib/api"
import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait } from "@/types/genome.types"
@@ -35,6 +35,7 @@ import { CategoryEvaluationCard } from "./genome/_components/category-evaluation
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
import { TraitDistributionCharts } from "./genome/_components/trait-distribution-charts"
import { MptTable } from "./reproduction/_components/mpt-table"
// 형질명 → 카테고리 매핑 (한우 35개 형질)
const TRAIT_CATEGORY_MAP: Record<string, string> = {
@@ -405,9 +406,13 @@ export default function CowOverviewPage() {
setGenomeRequest(null)
}
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
// TODO: 번식능력 API 연동
setHasReproductionData(false)
// 번식능력 데이터 조회
try {
const mptData = await mptApi.findByCowId(cowNo)
setHasReproductionData(mptData && mptData.length > 0)
} catch {
setHasReproductionData(false)
}
// 첫 번째 사용 가능한 탭 자동 선택
if (genomeExists) {
@@ -617,15 +622,19 @@ export default function CowOverviewPage() {
<ArrowLeft className="h-7 w-7 sm:h-4 sm:w-4" />
<span className="hidden sm:inline text-sm"></span>
</Button>
{/* 아이콘 */}
<div className="w-10 h-10 sm:w-14 sm:h-14 bg-primary rounded-xl flex items-center justify-center flex-shrink-0">
<BarChart3 className="h-5 w-5 sm:h-7 sm:w-7 text-primary-foreground" />
</div>
{/* 타이틀 */}
<div>
<h1 className="text-lg sm:text-3xl lg:text-4xl font-bold text-foreground"> </h1>
<p className="text-sm sm:text-lg text-muted-foreground">Analysis Report</p>
</div>
{/* 아이콘 + 타이틀 (클릭시 새로고침) */}
<button
onClick={() => window.location.reload()}
className="flex items-center gap-3 sm:gap-4 hover:opacity-80 transition-opacity cursor-pointer"
>
<div className="w-10 h-10 sm:w-14 sm:h-14 bg-primary rounded-xl flex items-center justify-center flex-shrink-0">
<BarChart3 className="h-5 w-5 sm:h-7 sm:w-7 text-primary-foreground" />
</div>
<div className="text-left">
<h1 className="text-lg sm:text-3xl lg:text-4xl font-bold text-foreground"> </h1>
<p className="text-sm sm:text-lg text-muted-foreground">Analysis Report</p>
</div>
</button>
</div>
</div>
@@ -658,6 +667,9 @@ export default function CowOverviewPage() {
>
<Activity className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></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>
@@ -692,12 +704,12 @@ export default function CowOverviewPage() {
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
<span className="text-base font-semibold text-muted-foreground"> ( )</span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
@@ -730,10 +742,10 @@ export default function CowOverviewPage() {
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> ()</span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
@@ -969,12 +981,12 @@ export default function CowOverviewPage() {
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
<span className="text-base font-semibold text-muted-foreground"> ( )</span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
@@ -1003,10 +1015,10 @@ export default function CowOverviewPage() {
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> ()</span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
@@ -1130,12 +1142,12 @@ export default function CowOverviewPage() {
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
<span className="text-base font-semibold text-muted-foreground"> ( )</span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
@@ -1168,10 +1180,10 @@ export default function CowOverviewPage() {
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> ()</span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
@@ -1666,14 +1678,10 @@ export default function CowOverviewPage() {
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
<span className="text-base font-semibold text-muted-foreground"> ( )</span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
<span className="text-2xl font-bold text-foreground">-</span>
</div>
</div>
<div>
@@ -1700,10 +1708,10 @@ export default function CowOverviewPage() {
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> ()</span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
{cow?.cowBirthDt && genomeData[0]?.request?.requestDt
? `${Math.floor((new Date(genomeData[0].request.requestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
@@ -1792,19 +1800,8 @@ export default function CowOverviewPage() {
{/* 번식능력 탭 */}
<TabsContent value="reproduction" className="mt-6 space-y-6">
{/* 혈액화학검사(MPT) 테이블 - 추후 사용
{/* 혈액화학검사(MPT) 테이블 */}
<MptTable cowShortNo={cowNo?.slice(-4)} cowNo={cowNo} farmNo={cow?.fkFarmNo} cow={cow} genomeRequest={genomeRequest} />
*/}
<Card className="bg-slate-50 border border-border rounded-2xl">
<CardContent className="p-8 text-center">
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground">
.
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>

View File

@@ -12,22 +12,21 @@ import { MPT_REFERENCE_RANGES } from "@/constants/mpt-reference"
// 혈액화학검사 카테고리별 항목
const MPT_CATEGORIES = [
{ name: '에너지 대사', items: ['glucose', 'cholesterol', 'nefa', 'bcs'], color: 'bg-muted/50' },
{ name: '단백질 대사', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' },
{ name: '에너지', items: ['glucose', 'cholesterol', 'nefa', 'bcs'], color: 'bg-muted/50' },
{ name: '단백질', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' },
{ name: '간기능', items: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' },
{ name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' },
{ name: '기타', items: ['creatine'], color: 'bg-muted/50' },
{ name: '별도', items: ['creatine'], color: 'bg-muted/50' },
]
// 측정값 상태 판정
function getMptValueStatus(key: string, value: number | null): 'normal' | 'warning' | 'danger' | 'unknown' {
// 측정값 상태 판정: 안전(safe) / 주의(caution)
function getMptValueStatus(key: string, value: number | null): 'safe' | 'caution' | 'unknown' {
if (value === null || value === undefined) return 'unknown'
const ref = MPT_REFERENCE_RANGES[key]
if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown'
if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'normal'
const margin = (ref.upperLimit - ref.lowerLimit) * 0.1
if (value >= ref.lowerLimit - margin && value <= ref.upperLimit + margin) return 'warning'
return 'danger'
// 하한값 ~ 상한값 사이면 안전, 그 외 주의
if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'safe'
return 'caution'
}
interface MptTableProps {
@@ -45,11 +44,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
useEffect(() => {
const fetchMptData = async () => {
if (!cowShortNo) return
if (!cowNo) return
setLoading(true)
try {
const data = await mptApi.findByCowShortNo(cowShortNo)
const data = await mptApi.findByCowId(cowNo)
setMptData(data)
if (data.length > 0) {
setSelectedMpt(data[0])
@@ -62,7 +61,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
}
fetchMptData()
}, [cowShortNo])
}, [cowNo])
if (loading) {
return (
@@ -82,7 +81,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
<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">
<div className="hidden lg:grid lg:grid-cols-3 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
@@ -101,25 +100,18 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'}
{(() => {
const sex = cow?.cowSex?.toUpperCase?.() || cow?.cowSex
if (sex === 'F' || sex === '암' || sex === '2') return '암소'
if (sex === 'M' || sex === '수' || sex === '1') return '수소'
return '-'
})()}
</span>
</div>
</div>
@@ -138,171 +130,21 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'}
{(() => {
const sex = cow?.cowSex?.toUpperCase?.() || cow?.cowSex
if (sex === 'F' || sex === '암' || sex === '2') return '암소'
if (sex === 'M' || sex === '수' || sex === '1') return '수소'
return '-'
})()}
</span>
</div>
</div>
</CardContent>
</Card>
{/* 혈통정보 섹션 */}
<h3 className="text-lg lg:text-xl 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-2 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> KPN번호</span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-muted/50 text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
return null
}
})()}
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-muted/50 text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
return null
}
})()}
</div>
</div>
</div>
{/* 모바일: 좌우 배치 리스트 */}
<div className="lg:hidden divide-y divide-border">
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN번호</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1 bg-muted/50 text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
return null
}
})()}
</div>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1 bg-muted/50 text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
return null
}
})()}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 검사 정보 */}
{selectedMpt && (
<>
@@ -383,7 +225,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
</>
)}
{/* 혈액화학검사 결과 테이블 */}
{/* 혈액화학검사 결과 테이블 - selectedMpt가 있을 때만 표시 */}
{selectedMpt ? (
<>
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
@@ -391,13 +235,13 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
<table className="w-full">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground w-28"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-24"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20"></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-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>
</tr>
</thead>
<tbody>
@@ -420,9 +264,8 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
<td className="px-4 py-3 text-sm font-medium text-foreground">{ref?.name || itemKey}</td>
<td className="px-4 py-3 text-center">
<span className={`text-lg font-bold ${
status === 'normal' ? 'text-green-600' :
status === 'warning' ? 'text-amber-600' :
status === 'danger' ? 'text-red-600' :
status === 'safe' ? 'text-green-600' :
status === 'caution' ? 'text-amber-600' :
'text-muted-foreground'
}`}>
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
@@ -434,14 +277,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
<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 ${
status === 'normal' ? 'bg-green-100 text-green-700' :
status === 'warning' ? 'bg-amber-100 text-amber-700' :
status === 'danger' ? 'bg-red-100 text-red-700' :
status === 'safe' ? 'bg-green-100 text-green-700' :
status === 'caution' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-500'
}`}>
{status === 'normal' ? '정상' :
status === 'warning' ? '주의' :
status === 'danger' ? '이상' : '-'}
{status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'}
</span>
) : (
<span className="text-muted-foreground">-</span>
@@ -479,19 +319,19 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
</Card>
</>
)}
{/* 데이터 없음 안내 */}
{/* {!selectedMpt && (
</>
) : (
/* 데이터 없음 안내 */
<Card className="bg-slate-50 border border-border rounded-2xl">
<CardContent className="p-8 text-center">
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">혈액화학검사 데이터 없음</h3>
<h3 className="text-lg font-semibold text-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground">
이 개체는 아직 혈액화학검사(MPT) 결과가 등록되지 않았습니다.
.
</p>
</CardContent>
</Card>
)} */}
)}
</div>
)
}

View File

@@ -56,8 +56,11 @@ interface CowWithGenes extends Cow {
rank?: number // 랭킹 순위
cowShortNo?: string // 개체 요약번호
cowReproType?: string // 번식 타입
anlysDt?: string // 분석일자
anlysDt?: string // 분석일자 (유전체)
unavailableReason?: string // 분석불가 사유 (부불일치, 모불일치, 모이력제부재 등)
hasMpt?: boolean // 번식능력검사(MPT) 여부
mptTestDt?: string // MPT 검사일
mptMonthAge?: number // MPT 검사일 기준 월령
}
function MyCowContent() {
@@ -75,7 +78,7 @@ function MyCowContent() {
const [rankingMode, setRankingMode] = useState<'gene' | 'genome'>('gene') // 유전자순/유전체순
const [sortBy, setSortBy] = useState<string>('rank') // 정렬 기준 (기본: 순위)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') // 정렬 방향
const [analysisFilter, setAnalysisFilter] = useState<'all' | 'completed' | 'unavailable'>('all') // 분석 상태 필터
const [analysisFilter, setAnalysisFilter] = useState<'all' | 'completed' | 'mptOnly' | 'unavailable'>('all') // 분석 상태 필터
// 커스텀 컬럼 표시 필터
const [selectedDisplayGenes, setSelectedDisplayGenes] = useState<string[]>([]) // 테이블에 표시할 유전자
@@ -355,6 +358,12 @@ function MyCowContent() {
anlysDt: item.entity.anlysDt ?? null,
// 분석불가 사유
unavailableReason: item.entity.unavailableReason ?? null,
// 번식능력검사(MPT) 여부
hasMpt: item.entity.hasMpt ?? false,
// MPT 검사일
mptTestDt: item.entity.mptTestDt ?? null,
// MPT 월령
mptMonthAge: item.entity.mptMonthAge ?? null,
//====================================================================================================================
// 형질 데이터 (백엔드에서 계산됨, 형질명 → 표준화육종가 매핑)
// 백엔드 응답: { traitName: string, traitVal: number, traitEbv: number, traitPercentile: number }
@@ -513,9 +522,14 @@ function MyCowContent() {
// 분석 상태 필터
if (analysisFilter === 'completed') {
// 유전체 완료
result = result.filter(cow => cow.genomeScore !== undefined && cow.genomeScore !== null)
} else if (analysisFilter === 'mptOnly') {
// 번식능력 검사 완료 (유전체 유무 상관없이)
result = result.filter(cow => cow.hasMpt === true)
} else if (analysisFilter === 'unavailable') {
result = result.filter(cow => cow.genomeScore === undefined || cow.genomeScore === null)
// 유전체 분석불가 (부불일치, 모불일치 등)
result = result.filter(cow => cow.unavailableReason !== null && cow.unavailableReason !== undefined)
}
// 정렬 (sortBy가 'none'이면 정렬하지 않음 - 전역 필터 순서 유지)
@@ -684,11 +698,11 @@ function MyCowContent() {
<p className="text-base max-sm:text-sm text-slate-500 mt-1">{'농장'} </p>
</div>
{/* 분석 상태 탭 필터 */}
<div className="flex rounded-lg border border-slate-200 bg-slate-50 p-1.5 gap-1.5 max-sm:p-1 max-sm:gap-1">
{/* 분석 상태 탭 필터 - 모바일: 2x2 그리드, 데스크톱: 가로 배치 */}
<div className="grid grid-cols-2 sm:grid-cols-4 rounded-lg border border-slate-200 bg-slate-50 p-1.5 gap-1.5 max-sm:p-1 max-sm:gap-1">
<button
onClick={() => setAnalysisFilter('all')}
className={`flex-1 px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'all'
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'all'
? 'bg-white text-slate-900 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
@@ -697,26 +711,38 @@ function MyCowContent() {
</button>
<button
onClick={() => setAnalysisFilter('completed')}
className={`flex-1 px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'completed'
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'completed'
? 'bg-white text-emerald-600 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center justify-center gap-1.5 max-sm:gap-1">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
<span className="font-bold">{cows.filter(c => c.genomeScore !== undefined && c.genomeScore !== null).length}</span>
<span className="font-bold">{cows.filter(c => c.genomeScore !== undefined && c.genomeScore !== null).length}</span>
</span>
</button>
<button
onClick={() => setAnalysisFilter('unavailable')}
className={`flex-1 px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'unavailable'
? 'bg-white text-slate-600 shadow-sm border border-slate-200'
onClick={() => setAnalysisFilter('mptOnly')}
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'mptOnly'
? 'bg-white text-amber-600 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center justify-center gap-1.5 max-sm:gap-1">
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
<span className="font-bold">{cows.filter(c => c.genomeScore === undefined || c.genomeScore === null).length}</span>
<span className="w-2 h-2 rounded-full bg-amber-500"></span>
<span className="font-bold">{cows.filter(c => c.hasMpt === true).length}</span>
</span>
</button>
<button
onClick={() => setAnalysisFilter('unavailable')}
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'unavailable'
? 'bg-white text-red-600 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center justify-center gap-1.5 max-sm:gap-1">
<span className="w-2 h-2 rounded-full bg-red-400"></span>
<span className="font-bold">{cows.filter(c => c.unavailableReason !== null && c.unavailableReason !== undefined).length}</span>
</span>
</button>
</div>
@@ -923,11 +949,15 @@ function MyCowContent() {
<th className="cow-table-header" style={{ width: '50px' }}></th>
<th className="cow-table-header" style={{ width: '220px' }}></th>
<th className="cow-table-header" style={{ width: '90px' }}></th>
<th className="cow-table-header" style={{ width: '70px' }}></th>
<th className="cow-table-header" style={{ width: '60px' }}></th>
<th className="cow-table-header" style={{ width: '100px' }}></th>
<th className="cow-table-header" style={{ width: '90px' }}> KPN</th>
<th className="cow-table-header" style={{ width: '90px' }}></th>
<th className="cow-table-header" style={{ width: '80px' }}>
{analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'}
</th>
<th className="cow-table-header" style={{ width: '90px' }}>
{analysisFilter === 'mptOnly' ? '검사일자' : '분석일자'}
</th>
<th className="cow-table-header border-r-2 border-r-gray-300" style={{ width: '100px' }}>
</th>
@@ -958,19 +988,26 @@ function MyCowContent() {
</div>
</td>
<td className="cow-table-cell">
{cow.cowBirthDt && new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
})}
</td>
<td className="cow-table-cell">
{cow.cowBirthDt ? (() => {
const birthDate = new Date(cow.cowBirthDt)
const today = new Date()
const ageInMonths = Math.floor((today.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
return `${ageInMonths}개월`
})() : '-'}
{(() => {
// 번식능력만 있는 개체 판단 (유전체 데이터 없음)
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: cowBirthDt 없으면 MPT로 역산
if ((analysisFilter === 'mptOnly' || hasMptOnly) && !cow.cowBirthDt && cow.mptTestDt && cow.mptMonthAge) {
const testDate = new Date(cow.mptTestDt)
const birthDate = new Date(testDate)
birthDate.setMonth(birthDate.getMonth() - cow.mptMonthAge)
return birthDate.toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
})
}
return cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : '-'
})()}
</td>
<td className="cow-table-cell">
{cow.cowSex === "수" ? "수소" : "암소"}
@@ -982,19 +1019,52 @@ function MyCowContent() {
{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</td>
<td className="cow-table-cell">
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : cow.unavailableReason ? (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason}
</span>
) : '-'}
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용
if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-'
}
if (cow.cowBirthDt && cow.anlysDt) {
const birthDate = new Date(cow.cowBirthDt)
const refDate = new Date(cow.anlysDt)
const ageInMonths = Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
return `${ageInMonths}개월`
}
return '-'
})()}
</td>
<td className="cow-table-cell">
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 사용
if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptTestDt ? new Date(cow.mptTestDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : '-'
}
// 유전체 탭: unavailableReason 있으면 배지, 없으면 분석일자
if (cow.unavailableReason) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason}
</span>
)
}
return cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : '-'
})()}
</td>
<td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5">
{(cow.genomeScore !== undefined && cow.genomeScore !== null) ? (
@@ -1169,13 +1239,40 @@ function MyCowContent() {
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-'}
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: cowBirthDt 없으면 MPT로 역산
if ((analysisFilter === 'mptOnly' || hasMptOnly) && !cow.cowBirthDt && cow.mptTestDt && cow.mptMonthAge) {
const testDate = new Date(cow.mptTestDt)
const birthDate = new Date(testDate)
birthDate.setMonth(birthDate.getMonth() - cow.mptMonthAge)
return birthDate.toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' })
}
return cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-'
})()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{(() => {
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
return (analysisFilter === 'mptOnly' || hasMptOnly) ? '월령 (검사일)' : '월령 (분석일)'
})()}
</span>
<span className="font-medium">
{cow.cowBirthDt ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용
if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-'
}
if (cow.cowBirthDt && cow.anlysDt) {
return `${Math.floor((new Date(cow.anlysDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
}
return '-'
})()}
</span>
</div>
<div className="flex justify-between">
@@ -1195,17 +1292,36 @@ function MyCowContent() {
<span className="font-medium">{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{cow.anlysDt ? '분석일' : '분석결과'}</span>
<span className="text-muted-foreground">
{(() => {
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
return (analysisFilter === 'mptOnly' || hasMptOnly) ? '검사일' : (cow.anlysDt ? '분석일' : '분석결과')
})()}
</span>
<span className="font-medium">
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : cow.unavailableReason ? (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason}
</span>
) : '-'}
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 사용
if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptTestDt ? new Date(cow.mptTestDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-'
}
if (cow.anlysDt) {
return new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' })
}
if (cow.unavailableReason) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason}
</span>
)
}
return '-'
})()}
</span>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
} from "@/components/ui/select"
import { apiClient, farmApi } from "@/lib/api"
import { DashboardStatsDto, FarmRegionRankingDto, YearlyTraitTrendDto, genomeApi } from "@/lib/api/genome.api"
import { mptApi, MptStatisticsDto } from "@/lib/api/mpt.api"
import { useAuthStore } from "@/store/auth-store"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import {
@@ -69,6 +70,7 @@ export default function DashboardPage() {
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<DashboardStatsDto | null>(null)
const [farmRanking, setFarmRanking] = useState<FarmRegionRankingDto | null>(null)
const [mptStats, setMptStats] = useState<MptStatisticsDto | null>(null)
// 모바일 감지 (반응형)
const [isMobile, setIsMobile] = useState(false)
@@ -167,12 +169,14 @@ export default function DashboardPage() {
}))
: undefined
try {
const [statsData, rankingData] = await Promise.all([
const [statsData, rankingData, mptStatsData] = await Promise.all([
genomeApi.getDashboardStats(farmNo),
genomeApi.getFarmRegionRanking(farmNo, traitConditions)
genomeApi.getFarmRegionRanking(farmNo, traitConditions),
mptApi.getMptStatistics(farmNo).catch(() => null)
])
setStats(statsData)
setFarmRanking(rankingData)
setMptStats(mptStatsData)
} catch (error) {
console.error('대시보드 통계 로드 실패:', error)
}
@@ -413,45 +417,38 @@ export default function DashboardPage() {
<>
{/* ========== 1. 핵심 KPI 카드 (2개) ========== */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-sm:gap-3">
{/* 총 분석 두수 + 암/수 */}
{/* 총 검사 개체 수 (합집합) */}
<div
className="bg-gradient-to-br from-primary/5 to-primary/10 rounded-xl border border-primary/20 p-5 pb-5 max-sm:p-4 max-sm:pb-4 shadow-sm hover:shadow-lg hover:border-primary/30 transition-all cursor-pointer"
onClick={() => router.push('/cow')}
>
<div className="flex items-center justify-between mb-2">
<p className="text-base max-sm:text-sm font-semibold text-primary/70"> </p>
<span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-primary/10 text-primary font-medium">
{' '}
{(() => {
const latestRequest = stats?.requestHistory
?.filter(r => r.requestDt)
?.sort((a, b) => new Date(b.requestDt!).getTime() - new Date(a.requestDt!).getTime())[0]
if (latestRequest?.requestDt) {
return new Date(latestRequest.requestDt).toLocaleDateString('ko-KR', { year: 'numeric', month: 'numeric', day: 'numeric' })
}
return '-'
})()}
</span>
<p className="text-base max-sm:text-sm font-semibold text-primary/70"> </p>
</div>
<p className="text-4xl max-sm:text-3xl font-bold text-primary">
{stats?.summary.totalRequests || 0}
{stats?.summary.totalCows || 0}
<span className="text-lg max-sm:text-base font-normal text-primary/60 ml-1"></span>
</p>
<div className="flex items-center gap-4 max-sm:gap-3 mt-3 pt-3 border-t border-primary/10">
<div className="flex items-center gap-1.5 max-sm:gap-1">
<span className="text-sm max-sm:text-xs text-primary/60"> </span>
<span className="text-base max-sm:text-sm font-bold text-primary">{stats?.summary.maleCount || 0}</span>
</div>
<div className="flex items-center gap-1.5 max-sm:gap-1">
<span className="text-sm max-sm:text-xs text-primary/60"> </span>
<span className="text-base max-sm:text-sm font-bold text-primary">{stats?.summary.femaleCount || 0}</span>
</div>
<div className="flex flex-wrap items-center gap-3 max-sm:gap-2 mt-3 pt-3 border-t border-primary/10 text-sm max-sm:text-xs text-primary/60">
<span> <span className="font-bold text-primary">{stats?.summary.genomeCowCount || 0}</span></span>
<span className="text-primary/30">·</span>
<span> <span className="font-bold text-primary">{stats?.summary.geneCowCount || 0}</span></span>
<span className="text-primary/30">·</span>
<span> <span className="font-bold text-primary">{stats?.summary.mptCowCount || 0}</span></span>
</div>
</div>
{/* 친자감별 결과 (넓게) */}
<div className="md:col-span-2 bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm hover:shadow-md transition-shadow">
<p className="text-base max-sm:text-sm font-semibold text-slate-700 mb-4"> </p>
{(stats?.summary.genomeCowCount || 0) === 0 ? (
<div className="flex items-center justify-center h-32 text-slate-400">
<div className="text-center">
<Dna className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex items-center gap-6 max-sm:flex-col max-sm:gap-4">
{/* 도넛 차트 */}
<div className="w-28 h-28 max-sm:w-24 max-sm:h-24 shrink-0 relative">
@@ -529,9 +526,104 @@ export default function DashboardPage() {
</div>
</div>
</div>
)}
</div>
</div>
{/* ========== 1-2. 번식능력검사 현황 ========== */}
{mptStats && mptStats.totalMptCows > 0 && (
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-4">
<p className="text-base max-sm:text-sm font-semibold text-slate-700"> </p>
<span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-pink-50 text-pink-700 font-medium">
{mptStats.totalMptCows}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-sm:gap-3">
{/* 에너지 균형 */}
<div className="bg-slate-50 rounded-lg p-4 max-sm:p-3">
<p className="text-sm max-sm:text-xs text-slate-500 mb-3 font-medium"> </p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-green-600">
<CheckCircle2 className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-green-600">{mptStats.categories.energy.safe}</span>
</div>
{mptStats.categories.energy.caution > 0 && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-amber-600">
<AlertCircle className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-amber-600">{mptStats.categories.energy.caution}</span>
</div>
)}
</div>
</div>
{/* 단백질 상태 */}
<div className="bg-slate-50 rounded-lg p-4 max-sm:p-3">
<p className="text-sm max-sm:text-xs text-slate-500 mb-3 font-medium"> </p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-green-600">
<CheckCircle2 className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-green-600">{mptStats.categories.protein.safe}</span>
</div>
{mptStats.categories.protein.caution > 0 && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-amber-600">
<AlertCircle className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-amber-600">{mptStats.categories.protein.caution}</span>
</div>
)}
</div>
</div>
{/* 간 건강 */}
<div className="bg-slate-50 rounded-lg p-4 max-sm:p-3">
<p className="text-sm max-sm:text-xs text-slate-500 mb-3 font-medium"> </p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-green-600">
<CheckCircle2 className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-green-600">{mptStats.categories.liver.safe}</span>
</div>
{mptStats.categories.liver.caution > 0 && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-amber-600">
<AlertCircle className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-amber-600">{mptStats.categories.liver.caution}</span>
</div>
)}
</div>
</div>
{/* 미네랄 균형 */}
<div className="bg-slate-50 rounded-lg p-4 max-sm:p-3">
<p className="text-sm max-sm:text-xs text-slate-500 mb-3 font-medium"> </p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-green-600">
<CheckCircle2 className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-green-600">{mptStats.categories.mineral.safe}</span>
</div>
{mptStats.categories.mineral.caution > 0 && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm max-sm:text-xs text-amber-600">
<AlertCircle className="w-4 h-4 max-sm:w-3 max-sm:h-3" />
</span>
<span className="text-lg max-sm:text-base font-bold text-amber-600">{mptStats.categories.mineral.caution}</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* ========== 2. 좌측(농가위치+순위) + 우측(연도별 육종가) - 높이 맞춤 ========== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-sm:gap-4">
{/* 좌측 영역: 보은군 내 농가 위치 + 순위 통합 */}
@@ -565,7 +657,7 @@ export default function DashboardPage() {
<span className="flex items-center gap-1.5 max-sm:gap-1 font-medium"><span className="w-3.5 h-3.5 max-sm:w-2.5 max-sm:h-2.5 rounded bg-slate-400"></span> </span>
</div>
</div>
{farmRanking && (farmRanking.farmAvgScore !== null || distributionBasis !== 'overall') ? (
{(stats?.summary.genomeCowCount || 0) > 0 && farmRanking && (farmRanking.farmAvgScore !== null || distributionBasis !== 'overall') ? (
<div className="bg-gradient-to-b from-slate-50 to-slate-100/50 rounded-xl p-4 max-sm:p-2 max-sm:-mx-2">
<ResponsiveContainer width="100%" height={350}>
<ComposedChart
@@ -796,14 +888,14 @@ export default function DashboardPage() {
) : (
<div className="h-[280px] flex items-center justify-center text-slate-400 text-sm bg-slate-50 rounded-xl">
<div className="text-center">
<MapPin className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p className="text-base"> </p>
<Dna className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p className="text-base"> </p>
</div>
</div>
)}
{/* 순위 정보 (차트 하단에 통합) - 드롭다운 선택에 따라 연동 */}
{(farmPositionData.rank !== null || farmRanking?.farmAvgScore !== null) && (
{(stats?.summary.genomeCowCount || 0) > 0 && (farmPositionData.rank !== null || farmRanking?.farmAvgScore !== null) && (
<div className="mt-4 pt-4 border-t border-slate-200">
{/* 현재 선택된 기준 표시 */}
<p className="text-xs text-slate-400 mb-2 text-center">
@@ -879,7 +971,14 @@ export default function DashboardPage() {
</Select>
</div>
{/* 차트 */}
{traitTrendLoading ? (
{(stats?.summary.genomeCowCount || 0) === 0 ? (
<div className="h-[280px] flex items-center justify-center text-slate-400 bg-slate-50 rounded-xl">
<div className="text-center">
<Dna className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p className="text-base"> </p>
</div>
</div>
) : traitTrendLoading ? (
<div className="h-[180px] flex items-center justify-center text-slate-400">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-[#1F3A8F] border-t-transparent"></div>
</div>
@@ -1061,7 +1160,7 @@ export default function DashboardPage() {
{/* ========== 3. 카테고리별 보은군 대비 비교 - 전체 너비 ========== */}
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm">
<h3 className="font-semibold text-slate-900 text-lg max-sm:text-base mb-4 max-sm:mb-3"> </h3>
{stats?.traitAverages && stats.traitAverages.length > 0 ? (
{(stats?.summary.genomeCowCount || 0) > 0 && stats?.traitAverages && stats.traitAverages.length > 0 ? (
(() => {
const categories = ['성장', '생산', '체형', '무게', '비율']
const categoryData = categories.map(cat => {
@@ -1372,15 +1471,20 @@ export default function DashboardPage() {
)
})()
) : (
<div className="h-[150px] flex items-center justify-center text-slate-400 text-sm max-sm:text-xs">
<div className="h-[280px] flex items-center justify-center text-slate-400 bg-slate-50 rounded-xl">
<div className="text-center">
<Dna className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p className="text-base"> </p>
</div>
</div>
)}
{/* 범례 */}
{(stats?.summary.genomeCowCount || 0) > 0 && (
<div className="mt-4 pt-3 border-t border-slate-100 flex flex-wrap items-center justify-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-[#1F3A8F]"></span></span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-slate-400 opacity-50"></span></span>
</div>
)}
</div>
</>

View File

@@ -8,7 +8,7 @@ export interface MptReferenceRange {
upperLimit: number | null;
lowerLimit: number | null;
unit: string;
category: '에너지' | '단백질' | '간기능' | '미네랄' | '기타';
category: '에너지' | '단백질' | '간기능' | '미네랄' | '별도';
description?: string; // 항목 설명 (선택)
}
@@ -38,6 +38,15 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
category: '에너지',
description: '혈액 내 유리지방산 수치',
},
bcs: {
name: 'BCS',
upperLimit: 3.5,
lowerLimit: 2.5,
unit: '-',
category: '에너지',
description: '체충실지수(Body Condition Score)',
},
// 단백질 카테고리
totalProtein: {
name: '총단백질',
@@ -140,13 +149,13 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
description: '혈액 내 마그네슘 수치',
},
// 기타 카테고리
// 별도 카테고리
creatine: {
name: '크레아틴',
upperLimit: 1.3,
lowerLimit: 1.0,
unit: 'mg/dL',
category: '기타',
category: '별도',
description: '혈액 내 크레아틴 수치',
},
};
@@ -154,7 +163,7 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
/**
* MPT 카테고리 목록 (표시 순서)
*/
export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '기타'] as const;
export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '별도'] as const;
/**
* 측정값이 정상 범위 내에 있는지 확인

View File

@@ -318,7 +318,11 @@ export interface DashboardStatsDto {
}[];
// 요약
summary: {
totalRequests: number;
totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외)
genomeCowCount: number; // 유전체 분석 개체 수
geneCowCount: number; // 유전자검사 개체 수
mptCowCount: number; // 번식능력검사 개체 수
totalRequests: number; // 유전체 의뢰 건수 (기존 호환성)
analyzedCount: number;
pendingCount: number;
mismatchCount: number;

View File

@@ -1,5 +1,26 @@
import apiClient from "../api-client";
/**
* MPT 통계 응답 DTO
*/
export interface MptStatisticsDto {
totalMptCows: number;
latestTestDate: string | null;
categories: {
energy: { safe: number; caution: number };
protein: { safe: number; caution: number };
liver: { safe: number; caution: number };
mineral: { safe: number; caution: number };
};
riskyCows: Array<{
cowId: string;
category: string;
itemName: string;
value: number;
status: 'high' | 'low';
}>;
}
/**
* MPT(혈액화학검사) 결과 DTO
*/
@@ -60,7 +81,7 @@ export const mptApi = {
},
/**
* GET /mpt?cowShortNo=:cowShortNo - 특정 개체의 검사 결과
* GET /mpt?cowShortNo=:cowShortNo - 특정 개체의 검사 결과 (뒤 4자리)
*/
findByCowShortNo: async (cowShortNo: string): Promise<MptDto[]> => {
return await apiClient.get("/mpt", {
@@ -68,6 +89,15 @@ export const mptApi = {
});
},
/**
* GET /mpt?cowId=:cowId - 특정 개체의 검사 결과 (전체 개체번호)
*/
findByCowId: async (cowId: string): Promise<MptDto[]> => {
return await apiClient.get("/mpt", {
params: { cowId },
});
},
/**
* GET /mpt/:id - 검사 결과 상세 조회
*/
@@ -102,4 +132,13 @@ export const mptApi = {
remove: async (id: number): Promise<void> => {
return await apiClient.delete(`/mpt/${id}`);
},
/**
* GET /mpt/statistics/:farmNo - 농장별 MPT 통계 조회
* - 카테고리별 정상/주의/위험 개체 수
* - 위험 개체 목록
*/
getMptStatistics: async (farmNo: number): Promise<MptStatisticsDto> => {
return await apiClient.get(`/mpt/statistics/${farmNo}`);
},
};

View File

@@ -27,6 +27,7 @@ export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
/** 개별 제외 개체 목록 (분석불가 등 특수 사유) */
export const EXCLUDED_COW_IDS = [
'KOR002191642861', // 모근량 갯수 적은데 1회분은 가능 아비명도 일치하는데 유전체 분석 정보가 없음
// 김정태님
];
/**

View File

@@ -20,6 +20,11 @@ export interface CowDto extends BaseFields {
fkFarmNo?: number; // 농장번호 FK
cowStatus?: string; // 개체상태
delDt?: string; // 삭제일시 (Soft Delete)
anlysDt?: string; // 분석일자
unavailableReason?: string; // 분석불가 사유
hasMpt?: boolean; // 번식능력검사(MPT) 여부
mptTestDt?: string; // MPT 검사일
mptMonthAge?: number; // MPT 검사일 기준 월령
// Relations
farm?: FarmDto; // 농장 정보 (조인)