Files
genome2025/frontend/src/app/cow/page.tsx

1203 lines
65 KiB
TypeScript

'use client'
import { AppSidebar } from "@/components/layout/app-sidebar"
import { SiteHeader } from "@/components/layout/site-header"
import { CowNumberDisplay } from "@/components/common/cow-number-display"
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button"
import { Cow, CowWithGenes, RankingItem } from "@/types/cow.types"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, ChevronRight, Search, ChevronsUpDown, Filter, Settings } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cowApi } from "@/lib/api/cow.api"
import { TRAIT_DISPLAY_NAMES } from "@/constants/traits"
import { useAuthStore } from "@/store/auth-store"
import { useFilterStore } from "@/store/filter-store"
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext"
import { AuthGuard } from "@/components/auth/auth-guard"
/**
* 개체 리스트 페이지
*/
function MyCowContent() {
const [cows, setCows] = useState<CowWithGenes[]>([])
const [filteredCows, setFilteredCows] = useState<CowWithGenes[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const { user } = useAuthStore()
const { filters, isLoading: isFilterLoading } = useFilterStore()
// 로컬 필터 상태 (검색, 랭킹모드, 정렬)
const [searchKeyword, setSearchKeyword] = useState('')
const [rankingMode, setRankingMode] = useState<'gene' | 'genome'>('gene') // 유전자순/유전체순
const [sortBy, setSortBy] = useState<string>('rank') // 정렬 기준 (기본: 순위)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') // 정렬 방향
const [analysisFilter, setAnalysisFilter] = useState<'all' | 'completed' | 'mptOnly' | 'unavailable'>('all') // 분석 상태 필터
// 커스텀 컬럼 표시 필터
const [selectedDisplayGenes, setSelectedDisplayGenes] = useState<string[]>([]) // 테이블에 표시할 유전자
const [selectedDisplayTraits, setSelectedDisplayTraits] = useState<string[]>([]) // 테이블에 표시할 형질
const [availableGenes, setAvailableGenes] = useState<string[]>([]) // 선택 가능한 유전자 목록
const [availableTraits, setAvailableTraits] = useState<string[]>([]) // 선택 가능한 형질 목록 (전역 필터 순서 유지)
const [expandedRows, setExpandedRows] = useState<Set<number | string>>(new Set()) // 유전자형 더보기 펼침 상태 (pkCowNo 또는 "pkCowNo-traits" 형태)
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 12
// 형질 가중치가 1개 이상 설정되어야 필터 활성화
const isFilterSet = filters.isActive && Object.values(filters.traitWeights).some(w => w > 0)
// ========================================
// 전역 필터 → 개체 리스트 표시 항목 동기화
// ========================================
useEffect(() => {
if (isFilterLoading) return // 필터 로딩 중이면 건너뛰기
const hasGenes = filters.isActive && filters.selectedGenes && filters.selectedGenes.length > 0
const hasTraits = filters.isActive && Object.values(filters.traitWeights).some(w => w > 0)
// 1. 마커 유전자 표시 목록 동기화 (육량형 , 육질형 추후 추가 연동 예정)
if (hasGenes) {
const pinnedGenes = filters.pinnedGenes || []
setAvailableGenes(filters.selectedGenes)
const pinnedInOrder = filters.selectedGenes.filter(g => pinnedGenes.includes(g))
setSelectedDisplayGenes(pinnedGenes.length > 0 ? pinnedInOrder : filters.selectedGenes)
} else {
setAvailableGenes([])
setSelectedDisplayGenes([])
}
// 2. 형질 표시 목록 동기화
if (filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0) {
const pinnedTraits = filters.pinnedTraits || []
setAvailableTraits(filters.selectedTraits)
const pinnedInOrder = filters.selectedTraits.filter(t => pinnedTraits.includes(t))
setSelectedDisplayTraits(pinnedTraits.length > 0 ? pinnedInOrder : filters.selectedTraits)
} else {
setAvailableTraits([])
setSelectedDisplayTraits([])
}
// 3. 랭킹 모드 자동 설정
// - 유전자 선택됨 → 유전자순 (GENE 모드)
// - 형질만 선택됨 → 유전체순 (GENOME 모드)
if (filters.isActive) {
if (hasGenes) {
setRankingMode('gene')
} else if (hasTraits) {
setRankingMode('genome')
}
}
}, [isFilterLoading, filters.isActive, filters.selectedGenes, filters.selectedTraits, filters.pinnedGenes, filters.pinnedTraits, filters.traitWeights])
// ========================================
// 개체 데이터 조회 (Ranking API)
// ========================================
useEffect(() => {
const fetchCows = async () => {
if (!isFilterSet) {
setLoading(false) // 필터가 설정되지 않았으면 API 호출하지 않음
setCows([])
setFilteredCows([])
return
}
try {
setLoading(true) // 필터가 설정되면 API 호출
setError(null)
// 1. 사용자 농장 필터 생성
let farmFilters: { field: string; operator: 'in'; value: number[] }[] = []
try {
const userNo = user?.pkUserNo
if (userNo) {
const { default: apiClient } = await import('@/lib/api-client')
const farmsResponse = await apiClient.get(`/farm?userId=${userNo}`)
const farms = (farmsResponse.data || farmsResponse) as { pkFarmNo: number }[]
if (farms && Array.isArray(farms) && farms.length > 0) {
const farmNos = farms.map((f) => f.pkFarmNo)
farmFilters = [{ field: 'cow.fkFarmNo', operator: 'in', value: farmNos }]
}
}
} catch (err) {
console.error('Failed to fetch farms:', err)
}
// 2. 랭킹 옵션 구성
// - GENE 모드: 마커 유전자 기반 정렬 (우량동형 → 이형 → 유전체점수) 추후 구현 예정
// - GENOME 모드: 형질 가중치 기반 정렬
let rankingOptions: any
const traitConditions = Object.entries(filters.traitWeights) // 형질 가중치 조건 1이 기본 (유전체 점수 계산용)
.filter(([, weight]) => weight > 0)
.map(([traitNm, weight]) => ({ traitNm, weight }))
if (rankingMode === 'gene' && filters.isActive && filters.selectedGenes && filters.selectedGenes.length > 0) {
// GENE 모드
rankingOptions = {
criteriaType: 'GENE',
geneConditions: filters.selectedGenes.map(markerNm => ({ markerNm, order: 'DESC' })),
traitConditions
}
} else if (rankingMode === 'genome' || !filters.isActive || !filters.selectedGenes || filters.selectedGenes.length === 0) {
// GENOME 모드
if (!filters.isActive) {
rankingOptions = {
criteriaType: 'GENOME',
traitConditions: [],
inbreedingCondition: { maxThreshold: 0, order: 'ASC' }
}
} else if (rankingMode === 'genome' && traitConditions.length === 0) {
rankingOptions = {
criteriaType: 'GENOME',
traitConditions: [],
inbreedingCondition: { maxThreshold: filters.inbreedingThreshold ?? 0, order: 'ASC' }
}
} else {
rankingOptions = {
criteriaType: 'GENOME',
traitConditions,
inbreedingCondition: { maxThreshold: filters.inbreedingThreshold ?? 0, order: 'ASC' }
}
}
} else {
// 기본값 (GENOME 모드)
rankingOptions = {
criteriaType: 'GENOME',
traitConditions: [],
inbreedingCondition: { maxThreshold: 0, order: 'ASC' }
}
}
// 3. API 호출 및 응답 매핑
const rankingRequest = {
filterOptions: { filters: farmFilters }, // 필터옵션과
rankingOptions // 랭킹옵션을 담아서 백엔드로 전달
}
const response = await cowApi.getRanking(rankingRequest)
const cowsData = response.items.map((item: RankingItem) => ({
...item.entity,
rank: item.rank,
genomeScore: item.sortValue,
inbreedingPercent: item.entity.inbreedingPercent ?? 0,
traits: item.ranking?.traits?.reduce((acc: Record<string, { breedVal: number | null, traitVal: number | null }>, t) => {
acc[t.traitName] = { breedVal: t.traitEbv, traitVal: t.traitVal }
return acc
}, {}) || {},
}))
setCows(cowsData)
setFilteredCows(cowsData)
} catch (err) {
console.error('개체 데이터 조회 실패:', err)
setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다')
} finally {
setLoading(false)
}
}
fetchCows()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, rankingMode, isFilterSet])
// ========================================
// 클라이언트 측 필터링 및 정렬
// ========================================
useEffect(() => {
let result = [...cows]
// 1. 검색 필터
if (searchKeyword) {
const keyword = searchKeyword.toLowerCase()
result = result.filter(cow =>
(cow.cowId && cow.cowId.toLowerCase().includes(keyword)) ||
(cow.cowShortNo && cow.cowShortNo.toLowerCase().includes(keyword)) ||
String(cow.pkCowNo).includes(searchKeyword)
)
}
// 2. 전역 필터 (유전자 기반)
if (filters.isActive && filters.analysisIndex === 'GENE' && filters.selectedGenes.length > 0) {
result = result.filter(cow => {
if (!cow.genes || !Array.isArray(cow.genes)) return false
return filters.selectedGenes.every(selectedGene =>
cow.genes!.some(g => g.name === selectedGene)
)
})
}
// 3. 분석 상태 필터
if (analysisFilter === 'completed') {
result = result.filter(cow => cow.genomeScore !== undefined && cow.genomeScore !== null)
} else if (analysisFilter === 'mptOnly') {
result = result.filter(cow => cow.hasMpt === true)
} else if (analysisFilter === 'unavailable') {
result = result.filter(cow => cow.unavailableReason !== null && cow.unavailableReason !== undefined)
}
// 4. 정렬
if (sortBy !== 'none') {
switch (sortBy) {
case 'rank':
result.sort((a, b) => {
const rankA = a.rank ?? 9999
const rankB = b.rank ?? 9999
return sortOrder === 'asc' ? rankA - rankB : rankB - rankA
})
break
case 'number':
result.sort((a, b) => {
const comparison = (a.cowId || '').localeCompare(b.cowId || '')
return sortOrder === 'asc' ? comparison : -comparison
})
break
case 'age':
result.sort((a, b) => {
const dateA = a.cowBirthDt ? new Date(a.cowBirthDt).getTime() : 0
const dateB = b.cowBirthDt ? new Date(b.cowBirthDt).getTime() : 0
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA
})
break
case 'score':
result.sort((a, b) => {
const scoreA = a.genomeScore ?? 0
const scoreB = b.genomeScore ?? 0
return sortOrder === 'asc' ? scoreA - scoreB : scoreB - scoreA
})
break
}
}
setFilteredCows(result)
setCurrentPage(1)
}, [searchKeyword, sortBy, sortOrder, cows, filters, analysisFilter])
// 페이지네이션
const totalPages = Math.ceil(filteredCows.length / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const paginatedCows = filteredCows.slice(startIndex, startIndex + itemsPerPage)
// handleCowClick - cowId 또는 pkCowNo로 상세 페이지 이동
const handleCowClick = (cowNo: number | string) => {
router.push(`/cow/${cowNo}`)
}
const handlePageChange = (page: number) => {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 랭킹 가져오기 (백엔드에서 계산된 값 사용)
const getRank = (cow: CowWithGenes): number => {
return cow.rank ?? 9999
}
if (loading) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col items-center justify-center">
<p> ...</p>
</div>
</SidebarInset>
</SidebarProvider>
)
}
if (error) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col items-center justify-center">
<p className="text-red-500">: {error}</p>
</div>
</SidebarInset>
</SidebarProvider>
)
}
// 필터 미설정 시 안내 화면
if (!isFilterSet) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col items-center justify-center p-8">
<div className="w-20 h-20 rounded-full bg-muted flex items-center justify-center mb-6">
<Filter className="h-10 w-10 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-3"> </h2>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
() 1 .
</p>
<Button
onClick={() => {
// SiteHeader의 필터 버튼 클릭
const filterButton = document.querySelector('[data-filter-button]') as HTMLButtonElement
if (filterButton) filterButton.click()
}}
className="gap-2"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</SidebarInset>
</SidebarProvider>
)
}
// 본문시작 ====================================================================================================
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 sm:gap-4 md:gap-5 lg:gap-6 py-5 sm:py-5 md:py-6 lg:py-8">
{/* 헤더 */}
<div className="px-4 sm:px-6 md:px-8 lg:px-10 mx-2 sm:mx-0">
<div className="flex flex-col gap-3 sm:gap-4">
{/* 제목 */}
<div>
<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>
{/* 분석 상태 탭 필터 - 모바일: 2x2 그리드, 데스크톱: 가로 배치 */}
<div className="grid grid-cols-2 sm:grid-cols-4 rounded-lg border border-slate-200 bg-slate-50 p-1.5 gap-1.5 max-sm:p-1 max-sm:gap-1">
<button
onClick={() => setAnalysisFilter('all')}
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'all'
? 'bg-white text-slate-900 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<span className="font-bold">{cows.length}</span>
</button>
<button
onClick={() => setAnalysisFilter('completed')}
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'completed'
? 'bg-white text-emerald-600 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center justify-center gap-1.5 max-sm:gap-1">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
<span className="font-bold">{cows.filter(c => c.genomeScore !== undefined && c.genomeScore !== null).length}</span>
</span>
</button>
<button
onClick={() => setAnalysisFilter('mptOnly')}
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'mptOnly'
? 'bg-white text-amber-600 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center justify-center gap-1.5 max-sm:gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500"></span>
<span className="font-bold">{cows.filter(c => c.hasMpt === true).length}</span>
</span>
</button>
<button
onClick={() => setAnalysisFilter('unavailable')}
className={`px-3 py-2.5 max-sm:px-2 max-sm:py-2 rounded-md text-base max-sm:text-sm font-medium transition-colors ${analysisFilter === 'unavailable'
? 'bg-white text-red-600 shadow-sm border border-slate-200'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center justify-center gap-1.5 max-sm:gap-1">
<span className="w-2 h-2 rounded-full bg-red-400"></span>
<span className="font-bold">{cows.filter(c => c.unavailableReason !== null && c.unavailableReason !== undefined).length}</span>
</span>
</button>
</div>
{/* 필터 및 검색 통합 박스 */}
<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="개체번호 검색..."
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={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
</div>
{/* 필터 옵션들 - 모바일: 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-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-xs sm:text-sm px-2 sm:px-3 border-slate-200 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gene"></SelectItem>
<SelectItem value="genome"></SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<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>
<SelectItem value="rank"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="age"></SelectItem>
<SelectItem value="score"></SelectItem>
</SelectContent>
</Select>
<Select
value={sortOrder}
onValueChange={(value) => setSortOrder(value as 'asc' | 'desc')}
>
<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>
<SelectItem value="asc"></SelectItem>
<SelectItem value="desc"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시항목 그룹 */}
<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-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>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<ScrollArea className="h-[300px]">
<div className="p-4 space-y-2">
{availableGenes.map((gene) => (
<div key={gene} className="flex items-center space-x-2">
<Checkbox
id={`gene-${gene}`}
checked={selectedDisplayGenes.includes(gene)}
onCheckedChange={(checked) => {
if (checked) {
// 전역 필터 순서대로 추가 (순서 유지)
const orderedGenes = availableGenes.filter(g =>
selectedDisplayGenes.includes(g) || g === gene
)
setSelectedDisplayGenes(orderedGenes)
} else {
setSelectedDisplayGenes(selectedDisplayGenes.filter(g => g !== gene))
}
}}
/>
<Label
htmlFor={`gene-${gene}`}
className="text-sm font-normal cursor-pointer"
>
{gene}
</Label>
</div>
))}
</div>
</ScrollArea>
<div className="border-t">
<div className="p-2 flex justify-between">
<Button
size="sm"
variant="ghost"
onClick={() => {
if (selectedDisplayGenes.length === availableGenes.length) {
setSelectedDisplayGenes([])
} else {
setSelectedDisplayGenes(availableGenes)
}
}}
>
{selectedDisplayGenes.length === availableGenes.length ? '전체 해제' : '전체 선택'}
</Button>
<span className="text-xs text-muted-foreground self-center">
{selectedDisplayGenes.length}/{availableGenes.length}
</span>
</div>
<div className="px-2 pb-2">
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<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>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="end">
<ScrollArea className="h-[300px]">
<div className="p-4 space-y-2">
{availableTraits.map((trait) => (
<div key={trait} className="flex items-center space-x-2">
<Checkbox
id={`trait-${trait}`}
checked={selectedDisplayTraits.includes(trait)}
onCheckedChange={(checked) => {
if (checked) {
// 전역 필터 순서대로 추가 (순서 유지)
const orderedTraits = availableTraits.filter(t =>
selectedDisplayTraits.includes(t) || t === trait
)
setSelectedDisplayTraits(orderedTraits)
} else {
setSelectedDisplayTraits(selectedDisplayTraits.filter(t => t !== trait))
}
}}
/>
<Label
htmlFor={`trait-${trait}`}
className="text-sm font-normal cursor-pointer"
>
{TRAIT_DISPLAY_NAMES[trait] || trait}
</Label>
</div>
))}
</div>
</ScrollArea>
<div className="border-t">
<div className="p-2 flex justify-between">
<Button
size="sm"
variant="ghost"
onClick={() => {
if (selectedDisplayTraits.length === availableTraits.length) {
setSelectedDisplayTraits([])
} else {
setSelectedDisplayTraits(availableTraits)
}
}}
>
{selectedDisplayTraits.length === availableTraits.length ? '전체 해제' : '전체 선택'}
</Button>
<span className="text-xs text-muted-foreground self-center">
{selectedDisplayTraits.length}/{availableTraits.length}
</span>
</div>
<div className="px-2 pb-2">
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
</div>
{/* 리스트 뷰 */}
<div className="px-4 sm:px-6 md:px-8 lg:px-10 mx-2 sm:mx-0">
{/* 데스크톱 테이블 뷰 */}
{(
<div className="hidden md:block border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th className="cow-table-header" style={{ width: '50px' }}></th>
<th className="cow-table-header" style={{ width: '220px' }}></th>
<th className="cow-table-header" style={{ width: '90px' }}></th>
<th className="cow-table-header" style={{ width: '60px' }}></th>
<th className="cow-table-header" style={{ width: '100px' }}></th>
<th className="cow-table-header" style={{ width: '90px' }}> KPN</th>
<th className="cow-table-header" style={{ width: '100px', whiteSpace: 'nowrap' }}>
{analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'}
</th>
<th className="cow-table-header" style={{ width: '90px' }}>
{analysisFilter === 'mptOnly' ? '검사일자' : '분석일자'}
</th>
<th className="cow-table-header border-r-2 border-r-gray-300" style={{ width: '100px' }}>
</th>
{selectedDisplayGenes.length > 0 && (
<th className="cow-table-header" style={{ width: '140px' }}></th>
)}
{selectedDisplayTraits.length > 0 && (
<th className="cow-table-header" style={{ width: '140px' }}></th>
)}
</tr>
</thead>
<tbody>
{paginatedCows.map((cow) => {
const rank = getRank(cow)
return (
<tr
key={cow.pkCowNo}
className="border-b cursor-pointer transition-colors hover:bg-muted/50"
onClick={() => handleCowClick(cow.cowId)} // cowId 개체번호로 이동
>
<td className="cow-table-cell">
<span className="font-semibold">{rank}</span>
</td>
<td className="cow-table-cell">
<div className="font-medium">
<CowNumberDisplay cowId={cow.cowId || cow.pkCowNo} cowShortNo={cow.cowShortNo} />
</div>
</td>
<td className="cow-table-cell">
{(() => {
// 번식능력만 있는 개체 판단 (유전체 데이터 없음)
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: cowBirthDt 없으면 MPT로 역산
if ((analysisFilter === 'mptOnly' || hasMptOnly) && !cow.cowBirthDt && cow.mptTestDt && cow.mptMonthAge) {
const testDate = new Date(cow.mptTestDt)
const birthDate = new Date(testDate)
birthDate.setMonth(birthDate.getMonth() - cow.mptMonthAge)
return birthDate.toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
})
}
return cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : '-'
})()}
</td>
<td className="cow-table-cell">
{cow.cowSex === "수" ? "수소" : "암소"}
</td>
<td className="cow-table-cell">
{cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'}
</td>
<td className="cow-table-cell">
{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</td>
<td className="cow-table-cell">
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
if (analysisFilter === 'mptOnly' || hasMptOnly) {
if (cow.cowBirthDt && cow.mptTestDt) {
const birthDate = new Date(cow.cowBirthDt)
const refDate = new Date(cow.mptTestDt)
return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
}
return '-'
}
// 유전체 분석일 기준 월령
if (cow.cowBirthDt && cow.anlysDt) {
const birthDate = new Date(cow.cowBirthDt)
const refDate = new Date(cow.anlysDt)
return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
}
return '-'
})()}
</td>
<td className="cow-table-cell">
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 사용
if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptTestDt ? new Date(cow.mptTestDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : '-'
}
// 유전체 탭: unavailableReason 있으면 배지, 없으면 분석일자
if (cow.unavailableReason) {
return (
<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' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason}
</span>
)
}
return cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : '-'
})()}
</td>
<td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5">
{(cow.genomeScore !== undefined && cow.genomeScore !== null) ? (
<div className="font-bold text-primary text-base">
{cow.genomeScore.toFixed(2)}
</div>
) : (
<Badge className="text-xs px-2 py-1 bg-slate-600 text-white border-0 font-semibold">
</Badge>
)}
</td>
{selectedDisplayGenes.length > 0 && (
<td
className="py-2 px-2 text-sm"
onClick={(e) => e.stopPropagation()}
>
{(() => {
const isExpanded = expandedRows.has(cow.pkCowNo)
const displayGenes = isExpanded ? selectedDisplayGenes : selectedDisplayGenes.slice(0, 3)
const remainingCount = selectedDisplayGenes.length - 3
return (
<div className="flex flex-col gap-1">
{displayGenes.map((geneName) => {
const gene = cow.genes?.find(g => g.name === geneName)
const genotype = gene?.genotype || '-'
return (
<div key={geneName} className="flex items-center gap-2">
<span className="text-xs text-slate-600 min-w-[60px]">{geneName}</span>
{gene ? (
<Badge className="text-xs font-semibold bg-blue-500 text-white">
{genotype}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
-
</Badge>
)}
</div>
)
})}
{selectedDisplayGenes.length > 3 && (
<button
onClick={() => {
const newExpanded = new Set(expandedRows)
if (isExpanded) {
newExpanded.delete(cow.pkCowNo)
} else {
newExpanded.add(cow.pkCowNo)
}
setExpandedRows(newExpanded)
}}
className="text-xs text-blue-600 hover:text-blue-800 font-semibold cursor-pointer text-center mt-0.5"
>
{isExpanded ? '접기' : `+${remainingCount}개 더보기`}
</button>
)}
</div>
)
})()}
</td>
)}
{selectedDisplayTraits.length > 0 && (
<td
className="py-2 px-2 text-sm"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col items-start gap-1.5">
{(() => {
const isExpanded = expandedRows.has(`${cow.pkCowNo}-traits`)
const displayTraits = isExpanded ? selectedDisplayTraits : selectedDisplayTraits.slice(0, 3)
const remainingCount = selectedDisplayTraits.length - 3
return (
<>
{displayTraits.map((trait) => {
let traitValue = '-'
if (cow.traits && cow.traits[trait] !== undefined) {
const traitData = cow.traits[trait]
if (typeof traitData === 'object' && traitData.traitVal !== undefined && traitData.traitVal !== null) {
traitValue = Number(traitData.traitVal).toFixed(1)
} else if (typeof traitData === 'object' && traitData.breedVal !== undefined) {
traitValue = Number(traitData.breedVal).toFixed(1)
} else if (typeof traitData === 'number') {
traitValue = Number(traitData).toFixed(1)
}
}
return (
<div key={trait} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground min-w-[65px]">{TRAIT_DISPLAY_NAMES[trait] || trait}</span>
<span className="font-semibold text-xs">{traitValue}</span>
</div>
)
})}
{selectedDisplayTraits.length > 3 && (
<button
onClick={() => {
const newExpanded = new Set(expandedRows)
const key = `${cow.pkCowNo}-traits`
if (isExpanded) {
newExpanded.delete(key)
} else {
newExpanded.add(key)
}
setExpandedRows(newExpanded)
}}
className="text-[10px] text-teal-600 hover:text-teal-800 font-medium mt-0.5 cursor-pointer"
>
{isExpanded ? '접기' : `+${remainingCount}개 더보기`}
</button>
)}
</>
)
})()}
</div>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* 모바일 컴팩트 카드 뷰 */}
{(
<div className="md:hidden space-y-2.5">
{paginatedCows.map((cow) => {
const rank = getRank(cow)
const isFemale = cow.cowSex !== '수'
return (
<div
key={cow.pkCowNo}
className="relative border rounded-lg p-3.5 hover:bg-muted/50 cursor-pointer transition-colors overflow-hidden"
onClick={() => handleCowClick(cow.cowId)}
>
{/* 1행: 순위, 개체번호, 성별, 선발지수 */}
<div className="flex items-center justify-between mb-2.5">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-base font-bold text-muted-foreground">{rank}</span>
<span className="font-bold text-lg truncate">
<CowNumberDisplay cowId={cow.cowId || cow.pkCowNo} cowShortNo={cow.cowShortNo} format="noKor" />
</span>
<Badge className={`text-xs px-1.5 py-0 font-medium flex-shrink-0 ${isFemale ? 'bg-slate-500 text-white' : 'bg-blue-500 text-white'}`}>
{isFemale ? '암' : '수'}
</Badge>
</div>
<div className="flex-shrink-0 ml-2">
{cow.genomeScore !== undefined && cow.genomeScore !== null ? (
<span className="font-bold text-xl text-primary">
{cow.genomeScore.toFixed(2)}
</span>
) : (
<Badge className="text-[11px] px-1.5 py-0.5 bg-slate-500 text-white border-0 font-medium">
</Badge>
)}
</div>
</div>
{/* 2행: 기본 정보 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-t pt-2.5">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: cowBirthDt 없으면 MPT로 역산
if ((analysisFilter === 'mptOnly' || hasMptOnly) && !cow.cowBirthDt && cow.mptTestDt && cow.mptMonthAge) {
const testDate = new Date(cow.mptTestDt)
const birthDate = new Date(testDate)
birthDate.setMonth(birthDate.getMonth() - cow.mptMonthAge)
return birthDate.toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' })
}
return cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-'
})()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{(() => {
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
return (analysisFilter === 'mptOnly' || hasMptOnly) ? '월령 (검사일)' : '월령 (분석일)'
})()}
</span>
<span className="font-medium">
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
if (analysisFilter === 'mptOnly' || hasMptOnly) {
if (cow.cowBirthDt && cow.mptTestDt) {
return `${Math.floor((new Date(cow.mptTestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
}
return '-'
}
// 유전체 분석일 기준 월령
if (cow.cowBirthDt && cow.anlysDt) {
return `${Math.floor((new Date(cow.anlysDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
}
return '-'
})()}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{cow.damCowId && cow.damCowId !== '0' ? (() => {
const digits = cow.damCowId.replace(/\D/g, '')
if (digits.length === 12) {
return `${digits.slice(0, 3)} ${digits.slice(3, 7)} ${digits.slice(7, 11)} ${digits.slice(11)}`
}
return cow.damCowId
})() : '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
{(() => {
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
return (analysisFilter === 'mptOnly' || hasMptOnly) ? '검사일' : (cow.anlysDt ? '분석일' : '분석결과')
})()}
</span>
<span className="font-medium">
{(() => {
// 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 사용
if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptTestDt ? new Date(cow.mptTestDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-'
}
if (cow.anlysDt) {
return new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' })
}
if (cow.unavailableReason) {
return (
<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' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason}
</span>
)
}
return '-'
})()}
</span>
</div>
</div>
{/* 유전자형 섹션 */}
{selectedDisplayGenes.length > 0 && (
<div className="pt-2.5 border-t mt-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-muted-foreground mr-0.5"></span>
{(() => {
const isExpanded = expandedRows.has(`${cow.pkCowNo}-mobile-genes`)
// selectedDisplayGenes가 이미 전역 필터 순서를 반영하고 있음
const displayGenes = isExpanded ? selectedDisplayGenes : selectedDisplayGenes.slice(0, 4)
const remainingCount = selectedDisplayGenes.length - 4
return (
<>
{displayGenes.map((geneName) => {
const gene = cow.genes?.find(g => g.name === geneName)
const genotype = gene?.genotype || '-'
return (
<Badge key={geneName} className="text-xs px-1.5 py-0.5 font-medium bg-blue-500 text-white">
{geneName} {genotype}
</Badge>
)
})}
{selectedDisplayGenes.length > 4 && (
<button
onClick={() => {
const newExpanded = new Set(expandedRows)
const key = `${cow.pkCowNo}-mobile-genes`
if (isExpanded) {
newExpanded.delete(key)
} else {
newExpanded.add(key)
}
setExpandedRows(newExpanded)
}}
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
>
{isExpanded ? '접기' : `+${remainingCount}개 더`}
</button>
)}
</>
)
})()}
</div>
</div>
)}
{/* 형질 섹션 */}
{selectedDisplayTraits.length > 0 && (
<div className="pt-2.5 border-t mt-2.5" onClick={(e) => e.stopPropagation()}>
{(() => {
const isExpanded = expandedRows.has(`${cow.pkCowNo}-mobile-traits`)
const displayTraits = isExpanded ? selectedDisplayTraits : selectedDisplayTraits.slice(0, 4)
const remainingCount = selectedDisplayTraits.length - 4
return (
<>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm">
{displayTraits.map((trait) => {
let traitValue = '-'
if (cow.traits && cow.traits[trait] !== undefined) {
const traitData = cow.traits[trait]
if (typeof traitData === 'object' && traitData.traitVal !== undefined && traitData.traitVal !== null) {
traitValue = Number(traitData.traitVal).toFixed(1)
} else if (typeof traitData === 'object' && traitData.breedVal !== undefined) {
traitValue = Number(traitData.breedVal).toFixed(1)
} else if (typeof traitData === 'number') {
traitValue = Number(traitData).toFixed(1)
}
}
return (
<div key={trait} className="flex items-center justify-between">
<span className="text-muted-foreground">{TRAIT_DISPLAY_NAMES[trait] || trait}</span>
<span className="font-medium">{traitValue}</span>
</div>
)
})}
</div>
{selectedDisplayTraits.length > 4 && (
<button
onClick={() => {
const newExpanded = new Set(expandedRows)
const key = `${cow.pkCowNo}-mobile-traits`
if (isExpanded) {
newExpanded.delete(key)
} else {
newExpanded.add(key)
}
setExpandedRows(newExpanded)
}}
className="text-xs text-teal-600 hover:text-teal-800 font-medium w-full text-center mt-1.5"
>
{isExpanded ? '접기' : `+${remainingCount}개 더`}
</button>
)}
</>
)
})()}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{filteredCows.length === 0 && selectedDisplayGenes.length > 0 && (
<div className="text-center py-12 text-muted-foreground">
{searchKeyword
? '검색 결과가 없습니다.'
: '등록된 소가 없습니다.'}
</div>
)}
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1.5 mt-5 sm:mt-6 px-4 sm:px-6 md:px-8 lg:px-10 mx-2 sm:mx-0 mb-4">
<Button
variant="outline"
size="icon"
className="h-8 w-8 sm:h-9 sm:w-9"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<div className="flex gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
// 모바일: 앞뒤 1개씩만, 데스크톱: 앞뒤 2개씩
const isMobileVisible = page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)
if (isMobileVisible) {
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="icon"
className="h-8 w-8 sm:h-9 sm:w-9 text-xs"
onClick={() => handlePageChange(page)}
>
{page}
</Button>
)
} else if (page === currentPage - 2 || page === currentPage + 2) {
return <span key={page} className="px-0.5 text-xs text-muted-foreground">...</span>
}
return null
})}
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 sm:h-9 sm:w-9"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider >
)
}
export default function MyCowPage() {
return (
<AuthGuard>
<AnalysisYearProvider>
<MyCowContent />
</AnalysisYearProvider>
</AuthGuard>
)
}