diff --git a/backend/src/common/config/CowPurposeConfig.ts b/backend/src/common/config/CowPurposeConfig.ts deleted file mode 100644 index 1b6406b..0000000 --- a/backend/src/common/config/CowPurposeConfig.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 소 용도 분류 설정 - * - * @description - * 소의 용도를 결정하는 비즈니스 로직 임계값 정의 - * - 도태 (Culling): 낮은 수태율, 번식 능력 부족 - * - 인공수정 (Artificial Insemination): 높은 수태율 + 우수 등급 - * - 공란우 (Donor): 중간 수태율 + 우수 등급 - * - 수란우 (Recipient): 높은 수태율 + 낮은 등급 - */ -export const COW_PURPOSE_CONFIG = { - /** - * 수태율 기반 임계값 (%) - */ - CONCEPTION_RATE_THRESHOLDS: { - /** - * 도태 대상 최대 수태율 (30% 미만) - * 수태율이 이 값보다 낮으면 번식 능력이 부족하여 도태 대상 - */ - CULLING_MAX: 30, - - /** - * 공란우 최대 수태율 (50% 미만) - * 수태율이 낮지만 우수한 유전자 보유 시 수정란 공급 - */ - DONOR_MAX: 50, - - /** - * 수란우 최소 수태율 (65% 이상) - * 높은 수태율을 가진 소에게 우수 수정란 이식 - */ - RECIPIENT_MIN: 65, - - /** - * 인공수정 최소 수태율 (65% 이상) - * 높은 수태율 + 우수 등급 → 일반 인공수정 대상 - */ - INSEMINATION_MIN: 65, - }, - - /** - * 나이 기반 임계값 (년) - */ - AGE_THRESHOLDS: { - /** - * 노령우 기준 (10년 이상) - * 이 나이 이상이면 도태 고려 대상 - */ - OLD_AGE_YEARS: 10, - - /** - * 번식 적정 최소 나이 (2년) - */ - BREEDING_MIN_AGE: 2, - - /** - * 번식 적정 최대 나이 (8년) - */ - BREEDING_MAX_AGE: 8, - }, -} as const; diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index d7bacb8..f6777f8 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -602,8 +602,8 @@ export class GenomeService { rank = lowerCount + 1; } else { // 나보다 높은 점수를 가진 농장 수 + 1 = 내 순위 (높을수록 좋음) - const higherCount = rankings.filter(r => r.avgEbv > farmData.avgEbv).length; - rank = higherCount + 1; + const higherCount = rankings.filter(r => r.avgEbv > farmData.avgEbv).length; + rank = higherCount + 1; } } @@ -1810,8 +1810,8 @@ export class GenomeService { ]; // inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 const traitConditions = inputTraitConditions && inputTraitConditions.length > 0 - ? inputTraitConditions - : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); + ? inputTraitConditions // 프론트에서 보낸 형질사용 + : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용 console.log('[getFarmRegionRanking] traitConditions:', traitConditions.length, 'traits'); diff --git a/backend/src/mpt/entities/mpt.entity.ts b/backend/src/mpt/entities/mpt.entity.ts index ba5ee02..2e07be2 100644 --- a/backend/src/mpt/entities/mpt.entity.ts +++ b/backend/src/mpt/entities/mpt.entity.ts @@ -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 diff --git a/frontend/src/app/cow/[cowNo]/page.tsx b/frontend/src/app/cow/[cowNo]/page.tsx index 2f3e55d..e77d80d 100644 --- a/frontend/src/app/cow/[cowNo]/page.tsx +++ b/frontend/src/app/cow/[cowNo]/page.tsx @@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useToast } from "@/hooks/use-toast" -import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail, GenomeRequestDto } from "@/lib/api" +import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail, GenomeRequestDto, mptApi, MptDto } from "@/lib/api" import { CowDetail } from "@/types/cow.types" import { GenomeTrait } from "@/types/genome.types" import { useGlobalFilter } from "@/contexts/GlobalFilterContext" @@ -35,6 +35,7 @@ import { TraitComparison } from "./genome/_components/genome-integrated-comparis import { CowNumberDisplay } from "@/components/common/cow-number-display" import { isValidGenomeAnalysis, getInvalidReason, getInvalidMessage } from "@/lib/utils/genome-analysis-config" import { AuthGuard } from "@/components/auth/auth-guard" +import { MptTable } from "./reproduction/_components/mpt-table" // 형질명 → 카테고리 매핑 (한우 35개 형질) const TRAIT_CATEGORY_MAP: Record = { @@ -1674,11 +1675,15 @@ export default function CowOverviewPage() { )} + {/* 번식능력 탭 */} + {/* 혈액화학검사(MPT) 테이블 */} + + + {/* TODO: 번식능력 분석 결과 (추후 사용) {hasReproductionData ? (
- {/* TODO: 번식능력 분석 결과 표시 */}

번식능력 분석 결과

) : ( @@ -1692,6 +1697,7 @@ export default function CowOverviewPage() { )} + */}
diff --git a/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx b/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx new file mode 100644 index 0000000..bcdbbbd --- /dev/null +++ b/frontend/src/app/cow/[cowNo]/reproduction/_components/mpt-table.tsx @@ -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([]) + const [selectedMpt, setSelectedMpt] = useState(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 ( +
+
+
+

데이터를 불러오는 중...

+
+
+ ) + } + + return ( +
+ {/* 개체 정보 섹션 */} +

개체 정보

+ + + {/* 데스크탑: 가로 그리드 */} +
+
+
+ 개체번호 +
+
+ +
+
+
+
+ 생년월일 +
+
+ + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} + +
+
+
+
+ 월령 +
+
+ + {cow?.cowBirthDt + ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + : '-'} + +
+
+
+
+ 성별 +
+
+ + {cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'} + +
+
+
+ {/* 모바일: 좌우 배치 리스트 */} +
+
+ 개체번호 +
+ +
+
+
+ 생년월일 + + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} + +
+
+ 월령 + + {cow?.cowBirthDt + ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + : '-'} + +
+
+ 성별 + + {cow?.cowSex === 'F' ? '암' : cow?.cowSex === 'M' ? '수' : '-'} + +
+
+
+
+ + {/* 혈통정보 섹션 */} +

혈통정보

+ + + {/* 데스크탑: 가로 그리드 */} +
+
+
+ 부 KPN번호 +
+
+ + {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return null + } + })()} +
+
+
+
+ 모 개체번호 +
+
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + return null + } + })()} +
+
+
+ {/* 모바일: 좌우 배치 리스트 */} +
+
+ 부 KPN번호 +
+ + {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return null + } + })()} +
+
+
+ 모 개체번호 +
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + return null + } + })()} +
+
+
+
+
+ + {/* 검사 정보 */} + {selectedMpt && ( + <> +

검사 정보

+ + +
+
+
+ 검사일자 +
+
+ + {selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'} + +
+
+
+
+ 월령 +
+
+ + {selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'} + +
+
+
+
+ 산차 +
+
+ + {selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'} + +
+
+
+
+ 유량 +
+
+ + {selectedMpt.milkYield ? `${selectedMpt.milkYield}kg` : '-'} + +
+
+
+ {/* 모바일 */} +
+
+ 검사일자 + + {selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'} + +
+
+ 월령 + + {selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'} + +
+
+ 산차 + + {selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'} + +
+
+ 유량 + + {selectedMpt.milkYield ? `${selectedMpt.milkYield}kg` : '-'} + +
+
+
+
+ + )} + + {/* 혈액화학검사 결과 테이블 */} +

혈액화학검사 결과

+ + +
+ + + + + + + + + + + + + + {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 ( + + {itemIdx === 0 && ( + + )} + + + + + + + + ) + }) + ))} + +
카테고리검사항목측정값하한값상한값단위상태
+ {category.name} + {ref?.name || itemKey} + + {value !== null && value !== undefined ? value.toFixed(2) : '-'} + + {ref?.lowerLimit ?? '-'}{ref?.upperLimit ?? '-'}{ref?.unit || '-'} + {value !== null && value !== undefined ? ( + + {status === 'normal' ? '정상' : + status === 'warning' ? '주의' : + status === 'danger' ? '이상' : '-'} + + ) : ( + - + )} +
+
+
+
+ + {/* 검사 이력 (여러 검사 결과가 있을 경우) */} + {mptData.length > 1 && ( + <> +

검사 이력

+ + +
+ {mptData.map((mpt, idx) => ( + + ))} +
+
+
+ + )} + + {/* 데이터 없음 안내 */} + {/* {!selectedMpt && ( + + + +

혈액화학검사 데이터 없음

+

+ 이 개체는 아직 혈액화학검사(MPT) 결과가 등록되지 않았습니다. +

+
+
+ )} */} +
+ ) +} diff --git a/frontend/src/app/cow/page.tsx b/frontend/src/app/cow/page.tsx index dbb4bf9..0ac1210 100644 --- a/frontend/src/app/cow/page.tsx +++ b/frontend/src/app/cow/page.tsx @@ -716,7 +716,7 @@ function MyCowContent() { > - 불가 {cows.filter(c => c.genomeScore === undefined || c.genomeScore === null).length} + 미검사 {cows.filter(c => c.genomeScore === undefined || c.genomeScore === null).length} diff --git a/frontend/src/app/mpt/page.tsx b/frontend/src/app/mpt/page.tsx new file mode 100644 index 0000000..35ed915 --- /dev/null +++ b/frontend/src/app/mpt/page.tsx @@ -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 = { + // 에너지 대사 + 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([]) + const [selectedMpt, setSelectedMpt] = useState(null) + const [cow, setCow] = useState(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 ( + + + + + +
+
+ {/* 헤더 */} +
+
+ +
+ +
+
+

혈액화학검사

+

Metabolic Profile Test

+
+
+
+ + {/* 검색 영역 */} + + +
+ setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="flex-1" + /> + +
+
+
+ + {loading && ( +
+
+
+

데이터를 불러오는 중...

+
+
+ )} + + {!loading && selectedMpt && ( + <> + {/* 개체 정보 */} +

개체 정보

+ + +
+
+
+ 개체번호 +
+
+ {selectedMpt.cowShortNo || '-'} +
+
+
+
+ 검사일자 +
+
+ + {selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'} + +
+
+
+
+ 월령 +
+
+ + {selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'} + +
+
+
+
+ 산차 +
+
+ + {selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'} + +
+
+
+ {/* 모바일 */} +
+
+ 개체번호 + {selectedMpt.cowShortNo || '-'} +
+
+ 검사일자 + + {selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'} + +
+
+ 월령 + + {selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'} + +
+
+ 산차 + + {selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'} + +
+
+
+
+ + {/* 혈액화학검사 결과 테이블 */} +

혈액화학검사 결과

+ + +
+ + + + + + + + + + + + + + {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 ( + + {itemIdx === 0 && ( + + )} + + + + + + + + ) + }) + ))} + +
카테고리검사항목측정값하한값상한값단위상태
+ {category.name} + {ref?.name || itemKey} + + {value !== null && value !== undefined ? value.toFixed(2) : '-'} + + {ref?.min ?? '-'}{ref?.max ?? '-'}{ref?.unit || '-'} + + {status === 'normal' ? '정상' : + status === 'warning' ? '주의' : + status === 'danger' ? '이상' : '-'} + +
+
+
+
+ + {/* 검사 이력 (여러 검사 결과가 있을 경우) */} + {mptData.length > 1 && ( + <> +

검사 이력

+ + +
+ {mptData.map((mpt, idx) => ( + + ))} +
+
+
+ + )} + + )} + + {!loading && !selectedMpt && cowShortNo && ( +
+ +

검사 결과가 없습니다

+

해당 개체의 혈액화학검사 결과를 찾을 수 없습니다.

+
+ )} + + {!loading && !selectedMpt && !cowShortNo && ( +
+ +

개체번호를 검색해주세요

+

개체 요약번호를 입력하여 혈액화학검사 결과를 조회합니다.

+
+ )} +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/common/global-filter-dialog.tsx b/frontend/src/components/common/global-filter-dialog.tsx index 838ee2d..d44ea08 100644 --- a/frontend/src/components/common/global-filter-dialog.tsx +++ b/frontend/src/components/common/global-filter-dialog.tsx @@ -504,13 +504,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou className="h-8 md:h-9 text-xs md:text-base font-semibold px-2 md:px-3 relative" > - 필터 - 0 || traitCount > 0) ? "default" : "outline"} - className={`ml-1.5 text-[9px] md:text-xs px-1.5 py-0 ${(geneCount > 0 || traitCount > 0) ? "bg-white text-primary" : "text-muted-foreground"}`} - > - {(geneCount > 0 || traitCount > 0) ? "활성" : "비활성"} - + 필터 ({traitCount}) diff --git a/frontend/src/constants/mpt-reference.ts b/frontend/src/constants/mpt-reference.ts index 93dfdcb..6537f40 100644 --- a/frontend/src/constants/mpt-reference.ts +++ b/frontend/src/constants/mpt-reference.ts @@ -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 = { // 에너지 카테고리 - '글루코스': { + 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; diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 39fbe12..64b37c7 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -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'; diff --git a/frontend/src/lib/api/mpt.api.ts b/frontend/src/lib/api/mpt.api.ts new file mode 100644 index 0000000..d5c7736 --- /dev/null +++ b/frontend/src/lib/api/mpt.api.ts @@ -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 => { + return await apiClient.get("/mpt"); + }, + + /** + * GET /mpt?farmId=:farmId - 특정 농장의 검사 결과 + */ + findByFarmId: async (farmId: number): Promise => { + return await apiClient.get("/mpt", { + params: { farmId }, + }); + }, + + /** + * GET /mpt?cowShortNo=:cowShortNo - 특정 개체의 검사 결과 + */ + findByCowShortNo: async (cowShortNo: string): Promise => { + return await apiClient.get("/mpt", { + params: { cowShortNo }, + }); + }, + + /** + * GET /mpt/:id - 검사 결과 상세 조회 + */ + findOne: async (id: number): Promise => { + return await apiClient.get(`/mpt/${id}`); + }, + + /** + * POST /mpt - 검사 결과 생성 + */ + create: async (data: Partial): Promise => { + return await apiClient.post("/mpt", data); + }, + + /** + * POST /mpt/bulk - 검사 결과 일괄 생성 + */ + bulkCreate: async (data: Partial[]): Promise => { + return await apiClient.post("/mpt/bulk", data); + }, + + /** + * PUT /mpt/:id - 검사 결과 수정 + */ + update: async (id: number, data: Partial): Promise => { + return await apiClient.put(`/mpt/${id}`, data); + }, + + /** + * DELETE /mpt/:id - 검사 결과 삭제 + */ + remove: async (id: number): Promise => { + return await apiClient.delete(`/mpt/${id}`); + }, +}; diff --git a/frontend/src/types/filter.types.ts b/frontend/src/types/filter.types.ts index 72bf2e8..a57f10e 100644 --- a/frontend/src/types/filter.types.ts +++ b/frontend/src/types/filter.types.ts @@ -112,9 +112,10 @@ export interface GlobalFilterSettings { } /** + * ==================================================================================================== * 기본 필터 초기값 설정 * 사용자가 해당 형질 선택하지 않았을때 필터의 초기 값 0 세팅 - * + * ==================================================================================================== * const [filterSettings, setFilterSettings] = useState(DEFAULT_FILTER_SETTINGS); * function resetFilter() { setFilterSettings(DEFAULT_FILTER_SETTINGS); // 초기화 @@ -134,22 +135,22 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = { "12개월령체중": 0, // 경제형질 (점수: 0 ~ 10) - 도체중: 0, - 등심단면적: 0, - 등지방두께: 0, - 근내지방도: 0, + 도체중: 1, + 등심단면적: 1, + 등지방두께: 1, + 근내지방도: 1, // 체형형질 (점수: 0 ~ 10) - DB 형질명과 일치 - 체고: 0, + 체고: 1, 십자: 0, - 체장: 0, + 체장: 1, 흉심: 0, 흉폭: 0, 고장: 0, 요각폭: 0, 좌골폭: 0, 곤폭: 0, - 흉위: 0, + 흉위: 1, // 부위별무게 (점수: 0 ~ 10) - DB 형질명과 일치 안심weight: 0, @@ -176,7 +177,8 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = { 갈비rate: 0, }, inbreedingThreshold: 0, // 근친도 기본값 0 - isActive: true, // 기본 7개 형질이 선택되어 있으므로 활성화 + isActive: true, // 기본 7개 형질이 선택되어 있으므로 활성화 + // 기본 각각 1점으로 세팅 updtDt: new Date(), };