개체분석 상태 값 수정

This commit is contained in:
2025-12-19 15:19:50 +09:00
parent abc2f20495
commit c8bd04f124
24 changed files with 596 additions and 1499 deletions

View File

@@ -1,43 +1,40 @@
'use client'
import { useSearchParams, useParams, useRouter } from "next/navigation"
import { AuthGuard } from "@/components/auth/auth-guard"
import { CowNumberDisplay } from "@/components/common/cow-number-display"
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 { 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 { useMediaQuery } from "@/hooks/use-media-query"
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"
import {
ArrowLeft,
ArrowUp,
BarChart3,
CheckCircle2,
Download,
Dna,
Activity,
X,
XCircle,
Search,
} from 'lucide-react'
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
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 { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait } from "@/types/genome.types"
import {
Activity,
ArrowLeft,
BarChart3,
CheckCircle2,
ChevronUp,
Dna,
Search,
X,
XCircle
} from 'lucide-react'
import { useParams, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useMemo, useRef, useState } from 'react'
import { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
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 { TraitComparison } from "./genome/_components/genome-integrated-comparison"
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<string, string> = {
@@ -254,6 +251,74 @@ export default function CowOverviewPage() {
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
// 부 KPN 배지 렌더링 (분석불가/일치/불일치)
const renderSireBadge = (chipSireName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => {
const sizeClasses = size === 'lg'
? 'gap-1.5 text-sm px-3 py-1.5'
: 'gap-1 text-xs px-2 py-1'
const iconSize = size === 'lg' ? 'w-4 h-4' : 'w-3 h-3'
// 분석불가 개체 먼저 체크 (EXCLUDED_COW_IDS 또는 DB에서 '분석불가'/'정보없음'으로 저장된 경우)
if (isExcludedCow(cow?.cowId) || chipSireName === '분석불가' || chipSireName === '정보없음') {
return (
<span className={`flex items-center ${sizeClasses} bg-slate-400 text-white font-semibold rounded-full shrink-0`}>
<XCircle className={iconSize} />
<span></span>
</span>
)
} else if (chipSireName === '일치') {
return (
<span className={`flex items-center ${sizeClasses} bg-primary text-primary-foreground font-semibold rounded-full shrink-0`}>
<CheckCircle2 className={iconSize} />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className={`flex items-center ${sizeClasses} bg-red-500 text-white font-semibold rounded-full shrink-0`}>
<XCircle className={iconSize} />
<span></span>
</span>
)
}
return null
}
// 모 개체 배지 렌더링 (일치/불일치/이력제부재)
const renderDamBadge = (chipDamName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => {
// 분석불가 개체는 어미 배지 표시 안 함
if (isExcludedCow(cow?.cowId)) return null
const sizeClasses = size === 'lg'
? 'gap-1.5 text-sm px-3 py-1.5'
: 'gap-1 text-xs px-2 py-1'
const iconSize = size === 'lg' ? 'w-4 h-4' : 'w-3 h-3'
if (chipDamName === '일치') {
return (
<span className={`flex items-center ${sizeClasses} bg-primary text-primary-foreground font-semibold rounded-full shrink-0`}>
<CheckCircle2 className={iconSize} />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className={`flex items-center ${sizeClasses} bg-red-500 text-white font-semibold rounded-full shrink-0`}>
<XCircle className={iconSize} />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className={`flex items-center ${sizeClasses} bg-amber-500 text-white font-semibold rounded-full shrink-0`}>
<XCircle className={iconSize} />
<span></span>
</span>
)
}
return null
}
// 유전자 데이터 지연 로드 함수
const loadGeneData = async () => {
if (geneDataLoaded || geneDataLoading) return // 이미 로드했거나 로딩 중이면 스킵
@@ -264,10 +329,12 @@ export default function CowOverviewPage() {
const geneList = geneDataResult || []
setGeneData(geneList)
setGeneDataLoaded(true)
setHasGeneData(geneList.length > 0)
} catch (geneErr) {
console.error('유전자 데이터 조회 실패:', geneErr)
setGeneData([])
setGeneDataLoaded(true)
setHasGeneData(false)
} finally {
setGeneDataLoading(false)
}
@@ -318,6 +385,11 @@ export default function CowOverviewPage() {
}
setCow(cowDetail)
// dataStatus에서 데이터 존재 여부 설정 (백엔드에서 가벼운 COUNT 쿼리로 확인)
if (cowData.dataStatus) {
setHasGeneData(cowData.dataStatus.hasGeneData)
}
// 유전체 데이터 가져오기
const genomeDataResult = await genomeApi.findByCowNo(cowNo)
setGenomeData(genomeDataResult)
@@ -333,9 +405,6 @@ export default function CowOverviewPage() {
setGenomeRequest(null)
}
// 유전자(SNP) 데이터는 탭 클릭 시 로드 (지연 로딩)
setHasGeneData(true) // 탭은 보여주되, 데이터는 나중에 로드
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
// TODO: 번식능력 API 연동
setHasReproductionData(false)
@@ -579,8 +648,8 @@ export default function CowOverviewPage() {
>
<Dna 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 ${hasGeneData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasGeneData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? '완료' : '미검사'}
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? '완료' : '미검사'}
</span>
</TabsTrigger>
<TabsTrigger
@@ -695,30 +764,7 @@ export default function CowOverviewPage() {
<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-primary 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 (
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName)}
</div>
</div>
<div>
@@ -731,34 +777,7 @@ export default function CowOverviewPage() {
) : (
<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-primary 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 {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
{renderDamBadge(genomeRequest?.chipDamName)}
</div>
</div>
</div>
@@ -770,30 +789,7 @@ export default function CowOverviewPage() {
<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-primary 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 (
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
</div>
</div>
<div className="flex items-center">
@@ -804,34 +800,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary 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 {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
</div>
</div>
</div>
@@ -1062,30 +1031,7 @@ export default function CowOverviewPage() {
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-primary 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 (
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName)}
</div>
</div>
<div>
@@ -1098,34 +1044,7 @@ export default function CowOverviewPage() {
) : (
<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-primary 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 {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
{renderDamBadge(genomeRequest?.chipDamName)}
</div>
</div>
</div>
@@ -1135,30 +1054,7 @@ export default function CowOverviewPage() {
<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">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary 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 (
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
</div>
</div>
<div className="flex items-center">
@@ -1169,34 +1065,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary 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 {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
</div>
</div>
</div>
@@ -1333,30 +1202,7 @@ export default function CowOverviewPage() {
<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-primary 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 (
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName)}
</div>
</div>
<div>
@@ -1369,33 +1215,7 @@ export default function CowOverviewPage() {
) : (
<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-primary 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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName)}
</div>
</div>
</div>
@@ -1407,30 +1227,7 @@ export default function CowOverviewPage() {
<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-primary 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 (
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
</div>
</div>
<div className="flex items-center">
@@ -1441,33 +1238,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary 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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
</div>
</div>
</div>
@@ -1477,8 +1248,8 @@ export default function CowOverviewPage() {
{/* 유전자 검색 및 필터 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
{/* 친자확인 결과에 따른 분기 */}
{isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? (
{/* 유전자 탭 분기: 분석불가/정보없음만 차단, 불일치/이력제부재는 유전자 데이터 표시 */}
{!(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? (
<>
<div className="flex flex-col gap-3 sm:gap-3 p-3.5 max-sm:p-3 sm:px-4 sm:py-3 rounded-xl bg-slate-50/50 border border-slate-200/50">
{/* 검색창 */}
@@ -1867,15 +1638,154 @@ export default function CowOverviewPage() {
)}
</>
) : (
<Card className="bg-slate-50 border border-border rounded-2xl">
<CardContent className="p-8 text-center">
<Dna 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">
(SNP) .
</p>
</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-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">-</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">-</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">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{renderSireBadge(genomeRequest?.chipSireName)}
</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>
)}
{renderDamBadge(genomeRequest?.chipDamName)}
</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">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
</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>
)}
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 유전자 분석 결과 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-slate-100 border border-slate-300 rounded-2xl">
<CardContent className="p-8 text-center">
<Dna className="h-12 w-12 text-slate-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-slate-700 mb-2">
{genomeRequest ? '유전자 분석 불가' : '유전자 분석불가'}
</h3>
<p className="text-sm text-slate-500">
{genomeRequest
? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId).replace('유전체', '유전자')
: '이 개체는 아직 유전자(SNP) 분석이 진행되지 않았습니다.'
}
</p>
</CardContent>
</Card>
</>
)}
</TabsContent>
@@ -1955,14 +1865,14 @@ export default function CowOverviewPage() {
</DialogContent>
</Dialog>
{/* 플로팅 맨 위로 버튼 */}
{/* 플로팅 맨 위로 버튼 - 글래스모피즘 */}
{showScrollTop && (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 z-50 w-12 h-12 bg-primary text-white rounded-full shadow-lg hover:bg-primary/90 transition-all duration-300 flex items-center justify-center hover:scale-110 active:scale-95"
className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-50 w-10 h-10 sm:w-12 sm:h-12 bg-white/80 backdrop-blur-md border border-white/50 text-slate-700 rounded-full shadow-lg hover:bg-white/90 transition-all duration-300 flex items-center justify-center hover:scale-110 active:scale-95"
aria-label="맨 위로"
>
<ArrowUp className="w-6 h-6" />
<ChevronUp className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
)}
</SidebarProvider>

View File

@@ -16,7 +16,7 @@ const MPT_CATEGORIES = [
{ 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' },
{ name: '기타', items: ['creatine'], color: 'bg-muted/50' },
]
// 측정값 상태 판정

View File

@@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { cowApi, reproApi } from "@/lib/api"
import { CowDetail } from "@/types/cow.types"
import { ReproMpt } from "@/types/reprompt.types"
import { ReproMpt } from "@/types/mpt.types"
import { Activity, AlertCircle, CheckCircle } from "lucide-react"
import { CowNavigation } from "../_components/navigation"
import { useToast } from "@/hooks/use-toast"
@@ -140,7 +140,7 @@ export default function ReproductionPage() {
{ name: '인', value: reproMpt[0].phosphorus, fieldName: 'phosphorus' },
{ name: '칼슘/인', value: reproMpt[0].caPRatio, fieldName: 'caPRatio' },
{ name: '마그네슘', value: reproMpt[0].magnesium, fieldName: 'magnesium' },
{ name: '크레아틴', value: reproMpt[0].creatinine, fieldName: 'creatinine' },
{ name: '크레아틴', value: reproMpt[0].creatine, fieldName: 'creatine' },
] : []
const normalItems = mptItems.filter(item => {

View File

@@ -680,7 +680,7 @@ function MyCowContent() {
<div className="flex flex-col gap-3 sm:gap-4">
{/* 제목 */}
<div>
<h1 className="text-3xl max-sm:text-2xl font-bold text-slate-900"> </h1>
<h1 className="text-3xl sm:text-3xl font-bold text-slate-900"> </h1>
<p className="text-base max-sm:text-sm text-slate-500 mt-1">{'농장'} </p>
</div>
@@ -737,12 +737,12 @@ function MyCowContent() {
{/* 필터 옵션들 - 모바일: 2행, 데스크톱: 1행 */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3">
{/* 랭킹/정렬 그룹 */}
<div className="grid grid-cols-3 sm:flex sm:items-center gap-2.5 max-sm:gap-2 sm:gap-2">
<div className="grid grid-cols-3 sm:flex sm:items-center gap-2.5 max-sm:gap-1.5 sm:gap-2">
<Select
value={rankingMode}
onValueChange={(value: 'gene' | 'genome') => setRankingMode(value)}
>
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-sm border-slate-200 bg-white">
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 border-slate-200 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -751,7 +751,7 @@ function MyCowContent() {
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-full sm:w-[110px] h-10 sm:h-9 text-sm border-slate-200 bg-white">
<SelectTrigger className="w-full sm:w-[110px] h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 border-slate-200 bg-white">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
@@ -765,7 +765,7 @@ function MyCowContent() {
value={sortOrder}
onValueChange={(value) => setSortOrder(value as 'asc' | 'desc')}
>
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-sm border-slate-200 bg-white">
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 border-slate-200 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -776,10 +776,10 @@ function MyCowContent() {
</div>
{/* 표시항목 그룹 */}
<div className="grid grid-cols-2 sm:flex sm:items-center gap-2.5 max-sm:gap-2 sm:gap-2">
<div className="grid grid-cols-2 sm:flex sm:items-center gap-2.5 max-sm:gap-1.5 sm:gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-10 sm:h-9 text-sm justify-between w-full sm:w-[100px] border-slate-200 bg-white">
<Button variant="outline" className="h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 justify-between w-full sm:w-[100px] border-slate-200 bg-white">
<span></span>
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -843,7 +843,7 @@ function MyCowContent() {
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-10 sm:h-9 text-sm justify-between w-full sm:w-[100px] border-slate-200 bg-white">
<Button variant="outline" className="h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 justify-between w-full sm:w-[100px] border-slate-200 bg-white">
<span></span>
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -986,15 +986,15 @@ function MyCowContent() {
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' :
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 || '분석불가'}
{cow.unavailableReason}
</span>
)}
) : '-'}
</td>
<td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5">
{(cow.genomeScore !== undefined && cow.genomeScore !== null) ? (
@@ -1197,15 +1197,15 @@ function MyCowContent() {
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{cow.anlysDt ? '분석일' : '분석결과'}</span>
<span className="font-medium">
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : (
{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' :
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 || '분석불가'}
{cow.unavailableReason}
</span>
)}
) : '-'}
</span>
</div>
</div>

View File

@@ -41,7 +41,7 @@ const MPT_REFERENCE_VALUES: Record<string, { min: number; max: number; unit: str
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: '크레아틴' },
creatine: { min: 1.0, max: 2.0, unit: 'mg/dL', name: '크레아틴' },
}
// 카테고리별 항목 그룹핑
@@ -63,7 +63,7 @@ const MPT_CATEGORIES = [
},
{
name: '미네랄',
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatinine'],
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatine'],
color: 'bg-purple-500',
},
]

View File

@@ -1,57 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
export function AnimatedDesktop() {
const [svgContent, setSvgContent] = useState('');
useEffect(() => {
// SVG 파일을 fetch해서 내용을 가져옴
fetch('/images/Desktop_SVG.svg')
.then(res => res.text())
.then(text => {
// SVG 내용에 CSS 애니메이션을 추가
const parser = new DOMParser();
const svgDoc = parser.parseFromString(text, 'image/svg+xml');
const svg = svgDoc.documentElement;
// style 태그 추가
const style = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = `
@keyframes rotate-circle {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.rotating-element {
animation: rotate-circle 4s linear infinite;
transform-origin: center;
transform-box: fill-box;
}
`;
svg.insertBefore(style, svg.firstChild);
// 회전시킬 요소 찾기 (fill="#38BDD4" and transform="translate(813,386)")
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
const fill = path.getAttribute('fill');
const transform = path.getAttribute('transform');
if (fill === '#38BDD4' && transform && transform.includes('translate(813,386)')) {
path.classList.add('rotating-element');
}
});
// 수정된 SVG를 문자열로 변환
const serializer = new XMLSerializer();
const modifiedSvg = serializer.serializeToString(svg);
setSvgContent(modifiedSvg);
})
.catch(console.error);
}, []);
return (
<div
className="relative w-full h-full flex items-center justify-center"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
);
}

View File

@@ -1,807 +0,0 @@
"use client"
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -295,21 +295,22 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
}, [open, filters])
// 전체 유전자 로드
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const loadAllGenes = async () => {
try {
setLoadingGenes(true)
const [qtyGenes, qltGenes] = await Promise.all([
geneApi.getGenesByType('QTY'),
geneApi.getGenesByType('QLT'),
])
setQuantityGenes(qtyGenes)
setQualityGenes(qltGenes)
} catch {
setQuantityGenes([])
setQualityGenes([])
} finally {
setLoadingGenes(false)
}
// try {
// setLoadingGenes(true)
// const [qtyGenes, qltGenes] = await Promise.all([
// geneApi.getGenesByType('QTY'),
// geneApi.getGenesByType('QLT'),
// ])
// setQuantityGenes(qtyGenes)
// setQualityGenes(qltGenes)
// } catch {
// setQuantityGenes([])
// setQualityGenes([])
// } finally {
// setLoadingGenes(false)
// }
}
// 필터 활성화 여부

View File

@@ -1,56 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts'
// 벤치마크 데이터
const benchmarkData = [
{ category: '유전체 점수', myFarm: 79.8, regional: 65.2, top10: 88.5 },
{ category: 'MPT 충족률', myFarm: 74.6, regional: 72.0, top10: 85.2 },
{ category: 'A등급 비율', myFarm: 37.5, regional: 18.8, top10: 45.0 },
{ category: '번식능력', myFarm: 72, regional: 70, top10: 82 },
]
export function RegionBenchmark() {
return (
<Card className="shadow-sm">
<CardHeader className="pb-2 md:pb-3">
<CardTitle className="text-xs md:text-sm font-semibold"> </CardTitle>
<CardDescription className="text-[11px] md:text-xs mt-0.5">
vs vs 10%
</CardDescription>
</CardHeader>
<CardContent className="pt-0 pb-3 md:pb-4">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={benchmarkData} barGap={4}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="category"
tick={{ fontSize: 11 }}
stroke="#6b7280"
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="#6b7280"
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '11px'
}}
formatter={(value: number) => `${value.toFixed(1)}`}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="regional" fill="#9ca3af" name="보은군 평균" radius={[4, 4, 0, 0]} />
<Bar dataKey="myFarm" fill="#2563eb" name="내 농장" radius={[4, 4, 0, 0]} />
<Bar dataKey="top10" fill="#10b981" name="상위 10%" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@@ -61,27 +61,28 @@ export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }
}
}, [open])
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const fetchMarkers = async () => {
try {
setLoading(true)
setError(null)
const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
// try {
// setLoading(true)
// setError(null)
// const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
// API 데이터를 GeneOption 형식으로 변환
const geneOptions: GeneOption[] = markers.map(marker => ({
name: marker.markerNm,
description: marker.relatedTrait || marker.markerDesc || '',
type: marker.markerTypeCd as 'QTY' | 'QLT',
relatedTrait: marker.relatedTrait || ''
}))
// // API 데이터를 GeneOption 형식으로 변환
// const geneOptions: GeneOption[] = markers.map(marker => ({
// name: marker.markerNm,
// description: marker.relatedTrait || marker.markerDesc || '',
// type: marker.markerTypeCd as 'QTY' | 'QLT',
// relatedTrait: marker.relatedTrait || ''
// }))
setAllMarkers(geneOptions)
} catch (err) {
console.error('Failed to fetch markers:', err)
setError('유전자 목록을 불러오는데 실패했습니다.')
} finally {
setLoading(false)
}
// setAllMarkers(geneOptions)
// } catch (err) {
// console.error('Failed to fetch markers:', err)
// setError('유전자 목록을 불러오는데 실패했습니다.')
// } finally {
// setLoading(false)
// }
}
// 육량형/육질형 필터링

View File

@@ -30,16 +30,17 @@ export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChan
}
}, [open])
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const loadAllGenes = async () => {
try {
setLoading(true)
const genes = await geneApi.getAllMarkers()
setAllGenes(genes)
} catch {
// 유전자 로드 실패 시 빈 배열 유지
} finally {
setLoading(false)
}
// try {
// setLoading(true)
// const genes = await geneApi.getAllMarkers()
// setAllGenes(genes)
// } catch {
// // 유전자 로드 실패 시 빈 배열 유지
// } finally {
// setLoading(false)
// }
}
// 검색 및 필터링

View File

@@ -38,15 +38,6 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
category: '에너지',
description: '혈액 내 유리지방산 수치',
},
bcs: {
name: 'BCS',
upperLimit: 3.5,
lowerLimit: 2.5,
unit: '-',
category: '에너지',
description: '혈액 내 BCS 수치',
},
// 단백질 카테고리
totalProtein: {
name: '총단백질',
@@ -150,13 +141,13 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
},
// 기타 카테고리
creatinine: {
name: '크레아티닌',
creatine: {
name: '크레아',
upperLimit: 1.3,
lowerLimit: 1.0,
unit: 'mg/dL',
category: '기타',
description: '혈액 내 크레아티닌 수치',
description: '혈액 내 크레아 수치',
},
};

View File

@@ -31,7 +31,7 @@ export interface MptDto {
phosphorus: number;
caPRatio: number;
magnesium: number;
creatinine: number;
creatine: number;
// Relations
farm?: {
pkFarmNo: number;

View File

@@ -1,5 +1,5 @@
import apiClient from '../api-client';
import { ReproMpt } from '@/types/reprompt.types';
import { ReproMpt } from '@/types/mpt.types';
/**
* 번식정보(Reproduction) 관련 API - MPT(혈액검사) 전용

View File

@@ -5,13 +5,13 @@
* 유전체 분석 데이터가 유효한지 판단하는 조건 정의
* 백엔드 GenomeAnalysisConfig.ts와 동일한 로직 유지
*
* =================유효 조건=======================
* ====================유효 조건====================
* 1. chipSireName === '일치' (아비 칩 데이터 일치)
* 2. chipDamName !== '불일치' (어미 칩 데이터 불일치가 아님)
* 3. chipDamName !== '이력제부재' (어미 이력제 부재가 아님)
* 4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
*
* 제외되는 경우:
* ====================제외되는 경우====================
* - chipSireName !== '일치' (아비 불일치, 이력제부재 등)
* - chipDamName === '불일치' (어미 불일치)
* - chipDamName === '이력제부재' (어미 이력제 부재)
@@ -29,6 +29,17 @@ export const EXCLUDED_COW_IDS = [
'KOR002191642861', // 모근량 갯수 적은데 1회분은 가능 아비명도 일치하는데 유전체 분석 정보가 없음
];
/**
* 분석불가 개체인지 확인 (모근 오염/불량 등 특수 사유)
*
* @param cowId - 개체식별번호
* @returns 분석불가 개체 여부
*/
export function isExcludedCow(cowId?: string | null): boolean {
if (!cowId) return false;
return EXCLUDED_COW_IDS.includes(cowId);
}
/**
* 유전체 분석 데이터 유효성 검사
*
@@ -54,9 +65,15 @@ export function isValidGenomeAnalysis(
chipDamName: string | null | undefined,
cowId?: string | null,
): boolean {
// 개별 제외 개체만 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
// 1. 분석불가 개체 (모근 오염/불량 등 특수 사유)
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
// 2. 아비명 일치 필수
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
// 3. 어미명 불일치/이력제부재 제외
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
return true;
}
@@ -73,18 +90,23 @@ export function getInvalidReason(
chipDamName: string | null | undefined,
cowId?: string | null,
): string | null {
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
if (!chipSireName) return '친자확인 정보 없음';
return '부 KPN 친자 불일치';
}
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
if (chipDamName === '이력제부재') return '모 이력제 부재';
return '모 친자 불일치';
}
// 1. 개별 제외 개체
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
return '분석 불가 개체';
return '분석불가';
}
// 2. 아비명 상태별 분류
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
if (chipSireName === '분석불가') return '분석불가';
if (chipSireName === '정보없음') return '분석불가';
if (!chipSireName) return null; // null은 '-' 표시
return '부 불일치'; // 불일치 등
}
// 3. 어미명 상태별 분류
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
if (chipDamName === '이력제부재') return '모 이력제부재';
return '모 불일치';
}
return null;
@@ -103,19 +125,24 @@ export function getInvalidMessage(
chipDamName: string | null | undefined,
cowId?: string | null,
): string {
// 1. 개별 제외 개체
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
return '모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다.';
}
// 2. 아비명 상태별 분류
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
if (!chipSireName) return '친자확인 정보가 없어 유전체 분석 보고서를 제공할 수 없습니다.';
if (chipSireName === '분석불가') return '모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다.';
if (chipSireName === '정보없음') return '개체 식별번호 및 형식오류로 유전체 분석 보고서를 제공할 수 없습니다.';
if (!chipSireName) return ''; // null은 '-' 표시
return '부 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
}
// 3. 어미명 상태별 분류
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
if (chipDamName === '이력제부재') return '모 이력제 정보가 부재하여 유전체 분석 보고서를 제공할 수 없습니다.';
return '모 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
}
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
return '해당 개체는 분석 불가 사유로 인해 유전체 분석 보고서를 제공할 수 없습니다.';
}
return '유전체 분석 보고서를 제공할 수 없습니다.';
}

View File

@@ -49,6 +49,12 @@ export interface CowDetailResponseDto extends CowDto {
totalCows?: number; // 농장 총 개체 수
inbreedingCoef?: number; // 근친계수 (0.0~1.0)
calvingCount?: number; // 분만회차
// 데이터 상태 (백엔드에서 조회)
dataStatus?: {
hasGenomeData: boolean; // 유전체 데이터 존재 여부
hasGeneData: boolean; // 유전자 데이터 존재 여부
};
}
/**

View File

@@ -41,7 +41,7 @@ export interface MptDto extends BaseFields {
phosphorus?: number; // 인 (mg/dL)
caPRatio?: number; // 칼슘/인 비율
magnesium?: number; // 마그네슘 (mg/dL)
creatinine?: number; // 크레아틴 (mg/dL)
creatine?: number; // 크레아틴 (mg/dL)
delDt?: string; // 삭제일시 (Soft Delete)
}
@@ -98,7 +98,7 @@ export interface ReproMpt {
phosphorus?: number;
caPRatio?: number;
magnesium?: number;
creatinine?: number;
creatine?: number;
reproMptNote?: string;
regDt?: string;