개체분석 상태 값 수정
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
// 측정값 상태 판정
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
}
|
||||
|
||||
// 필터 활성화 여부
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
}
|
||||
|
||||
// 육량형/육질형 필터링
|
||||
|
||||
@@ -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)
|
||||
// }
|
||||
}
|
||||
|
||||
// 검색 및 필터링
|
||||
|
||||
@@ -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: '혈액 내 크레아틴 수치',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface MptDto {
|
||||
phosphorus: number;
|
||||
caPRatio: number;
|
||||
magnesium: number;
|
||||
creatinine: number;
|
||||
creatine: number;
|
||||
// Relations
|
||||
farm?: {
|
||||
pkFarmNo: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from '../api-client';
|
||||
import { ReproMpt } from '@/types/reprompt.types';
|
||||
import { ReproMpt } from '@/types/mpt.types';
|
||||
|
||||
/**
|
||||
* 번식정보(Reproduction) 관련 API - MPT(혈액검사) 전용
|
||||
|
||||
@@ -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 '유전체 분석 보고서를 제공할 수 없습니다.';
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ export interface CowDetailResponseDto extends CowDto {
|
||||
totalCows?: number; // 농장 총 개체 수
|
||||
inbreedingCoef?: number; // 근친계수 (0.0~1.0)
|
||||
calvingCount?: number; // 분만회차
|
||||
|
||||
// 데이터 상태 (백엔드에서 조회)
|
||||
dataStatus?: {
|
||||
hasGenomeData: boolean; // 유전체 데이터 존재 여부
|
||||
hasGeneData: boolean; // 유전자 데이터 존재 여부
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user