mpt 페이지 추가
This commit is contained in:
@@ -37,67 +37,208 @@ export class MptModel extends BaseModel {
|
||||
})
|
||||
fkFarmNo: number;
|
||||
|
||||
@Column({ name: 'test_dt', type: 'date', nullable: true, comment: '검사일자' })
|
||||
@Column({
|
||||
name: 'test_dt',
|
||||
type: 'date',
|
||||
nullable: true,
|
||||
comment: '검사일자',
|
||||
})
|
||||
testDt: Date;
|
||||
|
||||
@Column({ name: 'month_age', type: 'int', nullable: true, comment: '월령' })
|
||||
@Column({
|
||||
name: 'month_age',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '월령',
|
||||
})
|
||||
monthAge: number;
|
||||
|
||||
@Column({ name: 'milk_yield', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '유량' })
|
||||
@Column({
|
||||
name: 'milk_yield',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '유량',
|
||||
})
|
||||
milkYield: number;
|
||||
|
||||
@Column({ name: 'parity', type: 'int', nullable: true, comment: '산차' })
|
||||
@Column({
|
||||
name: 'parity',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '산차',
|
||||
})
|
||||
parity: number;
|
||||
|
||||
@Column({ name: 'glucose', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '혈당' })
|
||||
@Column({
|
||||
name: 'glucose',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '혈당',
|
||||
})
|
||||
glucose: number;
|
||||
|
||||
@Column({ name: 'cholesterol', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '콜레스테롤' })
|
||||
@Column({
|
||||
name: 'cholesterol',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '콜레스테롤',
|
||||
})
|
||||
cholesterol: number;
|
||||
|
||||
@Column({ name: 'nefa', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '유리지방산(NEFA)' })
|
||||
@Column({
|
||||
name: 'nefa',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '유리지방산(NEFA)',
|
||||
})
|
||||
nefa: number;
|
||||
|
||||
@Column({ name: 'bcs', type: 'decimal', precision: 5, scale: 2, nullable: true, comment: 'BCS' })
|
||||
@Column({
|
||||
name: 'bcs',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: 'BCS',
|
||||
})
|
||||
bcs: number;
|
||||
|
||||
@Column({ name: 'total_protein', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '총단백질' })
|
||||
@Column({
|
||||
name: 'total_protein',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '총단백질',
|
||||
})
|
||||
totalProtein: number;
|
||||
|
||||
@Column({ name: 'albumin', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '알부민' })
|
||||
@Column({
|
||||
name: 'albumin',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '알부민',
|
||||
})
|
||||
albumin: number;
|
||||
|
||||
@Column({ name: 'globulin', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '총글로불린' })
|
||||
@Column({
|
||||
name: 'globulin',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '총글로불린',
|
||||
})
|
||||
globulin: number;
|
||||
|
||||
@Column({ name: 'ag_ratio', type: 'decimal', precision: 5, scale: 2, nullable: true, comment: 'A/G 비율' })
|
||||
@Column({
|
||||
name: 'ag_ratio',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: 'A/G 비율',
|
||||
})
|
||||
agRatio: number;
|
||||
|
||||
@Column({ name: 'bun', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '요소태질소(BUN)' })
|
||||
@Column({
|
||||
name: 'bun',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '요소태질소(BUN)',
|
||||
})
|
||||
bun: number;
|
||||
|
||||
@Column({ name: 'ast', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: 'AST' })
|
||||
@Column({
|
||||
name: 'ast',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: 'AST',
|
||||
})
|
||||
ast: number;
|
||||
|
||||
@Column({ name: 'ggt', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: 'GGT' })
|
||||
@Column({
|
||||
name: 'ggt',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: 'GGT',
|
||||
})
|
||||
ggt: number;
|
||||
|
||||
@Column({ name: 'fatty_liver_idx', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '지방간지수' })
|
||||
@Column({
|
||||
name: 'fatty_liver_idx',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '지방간지수',
|
||||
})
|
||||
fattyLiverIdx: number;
|
||||
|
||||
@Column({ name: 'calcium', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '칼슘' })
|
||||
@Column({
|
||||
name: 'calcium',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '칼슘',
|
||||
})
|
||||
calcium: number;
|
||||
|
||||
@Column({ name: 'phosphorus', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '인' })
|
||||
@Column({
|
||||
name: 'phosphorus',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '인',
|
||||
})
|
||||
phosphorus: number;
|
||||
|
||||
@Column({ name: 'ca_p_ratio', type: 'decimal', precision: 5, scale: 2, nullable: true, comment: '칼슘/인 비율' })
|
||||
@Column({
|
||||
name: 'ca_p_ratio',
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '칼슘/인 비율',
|
||||
})
|
||||
caPRatio: number;
|
||||
|
||||
@Column({ name: 'magnesium', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '마그네슘' })
|
||||
@Column({
|
||||
name: 'magnesium',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '마그네슘',
|
||||
})
|
||||
magnesium: number;
|
||||
|
||||
@Column({ name: 'creatinine', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '크레아틴' })
|
||||
@Column({
|
||||
name: 'creatinine',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
comment: '크레아틴',
|
||||
})
|
||||
creatinine: number;
|
||||
|
||||
// Relations
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { mptApi, MptDto } from "@/lib/api"
|
||||
import { Activity, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { GenomeRequestDto } from "@/lib/api"
|
||||
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: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' },
|
||||
{ name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' },
|
||||
{ name: '기타', items: ['creatinine'], color: 'bg-muted/50' },
|
||||
]
|
||||
|
||||
// 측정값 상태 판정
|
||||
function getMptValueStatus(key: string, value: number | null): 'normal' | 'warning' | 'danger' | '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'
|
||||
}
|
||||
|
||||
interface MptTableProps {
|
||||
cowShortNo?: string
|
||||
cowNo?: string
|
||||
farmNo?: number
|
||||
cow?: CowDetail | null
|
||||
genomeRequest?: GenomeRequestDto | null
|
||||
}
|
||||
|
||||
export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptTableProps) {
|
||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMptData = async () => {
|
||||
if (!cowShortNo) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mptApi.findByCowShortNo(cowShortNo)
|
||||
setMptData(data)
|
||||
if (data.length > 0) {
|
||||
setSelectedMpt(data[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MPT 데이터 로드 실패:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMptData()
|
||||
}, [cowShortNo])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 개체 정보 섹션 */}
|
||||
<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-4 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>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<CowNumberDisplay cowId={cowNo || ''} variant="highlight" className="text-2xl font-bold text-foreground" />
|
||||
</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 ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
||||
</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' ? '수' : '-'}
|
||||
</span>
|
||||
</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">개체번호</span>
|
||||
<div className="flex-1 px-4 py-3.5">
|
||||
<CowNumberDisplay cowId={cowNo || ''} variant="highlight" className="text-base font-bold text-foreground" />
|
||||
</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>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{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' ? '수' : '-'}
|
||||
</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 && (
|
||||
<>
|
||||
<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-4 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>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'}
|
||||
</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">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
</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">
|
||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
||||
</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">
|
||||
{selectedMpt.milkYield ? `${selectedMpt.milkYield}kg` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 모바일 */}
|
||||
<div className="lg:hidden divide-y divide-border">
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">검사일자</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">월령</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">산차</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground">유량</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.milkYield ? `${selectedMpt.milkYield}kg` : '-'}
|
||||
</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="overflow-x-auto">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MPT_CATEGORIES.map((category) => (
|
||||
category.items.map((itemKey, itemIdx) => {
|
||||
const ref = MPT_REFERENCE_RANGES[itemKey]
|
||||
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
||||
const status = getMptValueStatus(itemKey, value)
|
||||
|
||||
return (
|
||||
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
||||
{itemIdx === 0 && (
|
||||
<td
|
||||
rowSpan={category.items.length}
|
||||
className={`px-4 py-3 text-sm 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 text-center">
|
||||
<span className={`text-lg font-bold ${
|
||||
status === 'normal' ? 'text-green-600' :
|
||||
status === 'warning' ? 'text-amber-600' :
|
||||
status === 'danger' ? 'text-red-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{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">
|
||||
{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' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{status === 'normal' ? '정상' :
|
||||
status === 'warning' ? '주의' :
|
||||
status === 'danger' ? '이상' : '-'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
||||
{mptData.length > 1 && (
|
||||
<>
|
||||
<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-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mptData.map((mpt, idx) => (
|
||||
<Button
|
||||
key={mpt.pkMptNo}
|
||||
variant={selectedMpt?.pkMptNo === mpt.pkMptNo ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMpt(mpt)}
|
||||
>
|
||||
{mpt.testDt ? new Date(mpt.testDt).toLocaleDateString('ko-KR') : `검사 ${idx + 1}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 개체는 아직 혈액화학검사(MPT) 결과가 등록되지 않았습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
392
frontend/src/app/mpt/page.tsx
Normal file
392
frontend/src/app/mpt/page.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams, useRouter } from "next/navigation"
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||||
import { SiteHeader } from "@/components/layout/site-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { mptApi, MptDto, cowApi } from "@/lib/api"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||
|
||||
// 혈액화학검사 항목별 참조값 (정상 범위)
|
||||
const MPT_REFERENCE_VALUES: Record<string, { min: number; max: number; unit: string; name: string }> = {
|
||||
// 에너지 대사
|
||||
glucose: { min: 45, max: 75, unit: 'mg/dL', name: '혈당' },
|
||||
cholesterol: { min: 80, max: 120, unit: 'mg/dL', name: '콜레스테롤' },
|
||||
nefa: { min: 0, max: 0.4, unit: 'mEq/L', name: 'NEFA(유리지방산)' },
|
||||
bcs: { min: 2.5, max: 3.5, unit: '점', name: 'BCS' },
|
||||
// 단백질 대사
|
||||
totalProtein: { min: 6.5, max: 8.5, unit: 'g/dL', name: '총단백질' },
|
||||
albumin: { min: 3.0, max: 3.6, unit: 'g/dL', name: '알부민' },
|
||||
globulin: { min: 3.0, max: 5.0, unit: 'g/dL', name: '글로불린' },
|
||||
agRatio: { min: 0.6, max: 1.2, unit: '', name: 'A/G 비율' },
|
||||
bun: { min: 8, max: 25, unit: 'mg/dL', name: 'BUN(요소태질소)' },
|
||||
// 간기능
|
||||
ast: { min: 45, max: 110, unit: 'U/L', name: 'AST' },
|
||||
ggt: { min: 10, max: 36, unit: 'U/L', name: 'GGT' },
|
||||
fattyLiverIdx: { min: 0, max: 30, unit: '', name: '지방간지수' },
|
||||
// 미네랄
|
||||
calcium: { min: 8.5, max: 11.5, unit: 'mg/dL', name: '칼슘' },
|
||||
phosphorus: { min: 4.0, max: 7.5, unit: 'mg/dL', name: '인' },
|
||||
caPRatio: { min: 1.0, max: 2.0, unit: '', name: 'Ca/P 비율' },
|
||||
magnesium: { min: 1.8, max: 2.5, unit: 'mg/dL', name: '마그네슘' },
|
||||
creatinine: { min: 1.0, max: 2.0, unit: 'mg/dL', name: '크레아틴' },
|
||||
}
|
||||
|
||||
// 카테고리별 항목 그룹핑
|
||||
const MPT_CATEGORIES = [
|
||||
{
|
||||
name: '에너지 대사',
|
||||
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
name: '단백질 대사',
|
||||
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
name: '간기능',
|
||||
items: ['ast', 'ggt', 'fattyLiverIdx'],
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
name: '미네랄',
|
||||
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatinine'],
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
]
|
||||
|
||||
// 측정값 상태 판정 (정상/주의/위험)
|
||||
function getValueStatus(key: string, value: number | null): 'normal' | 'warning' | 'danger' | 'unknown' {
|
||||
if (value === null || value === undefined) return 'unknown'
|
||||
const ref = MPT_REFERENCE_VALUES[key]
|
||||
if (!ref) return 'unknown'
|
||||
|
||||
if (value >= ref.min && value <= ref.max) return 'normal'
|
||||
|
||||
// 10% 이내 범위 이탈은 주의
|
||||
const margin = (ref.max - ref.min) * 0.1
|
||||
if (value >= ref.min - margin && value <= ref.max + margin) return 'warning'
|
||||
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
export default function MptPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const cowShortNo = searchParams.get('cowShortNo')
|
||||
const farmNo = searchParams.get('farmNo')
|
||||
const { toast } = useToast()
|
||||
|
||||
const [searchInput, setSearchInput] = useState(cowShortNo || '')
|
||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = async () => {
|
||||
if (!searchInput.trim()) {
|
||||
toast({
|
||||
title: '검색어를 입력해주세요',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mptApi.findByCowShortNo(searchInput.trim())
|
||||
setMptData(data)
|
||||
if (data.length > 0) {
|
||||
setSelectedMpt(data[0]) // 가장 최근 검사 결과 선택
|
||||
} else {
|
||||
setSelectedMpt(null)
|
||||
toast({
|
||||
title: '검사 결과가 없습니다',
|
||||
description: `개체번호 ${searchInput}의 혈액화학검사 결과를 찾을 수 없습니다.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MPT 데이터 로드 실패:', error)
|
||||
toast({
|
||||
title: '데이터 로드 실패',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (cowShortNo) {
|
||||
handleSearch()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<main className="flex-1 overflow-y-auto bg-white min-h-screen">
|
||||
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted gap-1.5 -ml-2 px-2 sm:px-3"
|
||||
>
|
||||
<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">
|
||||
<Activity 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">Metabolic Profile Test</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="개체 요약번호 입력 (예: 4049)"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && 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">
|
||||
<div className="hidden lg:grid lg:grid-cols-4 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>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">{selectedMpt.cowShortNo || '-'}</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">
|
||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'}
|
||||
</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">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
</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">
|
||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
||||
</span>
|
||||
</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">개체번호</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">{selectedMpt.cowShortNo || '-'}</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">
|
||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).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">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
</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">
|
||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
||||
</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="overflow-x-auto">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MPT_CATEGORIES.map((category, catIdx) => (
|
||||
category.items.map((itemKey, itemIdx) => {
|
||||
const ref = MPT_REFERENCE_VALUES[itemKey]
|
||||
const value = selectedMpt[itemKey as keyof MptDto] as number | null
|
||||
const status = getValueStatus(itemKey, value)
|
||||
|
||||
return (
|
||||
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
||||
{itemIdx === 0 && (
|
||||
<td
|
||||
rowSpan={category.items.length}
|
||||
className={`px-4 py-3 text-sm font-semibold text-white ${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 text-center">
|
||||
<span className={`text-lg font-bold ${
|
||||
status === 'normal' ? 'text-green-600' :
|
||||
status === 'warning' ? 'text-amber-600' :
|
||||
status === 'danger' ? 'text-red-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.min ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.max ?? '-'}</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">
|
||||
<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' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{status === 'normal' ? '정상' :
|
||||
status === 'warning' ? '주의' :
|
||||
status === 'danger' ? '이상' : '-'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
||||
{mptData.length > 1 && (
|
||||
<>
|
||||
<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-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mptData.map((mpt, idx) => (
|
||||
<Button
|
||||
key={mpt.pkMptNo}
|
||||
variant={selectedMpt?.pkMptNo === mpt.pkMptNo ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMpt(mpt)}
|
||||
>
|
||||
{mpt.testDt ? new Date(mpt.testDt).toLocaleDateString('ko-KR') : `검사 ${idx + 1}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !selectedMpt && cowShortNo && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Activity className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">검사 결과가 없습니다</p>
|
||||
<p className="text-sm text-muted-foreground">해당 개체의 혈액화학검사 결과를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !selectedMpt && !cowShortNo && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Search className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">개체번호를 검색해주세요</p>
|
||||
<p className="text-sm text-muted-foreground">개체 요약번호를 입력하여 혈액화학검사 결과를 조회합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +1,178 @@
|
||||
/**
|
||||
* MPT (혈액대사판정시험) 항목별 권장치 참고 범위
|
||||
*
|
||||
* MPT 참조값의 중앙 관리 파일
|
||||
*/
|
||||
|
||||
export interface MptReferenceRange {
|
||||
name: string; // 한글 표시명
|
||||
upperLimit: number | null;
|
||||
lowerLimit: number | null;
|
||||
unit: string;
|
||||
category: '에너지' | '단백질' | '간기능' | '미네랄' | '별도';
|
||||
category: '에너지' | '단백질' | '간기능' | '미네랄' | '기타';
|
||||
description?: string; // 항목 설명 (선택)
|
||||
}
|
||||
|
||||
export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
||||
// 에너지 카테고리
|
||||
'글루코스': {
|
||||
glucose: {
|
||||
name: '혈당',
|
||||
upperLimit: 72,
|
||||
lowerLimit: 46.9,
|
||||
unit: 'mg/dL',
|
||||
category: '에너지',
|
||||
description: '에너지 대사 상태 지표',
|
||||
},
|
||||
'NEFA': {
|
||||
upperLimit: 382,
|
||||
lowerLimit: 118,
|
||||
unit: 'uEq/L',
|
||||
category: '에너지',
|
||||
},
|
||||
'BHBA': {
|
||||
upperLimit: 7.9,
|
||||
lowerLimit: 4.3,
|
||||
unit: 'mg/dL',
|
||||
category: '에너지',
|
||||
},
|
||||
'콜레스테롤': {
|
||||
cholesterol: {
|
||||
name: '콜레스테롤',
|
||||
upperLimit: 169,
|
||||
lowerLimit: 117,
|
||||
unit: 'mg/dL',
|
||||
category: '에너지',
|
||||
description: '혈액 내 콜레스테롤 수치',
|
||||
},
|
||||
nefa: {
|
||||
name: '유리지방산(NEFA)',
|
||||
upperLimit: 382,
|
||||
lowerLimit: 118,
|
||||
unit: 'uEq/L',
|
||||
category: '에너지',
|
||||
description: '혈액 내 유리지방산 수치',
|
||||
},
|
||||
bcs: {
|
||||
name: 'BCS',
|
||||
upperLimit: 3.5,
|
||||
lowerLimit: 2.5,
|
||||
unit: '-',
|
||||
category: '에너지',
|
||||
description: '혈액 내 BCS 수치',
|
||||
},
|
||||
|
||||
// 단백질 카테고리
|
||||
'알부민': {
|
||||
totalProtein: {
|
||||
name: '총단백질',
|
||||
upperLimit: 8.5,
|
||||
lowerLimit: 6.5,
|
||||
unit: 'g/dL',
|
||||
category: '단백질',
|
||||
description: '혈액 내 총단백질 수치',
|
||||
},
|
||||
albumin: {
|
||||
name: '알부민',
|
||||
upperLimit: 4.3,
|
||||
lowerLimit: 3.3,
|
||||
unit: 'g/dL',
|
||||
category: '단백질',
|
||||
description: '혈액 내 알부민 수치',
|
||||
},
|
||||
'총글로불린': {
|
||||
globulin: {
|
||||
name: '총글로불린',
|
||||
upperLimit: 36.1,
|
||||
lowerLimit: 9.1,
|
||||
unit: 'g/L',
|
||||
category: '단백질',
|
||||
description: '혈액 내 총글로불린 수치',
|
||||
},
|
||||
'A/G': {
|
||||
agRatio: {
|
||||
name: 'A/G ',
|
||||
upperLimit: 0.4,
|
||||
lowerLimit: 0.1,
|
||||
unit: '-',
|
||||
category: '단백질',
|
||||
description: '혈액 내 A/G 수치',
|
||||
},
|
||||
'요소태질소(BUN)': {
|
||||
bun: {
|
||||
name: '요소태질소(BUN)',
|
||||
upperLimit: 18.9,
|
||||
lowerLimit: 11.7,
|
||||
unit: 'mg/dL',
|
||||
category: '단백질',
|
||||
description: '혈액 내 요소태질소 수치',
|
||||
},
|
||||
|
||||
// 간기능 카테고리
|
||||
'AST': {
|
||||
ast: {
|
||||
name: 'AST',
|
||||
upperLimit: 92,
|
||||
lowerLimit: 47,
|
||||
unit: 'U/L',
|
||||
category: '간기능',
|
||||
description: '혈액 내 AST 수치',
|
||||
},
|
||||
'GGT': {
|
||||
ggt: {
|
||||
name: 'GGT',
|
||||
upperLimit: 32,
|
||||
lowerLimit: 11,
|
||||
unit: 'U/L',
|
||||
category: '간기능',
|
||||
description: '혈액 내 GGT 수치',
|
||||
},
|
||||
'지방간 지수': {
|
||||
fattyLiverIdx: {
|
||||
name: '지방간 지수',
|
||||
upperLimit: 9.9,
|
||||
lowerLimit: -1.2,
|
||||
unit: '-',
|
||||
category: '간기능',
|
||||
description: '혈액 내 지방간 지수 수치',
|
||||
},
|
||||
|
||||
// 미네랄 카테고리
|
||||
'칼슘': {
|
||||
calcium: {
|
||||
name: '칼슘',
|
||||
upperLimit: 10.6,
|
||||
lowerLimit: 8.1,
|
||||
unit: 'mg/dL',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 칼슘 수치',
|
||||
},
|
||||
'인': {
|
||||
phosphorus: {
|
||||
name: '인',
|
||||
upperLimit: 8.9,
|
||||
lowerLimit: 6.2,
|
||||
unit: 'mg/dL',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 인 수치',
|
||||
},
|
||||
'칼슘/인': {
|
||||
caPRatio: {
|
||||
name: '칼슘/인 비율',
|
||||
upperLimit: 1.3,
|
||||
lowerLimit: 1.2,
|
||||
unit: '-',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 칼슘/인 비율 수치',
|
||||
},
|
||||
'마그네슘': {
|
||||
magnesium: {
|
||||
name: '마그네슘',
|
||||
upperLimit: 3.3,
|
||||
lowerLimit: 1.6,
|
||||
unit: 'mg/dL',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 마그네슘 수치',
|
||||
},
|
||||
|
||||
// 별도 카테고리
|
||||
'총빌리루빈': {
|
||||
upperLimit: null,
|
||||
lowerLimit: null,
|
||||
unit: 'mg/dL',
|
||||
category: '별도',
|
||||
},
|
||||
'크레아틴': {
|
||||
// 기타 카테고리
|
||||
creatinine: {
|
||||
name: '크레아티닌',
|
||||
upperLimit: 2.0,
|
||||
lowerLimit: 1.0,
|
||||
unit: 'mg/dL',
|
||||
category: '별도',
|
||||
category: '기타',
|
||||
description: '혈액 내 크레아티닌 수치',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* MPT 카테고리 목록 (표시 순서)
|
||||
*/
|
||||
export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '별도'] as const;
|
||||
export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '기타'] as const;
|
||||
|
||||
/**
|
||||
* 측정값이 정상 범위 내에 있는지 확인
|
||||
*/
|
||||
export function isWithinRange(
|
||||
value: number,
|
||||
itemName: string
|
||||
itemKey: string
|
||||
): 'normal' | 'high' | 'low' | 'unknown' {
|
||||
const reference = MPT_REFERENCE_RANGES[itemName];
|
||||
const reference = MPT_REFERENCE_RANGES[itemKey];
|
||||
|
||||
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
||||
return 'unknown';
|
||||
@@ -157,8 +193,8 @@ export function getMptItemsByCategory() {
|
||||
grouped[category] = [];
|
||||
});
|
||||
|
||||
Object.entries(MPT_REFERENCE_RANGES).forEach(([itemName, reference]) => {
|
||||
grouped[reference.category].push(itemName);
|
||||
Object.entries(MPT_REFERENCE_RANGES).forEach(([itemKey, reference]) => {
|
||||
grouped[reference.category].push(itemKey);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
|
||||
@@ -20,3 +20,4 @@ export { breedApi } from './breed.api';
|
||||
|
||||
// API 클라이언트도 export (필요 시 직접 사용 가능)
|
||||
export { default as apiClient } from '../api-client';
|
||||
export { mptApi, type MptDto } from './mpt.api';
|
||||
|
||||
105
frontend/src/lib/api/mpt.api.ts
Normal file
105
frontend/src/lib/api/mpt.api.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import apiClient from "../api-client";
|
||||
|
||||
/**
|
||||
* MPT(혈액화학검사) 결과 DTO
|
||||
*/
|
||||
export interface MptDto {
|
||||
pkMptNo: number;
|
||||
cowShortNo: string;
|
||||
fkFarmNo: number;
|
||||
testDt: string;
|
||||
monthAge: number;
|
||||
milkYield: number;
|
||||
parity: number;
|
||||
// 에너지 대사
|
||||
glucose: number;
|
||||
cholesterol: number;
|
||||
nefa: number;
|
||||
bcs: number;
|
||||
// 단백질 대사
|
||||
totalProtein: number;
|
||||
albumin: number;
|
||||
globulin: number;
|
||||
agRatio: number;
|
||||
bun: number;
|
||||
// 간기능
|
||||
ast: number;
|
||||
ggt: number;
|
||||
fattyLiverIdx: number;
|
||||
// 미네랄
|
||||
calcium: number;
|
||||
phosphorus: number;
|
||||
caPRatio: number;
|
||||
magnesium: number;
|
||||
creatinine: number;
|
||||
// Relations
|
||||
farm?: {
|
||||
pkFarmNo: number;
|
||||
farmerName: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT(혈액화학검사) 관련 API
|
||||
*/
|
||||
export const mptApi = {
|
||||
/**
|
||||
* GET /mpt - 전체 검사 결과 목록
|
||||
*/
|
||||
findAll: async (): Promise<MptDto[]> => {
|
||||
return await apiClient.get("/mpt");
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt?farmId=:farmId - 특정 농장의 검사 결과
|
||||
*/
|
||||
findByFarmId: async (farmId: number): Promise<MptDto[]> => {
|
||||
return await apiClient.get("/mpt", {
|
||||
params: { farmId },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt?cowShortNo=:cowShortNo - 특정 개체의 검사 결과
|
||||
*/
|
||||
findByCowShortNo: async (cowShortNo: string): Promise<MptDto[]> => {
|
||||
return await apiClient.get("/mpt", {
|
||||
params: { cowShortNo },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt/:id - 검사 결과 상세 조회
|
||||
*/
|
||||
findOne: async (id: number): Promise<MptDto> => {
|
||||
return await apiClient.get(`/mpt/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /mpt - 검사 결과 생성
|
||||
*/
|
||||
create: async (data: Partial<MptDto>): Promise<MptDto> => {
|
||||
return await apiClient.post("/mpt", data);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /mpt/bulk - 검사 결과 일괄 생성
|
||||
*/
|
||||
bulkCreate: async (data: Partial<MptDto>[]): Promise<MptDto[]> => {
|
||||
return await apiClient.post("/mpt/bulk", data);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /mpt/:id - 검사 결과 수정
|
||||
*/
|
||||
update: async (id: number, data: Partial<MptDto>): Promise<MptDto> => {
|
||||
return await apiClient.put(`/mpt/${id}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /mpt/:id - 검사 결과 삭제
|
||||
*/
|
||||
remove: async (id: number): Promise<void> => {
|
||||
return await apiClient.delete(`/mpt/${id}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user