페이지 화면 수정 및 dockerfile 수정
This commit is contained in:
@@ -17,6 +17,7 @@ COPY . .
|
||||
# 빌드 시 필요한 환경 변수 설정
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_API_URL=/backend/api
|
||||
|
||||
# Next.js 빌드
|
||||
RUN npm run build
|
||||
|
||||
@@ -13,7 +13,7 @@ const nextConfig: NextConfig = {
|
||||
return [
|
||||
{
|
||||
source: '/backend/api/:path*', // /api가 붙은 모든 요청
|
||||
destination: 'http://backend:4000/:path*', // 백엔드 API로 요청
|
||||
destination: 'http://192.168.11.249:4000/:path*', // 백엔드 API로 요청
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi } from "@/lib/api"
|
||||
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail } from "@/lib/api"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { GenomeTrait } from "@/types/genome.types"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
Activity,
|
||||
X,
|
||||
XCircle,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
|
||||
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
|
||||
@@ -144,7 +147,7 @@ export default function CowOverviewPage() {
|
||||
|
||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||
const [genomeData, setGenomeData] = useState<GenomeTrait[]>([])
|
||||
const [geneData, setGeneData] = useState<any[]>([])
|
||||
const [geneData, setGeneData] = useState<GeneDetail[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<string>('genome')
|
||||
|
||||
@@ -193,6 +196,13 @@ export default function CowOverviewPage() {
|
||||
// 차트 형질 필터 (전체 선발지수 또는 개별 형질)
|
||||
const [chartFilterTrait, setChartFilterTrait] = useState<string>('overall')
|
||||
|
||||
// 유전자 탭 필터 상태
|
||||
const [geneSearchKeyword, setGeneSearchKeyword] = useState('')
|
||||
const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all')
|
||||
const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all')
|
||||
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'genotype'>('snpName')
|
||||
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
// 농가/보은군 배지 클릭 시 차트로 스크롤 + 하이라이트
|
||||
const handleComparisonClick = (mode: 'farm' | 'region') => {
|
||||
// 토글: 같은 모드 클릭 시 해제
|
||||
@@ -236,10 +246,17 @@ export default function CowOverviewPage() {
|
||||
const genomeExists = genomeDataResult.length > 0 && !!genomeDataResult[0].genomeCows && genomeDataResult[0].genomeCows.length > 0
|
||||
setHasGenomeData(genomeExists)
|
||||
|
||||
// 유전자(SNP) 데이터 가져오기 (gene.api 제거됨 - 추후 백엔드 구현 시 복구)
|
||||
// TODO: gene API 구현 후 복구
|
||||
setGeneData([])
|
||||
setHasGeneData(false)
|
||||
// 유전자(SNP) 데이터 가져오기
|
||||
try {
|
||||
const geneDataResult = await geneApi.findByCowId(cowNo)
|
||||
setGeneData(geneDataResult)
|
||||
setHasGeneData(geneDataResult.length > 0)
|
||||
} catch (geneErr) {
|
||||
console.error('유전자 데이터 조회 실패:', geneErr)
|
||||
setGeneData([])
|
||||
// UI 확인을 위해 임시로 true 설정 (데이터 없어도 UI는 보여줌)
|
||||
setHasGeneData(true)
|
||||
}
|
||||
|
||||
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
|
||||
// TODO: 번식능력 API 연동
|
||||
@@ -481,9 +498,6 @@ 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 ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
||||
{hasGeneData ? '완료' : '미검사'}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="reproduction"
|
||||
@@ -491,9 +505,6 @@ export default function CowOverviewPage() {
|
||||
>
|
||||
<Activity className="hidden sm:block h-6 w-6 shrink-0" />
|
||||
<span className="font-bold text-sm sm:text-xl">번식능력</span>
|
||||
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasReproductionData ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
||||
{hasReproductionData ? '완료' : '미검사'}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -899,90 +910,533 @@ export default function CowOverviewPage() {
|
||||
<TabsContent value="gene" className="mt-6 space-y-6">
|
||||
{hasGeneData ? (
|
||||
<>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전자(SNP) 분석 결과</h3>
|
||||
{/* 개체 정보 섹션 (유전체 탭과 동일) */}
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">개체 정보</h3>
|
||||
|
||||
{/* 유전자 타입별 요약 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Dna className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-bold text-blue-900">육량형 유전자</h4>
|
||||
<p className="text-sm text-blue-600">체중, 성장 관련</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{geneData.filter(g => g.snpInfo?.markerSnps?.some((ms: any) => ms.marker?.markerTypeCd === 'QTY')).slice(0, 5).map((gene, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between py-2 border-b border-blue-200 last:border-0">
|
||||
<span className="font-medium text-blue-800">{gene.snpInfo?.markerSnps?.[0]?.marker?.markerNm || gene.snpInfo?.snpNm}</span>
|
||||
<Badge className="bg-blue-500 text-white">{gene.genotype || `${gene.allele1Top}${gene.allele2Top}`}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-50 border-purple-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
|
||||
<Dna className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-bold text-purple-900">육질형 유전자</h4>
|
||||
<p className="text-sm text-purple-600">근내지방, 연도 관련</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{geneData.filter(g => g.snpInfo?.markerSnps?.some((ms: any) => ms.marker?.markerTypeCd === 'QLT')).slice(0, 5).map((gene, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between py-2 border-b border-purple-200 last:border-0">
|
||||
<span className="font-medium text-purple-800">{gene.snpInfo?.markerSnps?.[0]?.marker?.markerNm || gene.snpInfo?.snpNm}</span>
|
||||
<Badge className="bg-purple-500 text-white">{gene.genotype || `${gene.allele1Top}${gene.allele2Top}`}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 전체 유전자 목록 */}
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">전체 유전자 목록</h3>
|
||||
<Card className="bg-white border border-border rounded-xl overflow-hidden">
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">SNP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">마커</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">염색체</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">유전자형</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{geneData.slice(0, 50).map((gene, idx) => (
|
||||
<tr key={idx} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-sm font-medium">{gene.snpInfo?.snpNm || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">{gene.snpInfo?.markerSnps?.[0]?.marker?.markerNm || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">{gene.snpInfo?.chr || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline">{gene.genotype || `${gene.allele1Top || ''}${gene.allele2Top || ''}`}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{geneData.length > 50 && (
|
||||
<div className="px-4 py-3 bg-muted/30 text-center text-sm text-muted-foreground">
|
||||
전체 {geneData.length}개 중 50개 표시
|
||||
{/* 데스크탑: 가로 그리드 */}
|
||||
<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">
|
||||
{genomeData[0]?.request?.chipReportDt
|
||||
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
|
||||
: '-'}
|
||||
</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">
|
||||
{genomeData[0]?.request?.chipReportDt
|
||||
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 친자확인 결과 섹션 (유전체 탭과 동일) */}
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">친자확인 결과</h3>
|
||||
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{/* 데스크탑: 가로 그리드 */}
|
||||
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
|
||||
<div>
|
||||
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||
<span className="text-base font-semibold text-muted-foreground">부 KPN번호</span>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||
<span className="text-2xl font-bold text-foreground break-all">
|
||||
{cow?.sireKpn || '-'}
|
||||
</span>
|
||||
{(() => {
|
||||
const chipSireName = genomeData[0]?.request?.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>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</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 ? (
|
||||
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-foreground">-</span>
|
||||
)}
|
||||
{(() => {
|
||||
const chipDamName = genomeData[0]?.request?.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
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 모바일: 좌우 배치 리스트 */}
|
||||
<div className="lg:hidden divide-y divide-border">
|
||||
<div className="flex items-center">
|
||||
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN번호</span>
|
||||
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
||||
<span className="text-base font-bold text-foreground break-all">
|
||||
{cow?.sireKpn || '-'}
|
||||
</span>
|
||||
{(() => {
|
||||
const chipSireName = genomeData[0]?.request?.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>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</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 ? (
|
||||
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
|
||||
) : (
|
||||
<span className="text-base font-bold text-foreground">-</span>
|
||||
)}
|
||||
{(() => {
|
||||
const chipDamName = genomeData[0]?.request?.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
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 유전자 검색 및 필터 섹션 */}
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전자 분석 결과</h3>
|
||||
|
||||
<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">
|
||||
{/* 검색창 */}
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="SNP명, 염색체 검색..."
|
||||
className="pl-9 h-11 max-sm:h-10 text-base max-sm:text-sm border-slate-200 bg-white focus:border-blue-400 focus:ring-blue-100"
|
||||
value={geneSearchKeyword}
|
||||
onChange={(e) => setGeneSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 옵션들 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3">
|
||||
{/* 유전자 타입 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-600 shrink-0">구분:</span>
|
||||
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
||||
<button
|
||||
onClick={() => setGeneTypeFilter('all')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
geneTypeFilter === 'all'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGeneTypeFilter('QTY')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
geneTypeFilter === 'QTY'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
육량형
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGeneTypeFilter('QLT')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
geneTypeFilter === 'QLT'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
육질형
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유전자형 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-600 shrink-0">유전자형:</span>
|
||||
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
||||
<button
|
||||
onClick={() => setGenotypeFilter('all')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
genotypeFilter === 'all'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGenotypeFilter('homozygous')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
genotypeFilter === 'homozygous'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
동형접합
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGenotypeFilter('heterozygous')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
genotypeFilter === 'heterozygous'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
이형접합
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정렬 드롭다운 */}
|
||||
<div className="flex items-center gap-2 sm:ml-auto">
|
||||
<Select
|
||||
value={geneSortBy}
|
||||
onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'genotype') => setGeneSortBy(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[110px] h-9 text-sm border-slate-200 bg-white">
|
||||
<SelectValue placeholder="정렬 기준" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="snpName">SNP명</SelectItem>
|
||||
<SelectItem value="chromosome">염색체</SelectItem>
|
||||
<SelectItem value="position">위치</SelectItem>
|
||||
<SelectItem value="genotype">유전자형</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={geneSortOrder}
|
||||
onValueChange={(value: 'asc' | 'desc') => setGeneSortOrder(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm border-slate-200 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">오름차순</SelectItem>
|
||||
<SelectItem value="desc">내림차순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유전자 테이블/카드 */}
|
||||
{(() => {
|
||||
const filteredData = geneData.filter(gene => {
|
||||
// 검색 필터
|
||||
if (geneSearchKeyword) {
|
||||
const keyword = geneSearchKeyword.toLowerCase()
|
||||
const snpName = (gene.snpName || '').toLowerCase()
|
||||
const chromosome = (gene.chromosome || '').toLowerCase()
|
||||
const position = (gene.position || '').toLowerCase()
|
||||
if (!snpName.includes(keyword) && !chromosome.includes(keyword) && !position.includes(keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 유전자형 필터
|
||||
if (genotypeFilter !== 'all') {
|
||||
const isHomozygous = gene.allele1 === gene.allele2
|
||||
if (genotypeFilter === 'homozygous' && !isHomozygous) return false
|
||||
if (genotypeFilter === 'heterozygous' && isHomozygous) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 정렬
|
||||
const sortedData = [...filteredData].sort((a, b) => {
|
||||
let aVal: string | number = ''
|
||||
let bVal: string | number = ''
|
||||
|
||||
switch (geneSortBy) {
|
||||
case 'snpName':
|
||||
aVal = a.snpName || ''
|
||||
bVal = b.snpName || ''
|
||||
break
|
||||
case 'chromosome':
|
||||
aVal = parseInt(a.chromosome || '0') || 0
|
||||
bVal = parseInt(b.chromosome || '0') || 0
|
||||
break
|
||||
case 'position':
|
||||
aVal = parseInt(a.position || '0') || 0
|
||||
bVal = parseInt(b.position || '0') || 0
|
||||
break
|
||||
case 'genotype':
|
||||
aVal = `${a.allele1 || ''}${a.allele2 || ''}`
|
||||
bVal = `${b.allele1 || ''}${b.allele2 || ''}`
|
||||
break
|
||||
}
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal
|
||||
}
|
||||
|
||||
const strA = String(aVal)
|
||||
const strB = String(bVal)
|
||||
return geneSortOrder === 'asc'
|
||||
? strA.localeCompare(strB)
|
||||
: strB.localeCompare(strA)
|
||||
})
|
||||
|
||||
const displayData = sortedData.length > 0
|
||||
? sortedData.slice(0, 50)
|
||||
: Array(10).fill(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 데스크톱: 테이블 */}
|
||||
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">SNP 이름</th>
|
||||
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">염색체 위치</th>
|
||||
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">Position</th>
|
||||
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">SNP 구분</th>
|
||||
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">첫번째 대립유전자</th>
|
||||
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">두번째 대립유전자</th>
|
||||
<th className="px-5 py-3 text-center text-base font-semibold text-muted-foreground">설명</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{displayData.map((gene, idx) => {
|
||||
if (!gene) {
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-muted/30">
|
||||
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
|
||||
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
|
||||
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
|
||||
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
|
||||
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
|
||||
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
|
||||
<td className="px-5 py-4 text-base text-center text-muted-foreground">-</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-muted/30">
|
||||
<td className="px-5 py-4 text-center text-base font-medium text-foreground">{gene.snpName || '-'}</td>
|
||||
<td className="px-5 py-4 text-center text-base text-foreground">{gene.chromosome || '-'}</td>
|
||||
<td className="px-5 py-4 text-center text-base text-foreground">{gene.position || '-'}</td>
|
||||
<td className="px-5 py-4 text-center text-base text-foreground">{gene.snpType || '-'}</td>
|
||||
<td className="px-5 py-4 text-center text-base text-foreground">{gene.allele1 || '-'}</td>
|
||||
<td className="px-5 py-4 text-center text-base text-foreground">{gene.allele2 || '-'}</td>
|
||||
<td className="px-5 py-4 text-center text-base text-muted-foreground">{gene.remarks || '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{geneData.length > 50 && (
|
||||
<div className="px-4 py-3 bg-muted/30 text-center text-sm text-muted-foreground border-t">
|
||||
전체 {geneData.length}개 중 50개 표시
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 모바일: 카드 뷰 */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{displayData.map((gene, idx) => {
|
||||
return (
|
||||
<Card key={idx} className="bg-white border border-border shadow-sm rounded-xl">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">SNP 이름</span>
|
||||
<span className="text-base font-semibold text-foreground">{gene?.snpName || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">염색체 위치</span>
|
||||
<span className="text-base text-foreground">{gene?.chromosome || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Position</span>
|
||||
<span className="text-base text-foreground">{gene?.position || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">SNP 구분</span>
|
||||
<span className="text-base text-foreground">{gene?.snpType || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">첫번째 대립유전자</span>
|
||||
<span className="text-base text-foreground">{gene?.allele1 || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">두번째 대립유전자</span>
|
||||
<span className="text-base text-foreground">{gene?.allele2 || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">설명</span>
|
||||
<span className="text-base text-muted-foreground">{gene?.remarks || '-'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{geneData.length > 50 && (
|
||||
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
전체 {geneData.length}개 중 50개 표시
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<Card className="bg-slate-50 border border-border rounded-2xl">
|
||||
|
||||
@@ -1,40 +1,96 @@
|
||||
/**
|
||||
* Gene API (임시 Mock)
|
||||
* TODO: 백엔드 구현 후 실제 API로 교체
|
||||
* Gene API
|
||||
* 유전자(SNP) 분석 결과 조회 API
|
||||
*/
|
||||
|
||||
import apiClient from "../api-client";
|
||||
|
||||
export interface MarkerModel {
|
||||
markerNm: string;
|
||||
markerTypeCd: string; // 'QTY' | 'QLT'
|
||||
markerDesc?: string;
|
||||
relatedTrait?: string;
|
||||
favorableAllele?: string;
|
||||
/**
|
||||
* 유전자 상세 정보 타입
|
||||
*/
|
||||
export interface GeneDetail {
|
||||
pkGeneDetailNo: number;
|
||||
fkRequestNo: number | null;
|
||||
cowId: string | null;
|
||||
snpName: string | null;
|
||||
chromosome: string | null;
|
||||
position: string | null;
|
||||
snpType: string | null;
|
||||
allele1: string | null;
|
||||
allele2: string | null;
|
||||
remarks: string | null;
|
||||
regDt?: string;
|
||||
updtDt?: string;
|
||||
genomeRequest?: {
|
||||
pkRequestNo: number;
|
||||
requestDt: string | null;
|
||||
chipReportDt: string | null;
|
||||
chipSireName: string | null;
|
||||
chipDamName: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 요약 정보 타입
|
||||
*/
|
||||
export interface GeneSummary {
|
||||
total: number;
|
||||
homozygousCount: number;
|
||||
heterozygousCount: number;
|
||||
}
|
||||
|
||||
export const geneApi = {
|
||||
/**
|
||||
* 전체 마커 목록 조회 (임시 빈 배열 반환)
|
||||
* 개체식별번호로 유전자 상세 정보 조회
|
||||
* GET /gene/:cowId
|
||||
*/
|
||||
getAllMarkers: async (): Promise<MarkerModel[]> => {
|
||||
// TODO: 백엔드 구현 후 실제 API 연동
|
||||
return [];
|
||||
findByCowId: async (cowId: string): Promise<GeneDetail[]> => {
|
||||
const response = await apiClient.get<GeneDetail[]>(`/gene/${cowId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 타입별 마커 목록 조회 (임시 빈 배열 반환)
|
||||
* 개체별 유전자 요약 정보 조회
|
||||
* GET /gene/summary/:cowId
|
||||
*/
|
||||
getGenesByType: async (_typeCd: string): Promise<MarkerModel[]> => {
|
||||
// TODO: 백엔드 구현 후 실제 API 연동
|
||||
return [];
|
||||
getGeneSummary: async (cowId: string): Promise<GeneSummary> => {
|
||||
const response = await apiClient.get<GeneSummary>(`/gene/summary/${cowId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 개체별 유전자(SNP) 데이터 조회 (임시 빈 배열 반환)
|
||||
* 의뢰번호로 유전자 상세 정보 조회
|
||||
* GET /gene/request/:requestNo
|
||||
*/
|
||||
findByCowNo: async (_cowNo: string | number): Promise<any[]> => {
|
||||
// TODO: 백엔드 구현 후 실제 API 연동
|
||||
return [];
|
||||
findByRequestNo: async (requestNo: number): Promise<GeneDetail[]> => {
|
||||
const response = await apiClient.get<GeneDetail[]>(`/gene/request/${requestNo}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 단건 조회
|
||||
* GET /gene/detail/:geneDetailNo
|
||||
*/
|
||||
findOne: async (geneDetailNo: number): Promise<GeneDetail> => {
|
||||
const response = await apiClient.get<GeneDetail>(`/gene/detail/${geneDetailNo}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 생성
|
||||
* POST /gene
|
||||
*/
|
||||
create: async (data: Partial<GeneDetail>): Promise<GeneDetail> => {
|
||||
const response = await apiClient.post<GeneDetail>('/gene', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 일괄 생성
|
||||
* POST /gene/bulk
|
||||
*/
|
||||
createBulk: async (dataList: Partial<GeneDetail>[]): Promise<GeneDetail[]> => {
|
||||
const response = await apiClient.post<GeneDetail[]>('/gene/bulk', dataList);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export { authApi } from './auth.api'; // 인증 API
|
||||
export { cowApi } from './cow.api';
|
||||
export { dashboardApi } from './dashboard.api';
|
||||
export { farmApi } from './farm.api';
|
||||
export { geneApi, type GeneDetail, type GeneSummary } from './gene.api';
|
||||
export { genomeApi, type ComparisonAveragesDto, type CategoryAverageDto, type FarmTraitComparisonDto, type TraitComparisonAveragesDto, type TraitAverageDto } from './genome.api';
|
||||
export { reproApi } from './repro.api';
|
||||
export { breedApi } from './breed.api';
|
||||
|
||||
Reference in New Issue
Block a user