페이지 화면 수정 및 dockerfile 수정

This commit is contained in:
2025-12-10 12:02:40 +09:00
parent 83dc4c86da
commit 6731eec802
11 changed files with 931 additions and 115 deletions

View File

@@ -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

View File

@@ -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로 요청
},
];
},

View File

@@ -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">

View File

@@ -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;
},
};

View File

@@ -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';