1203 lines
65 KiB
TypeScript
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>
|
|
)
|
|
}
|