From dae38082215b563e8f02bb75f497d7572e354681 Mon Sep 17 00:00:00 2001 From: NYD Date: Wed, 7 Jan 2026 15:13:42 +0900 Subject: [PATCH] update_cow_list_ui --- backend/src/admin/admin.service.ts | 2 +- backend/src/auth/auth.module.ts | 4 +- backend/src/auth/auth.service.ts | 65 +- backend/src/auth/dto/login-response.dto.ts | 1 + backend/src/genome/genome.controller.ts | 10 + backend/src/genome/genome.service.ts | 65 + frontend/src/app/cow/page.tsx | 1356 +++++++++++------ frontend/src/app/globals.css | 24 +- .../src/components/layout/app-sidebar.tsx | 4 +- frontend/src/contexts/AnalysisYearContext.tsx | 52 +- 10 files changed, 1081 insertions(+), 502 deletions(-) diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index cb33253..3a1266f 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -806,7 +806,7 @@ export class AdminService { } return true; } catch (error) { - this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, testDt: ${item.testDt}): ${error.message}`); + this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, ${error.message}`); return false; } }); diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 8768831..e685029 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { UserModel } from '../user/entities/user.entity'; +import { FarmModel } from '../farm/entities/farm.entity'; +import { GenomeRequestModel } from '../genome/entities/genome-request.entity'; import { JwtModule } from 'src/common/jwt/jwt.module'; import { EmailModule } from 'src/shared/email/email.module'; import { VerificationModule } from 'src/shared/verification/verification.module'; @@ -13,7 +15,7 @@ import { VerificationModule } from 'src/shared/verification/verification.module' */ @Module({ imports: [ - TypeOrmModule.forFeature([UserModel]), + TypeOrmModule.forFeature([UserModel, FarmModel, GenomeRequestModel]), JwtModule, EmailModule, VerificationModule, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 57fa64e..20c5ffa 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -7,7 +7,9 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { UserModel } from '../user/entities/user.entity'; -import { Repository } from 'typeorm'; +import { FarmModel } from '../farm/entities/farm.entity'; +import { GenomeRequestModel } from '../genome/entities/genome-request.entity'; +import { Repository, IsNull } from 'typeorm'; import { LoginDto } from './dto/login.dto'; import { LoginResponseDto } from './dto/login-response.dto'; import { SignupDto } from './dto/signup.dto'; @@ -36,6 +38,10 @@ export class AuthService { constructor( @InjectRepository(UserModel) private readonly userRepository: Repository, + @InjectRepository(FarmModel) + private readonly farmRepository: Repository, + @InjectRepository(GenomeRequestModel) + private readonly genomeRequestRepository: Repository, private readonly emailService: EmailService, private readonly verificationService: VerificationService, private readonly jwtService: JwtService, @@ -78,7 +84,10 @@ export class AuthService { const accessToken = this.jwtService.sign(payload as any); - this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`); + // 최근 검사 년도 조회 + const defaultAnalysisYear = await this.getDefaultAnalysisYear(user.pkUserNo); + + this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}, defaultAnalysisYear: ${defaultAnalysisYear}`); return { message: '로그인 성공', @@ -90,9 +99,61 @@ export class AuthService { userEmail: user.userEmail, userRole: user.userRole || 'USER', }, + defaultAnalysisYear, }; } + /** + * 사용자의 최근 검사 년도 조회 + * @param userNo - 사용자 번호 + * @returns 최근 검사 년도 (없으면 현재 년도) + */ + private async getDefaultAnalysisYear(userNo: number): Promise { + try { + // 1. 사용자의 농장 번호 조회 + const farm = await this.farmRepository.findOne({ + where: { fkUserNo: userNo, delDt: IsNull() }, + select: ['pkFarmNo'], + }); + + if (!farm) { + this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, No farm found, returning current year`); + return new Date().getFullYear(); + } + + // 2. 농장의 검사 이력에서 최신 날짜 조회 + const result = await this.genomeRequestRepository + .createQueryBuilder('request') + .select('MAX(request.chipReportDt)', 'maxChipDt') + .addSelect('MAX(request.msReportDt)', 'maxMsDt') + .where('request.fkFarmNo = :farmNo', { farmNo: farm.pkFarmNo }) + .andWhere('request.delDt IS NULL') + .getRawOne(); + + const maxChipDt = result?.maxChipDt ? new Date(result.maxChipDt) : null; + const maxMsDt = result?.maxMsDt ? new Date(result.maxMsDt) : null; + + // 둘 중 최신 날짜 선택 + let latestDate: Date | null = null; + if (maxChipDt && maxMsDt) { + latestDate = maxChipDt > maxMsDt ? maxChipDt : maxMsDt; + } else if (maxChipDt) { + latestDate = maxChipDt; + } else if (maxMsDt) { + latestDate = maxMsDt; + } + + const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear(); + + this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, farmNo: ${farm.pkFarmNo}, maxChipDt: ${maxChipDt?.toISOString()}, maxMsDt: ${maxMsDt?.toISOString()}, year: ${year}`); + + return year; + } catch (error) { + this.logger.error(`[getDefaultAnalysisYear] Error: ${error.message}`); + return new Date().getFullYear(); + } + } + /** * 회원가입 */ diff --git a/backend/src/auth/dto/login-response.dto.ts b/backend/src/auth/dto/login-response.dto.ts index a2ef098..4046414 100644 --- a/backend/src/auth/dto/login-response.dto.ts +++ b/backend/src/auth/dto/login-response.dto.ts @@ -11,4 +11,5 @@ export class LoginResponseDto { userEmail: string; userRole: 'USER' | 'ADMIN'; }; + defaultAnalysisYear: number; // 최근 검사 년도 } diff --git a/backend/src/genome/genome.controller.ts b/backend/src/genome/genome.controller.ts index 29c3bd4..2ec1d12 100644 --- a/backend/src/genome/genome.controller.ts +++ b/backend/src/genome/genome.controller.ts @@ -115,6 +115,16 @@ export class GenomeController { return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName); } + /** + * GET /genome/latest-analysis-year/:farmNo + * 농장의 가장 최근 분석 연도 조회 (chip_report_dt 또는 ms_report_dt 기준) + * @param farmNo - 농장 번호 + */ + @Get('latest-analysis-year/:farmNo') + getLatestAnalysisYear(@Param('farmNo') farmNo: string) { + return this.genomeService.getLatestAnalysisYear(+farmNo); + } + /** * GET /genome/:cowId * cowId(개체식별번호)로 유전체 데이터 조회 diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index db4c914..29c23ea 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -1874,4 +1874,69 @@ export class GenomeService { }, }; } + + /** + * 농장의 가장 최근 분석 연도 조회 + * chip_report_dt 또는 ms_report_dt 중 가장 최근 날짜의 년도 반환 + * 둘 다 없으면 현재 년도 반환 + * + * @param farmNo - 농장 번호 + * @returns { year: number } - 가장 최근 분석 연도 + */ + async getLatestAnalysisYear(farmNo: number): Promise<{ year: number }> { + console.log(`[getLatestAnalysisYear] farmNo: ${farmNo}`); + + // 농장의 모든 분석 의뢰 조회 + const requests = await this.genomeRequestRepository.find({ + where: { fkFarmNo: farmNo, delDt: IsNull() }, + select: ['chipReportDt', 'msReportDt'], + }); + + console.log(`[getLatestAnalysisYear] Found ${requests?.length || 0} requests`); + + if (!requests || requests.length === 0) { + console.log('[getLatestAnalysisYear] No requests found, returning current year'); + return { year: new Date().getFullYear() }; + } + + // chip_report_dt와 ms_report_dt 중 가장 최근 날짜 찾기 + let latestDate: Date | null = null; + let latestChipDate: Date | null = null; + let latestMsDate: Date | null = null; + + for (const request of requests) { + // chip_report_dt 확인 + if (request.chipReportDt) { + const chipDate = new Date(request.chipReportDt); + if (!latestChipDate || chipDate > latestChipDate) { + latestChipDate = chipDate; + } + if (!latestDate || chipDate > latestDate) { + latestDate = chipDate; + } + } + + // ms_report_dt 확인 + if (request.msReportDt) { + const msDate = new Date(request.msReportDt); + if (!latestMsDate || msDate > latestMsDate) { + latestMsDate = msDate; + } + if (!latestDate || msDate > latestDate) { + latestDate = msDate; + } + } + } + + console.log(`[getLatestAnalysisYear] Latest chip_report_dt: ${latestChipDate?.toISOString()}`); + console.log(`[getLatestAnalysisYear] Latest ms_report_dt: ${latestMsDate?.toISOString()}`); + console.log(`[getLatestAnalysisYear] Latest date overall: ${latestDate?.toISOString()}`); + + // 가장 최근 날짜가 있으면 그 연도, 없으면 현재 연도 + const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear(); + + console.log(`[getLatestAnalysisYear] Returning year: ${year}`); + + return { year }; + } } \ No newline at end of file diff --git a/frontend/src/app/cow/page.tsx b/frontend/src/app/cow/page.tsx index 9eb8c25..d855db7 100644 --- a/frontend/src/app/cow/page.tsx +++ b/frontend/src/app/cow/page.tsx @@ -9,16 +9,12 @@ import { } from "@/components/ui/sidebar" import { Button } from "@/components/ui/button" import { Cow, CowWithGenes, RankingItem } from "@/types/cow.types" -import { useEffect, useState } from "react" +import { useEffect, useState, useRef } from "react" import { useRouter } from "next/navigation" -import { ChevronLeft, ChevronRight, Search, ChevronsUpDown, Filter, Settings } from "lucide-react" +import { ChevronLeft, ChevronRight, Search, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { cowApi } from "@/lib/api/cow.api" import { TRAIT_DISPLAY_NAMES } from "@/constants/traits" import { useAuthStore } from "@/store/auth-store" @@ -38,12 +34,42 @@ function MyCowContent() { const { user } = useAuthStore() const { filters, isLoading: isFilterLoading } = useFilterStore() - // 로컬 필터 상태 (검색, 랭킹모드, 정렬) - const [searchKeyword, setSearchKeyword] = useState('') + // 로컬 필터 상태 (검색, 랭킹모드, 정렬) - localStorage에서 초기값 로드 + const [searchKeyword, setSearchKeyword] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('cowListSearchKeyword') + return saved || '' + } + return '' + }) const [rankingMode, setRankingMode] = useState<'gene' | 'genome'>('gene') // 유전자순/유전체순 - const [sortBy, setSortBy] = useState('rank') // 정렬 기준 (기본: 순위) - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') // 정렬 방향 - const [analysisFilter, setAnalysisFilter] = useState<'all' | 'completed' | 'mptOnly' | 'unavailable'>('all') // 분석 상태 필터 + const [sortBy, setSortBy] = useState<'rank' | 'number' | 'birth' | 'dam' | 'sire' | 'monthAge' | 'analysisDate' | 'score'>(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('cowListSortBy') + if (saved && ['rank', 'number', 'birth', 'dam', 'sire', 'monthAge', 'analysisDate', 'score'].includes(saved)) { + return saved as 'rank' | 'number' | 'birth' | 'dam' | 'sire' | 'monthAge' | 'analysisDate' | 'score' + } + } + return 'rank' + }) + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('cowListSortOrder') + if (saved === 'asc' || saved === 'desc') { + return saved + } + } + return 'asc' + }) + const [analysisFilter, setAnalysisFilter] = useState<'all' | 'pedigreeMatch' | 'pedigreeMismatch' | 'mptOnly'>(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('cowListAnalysisFilter') + if (saved && ['all', 'pedigreeMatch', 'pedigreeMismatch', 'mptOnly'].includes(saved)) { + return saved as 'all' | 'pedigreeMatch' | 'pedigreeMismatch' | 'mptOnly' + } + } + return 'all' + }) // 커스텀 컬럼 표시 필터 const [selectedDisplayGenes, setSelectedDisplayGenes] = useState([]) // 테이블에 표시할 유전자 @@ -51,15 +77,148 @@ function MyCowContent() { const [availableGenes, setAvailableGenes] = useState([]) // 선택 가능한 유전자 목록 const [availableTraits, setAvailableTraits] = useState([]) // 선택 가능한 형질 목록 (전역 필터 순서 유지) const [expandedRows, setExpandedRows] = useState>(new Set()) // 유전자형 더보기 펼침 상태 (pkCowNo 또는 "pkCowNo-traits" 형태) + const [theadTraitIndex, setTheadTraitIndex] = useState(0) // thead 형질 네비게이션 시작 인덱스 + const [rowTraitIndices, setRowTraitIndices] = useState>(new Map()) // 각 행별 형질 인덱스 + + // 형질 태그 실제 크기 측정용 + const firstTraitRef = useRef(null) + const [calculatedMoveDistance, setCalculatedMoveDistance] = useState(78) // 동적 계산된 이동 거리 - // 페이지네이션 - const [currentPage, setCurrentPage] = useState(1) - const itemsPerPage = 12 + // 초기 마운트 추적 + const isInitialMount = useRef(true) + + // 이전 값 추적 (실제 변경 감지용) + const prevAnalysisFilter = useRef(null) + const prevItemsPerPage = useRef(null) + const prevSearchKeyword = useRef(null) + + // 형질 태그 실제 크기 측정 및 이동 거리 계산 + useEffect(() => { + const measureTraitWidth = () => { + if (firstTraitRef.current) { + const traitWidth = firstTraitRef.current.offsetWidth + const gap = 8 // gap-2 + const moveDistance = traitWidth + gap + + setCalculatedMoveDistance(moveDistance) + + console.log('[형질 이동 거리 계산]', { + 형질폭: traitWidth, + gap간격: gap, + 이동거리: moveDistance + }) + } + } + + // 초기 측정 + const timer = setTimeout(measureTraitWidth, 100) + + // 화면 크기 변경 감지 + const resizeObserver = new ResizeObserver(measureTraitWidth) + if (firstTraitRef.current) { + resizeObserver.observe(firstTraitRef.current) + } + + return () => { + clearTimeout(timer) + resizeObserver.disconnect() + } + }, [selectedDisplayTraits, filteredCows]) + + // 무한 스크롤 페이지네이션 + const [currentLoadedPage, setCurrentLoadedPage] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('cowListLoadedPage') + return saved ? parseInt(saved, 10) : 1 + } + return 1 + }) + const [itemsPerPage, setItemsPerPage] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('cowListItemsPerPage') + return saved ? parseInt(saved, 10) : 50 + } + return 50 + }) + const [displayedCows, setDisplayedCows] = useState([]) // 화면에 표시되는 소 목록 + const tableScrollRef = useRef(null) // 테이블 스크롤 ref + const [isLoadingMore, setIsLoadingMore] = useState(false) // 추가 로딩 중 // 형질 가중치가 1개 이상 설정되어야 필터 활성화 const isFilterSet = filters.isActive && Object.values(filters.traitWeights).some(w => w > 0) + // ======================================== + // URL 파라미터로 초기화 여부 확인 (사이드바 클릭 시) + // ======================================== + useEffect(() => { + if (typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search) + const reset = urlParams.get('reset') + + if (reset === 'true') { + // 초기화 + setCurrentLoadedPage(1) + setItemsPerPage(50) + setAnalysisFilter('all') + setSortBy('rank') + setSortOrder('asc') + setSearchKeyword('') + + // localStorage 초기화 + localStorage.setItem('cowListLoadedPage', '1') + localStorage.setItem('cowListItemsPerPage', '50') + localStorage.setItem('cowListAnalysisFilter', 'all') + localStorage.setItem('cowListSortBy', 'rank') + localStorage.setItem('cowListSortOrder', 'asc') + localStorage.setItem('cowListSearchKeyword', '') + + // URL에서 reset 파라미터 제거 + window.history.replaceState({}, '', '/cow') + } + } + }, []) + + // ======================================== + // 상태를 localStorage에 저장 + // ======================================== + // localStorage 저장은 displayedCows useEffect에서 처리 + // useEffect(() => { + // if (typeof window !== 'undefined') { + // localStorage.setItem('cowListLoadedPage', currentLoadedPage.toString()) + // } + // }, [currentLoadedPage]) + + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('cowListItemsPerPage', itemsPerPage.toString()) + } + }, [itemsPerPage]) + + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('cowListSearchKeyword', searchKeyword) + } + }, [searchKeyword]) + + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('cowListAnalysisFilter', analysisFilter) + } + }, [analysisFilter]) + + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('cowListSortBy', sortBy) + } + }, [sortBy]) + + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('cowListSortOrder', sortOrder) + } + }, [sortOrder]) + // ======================================== // 전역 필터 → 개체 리스트 표시 항목 동기화 // ======================================== @@ -80,12 +239,17 @@ function MyCowContent() { 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) + // 2. 형질 표시 목록 동기화 (유전체형질: traitWeights > 0인 것만, 상위 7개) + if (filters.isActive && filters.traitWeights) { + // traitWeights > 0인 형질만 추출 (유전체형질) + const genomeTraits = Object.entries(filters.traitWeights) + .filter(([_, weight]) => weight > 0) + .map(([traitName, _]) => traitName) + + // 상위 7개까지만 표시 + const topSevenTraits = genomeTraits.slice(0, 7) + setAvailableTraits(topSevenTraits) + setSelectedDisplayTraits(topSevenTraits) } else { setAvailableTraits([]) setSelectedDisplayTraits([]) @@ -241,66 +405,245 @@ function MyCowContent() { } // 3. 분석 상태 필터 - if (analysisFilter === 'completed') { - result = result.filter(cow => cow.genomeScore !== undefined && cow.genomeScore !== null) + if (analysisFilter === 'pedigreeMatch') { + result = result.filter(cow => cow.unavailableReason === null || cow.unavailableReason === undefined) + } else if (analysisFilter === 'pedigreeMismatch') { + result = result.filter(cow => cow.unavailableReason !== null && cow.unavailableReason !== undefined) } 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 - } + 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 'birth': + 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 'dam': + result.sort((a, b) => { + const damA = a.damCowId || '' + const damB = b.damCowId || '' + const comparison = damA.localeCompare(damB) + return sortOrder === 'asc' ? comparison : -comparison + }) + break + case 'sire': + result.sort((a, b) => { + const sireA = a.sireKpn || '' + const sireB = b.sireKpn || '' + const comparison = sireA.localeCompare(sireB) + return sortOrder === 'asc' ? comparison : -comparison + }) + break + case 'monthAge': + result.sort((a, b) => { + let monthAgeA = 0 + let monthAgeB = 0 + + const hasMptOnlyA = a.hasMpt && !a.genomeScore && !a.anlysDt + const hasMptOnlyB = b.hasMpt && !b.genomeScore && !b.anlysDt + + if (analysisFilter === 'mptOnly' || hasMptOnlyA) { + if (a.cowBirthDt && a.mptTestDt) { + monthAgeA = Math.floor((new Date(a.mptTestDt).getTime() - new Date(a.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44)) + } + } else if (a.cowBirthDt && a.anlysDt) { + monthAgeA = Math.floor((new Date(a.anlysDt).getTime() - new Date(a.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44)) + } + + if (analysisFilter === 'mptOnly' || hasMptOnlyB) { + if (b.cowBirthDt && b.mptTestDt) { + monthAgeB = Math.floor((new Date(b.mptTestDt).getTime() - new Date(b.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44)) + } + } else if (b.cowBirthDt && b.anlysDt) { + monthAgeB = Math.floor((new Date(b.anlysDt).getTime() - new Date(b.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44)) + } + + return sortOrder === 'asc' ? monthAgeA - monthAgeB : monthAgeB - monthAgeA + }) + break + case 'analysisDate': + result.sort((a, b) => { + const hasMptOnlyA = a.hasMpt && !a.genomeScore && !a.anlysDt + const hasMptOnlyB = b.hasMpt && !b.genomeScore && !b.anlysDt + + let dateA = 0 + let dateB = 0 + + if (analysisFilter === 'mptOnly' || hasMptOnlyA) { + dateA = a.mptTestDt ? new Date(a.mptTestDt).getTime() : 0 + } else { + dateA = a.anlysDt ? new Date(a.anlysDt).getTime() : 0 + } + + if (analysisFilter === 'mptOnly' || hasMptOnlyB) { + dateB = b.mptTestDt ? new Date(b.mptTestDt).getTime() : 0 + } else { + dateB = b.anlysDt ? new Date(b.anlysDt).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]) - // 페이지네이션 + // 초기 마운트 후 플래그 업데이트 및 이전 값 설정 + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + // 초기 값 설정 + prevAnalysisFilter.current = analysisFilter + prevItemsPerPage.current = itemsPerPage + prevSearchKeyword.current = searchKeyword + } + }, []) + + // 검색어, 필터, 리스트 개수가 실제로 변경되었을 때만 페이지를 1로 리셋 + useEffect(() => { + if (!isInitialMount.current) { + let shouldReset = false + + // 검색어 변경 확인 + if (prevSearchKeyword.current !== null && prevSearchKeyword.current !== searchKeyword) { + shouldReset = true + } + + // 필터 변경 확인 + if (prevAnalysisFilter.current !== null && prevAnalysisFilter.current !== analysisFilter) { + shouldReset = true + } + + // 리스트 개수 변경 확인 + if (prevItemsPerPage.current !== null && prevItemsPerPage.current !== itemsPerPage) { + shouldReset = true + } + + // 이전 값 업데이트 + prevSearchKeyword.current = searchKeyword + prevAnalysisFilter.current = analysisFilter + prevItemsPerPage.current = itemsPerPage + + // 변경이 있었으면 페이지 리셋 + if (shouldReset) { + setCurrentLoadedPage(1) + } + } + }, [searchKeyword, analysisFilter, itemsPerPage]) + + // 무한 스크롤: 로드된 페이지까지의 데이터 표시 + useEffect(() => { + const totalItems = currentLoadedPage * itemsPerPage + const newDisplayedCows = filteredCows.slice(0, totalItems) + setDisplayedCows(newDisplayedCows) + + // localStorage에 현재 로드된 페이지 저장 + localStorage.setItem('cowListLoadedPage', currentLoadedPage.toString()) + }, [filteredCows, currentLoadedPage, itemsPerPage]) + + // 스크롤 이벤트 핸들러: 끝에 도달하면 다음 페이지 로드 + useEffect(() => { + const scrollContainer = tableScrollRef.current + if (!scrollContainer) return + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100 // 100px 여유 + + if (isNearBottom && !isLoadingMore) { + const totalPages = Math.ceil(filteredCows.length / itemsPerPage) + if (currentLoadedPage < totalPages) { + setIsLoadingMore(true) + setTimeout(() => { + setCurrentLoadedPage(prev => prev + 1) + setIsLoadingMore(false) + }, 300) // 약간의 딜레이 + } + } + } + + scrollContainer.addEventListener('scroll', handleScroll) + return () => scrollContainer.removeEventListener('scroll', handleScroll) + }, [currentLoadedPage, filteredCows.length, itemsPerPage, isLoadingMore]) + + // 페이지네이션 (참고용 - 무한 스크롤로 대체) 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 handlePageChange = (page: number) => { + // setCurrentLoadedPage(page) + // window.scrollTo({ top: 0, behavior: 'smooth' }) + // } + + // 정렬 핸들러 + const handleSort = (column: typeof sortBy) => { + if (sortBy === column) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(column) + setSortOrder('asc') + } } + // thead 형질 네비게이션 핸들러 - 모든 행의 인덱스를 thead와 동일하게 통일 + const handleGlobalTraitNext = () => { + if (theadTraitIndex + 2 < selectedDisplayTraits.length) { + const newIndex = theadTraitIndex + 2 + setTheadTraitIndex(newIndex) + // 모든 tbody 행의 인덱스를 thead 인덱스로 통일 + const newIndices = new Map() + displayedCows.forEach(cow => { + newIndices.set(cow.pkCowNo, newIndex) + }) + setRowTraitIndices(newIndices) + } + } + + const handleGlobalTraitPrev = () => { + if (theadTraitIndex > 0) { + const newIndex = Math.max(0, theadTraitIndex - 2) + setTheadTraitIndex(newIndex) + // 모든 tbody 행의 인덱스를 thead 인덱스로 통일 + const newIndices = new Map() + displayedCows.forEach(cow => { + newIndices.set(cow.pkCowNo, newIndex) + }) + setRowTraitIndices(newIndices) + } + } + + // 랭킹 가져오기 (백엔드에서 계산된 값 사용) const getRank = (cow: CowWithGenes): number => { return cow.rank ?? 9999 @@ -342,11 +685,11 @@ function MyCowContent() {
-
- +
+
-

필터 설정이 필요합니다

-

+

필터 설정이 필요합니다

+

개체 목록을 조회하려면 먼저 분석에 사용할 형질(유전체)을 1개 이상 선택해주세요.

@@ -372,339 +715,265 @@ function MyCowContent() { -
-
-
+
+
+
{/* 헤더 */} -
-
+
+
{/* 제목 */} -
-

개체 목록

-

{'농장'} 보유 개체 현황

+
+
+

개체 목록

+

{'농장'} 보유 개체 현황

+
+
{/* 분석 상태 탭 필터 - 모바일: 2x2 그리드, 데스크톱: 가로 배치 */} -
+
- +
- {/* 필터 및 검색 통합 박스 */} -
+ {/* 검색 박스 */} +
{/* 검색창 */}
- + setSearchKeyword(e.target.value)} />
- - {/* 필터 옵션들 - 모바일: 2행, 데스크톱: 1행 */} -
- {/* 랭킹/정렬 그룹 */} -
- - - -
- - {/* 표시항목 그룹 */} -
- - - - - - -
- {availableGenes.map((gene) => ( -
- { - if (checked) { - // 전역 필터 순서대로 추가 (순서 유지) - const orderedGenes = availableGenes.filter(g => - selectedDisplayGenes.includes(g) || g === gene - ) - setSelectedDisplayGenes(orderedGenes) - } else { - setSelectedDisplayGenes(selectedDisplayGenes.filter(g => g !== gene)) - } - }} - /> - -
- ))} -
-
-
-
- - - {selectedDisplayGenes.length}/{availableGenes.length}개 - -
-
-

- 순서는 전역 필터에서 설정하세요 -

-
-
-
-
- - - - - - -
- {availableTraits.map((trait) => ( -
- { - if (checked) { - // 전역 필터 순서대로 추가 (순서 유지) - const orderedTraits = availableTraits.filter(t => - selectedDisplayTraits.includes(t) || t === trait - ) - setSelectedDisplayTraits(orderedTraits) - } else { - setSelectedDisplayTraits(selectedDisplayTraits.filter(t => t !== trait)) - } - }} - /> - -
- ))} -
-
-
-
- - - {selectedDisplayTraits.length}/{availableTraits.length}개 - -
-
-

- 순서는 전역 필터에서 설정하세요 -

-
-
-
-
-
-
+
{/* 리스트 뷰 */} -
+
{/* 데스크톱 테이블 뷰 */} {(
-
+
- - - - - - - - - + {/* */} + + - - + + + + + {selectedDisplayGenes.length > 0 && ( - + )} {selectedDisplayTraits.length > 0 && ( - + )} - {paginatedCows.map((cow) => { + {displayedCows.map((cow) => { const rank = getRank(cow) return ( handleCowClick(cow.cowId)} // cowId 개체번호로 이동 + className="border-b transition-colors hover:bg-muted/50" > - - - - - - - - + + + {selectedDisplayGenes.length > 0 && ( )} @@ -887,35 +1269,31 @@ function MyCowContent() { {/* 모바일 컴팩트 카드 뷰 */} {( -
- {paginatedCows.map((cow) => { +
+ {displayedCows.map((cow) => { const rank = getRank(cow) - const isFemale = cow.cowSex !== '수' return (
handleCowClick(cow.cowId)} > - {/* 1행: 순위, 개체번호, 성별, 선발지수 */} -
-
- {rank}위 - + {/* 1행: 순위, 개체번호, 선발지수 */} +
+
+ {rank}위 + - - {isFemale ? '암' : '수'} -
-
+
{cow.genomeScore !== undefined && cow.genomeScore !== null ? ( - + {cow.genomeScore.toFixed(2)} ) : ( - + 분석불가 )} @@ -923,7 +1301,7 @@ function MyCowContent() {
{/* 2행: 기본 정보 */} -
+
생년월일 @@ -969,13 +1347,14 @@ function MyCowContent() {
- + {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)}` + // KOR 제거 및 포맷팅 + const cleaned = cow.damCowId.replace(/^KOR/i, '').replace(/\D/g, '') + if (cleaned.length === 12) { + return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 7)} ${cleaned.slice(7, 11)} ${cleaned.slice(11)}` } - return cow.damCowId + return cow.damCowId.replace(/^KOR/i, '').trim() })() : '-'}
@@ -1020,9 +1399,9 @@ function MyCowContent() { {/* 유전자형 섹션 */} {selectedDisplayGenes.length > 0 && ( -
e.stopPropagation()}> -
- 유전자 +
e.stopPropagation()}> +
+ 유전자 {(() => { const isExpanded = expandedRows.has(`${cow.pkCowNo}-mobile-genes`) // selectedDisplayGenes가 이미 전역 필터 순서를 반영하고 있음 @@ -1036,7 +1415,7 @@ function MyCowContent() { const genotype = gene?.genotype || '-' return ( - + {geneName} {genotype} ) @@ -1053,7 +1432,7 @@ function MyCowContent() { } setExpandedRows(newExpanded) }} - className="text-xs text-blue-600 hover:text-blue-800 font-medium" + className="text-sm text-blue-600 hover:text-blue-800 font-medium" > {isExpanded ? '접기' : `+${remainingCount}개 더`} @@ -1067,7 +1446,7 @@ function MyCowContent() { {/* 형질 섹션 */} {selectedDisplayTraits.length > 0 && ( -
e.stopPropagation()}> +
e.stopPropagation()}> {(() => { const isExpanded = expandedRows.has(`${cow.pkCowNo}-mobile-traits`) const displayTraits = isExpanded ? selectedDisplayTraits : selectedDisplayTraits.slice(0, 4) @@ -1075,7 +1454,7 @@ function MyCowContent() { return ( <> -
+
{displayTraits.map((trait) => { let traitValue = '-' if (cow.traits && cow.traits[trait] !== undefined) { @@ -1090,8 +1469,8 @@ function MyCowContent() { } return (
- {TRAIT_DISPLAY_NAMES[trait] || trait} - {traitValue} + {TRAIT_DISPLAY_NAMES[trait] || trait} + {traitValue}
) })} @@ -1108,7 +1487,7 @@ function MyCowContent() { } setExpandedRows(newExpanded) }} - className="text-xs text-teal-600 hover:text-teal-800 font-medium w-full text-center mt-1.5" + className="text-sm text-teal-600 hover:text-teal-800 font-medium w-full text-center mt-2" > {isExpanded ? '접기' : `+${remainingCount}개 더`} @@ -1123,69 +1502,82 @@ function MyCowContent() { })}
)} -
- {filteredCows.length === 0 && selectedDisplayGenes.length > 0 && ( -
- {searchKeyword - ? '검색 결과가 없습니다.' - : '등록된 소가 없습니다.'} -
- )} - - {/* 페이지네이션 */} - {totalPages > 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 ( - - ) - } else if (page === currentPage - 2 || page === currentPage + 2) { - return ... - } - return null - })} + {filteredCows.length === 0 && selectedDisplayGenes.length > 0 && ( +
+ {searchKeyword + ? '검색 결과가 없습니다.' + : '등록된 소가 없습니다.'}
+ )} - + {/* 페이지네이션 - 무한 스크롤로 대체됨 */} + {/* {totalPages > 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 ( + + ) + } else if (page === currentPage - 2 || page === currentPage + 2) { + return ... + } + return null + })} +
+ + +
+ )} */} + + {/* 현황 정보 표시 */} +
+ + {filteredCows.length > 0 ? ( + <> + 전체 {filteredCows.length.toLocaleString()}개 중 1-{displayedCows.length.toLocaleString()}번째 + {isLoadingMore && ' (로딩 중...)'} + + ) : ( + '데이터 없음' + )} +
- )} +
-
- + ) } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c330ab5..1c05373 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -404,14 +404,30 @@ /* 테이블 헤더 셀 */ .cow-table-header { - @apply text-center py-3 px-3 font-semibold; - font-size: 0.9375rem; /* 15px */ + @apply text-center font-bold; + padding: 0.5rem 0.5rem; /* py-2 px-2 */ + font-size: 1.25rem; /* 20px */ +} + +@media (min-width: 640px) { + .cow-table-header { + padding: 0.625rem 0.625rem; /* py-2.5 px-2.5 */ + font-size: 1.375rem; /* 22px */ + } +} + +@media (min-width: 768px) { + .cow-table-header { + padding: 0.75rem 0.75rem; /* py-3 px-3 */ + font-size: 1.5rem; /* 24px */ + } } /* 테이블 바디 셀 */ .cow-table-cell { - @apply text-center py-3 px-3; - font-size: 0.9375rem; /* 15px */ + @apply text-center; + padding: 0.5rem 0.375rem; /* py-2 px-1.5 */ + font-size: 1.35rem; /* 21.6px */ } /* 분석불가 행 - 각 td에 오버레이 */ diff --git a/frontend/src/components/layout/app-sidebar.tsx b/frontend/src/components/layout/app-sidebar.tsx index 69b080e..a4e9f44 100644 --- a/frontend/src/components/layout/app-sidebar.tsx +++ b/frontend/src/components/layout/app-sidebar.tsx @@ -31,12 +31,12 @@ const userNavMain = [ }, { title: "개체 조회", - url: "/cow", + url: "/cow?reset=true", icon: IconListDetails, items: [ { title: "개체 목록", - url: "/cow", + url: "/cow?reset=true", }, ], }, diff --git a/frontend/src/contexts/AnalysisYearContext.tsx b/frontend/src/contexts/AnalysisYearContext.tsx index 7e6e897..10ea01d 100644 --- a/frontend/src/contexts/AnalysisYearContext.tsx +++ b/frontend/src/contexts/AnalysisYearContext.tsx @@ -6,6 +6,7 @@ * 기능: * - 현재 연도부터 5년 전까지 선택 가능 (예: 2025~2020) * - URL 파라미터 ?year=2024 와 동기화 + * - 농장의 가장 최근 분석 연도를 기본값으로 사용 * * 사용처: * - site-header.tsx: 헤더 연도 선택 드롭다운 @@ -33,28 +34,59 @@ function AnalysisYearProviderInner({ children }: { children: React.ReactNode }) const currentYear = new Date().getFullYear() const availableYears = Array.from({ length: 6 }, (_, i) => currentYear - i) - // URL 파라미터에서 연도 가져오기, 없으면 현재 연도 사용 - const yearFromUrl = searchParams.get('year') - const initialYear = yearFromUrl && !isNaN(Number(yearFromUrl)) - ? Number(yearFromUrl) - : currentYear + // 초기 년도는 현재 년도로 설정 (클라이언트에서 useEffect로 업데이트) + const [selectedYear, setSelectedYearState] = useState(currentYear) + const [isInitialized, setIsInitialized] = useState(false) - const [selectedYear, setSelectedYearState] = useState(initialYear) - - // URL 파라미터와 동기화 + // 클라이언트 사이드에서만 실행: localStorage와 URL에서 초기 년도 설정 useEffect(() => { + if (isInitialized || typeof window === 'undefined') return + + const yearFromUrl = searchParams.get('year') + if (yearFromUrl && !isNaN(Number(yearFromUrl))) { + console.log('[AnalysisYear] Initial year from URL:', yearFromUrl) + setSelectedYearState(Number(yearFromUrl)) + setIsInitialized(true) + return + } + + const savedYear = localStorage.getItem('defaultAnalysisYear') + if (savedYear && !isNaN(Number(savedYear))) { + console.log('[AnalysisYear] Initial year from localStorage:', savedYear) + const year = Number(savedYear) + setSelectedYearState(year) + // URL에 year 파라미터 추가 + const params = new URLSearchParams(searchParams.toString()) + params.set('year', year.toString()) + router.replace(`${pathname}?${params.toString()}`) + } + + setIsInitialized(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // 의도적으로 빈 배열 사용 (최초 1회만 실행) + + // URL 파라미터와 동기화 (초기화 이후에만 실행) + useEffect(() => { + if (!isInitialized) return + const yearParam = searchParams.get('year') if (yearParam && !isNaN(Number(yearParam))) { const year = Number(yearParam) - if (availableYears.includes(year)) { + if (availableYears.includes(year) && year !== selectedYear) { setSelectedYearState(year) } } - }, [searchParams, availableYears]) + }, [searchParams, availableYears, isInitialized, selectedYear]) const setSelectedYear = (year: number) => { + console.log('[AnalysisYear] setSelectedYear:', year) setSelectedYearState(year) + // localStorage 업데이트 (사용자가 선택한 년도를 디폴트로 저장) + if (typeof window !== 'undefined') { + localStorage.setItem('defaultAnalysisYear', year.toString()) + } + // URL 파라미터 업데이트 const params = new URLSearchParams(searchParams.toString()) params.set('year', year.toString())
순위개체번호생년월일성별모개체번호아비 KPN - {analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'} +
handleSort('rank')} + > +
+ 순위 + {sortBy === 'rank' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
- {analysisFilter === 'mptOnly' ? '검사일자' : '분석일자'} + handleSort('number')} + > +
+ 개체번호 + {sortBy === 'number' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
- 선발지수 + handleSort('monthAge')} + > +
+ {analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'} + {sortBy === 'monthAge' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
+
handleSort('birth')} + > +
+ 생년월일 + {sortBy === 'birth' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
+
handleSort('dam')} + > +
+ 모개체번호 + {sortBy === 'dam' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
+
handleSort('sire')} + > +
+ 아비 KPN + {sortBy === 'sire' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
+
handleSort('analysisDate')} + > +
+ {analysisFilter === 'mptOnly' ? '검사일자' : '분석일자'} + {sortBy === 'analysisDate' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
+
handleSort('score')} + > +
+ 선발지수 + {sortBy === 'score' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} +
유전자형유전자형형질 +
+ {/* 1번 영역: 왼쪽 버튼 */} +
+ +
+ + {/* 2번 영역: 형질 타이틀 */} +
+ 형질 +
+ + {/* 3번 영역: 오른쪽 버튼 */} +
+ +
+
+
- {rank} + handleCowClick(cow.cowId)}> + {rank} -
- +
handleCowClick(cow.cowId)}> +
+
- {(() => { - // 번식능력만 있는 개체 판단 (유전체 데이터 없음) - 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' - }) : '-' - })()} - - {cow.cowSex === "수" ? "수소" : "암소"} - - {cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'} - - {cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - + handleCowClick(cow.cowId)}> {(() => { // 번식능력만 있는 개체 판단 const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt @@ -726,7 +995,42 @@ function MyCowContent() { return '-' })()} + handleCowClick(cow.cowId)}> + {(() => { + // 번식능력만 있는 개체 판단 (유전체 데이터 없음) + 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' + }) : '-' + })()} + handleCowClick(cow.cowId)}> + {cow.damCowId && cow.damCowId !== '0' ? (() => { + // KOR 제거 및 포맷팅 + const cleaned = cow.damCowId.replace(/^KOR/i, '').replace(/\D/g, '') + if (cleaned.length === 12) { + return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 7)} ${cleaned.slice(7, 11)} ${cleaned.slice(11)}` + } + return cow.damCowId.replace(/^KOR/i, '').trim() + })() : '-'} + handleCowClick(cow.cowId)}> + {cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + handleCowClick(cow.cowId)}> {(() => { // 번식능력만 있는 개체 판단 const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt @@ -741,7 +1045,7 @@ function MyCowContent() { // 유전체 탭: unavailableReason 있으면 배지, 없으면 분석일자 if (cow.unavailableReason) { return ( - - + handleCowClick(cow.cowId)}> {(cow.genomeScore !== undefined && cow.genomeScore !== null) ? ( -
+
{cow.genomeScore.toFixed(2)}
) : ( - + 분석불가 )}
e.stopPropagation()} + className="py-3 px-2 text-sm cursor-pointer" + onClick={() => handleCowClick(cow.cowId)} > {(() => { const isExpanded = expandedRows.has(cow.pkCowNo) @@ -779,20 +1083,20 @@ function MyCowContent() { const remainingCount = selectedDisplayGenes.length - 3 return ( -
+
{displayGenes.map((geneName) => { const gene = cow.genes?.find(g => g.name === geneName) const genotype = gene?.genotype || '-' return (
- {geneName} + {geneName} {gene ? ( - + {genotype} ) : ( - + - )} @@ -810,7 +1114,7 @@ function MyCowContent() { } setExpandedRows(newExpanded) }} - className="text-xs text-blue-600 hover:text-blue-800 font-semibold cursor-pointer text-center mt-0.5" + className="text-sm text-blue-600 hover:text-blue-800 font-semibold cursor-pointer text-center mt-0.5" > {isExpanded ? '접기' : `+${remainingCount}개 더보기`} @@ -822,57 +1126,135 @@ function MyCowContent() { )} {selectedDisplayTraits.length > 0 && (
e.stopPropagation()} + className="py-2 px-2 text-sm select-none relative" + style={{ width: '250px', maxWidth: '250px', userSelect: 'none', paddingLeft: '0.8rem' , paddingRight: '0.8rem'}} > -
- {(() => { - 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) - } +
+ {/* 1번 영역: 왼쪽 버튼 */} +
+ +
+ + {/* 2번 영역: 형질 표시 */} +
{ + e.stopPropagation() + const container = e.currentTarget + const innerDiv = container.firstElementChild as HTMLElement + if (!innerDiv) return + + let isDown = true + const startX = e.pageX + const currentIndex = rowTraitIndices.get(cow.pkCowNo) ?? theadTraitIndex + const startTranslate = -currentIndex * calculatedMoveDistance + let currentTranslate = startTranslate + + const handleMouseMove = (e: MouseEvent) => { + if (!isDown) return + e.preventDefault() + const deltaX = e.pageX - startX + currentTranslate = startTranslate + deltaX + + // 최소/최대 범위 제한 + const maxTranslate = 0 + const minTranslate = -(selectedDisplayTraits.length - 2) * calculatedMoveDistance + currentTranslate = Math.max(minTranslate, Math.min(maxTranslate, currentTranslate)) + + innerDiv.style.transform = `translateX(${currentTranslate}px)` + innerDiv.style.transition = 'none' + } + + const handleMouseUp = () => { + isDown = false + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + + // 드래그 끝: 현재 위치로부터 인덱스 계산 + const offset = -currentTranslate + const calculatedIndex = Math.round(offset / calculatedMoveDistance) + const maxIndex = Math.max(0, selectedDisplayTraits.length - 2) + const finalIndex = Math.min(maxIndex, Math.max(0, calculatedIndex)) + + // 인덱스 저장 + const newIndices = new Map(rowTraitIndices) + newIndices.set(cow.pkCowNo, finalIndex) + setRowTraitIndices(newIndices) + + // transition 복원 + innerDiv.style.transition = '' + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }} + > +
+ {selectedDisplayTraits.map((trait, index) => { + 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 ( -
- {TRAIT_DISPLAY_NAMES[trait] || trait} - {traitValue} -
- ) - })} - {selectedDisplayTraits.length > 3 && ( - - )} - - ) - })()} + {TRAIT_DISPLAY_NAMES[trait] || trait} + {traitValue} +
+ ) + })} +
+
+ + {/* 3번 영역: 오른쪽 버튼 */} +
+ +