# 유전능력 컨설팅 서비스 프론트엔드 구현 가이드 **작성일**: 2025-10-26 **버전**: 1.0 **목적**: 백엔드 API 완전 활용한 완벽한 프론트엔드 구현 --- ## 목차 1. [개요](#1-개요) 2. [기술 스택](#2-기술-스택) 3. [프로젝트 구조](#3-프로젝트-구조) 4. [백엔드 API 매핑](#4-백엔드-api-매핑) 5. [페이지별 구현 가이드](#5-페이지별-구현-가이드) 6. [컴포넌트 설계](#6-컴포넌트-설계) 7. [상태 관리](#7-상태-관리) 8. [API 클라이언트](#8-api-클라이언트) 9. [개발 우선순위](#9-개발-우선순위) 10. [테스트 가이드](#10-테스트-가이드) --- ## 1. 개요 ### 1.1 프로젝트 소개 한우 유전능력 컨설팅 서비스의 프론트엔드 애플리케이션으로, 농장주가 개체별 유전능력, 유전자 정보를 확인하고 최적의 KPN(종모우)을 추천받아 교배 계획을 수립할 수 있는 웹 애플리케이션입니다. ### 1.2 주요 기능 - **농장 대시보드**: 농장 종합 현황, 분석연도별 비교, 등급 분포 - **개체 관리**: 개체 목록 조회, 상세 정보, 유전자/유전능력 분석 - **KPN 추천 시스템**: - 암소→KPN 추천 (4단계 알고리즘) - KPN→암소 추천 (역방향) - 대립유전자 시뮬레이션 - 근친도 계산 - **교배 조합 관리**: 교배 조합 저장, 수정, 삭제, 조회 - **다세대 시뮬레이션**: 1~20세대 교배 시뮬레이션, KPN 순환 전략 - **농장 패키지 추천**: 농장 전체 최적 KPN 패키지 추천 ### 1.3 PRD 참조 - 기능 요구사항: `E:\repo5\prd\기능요구사항14.md` - 백엔드 태스크: `E:\repo5\prd\backend-tasklist.md` - 백엔드 상세: `E:\repo5\prd\backend-detailed-tasks.md` - DB 스키마: `E:\repo5\prd\GENE_TABLE_SPEC.md` --- ## 2. 기술 스택 ### 2.1 프레임워크 및 라이브러리 | 카테고리 | 기술 | 버전 | 용도 | |---------|------|------|------| | **프레임워크** | Next.js | 14+ | React 기반 풀스택 프레임워크 | | **언어** | TypeScript | 5+ | 타입 안전성 | | **UI 라이브러리** | shadcn/ui | latest | 재사용 가능한 컴포넌트 | | **스타일링** | Tailwind CSS | 3+ | 유틸리티 기반 CSS | | **상태 관리** | Zustand | 4+ | 경량 상태 관리 | | **API 클라이언트** | Axios | 1+ | HTTP 클라이언트 | | **차트** | Recharts | 2+ | 데이터 시각화 | | **폼 관리** | React Hook Form | 7+ | 폼 상태 관리 | | **유효성 검사** | Zod | 3+ | 스키마 기반 검증 | ### 2.2 개발 환경 - **Node.js**: 18+ - **패키지 매니저**: npm or yarn - **IDE**: VSCode (권장) - **브라우저**: Chrome, Edge (최신 버전) --- ## 3. 프로젝트 구조 ``` frontend/ ├── src/ │ ├── app/ # Next.js App Router │ │ ├── (auth)/ # 인증 관련 라우트 │ │ │ ├── login/ │ │ │ ├── signup/ │ │ │ ├── findid/ │ │ │ └── findpw/ │ │ ├── dashboard/ # 대시보드 │ │ │ ├── page.tsx │ │ │ └── cows/ # 개체 관리 서브 페이지 │ │ │ ├── list/ │ │ │ ├── excellent/ │ │ │ └── cull/ │ │ ├── cow/ # 개체 상세 │ │ │ ├── page.tsx │ │ │ ├── [cowNo]/ │ │ │ │ ├── page.tsx │ │ │ │ └── genes/ │ │ │ │ ├── [category]/ │ │ │ │ │ └── page.tsx │ │ ├── kpn/ # KPN 관리 │ │ │ ├── page.tsx │ │ │ ├── [kpnNo]/ │ │ │ │ └── page.tsx │ │ │ ├── recommend/ │ │ │ │ └── page.tsx │ │ │ └── package/ │ │ │ └── page.tsx │ │ ├── breeding/ # 교배 관리 │ │ │ ├── page.tsx │ │ │ ├── saved/ │ │ │ │ └── page.tsx │ │ │ └── simulator/ │ │ │ └── page.tsx │ │ └── layout.tsx │ │ │ ├── components/ # 재사용 컴포넌트 │ │ ├── ui/ # shadcn/ui 기본 컴포넌트 │ │ ├── dashboard/ # 대시보드 전용 컴포넌트 │ │ │ ├── section-cards.tsx │ │ │ ├── year-comparison.tsx │ │ │ ├── grade-distribution.tsx │ │ │ └── gene-status.tsx │ │ ├── cow/ # 개체 관련 컴포넌트 │ │ │ ├── cow-list-table.tsx │ │ │ ├── cow-detail-card.tsx │ │ │ └── cow-ranking-table.tsx │ │ ├── kpn/ # KPN 관련 컴포넌트 │ │ │ ├── kpn-recommendation-card.tsx │ │ │ ├── kpn-detail-modal.tsx │ │ │ └── allele-simulation-chart.tsx │ │ ├── breeding/ # 교배 관련 컴포넌트 │ │ │ ├── breeding-combination-card.tsx │ │ │ └── multi-generation-timeline.tsx │ │ ├── shared/ # 공통 컴포넌트 │ │ │ ├── app-sidebar.tsx │ │ │ ├── site-header.tsx │ │ │ ├── filter-panel.tsx │ │ │ └── pagination.tsx │ │ └── charts/ # 차트 컴포넌트 │ │ ├── bar-chart.tsx │ │ ├── line-chart.tsx │ │ └── radar-chart.tsx │ │ │ ├── lib/ # 유틸리티 및 헬퍼 │ │ ├── api/ # API 클라이언트 │ │ │ ├── client.ts # Axios 설정 │ │ │ ├── cow.api.ts │ │ │ ├── kpn.api.ts │ │ │ ├── dashboard.api.ts │ │ │ ├── breed.api.ts │ │ │ ├── gene.api.ts │ │ │ ├── genome.api.ts │ │ │ └── index.ts │ │ ├── utils.ts # 공통 유틸 │ │ └── constants.ts # 상수 │ │ │ ├── types/ # TypeScript 타입 정의 │ │ ├── cow.types.ts │ │ ├── kpn.types.ts │ │ ├── gene.types.ts │ │ ├── genome.types.ts │ │ ├── breeding.types.ts │ │ ├── dashboard.types.ts │ │ └── api.types.ts │ │ │ ├── store/ # Zustand 스토어 │ │ ├── auth-store.ts │ │ ├── filter-store.ts │ │ └── farm-store.ts │ │ │ ├── contexts/ # React Context │ │ ├── GlobalFilterContext.tsx │ │ └── AnalysisYearContext.tsx │ │ │ └── hooks/ # Custom Hooks │ ├── use-cow-data.ts │ ├── use-kpn-recommendation.ts │ └── use-pagination.ts │ ├── public/ # 정적 파일 └── package.json ``` --- ## 4. 백엔드 API 매핑 ### 4.1 API 베이스 URL - **개발**: `http://localhost:4000` - **프로덕션**: `https://api.example.com` ### 4.2 인증 모든 API 요청은 JWT 토큰을 포함해야 합니다: ```typescript headers: { Authorization: `Bearer ${token}` } ``` ### 4.3 API 엔드포인트 전체 목록 #### 4.3.1 인증 (`/auth`) | 메서드 | 엔드포인트 | 설명 | 요청 | 응답 | |--------|-----------|------|------|------| | POST | `/auth/login` | 로그인 | `{ userId, password }` | `{ accessToken, user }` | | POST | `/auth/signup` | 회원가입 | `CreateUserDto` | `{ success: true }` | | POST | `/auth/find-id` | 아이디 찾기 | `{ name, phone }` | `{ userId }` | | POST | `/auth/reset-password` | 비밀번호 재설정 | `{ userId, phone, newPassword }` | `{ success: true }` | #### 4.3.2 개체 관리 (`/cow`) | 메서드 | 엔드포인트 | 설명 | PRD 참조 | |--------|-----------|------|----------| | GET | `/cow` | 전체 개체 목록 | SFR-COW-001 | | GET | `/cow/paginated?page=1&limit=10` | 페이징 목록 | SFR-COW-001 | | GET | `/cow/farm/:farmNo` | 농장별 개체 목록 | - | | GET | `/cow/search?keyword=...&farmNo=...` | 개체 검색 | SFR-COW-002 | | GET | `/cow/:cowNo` | 개체 상세 정보 | SFR-COW-004 | | POST | `/cow/ranking` | 개체 랭킹 (필터+정렬) | SFR-COW-003 | | POST | `/cow/:cowNo/recommendations` | KPN 추천 | SFR-COW-016 | | POST | `/cow/:cowNo/recommendations/:kpnNo/next-generation` | 다음 세대 KPN 추천 | - | | POST | `/cow/simulate-breeding` | 교배 시뮬레이션 | SFR-COW-015 | | POST | `/cow/simulate-multi-generation` | 다세대 시뮬레이션 | SFR-COW-038, 039 | | POST | `/cow/farm-package-recommendation` | 농장 패키지 추천 | SFR-COW-037 | | POST | `/cow/rotation-strategy` | KPN 순환 전략 | SFR-COW-038 | **POST /cow/ranking 요청 예시**: ```typescript { "filterOptions": { "filters": [ { "field": "grade", "operator": "in", "value": ["A", "B"] } ], "sorts": [ { "field": "overallScore", "direction": "DESC" } ], "pagination": { "page": 1, "limit": 10 } }, "rankingOptions": { "criteriaType": "GENOME", "traitConditions": [ { "traitNm": "근내지방도", "weight": 1.0 } ], "limit": 100 } } ``` **POST /cow/:cowNo/recommendations 요청 예시**: ```typescript { "targetGenes": ["PLAG1", "CAPN1", "FASN"], "inbreedingThreshold": 12.5, "limit": 10 } ``` **응답 예시**: ```typescript { "cow": { "cowNo": "KOR002108023350", "genes": [ { "markerNm": "PLAG1", "genotype": "AG" } ] }, "recommendations": [ { "kpn": { "kpnNo": "KPN1385", "kpnNm": "..." }, "rank": 1, "matchingScore": 90, "inbreeding": { "generation1": 6.25, "generation2": 3.125, "riskLevel": "normal" }, "recommendationReason": "PLAG1 AA 보유로 자손 50% AA 고정 가능", "geneMatching": [ { "markerNm": "PLAG1", "cowGenotype": "AG", "kpnGenotype": "AA", "offspringProbability": { "AA": 0.5, "AG": 0.5, "GG": 0.0 }, "favorableProbability": 0.5 } ], "isOwned": true, "isSaved": false } ], "totalCount": 10 } ``` #### 4.3.3 KPN 관리 (`/kpn`) | 메서드 | 엔드포인트 | 설명 | PRD 참조 | |--------|-----------|------|----------| | GET | `/kpn/search?keyword=...` | KPN 검색 | - | | GET | `/kpn/owned` | 보유 KPN 목록 | SFR-COW-026 | | GET | `/kpn/owned/check/:kpnNo` | 보유 여부 확인 | SFR-COW-028 | | POST | `/kpn/ranking` | KPN 랭킹 | - | | POST | `/kpn/:kpnNo/recommendations` | KPN→암소 역추천 | SFR-COW-016-4 | | POST | `/kpn/owned` | 보유 KPN 등록 | SFR-COW-025 | | PATCH | `/kpn/owned/:id` | 보유 KPN 수정 | - | | DELETE | `/kpn/owned/:id` | 보유 KPN 삭제 | SFR-COW-027 | #### 4.3.4 대시보드 (`/dashboard`) | 메서드 | 엔드포인트 | 설명 | PRD 참조 | |--------|-----------|------|----------| | GET | `/dashboard/summary/:farmNo` | 농장 요약 통계 | SFR-HOME-001 | | GET | `/dashboard/analysis-completion/:farmNo` | 분석 완료 현황 | - | | GET | `/dashboard/evaluation/:farmNo` | 농장 종합 평가 | SFR-FARM-002 | | GET | `/dashboard/region-comparison/:farmNo` | 보은군 비교 | SFR-FARM-003 | | GET | `/dashboard/cow-distribution/:farmNo` | 등급별 분포 | - | | GET | `/dashboard/kpn-aggregation/:farmNo` | KPN 추천 집계 | SFR-HOME-009 | | GET | `/dashboard/farm-kpn-inventory/:farmNo` | KPN 재고 현황 | SFR-COW-024 | | GET | `/dashboard/analysis-years/:farmNo` | 분석연도 목록 | SFR-HOME-002 | | GET | `/dashboard/analysis-years/:farmNo/latest` | 최신 분석연도 | - | | GET | `/dashboard/year-comparison/:farmNo` | 3개년 비교 | SFR-HOME-011 | | GET | `/dashboard/gene-status/:farmNo` | 유전자 보유 현황 | - | | GET | `/dashboard/repro-efficiency/:farmNo` | 번식 효율 분석 | - | | GET | `/dashboard/excellent-cows/:farmNo?limit=10` | 우수 개체 추천 | SFR-HOME-007 | | GET | `/dashboard/cull-cows/:farmNo` | 도태 후보 추천 | SFR-HOME-008 | **응답 예시 - /dashboard/summary/:farmNo**: ```typescript { "totalCows": 50, "analysisSummary": { "geneAnalyzed": 45, "genomeAnalyzed": 48, "fertilityAnalyzed": 40 }, "breedingTypeDistribution": { "AI": 30, "Donor": 10, "Recipient": 8, "Cull": 2 }, "cowStatusDistribution": { "Active": 45, "Sold": 3, "Dead": 2 } } ``` **응답 예시 - /dashboard/year-comparison/:farmNo**: ```typescript { "years": [2024, 2023, 2022], "compositeScores": [82.5, 80.0, 78.5], "traitTrends": { "coldCarcassWeight": [78, 76, 75], "marbling": [85, 83, 80] }, "genePossessionTrends": { "meatQuality": [65.0, 62.0, 60.0], "meatQuantity": [68.0, 66.0, 65.0] }, "trendAnalysis": { "overall": "improvement", "change": "+4.0", "insights": "최근 3년간 지속적 개선 중" } } ``` #### 4.3.5 교배 조합 (`/breed`) | 메서드 | 엔드포인트 | 설명 | PRD 참조 | |--------|-----------|------|----------| | GET | `/breed` | 교배 조합 목록 | SFR-COW-018 | | GET | `/breed/search?keyword=...` | 교배 조합 검색 | - | | GET | `/breed/:id` | 교배 조합 상세 | - | | GET | `/breed/cow/:cowNo` | 암소별 교배 이력 | SFR-COW-020 | | GET | `/breed/kpn/:kpnNo` | KPN별 사용 이력 | SFR-COW-021 | | GET | `/breed/user/:userNo` | 사용자별 조회 | - | | POST | `/breed` | 교배 조합 저장 | SFR-COW-017 | | PATCH | `/breed/:id` | 교배 조합 수정 | - | | DELETE | `/breed/:id` | 교배 조합 삭제 | SFR-COW-019 | **POST /breed 요청 예시**: ```typescript { "fkUserNo": 1, "fkCowNo": "002012345678", "fkKpnNo": "KPN1385", "saveMemo": "육질 개선을 위한 교배 조합" } ``` #### 4.3.6 농장 관리 (`/farm`) | 메서드 | 엔드포인트 | 설명 | |--------|-----------|------| | GET | `/farm` | 현재 사용자 농장 목록 | | GET | `/farm/:id` | 농장 상세 정보 | | GET | `/farm/:farmNo/kpn` | 농장 보유 KPN 목록 | | GET | `/farm/:farmNo/kpn/:kpnNo/check` | KPN 보유 여부 확인 | | POST | `/farm/:farmNo/kpn` | 보유 KPN 등록 | | DELETE | `/farm/:farmNo/kpn/:kpnNo` | 보유 KPN 삭제 | #### 4.3.7 유전자 (`/gene`) | 메서드 | 엔드포인트 | 설명 | |--------|-----------|------| | GET | `/gene` | 전체 유전자 목록 | | GET | `/gene/major` | 주요 유전자 20개 | | GET | `/gene/types` | 유전자 타입 (QTY/QLT) | | GET | `/gene/type/:typeCd` | 타입별 유전자 | | GET | `/gene/search?keyword=...` | 유전자 검색 | | GET | `/gene/paginated` | 페이징 검색 (가상 스크롤) | | GET | `/gene/marker/:markerName/cows` | 특정 유전자 보유 개체 | | GET | `/gene/cows/multi?genes=PLAG1,CAPN1` | 다중 유전자 보유 개체 (AND) | | GET | `/gene/cow-profile/:cowNo` | 개체 유전자 프로필 | | GET | `/gene/:cowNo` | 개체 SNP 데이터 | #### 4.3.8 유전체 (`/genome`) | 메서드 | 엔드포인트 | 설명 | |--------|-----------|------| | GET | `/genome` | 전체 유전체 데이터 | | GET | `/genome/:cowNo` | 개체 유전체 데이터 | --- ## 5. 페이지별 구현 가이드 ### 5.1 대시보드 (`/dashboard`) #### 5.1.1 페이지 개요 **목적**: 농장 전체 현황을 한눈에 파악 **PRD 참조**: SFR-HOME-001~011, SFR-FARM-001~004 #### 5.1.2 레이아웃 ``` ┌─────────────────────────────────────────────────────┐ │ 사이드바 │ 헤더 (분석연도 선택, 전역 필터) │ ├─────────────────────────────────────────────────────┤ │ │ 전역 필터 적용 현황 카드 │ │ ├─────────────────────────────┤ │ │ 기본 정보 카드 (4개) │ │ 네비게이션 │ - 전체 개체 수 │ │ - 대시보드 │ - 분석 완료율 │ │ - 개체 관리 │ - 평균 유전능력 점수 │ │ - KPN 추천 │ - 농장 종합 등급 │ │ - 교배 관리 ├─────────────────────────────┤ │ - 설정 │ 농장 현황 섹션 │ │ │ ├─ 연도별 비교 차트 │ │ │ └─ 등급별 분포 차트 │ │ ├─────────────────────────────┤ │ │ 유전자 보유 현황 │ │ │ (주요 마커별 보유율 레이더 차트) │ │ ├─────────────────────────────┤ │ │ 주요 개체 및 추천 │ │ │ ├─ 우수 개체 TOP 3 │ │ │ ├─ 도태 후보 추천 │ │ │ └─ 추천 KPN TOP 3 │ └─────────────────────────────────────────────────────┘ ``` #### 5.1.3 컴포넌트 구성 **1. SectionCards (기본 정보 카드)** API: `GET /dashboard/summary/:farmNo` ```tsx // src/components/dashboard/section-cards.tsx export function SectionCards({ farmNo }: { farmNo: number }) { const { data, isLoading } = useDashboardSummary(farmNo) return (
전체 개체 수

{data?.totalCows || 0}두

분석 완료율

{Math.round((data?.analysisSummary.geneAnalyzed / data?.totalCows) * 100)}%

{/* 평균 유전능력 점수, 농장 종합 등급 */}
) } ``` **2. YearOverYearComparison (연도별 비교)** API: `GET /dashboard/year-comparison/:farmNo` ```tsx // src/components/dashboard/year-over-year-comparison.tsx export function YearOverYearComparison({ farmNo }: { farmNo: number }) { const { data } = useYearComparison(farmNo) return ( 연도별 비교 (3개년) ({ year, score: data.compositeScores[idx], meatQuality: data.genePossessionTrends.meatQuality[idx], meatQuantity: data.genePossessionTrends.meatQuantity[idx] }))} xKey="year" lines={[ { key: 'score', label: '종합점수', color: '#8884d8' }, { key: 'meatQuality', label: '육질형', color: '#82ca9d' }, { key: 'meatQuantity', label: '육량형', color: '#ffc658' } ]} /> {/* 추세 분석 표시 */}
{data?.trendAnalysis.insights}
) } ``` **3. GradeDistribution (등급별 분포)** API: `GET /dashboard/cow-distribution/:farmNo` ```tsx // src/components/dashboard/grade-distribution.tsx export function GradeDistribution({ farmNo }: { farmNo: number }) { const { data } = useCowDistribution(farmNo) return ( 등급별 분포 ) } ``` **4. GenePossessionStatus (유전자 보유 현황)** API: `GET /dashboard/gene-status/:farmNo` ```tsx // src/components/dashboard/gene-possession-status.tsx export function GenePossessionStatus({ farmNo }: { farmNo: number }) { const { data } = useGeneStatus(farmNo) return ( 주요 유전자 보유 현황 ({ marker: marker.markerNm, possession: marker.possessionRate, target: 80 // 목표 보유율 }))} keys={['possession', 'target']} /> ) } ``` **5. Top3Lists (우수 개체 및 추천)** API: - `GET /dashboard/excellent-cows/:farmNo?limit=3` - `GET /dashboard/cull-cows/:farmNo` - `GET /dashboard/kpn-aggregation/:farmNo` ```tsx // src/components/dashboard/top3-lists.tsx export function Top3Lists({ farmNo }: { farmNo: number }) { const { data: excellentCows } = useExcellentCows(farmNo, 3) const { data: cullCows } = useCullCows(farmNo) const { data: kpnAggregation } = useKpnAggregation(farmNo) return (
{/* 우수 개체 TOP 3 */} 우수 개체 TOP 3 {excellentCows?.map((cow, idx) => (
{idx + 1}

{cow.cowNo}

{cow.grade}등급 | {cow.overallScore}점

))}
{/* 도태 후보 추천 */} 도태 후보 추천 {cullCows?.map((cow) => (

{cow.cowNo}

{cow.cullReason}

))}
{/* 추천 KPN TOP 3 */} 추천 KPN TOP 3 {kpnAggregation?.slice(0, 3).map((kpn, idx) => (
{idx + 1}

{kpn.kpnNo}

{kpn.recommendedCount}두 추천 | 평균 {kpn.avgMatchingScore}점

))}
) } ``` #### 5.1.4 전역 필터 기능 **GlobalFilterContext 구현**: ```tsx // src/contexts/GlobalFilterContext.tsx interface GlobalFilterState { isActive: boolean viewMode: 'QUANTITY' | 'QUALITY' analysisIndex: 'GENE' | 'GENOME' selectedGenes: string[] selectedTraits: string[] inbreedingThreshold: number } export const GlobalFilterProvider = ({ children }) => { const [filters, setFilters] = useState({ isActive: false, viewMode: 'QUANTITY', analysisIndex: 'GENE', selectedGenes: [], selectedTraits: [], inbreedingThreshold: 12.5 }) return ( {children} ) } ``` #### 5.1.5 분석연도 선택 **AnalysisYearContext 구현**: ```tsx // src/contexts/AnalysisYearContext.tsx export const AnalysisYearProvider = ({ children, farmNo }) => { const [selectedYear, setSelectedYear] = useState(null) const [availableYears, setAvailableYears] = useState([]) useEffect(() => { // API 호출: GET /dashboard/analysis-years/:farmNo dashboardApi.getAnalysisYears(farmNo).then(years => { setAvailableYears(years) setSelectedYear(years[0]) // 최신 연도 자동 선택 }) }, [farmNo]) return ( {children} ) } ``` --- ### 5.2 개체 관리 페이지 #### 5.2.1 개체 목록 (`/dashboard/cows/list`) **목적**: 필터, 정렬, 랭킹 기능을 갖춘 개체 목록 **PRD 참조**: SFR-COW-001, SFR-COW-003 **레이아웃**: ``` ┌─────────────────────────────────────────────────────┐ │ 개체 관리 │ ├─────────────────────────────────────────────────────┤ │ [필터 패널] │ │ - 유전자 선택 (다중 선택) │ │ - 등급 필터 (A/B/C/D/E) │ │ - 정렬 기준 (유전자 순위/등급/점수/개체번호) │ ├─────────────────────────────────────────────────────┤ │ [개체 테이블] │ │ 순위 | 개체번호 | 등급 | 점수 | PLAG1 | CAPN1 | ... │ │ 1 | KOR... | A | 95 | AA | CC | ... │ │ 2 | KOR... | A | 92 | AG | CC | ... │ │ ... │ ├─────────────────────────────────────────────────────┤ │ [페이지네이션] [총 50개체 / 10개씩 표시] │ └─────────────────────────────────────────────────────┘ ``` **구현**: ```tsx // src/app/dashboard/cows/list/page.tsx 'use client' export default function CowListPage() { const { filters } = useGlobalFilter() const [selectedGenes, setSelectedGenes] = useState([]) const [gradeFilter, setGradeFilter] = useState([]) const [sortBy, setSortBy] = useState<'gene-rank' | 'grade' | 'score'>('gene-rank') const [page, setPage] = useState(1) const limit = 10 // API 호출: POST /cow/ranking const { data, isLoading } = useCowRanking({ filterOptions: { filters: [ ...(gradeFilter.length > 0 ? [{ field: 'grade', operator: 'in', value: gradeFilter }] : []), ], sorts: [ { field: sortBy === 'score' ? 'overallScore' : sortBy, direction: 'DESC' } ], pagination: { page, limit } }, rankingOptions: { criteriaType: filters.analysisIndex === 'GENE' ? 'GENE' : 'GENOME', geneConditions: selectedGenes.map(gene => ({ markerNm: gene, order: 'DESC' })), limit: 100 } }) // 동적 컬럼 생성 const columns = [ { key: 'rank', label: '순위' }, { key: 'cowNo', label: '개체번호' }, { key: 'grade', label: '등급' }, { key: 'score', label: '점수' }, ...selectedGenes.map(gene => ({ key: gene, label: gene })) ] return (
{/* 필터 패널 */} 필터 및 정렬
{/* 유전자 선택 */}
{/* 등급 필터 */}
A B C D E
{/* 정렬 기준 */}
{/* 개체 테이블 */} router.push(`/cow/${row.entity.pkCowNo}`)} /> {/* 페이지네이션 */}
) } ``` #### 5.2.2 개체 상세 (`/cow/[cowNo]`) **이미 잘 구현되어 있음!** 추가 개선 사항: 1. **유전자 전체 보기 페이지** (`/cow/[cowNo]/genes/[category]`) - API: `GET /gene/cow-profile/:cowNo` - 육량형/육질형 전체 유전자 목록 (2500개) - 가상 스크롤링 적용 2. **KPN 추천 바로가기** 버튼 개선 - 현재: `/kpn/recommend?cowNo=${cowNo}` - 개선: 버튼 클릭 시 즉시 추천 API 호출 후 결과 표시 --- ### 5.3 KPN 추천 페이지 (`/kpn/recommend`) #### 5.3.1 페이지 개요 **목적**: 암소에 최적의 KPN을 추천받고 교배 조합 저장 **PRD 참조**: SFR-COW-013, SFR-COW-016 #### 5.3.2 레이아웃 ``` ┌─────────────────────────────────────────────────────┐ │ KPN 추천 │ ├─────────────────────────────────────────────────────┤ │ [암소 선택 섹션] │ │ - 암소 검색 (개체번호 또는 이름) │ │ - 선택된 암소 정보 카드 │ │ - 개체번호, 등급, 유전자 요약 │ ├─────────────────────────────────────────────────────┤ │ [필터 설정] │ │ - 타겟 유전자 선택 │ │ - 근친도 임계값 (슬라이더) │ │ - 성별 필터 (수컷/암컷) │ │ [추천 받기 버튼] │ ├─────────────────────────────────────────────────────┤ │ [추천 결과] │ │ ┌─────────────────────────────────────────────┐ │ │ │ 순위 1 | KPN1385 | 매칭점수: 90점 │ │ │ │ 근친도: 6.25% (안전) | 보유 여부: O │ │ │ │ 추천 이유: PLAG1 AA 보유로 자손 50% AA... │ │ │ │ [유전자 매칭 상세] [저장] [상세보기] │ │ │ └─────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 순위 2 | KPN2456 | 매칭점수: 88점 │ │ │ │ ... │ │ │ └─────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ``` #### 5.3.3 구현 ```tsx // src/app/kpn/recommend/page.tsx 'use client' export default function KpnRecommendPage() { const router = useRouter() const searchParams = useSearchParams() const cowNoParam = searchParams.get('cowNo') const [selectedCow, setSelectedCow] = useState(null) const [targetGenes, setTargetGenes] = useState([]) const [inbreedingThreshold, setInbreedingThreshold] = useState(12.5) const [limit, setLimit] = useState(10) // 암소 검색 const { data: searchResults, isSearching } = useCowSearch(searchQuery) // KPN 추천 API 호출 const { data: recommendations, isLoading, mutate: getRecommendations } = useKpnRecommendation() useEffect(() => { if (cowNoParam) { // URL 파라미터로 전달된 암소 자동 로드 cowApi.findOne(cowNoParam).then(setSelectedCow) } }, [cowNoParam]) const handleRecommend = () => { if (!selectedCow) return getRecommendations({ cowNo: selectedCow.pkCowNo, targetGenes, inbreedingThreshold, limit }) } const handleSaveBreeding = async (kpn: KpnRecommendation) => { if (!selectedCow) return try { await breedApi.create({ fkUserNo: user.pkUserNo, fkCowNo: selectedCow.pkCowNo, fkKpnNo: kpn.kpn.kpnNo, saveMemo: `매칭점수: ${kpn.matchingScore}점, 근친도: ${kpn.inbreeding.generation1}%` }) toast.success('교배 조합이 저장되었습니다') } catch (error) { toast.error('저장 실패') } } return (
{/* 1. 암소 선택 섹션 */} 암소 선택 {!selectedCow ? (
setSearchQuery(e.target.value)} />
{/* 검색 결과 */} {searchResults && searchResults.length > 0 && (
{searchResults.map(cow => (
setSelectedCow(cow)} >

{cow.pkCowNo}

{cow.grade}등급 | {cow.cowSex === 'F' ? '암소' : '수소'}

))}
)}
) : (

{selectedCow.pkCowNo}

{selectedCow.grade}등급 | 생년월일: {selectedCow.cowBirthDt}

)}
{/* 2. 필터 설정 */} {selectedCow && ( 추천 필터 설정 {/* 타겟 유전자 선택 */}

{targetGenes.length}개 선택됨

{/* 근친도 임계값 */}
setInbreedingThreshold(value[0])} />

{inbreedingThreshold < 10 ? '엄격' : inbreedingThreshold < 15 ? '보통' : '관대'}

{/* 추천 개수 */}
{/* 추천 받기 버튼 */}
)} {/* 3. 추천 결과 */} {recommendations && ( 추천 결과 (총 {recommendations.totalCount}개) {recommendations.recommendations.map((rec) => ( handleSaveBreeding(rec)} onViewDetail={() => router.push(`/kpn/${rec.kpn.kpnNo}?cowNo=${selectedCow.pkCowNo}`)} /> ))} {/* 제외된 KPN 정보 */} {recommendations.excludedKpns && recommendations.excludedKpns.count > 0 && (

교배 이력으로 제외된 KPN ({recommendations.excludedKpns.count}개)

{recommendations.excludedKpns.list.map((excluded) => (
{excluded.kpnNumber} - 마지막 사용: {excluded.lastUsedDate} (재사용 가능: {excluded.reusableDate})
))}
)}
)}
) } ``` #### 5.3.4 KPN 추천 카드 컴포넌트 ```tsx // src/components/kpn/kpn-recommendation-card.tsx interface KpnRecommendationCardProps { recommendation: KpnRecommendation onSave: () => void onViewDetail: () => void } export function KpnRecommendationCard({ recommendation: rec, onSave, onViewDetail }: KpnRecommendationCardProps) { const [showGeneMatching, setShowGeneMatching] = useState(false) const getRiskColor = (riskLevel: string) => { if (riskLevel === 'normal') return 'text-green-600' if (riskLevel === 'warning') return 'text-yellow-600' return 'text-red-600' } return (
{/* 헤더 */}
순위 {rec.rank}

{rec.kpn.kpnNo}

{rec.kpn.kpnNm}

매칭점수

{rec.matchingScore}점

{/* 근친도 정보 */}

1세대 근친도

{rec.inbreeding.generation1}%

2세대

{rec.inbreeding.generation2}%

3세대

{rec.inbreeding.generation3}%

{/* 보유 여부 및 저장 여부 */}
{rec.isOwned && 보유 중} {rec.isSaved && 저장됨} {rec.isExcludedByHistory && 이력 제외}
{/* 추천 이유 */}

추천 이유

{rec.recommendationReason}

전략: {rec.strategy}

{/* 유전자 매칭 상세 (토글) */}
{showGeneMatching && (
{rec.geneMatching.map((match, idx) => (

{match.markerNm}

0.5 ? 'default' : 'outline'}> 우량확률 {Math.round(match.favorableProbability * 100)}%

암소 유전자형

{match.cowGenotype}

KPN 유전자형

{match.kpnGenotype}

자손 유전자형 확률

{Object.entries(match.offspringProbability).map(([genotype, prob]) => (

{genotype}

{Math.round(prob * 100)}%

))}
{match.improvementReason && (

{match.improvementReason}

)}
))}
)}
{/* 액션 버튼 */}
) } ``` --- ### 5.4 KPN 상세 페이지 (`/kpn/[kpnNo]`) #### 5.4.1 페이지 개요 **목적**: KPN 상세 정보 + 유전자 매칭 시뮬레이션 **PRD 참조**: SFR-COW-014 #### 5.4.2 구현 ```tsx // src/app/kpn/[kpnNo]/page.tsx 'use client' export default function KpnDetailPage() { const params = useParams() const searchParams = useSearchParams() const kpnNo = params.kpnNo as string const cowNo = searchParams.get('cowNo') // 암소 선택 시 시뮬레이션 const { data: kpn, isLoading } = useKpnDetail(kpnNo, cowNo) return (
{/* 1. KPN 기본 정보 */} KPN 기본 정보

KPN 번호

{kpn?.basicInfo.kpnNumber}

출생지

{kpn?.basicInfo.origin}

생년월일

{kpn?.basicInfo.birthDate}

모색

{kpn?.basicInfo.coatColor}

{/* 보유 여부 */}
{kpn?.isOwned ? ( 보유 중 ) : ( 미보유 )} {kpn?.isSaved && 저장됨}
{/* 2. 혈통 정보 (3대) */} 혈통 정보 (3대) {/* 3. 유전자 정보 */} 보유 유전자 육질형 육량형
{kpn?.genes.meatQuality.map((gene) => ( {gene.geneName}: {gene.genotype} ))}
{kpn?.genes.meatQuantity.map((gene) => ( {gene.geneName}: {gene.genotype} ))}
{/* 4. 유전능력 평가 */} 유전능력 종합 평가

종합점수

{kpn?.geneticAbility.compositeScore}

등급

{kpn?.geneticAbility.grade}
{/* 경제형질 추가 */}
{/* 5. 유전자 매칭 시뮬레이션 (cowNo 있을 때만) */} {cowNo && kpn?.geneMatchingSimulation && ( 유전자 매칭 시뮬레이션 선택한 암소({cowNo})와의 교배 시 예상 결과
{kpn.geneMatchingSimulation.detailedSimulation.map((sim, idx) => ( ))} {/* 종합 요약 */}

종합 요약

육질형: {kpn.geneMatchingSimulation.overallSummary.meatQuality}

육량형: {kpn.geneMatchingSimulation.overallSummary.meatQuantity}

)}
) } ``` --- ### 5.5 교배 조합 관리 (`/breeding/saved`) #### 5.5.1 구현 ```tsx // src/app/breeding/saved/page.tsx 'use client' export default function SavedBreedingPage() { const { user } = useAuthStore() const [searchKeyword, setSearchKeyword] = useState('') const [page, setPage] = useState(1) const limit = 20 // API 호출: GET /breed/search?keyword=...&userNo=... const { data, isLoading } = useSavedBreedings({ keyword: searchKeyword, userNo: user?.pkUserNo, limit, page }) const handleDelete = async (id: number) => { if (!confirm('삭제하시겠습니까?')) return try { await breedApi.remove(id) toast.success('삭제되었습니다') mutate() // 목록 새로고침 } catch (error) { toast.error('삭제 실패') } } const handleUpdateMemo = async (id: number, memo: string) => { try { await breedApi.update(id, { saveMemo: memo }) toast.success('메모가 수정되었습니다') } catch (error) { toast.error('수정 실패') } } return (
저장된 교배 조합 {/* 검색 */}
setSearchKeyword(e.target.value)} />
{/* 목록 */}
{data?.items.map((breeding) => ( handleDelete(breeding.pkSaveNo)} onUpdateMemo={(memo) => handleUpdateMemo(breeding.pkSaveNo, memo)} /> ))}
{/* 페이지네이션 */}
) } ``` --- ### 5.6 다세대 교배 시뮬레이터 (`/breeding/simulator`) #### 5.6.1 페이지 개요 **목적**: 1~20세대 교배 시뮬레이션, KPN 순환 전략 **PRD 참조**: SFR-COW-038, SFR-COW-039 #### 5.6.2 레이아웃 ``` ┌─────────────────────────────────────────────────────┐ │ 다세대 교배 시뮬레이터 │ ├─────────────────────────────────────────────────────┤ │ [설정 패널] │ │ - 암소 선택 │ │ - 초기 KPN 선택 │ │ - 대체 KPN 선택 (최대 5개) │ │ - 세대 수 (1~20) │ │ - 근친도 임계값 │ │ [시뮬레이션 실행] │ ├─────────────────────────────────────────────────────┤ │ [시뮬레이션 결과] │ │ ┌─────────────────────────────────────────────┐ │ │ │ 타임라인 (세대별 KPN 배정) │ │ │ │ Gen1: KPN1385 (근친도 6.25%) │ │ │ │ Gen2: KPN2456 (근친도 3.12%) │ │ │ │ Gen3: KPN3789 (근친도 1.56%) │ │ │ │ Gen4: KPN1385 🔄 (근친도 0.78%, 순환) │ │ │ └─────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 근친도 변화 그래프 (세로축: %, 가로축: 세대)│ │ │ └─────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 인사이트 │ │ │ │ - 순환 시작 세대: 4세대 │ │ │ │ - 권장 순환 주기: 4-5세대마다 │ │ │ │ - 근친도 영향 감소: 세대마다 50% │ │ │ └─────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ``` #### 5.6.3 구현 ```tsx // src/app/breeding/simulator/page.tsx 'use client' export default function MultiGenerationSimulatorPage() { const [selectedCow, setSelectedCow] = useState(null) const [initialKpn, setInitialKpn] = useState('') const [alternativeKpns, setAlternativeKpns] = useState([]) const [generations, setGenerations] = useState(10) const [inbreedingThreshold, setInbreedingThreshold] = useState(12.5) const { data: result, isLoading, mutate: simulate } = useMultiGenerationSimulation() const handleSimulate = () => { if (!selectedCow || !initialKpn) { toast.error('암소와 초기 KPN을 선택해주세요') return } simulate({ cowNo: selectedCow.pkCowNo, kpnNo: initialKpn, targetGenes: [], // 전역 필터에서 가져오기 generations, inbreedingThreshold, alternativeKpns }) } return (
{/* 설정 패널 */} 시뮬레이션 설정 {/* 암소 선택 */}
{/* 초기 KPN */}
{/* 대체 KPN (최대 5개) */}
{/* 세대 수 */}
setGenerations(v[0])} />
{/* 근친도 임계값 */}
setInbreedingThreshold(v[0])} />
{/* 시뮬레이션 결과 */} {result && ( <> {/* 타임라인 */} 세대별 KPN 배정 타임라인 {/* 근친도 그래프 */} 세대별 근친도 변화 ({ generation: gen.generation, inbreeding: gen.cumulativeInbreeding, threshold: inbreedingThreshold }))} xKey="generation" lines={[ { key: 'inbreeding', label: '누적 근친도', color: '#8884d8' }, { key: 'threshold', label: '임계값', color: '#ff0000', strokeDasharray: '5 5' } ]} /> {/* 인사이트 */} 시뮬레이션 인사이트
순환 시작 세대

{result.rotationInsights.rotationStartGeneration}세대

권장 순환 주기

{result.rotationInsights.rotationCycle}

근친도 감소 원리

{result.rotationInsights.inbreedingReductionPrinciple}

{/* 경고 */} {result.warnings && result.warnings.length > 0 && (

경고

{result.warnings.map((warning, idx) => (

{warning}

))}
)}
)}
) } ``` --- ### 5.7 농장 KPN 패키지 추천 (`/kpn/package`) #### 5.7.1 페이지 개요 **목적**: 농장 전체 최적 KPN 패키지 추천 **PRD 참조**: SFR-COW-037 #### 5.7.2 구현 ```tsx // src/app/kpn/package/page.tsx 'use client' export default function KpnPackagePage() { const { user } = useAuthStore() const [farmNo, setFarmNo] = useState(null) const [targetGenes, setTargetGenes] = useState([]) const [inbreedingThreshold, setInbreedingThreshold] = useState(12.5) const [maxPackageSize, setMaxPackageSize] = useState(5) const { data: result, isLoading, mutate: getPackage } = useFarmPackageRecommendation() const handleRecommend = () => { if (!farmNo) return getPackage({ farmNo, targetGenes, inbreedingThreshold, maxPackageSize }) } return (
{/* 설정 */} 농장 KPN 패키지 추천 설정
setInbreedingThreshold(v[0])} />
{/* 추천 결과 */} {result && ( <> {/* 추천 KPN 패키지 */} 추천 KPN 패키지
{result.packageRecommendation.map((kpn, idx) => (
{idx + 1}

{kpn.kpnNumber}

{kpn.classification === 'essential' ? '필수' : '추천'}

추천 두수

{kpn.recommendedCount}두

평균 매칭점수

{kpn.avgMatchingScore}점

1세대 수요

{kpn.generationDemand.generation1}개

2세대 수요

{kpn.generationDemand.generation2}개

{/* 적용 가능 암소 */}

적용 가능 암소 ({kpn.applicableCows.length}두)

{kpn.applicableCows.slice(0, 10).map((cow) => ( {cow.cowNumber} ))} {kpn.applicableCows.length > 10 && ( +{kpn.applicableCows.length - 10}두 )}
))}
{/* 보유 KPN 비교 */} 보유 KPN 비교 및 구매 가이드

보유 중인 KPN

{result.ownedComparison.ownedKpns.map((kpn) => ( {kpn} ))}

구매 필요 KPN

{result.ownedComparison.missingKpns.map((kpn) => ( {kpn} ))}

구매 가이드

{result.ownedComparison.purchaseGuide}

{/* 패키지 효과 분석 */} 패키지 적용 시 예상 효과

육질형 개선 두수

+{result.packageEffect.meatQualityImprovement}두

육량형 개선 두수

+{result.packageEffect.meatQuantityImprovement}두

평균 등급 상승

{result.packageEffect.expectedGradeIncrease}

)}
) } ``` --- ## 6. 컴포넌트 설계 ### 6.1 공통 컴포넌트 #### 6.1.1 GeneFilterModal (유전자 선택 모달) ```tsx // src/components/gene-filter-modal.tsx interface GeneFilterModalProps { selectedGenes: string[] onSelectionChange: (genes: string[]) => void maxSelection?: number } export function GeneFilterModal({ selectedGenes, onSelectionChange, maxSelection = 20 }: GeneFilterModalProps) { const [open, setOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [selectedType, setSelectedType] = useState<'QTY' | 'QLT' | 'ALL'>('ALL') // API: GET /gene/paginated?keyword=...&typeCd=... const { data: genes } = useGenes({ keyword: searchQuery, typeCd: selectedType === 'ALL' ? undefined : selectedType, page: 1, limit: 100 }) const handleToggle = (geneName: string) => { if (selectedGenes.includes(geneName)) { onSelectionChange(selectedGenes.filter(g => g !== geneName)) } else { if (selectedGenes.length >= maxSelection) { toast.error(`최대 ${maxSelection}개까지 선택 가능합니다`) return } onSelectionChange([...selectedGenes, geneName]) } } return ( 유전자 선택 {/* 검색 */} setSearchQuery(e.target.value)} /> {/* 타입 필터 */} 전체 육량형 육질형 {/* 유전자 목록 (가상 스크롤) */}
{genes?.items.map((gene) => (
handleToggle(gene.markerNm)} >

{gene.markerNm}

{gene.fkMarkerType}
{gene.markerDesc && (

{gene.markerDesc}

)}
))}
{/* 선택된 유전자 */}

선택된 유전자 ({selectedGenes.length}개)

{selectedGenes.map((gene) => ( {gene} ))}
) } ``` #### 6.1.2 DataTable (재사용 가능한 테이블) ```tsx // src/components/data-table.tsx interface Column { key: string label: string render?: (value: any, row: any) => React.ReactNode } interface DataTableProps { columns: Column[] data: any[] loading?: boolean onRowClick?: (row: any) => void } export function DataTable({ columns, data, loading, onRowClick }: DataTableProps) { if (loading) { return } return ( {columns.map((col) => ( {col.label} ))} {data.map((row, idx) => ( onRowClick?.(row)} > {columns.map((col) => ( {col.render ? col.render(row[col.key], row) : row[col.key]} ))} ))}
) } ``` --- ## 7. 상태 관리 ### 7.1 Zustand Store #### 7.1.1 AuthStore ```tsx // src/store/auth-store.ts interface AuthState { user: User | null token: string | null setUser: (user: User) => void setToken: (token: string) => void logout: () => void } export const useAuthStore = create()( persist( (set) => ({ user: null, token: null, setUser: (user) => set({ user }), setToken: (token) => set({ token }), logout: () => set({ user: null, token: null }) }), { name: 'auth-storage' } ) ) ``` #### 7.1.2 FilterStore ```tsx // src/store/filter-store.ts interface FilterState { globalFilters: GlobalFilterState setGlobalFilters: (filters: GlobalFilterState) => void resetFilters: () => void } export const useFilterStore = create((set) => ({ globalFilters: { isActive: false, viewMode: 'QUANTITY', analysisIndex: 'GENE', selectedGenes: [], selectedTraits: [], inbreedingThreshold: 12.5 }, setGlobalFilters: (filters) => set({ globalFilters: filters }), resetFilters: () => set({ globalFilters: { isActive: false, viewMode: 'QUANTITY', analysisIndex: 'GENE', selectedGenes: [], selectedTraits: [], inbreedingThreshold: 12.5 } }) })) ``` --- ## 8. API 클라이언트 ### 8.1 Axios 설정 ```tsx // src/lib/api/client.ts import axios from 'axios' import { useAuthStore } from '@/store/auth-store' const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' export const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json' } }) // 요청 인터셉터: JWT 토큰 자동 추가 apiClient.interceptors.request.use((config) => { const token = useAuthStore.getState().token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 응답 인터셉터: 자동 언래핑 apiClient.interceptors.response.use( (response) => { // 백엔드가 { data: ... } 형태로 응답하는 경우 자동 언래핑 if (response.data && response.data.data !== undefined) { return response.data.data } return response.data }, (error) => { if (error.response?.status === 401) { // 토큰 만료 시 로그아웃 useAuthStore.getState().logout() window.location.href = '/login' } return Promise.reject(error) } ) ``` ### 8.2 API 함수 예시 ```tsx // src/lib/api/cow.api.ts export const cowApi = { findAll: () => apiClient.get('/cow'), findOne: (cowNo: string) => apiClient.get(`/cow/${cowNo}`), search: (keyword: string, farmNo?: number, limit = 20) => apiClient.get('/cow/search', { params: { keyword, farmNo, limit } }), ranking: (payload: CowRankingRequest) => apiClient.post('/cow/ranking', payload), getRecommendations: (cowNo: string, payload: RecommendationRequest) => apiClient.post(`/cow/${cowNo}/recommendations`, payload), simulateBreeding: (payload: BreedingSimulationRequest) => apiClient.post('/cow/simulate-breeding', payload), simulateMultiGeneration: (payload: MultiGenerationRequest) => apiClient.post('/cow/simulate-multi-generation', payload), farmPackageRecommendation: (payload: FarmPackageRequest) => apiClient.post('/cow/farm-package-recommendation', payload) } ``` --- ## 9. 개발 우선순위 ### 9.1 Phase 1: 핵심 기능 (우선순위: 높음) **기간**: 2-3주 1. **대시보드 완성** (3일) - 모든 컴포넌트 백엔드 API 연동 - 차트 및 시각화 개선 2. **개체 목록 고도화** (2일) - 랭킹 시스템 구현 - 동적 컬럼 생성 3. **KPN 추천 페이지** (5일) - 암소→KPN 추천 - 추천 결과 상세 표시 - 유전자 매칭 시뮬레이션 4. **교배 조합 저장/관리** (2일) - CRUD 기능 완성 ### 9.2 Phase 2: 고급 기능 (우선순위: 중간) **기간**: 2-3주 1. **다세대 시뮬레이터** (5일) - 타임라인 UI - 근친도 그래프 2. **농장 KPN 패키지 추천** (3일) - 패키지 추천 로직 - 효과 분석 UI 3. **보유 KPN 관리** (2일) - 등록/수정/삭제 ### 9.3 Phase 3: UX 개선 (우선순위: 낮음) **기간**: 1-2주 1. 로딩 상태 개선 2. 에러 핸들링 강화 3. 반응형 디자인 최적화 4. 애니메이션 및 트랜지션 --- ## 10. 테스트 가이드 ### 10.1 단위 테스트 ```tsx // __tests__/components/KpnRecommendationCard.test.tsx import { render, screen } from '@testing-library/react' import { KpnRecommendationCard } from '@/components/kpn/kpn-recommendation-card' describe('KpnRecommendationCard', () => { it('renders recommendation correctly', () => { const mockRecommendation = { rank: 1, kpn: { kpnNo: 'KPN1385', kpnNm: 'Test KPN' }, matchingScore: 90, inbreeding: { generation1: 6.25, riskLevel: 'normal' } } render() expect(screen.getByText('KPN1385')).toBeInTheDocument() expect(screen.getByText('90점')).toBeInTheDocument() }) }) ``` ### 10.2 E2E 테스트 (Playwright) ```tsx // e2e/kpn-recommendation.spec.ts import { test, expect } from '@playwright/test' test('KPN recommendation flow', async ({ page }) => { await page.goto('/login') await page.fill('input[name="userId"]', 'testuser') await page.fill('input[name="password"]', 'password123') await page.click('button[type="submit"]') await page.goto('/kpn/recommend') await page.fill('input[placeholder="개체번호"]', 'KOR002108023350') await page.click('button:has-text("추천 받기")') await expect(page.locator('.recommendation-card').first()).toBeVisible() }) ``` --- ## 부록 A: TypeScript 타입 정의 ```typescript // src/types/cow.types.ts export interface Cow { pkCowNo: string cowShortNo?: string fkFarmNo: number cowSex: 'F' | 'M' cowBirthDt?: string cowReproType?: 'AI' | 'Donor' | 'Recipient' | 'Cull' analysStat?: 'Match' | 'Mismatch' | 'NotAnalyzed' | 'NoRecord' cowStatus: 'Active' | 'Dead' | 'Sold' | 'Slaughtered' grade?: 'A' | 'B' | 'C' | 'D' | 'E' overallScore?: number } export interface CowRankingRequest { filterOptions?: { filters?: FilterCondition[] sorts?: SortOption[] pagination?: { page: number; limit: number } } rankingOptions: { criteriaType: 'GENE' | 'GENOME' | 'CONCEPTION_RATE' | 'BCS' | 'MPT' | 'INBREEDING' | 'COMPOSITE' geneConditions?: { markerNm: string; order: 'ASC' | 'DESC' }[] traitConditions?: { traitNm: string; weight: number }[] limit?: number offset?: number } } export interface RecommendationRequest { targetGenes: string[] inbreedingThreshold: number limit?: number } export interface RecommendationResponse { cow: { cowNo: string genes: { markerNm: string; genotype: string }[] } recommendations: KpnRecommendation[] excludedKpns?: { count: number list: { kpnNumber: string; lastUsedDate: string; reusableDate: string }[] } totalCount: number } export interface KpnRecommendation { kpn: { kpnNo: string; kpnNm: string } rank: number matchingScore: number inbreeding: { generation1: number generation2: number generation3: number riskLevel: 'normal' | 'warning' | 'danger' } recommendationReason: string strategy: string isOwned: boolean isSaved: boolean isExcludedByHistory: boolean geneMatching: GeneMatching[] } export interface GeneMatching { markerNm: string cowGenotype: string kpnGenotype: string offspringProbability: { [key: string]: number } favorableProbability: number improvementReason?: string } ``` --- ## 부록 B: 환경 변수 설정 ```env # .env.local NEXT_PUBLIC_API_URL=http://localhost:4000 NEXT_PUBLIC_APP_NAME=유전능력 컨설팅 서비스 ``` --- ## 부록 C: 참고 자료 - **백엔드 API 문서**: `http://localhost:4000/api-docs` (Swagger) - **PRD 문서**: `E:\repo5\prd\기능요구사항14.md` - **DB 스키마**: `E:\repo5\prd\GENE_TABLE_SPEC.md` - **Next.js 공식 문서**: https://nextjs.org/docs - **shadcn/ui 컴포넌트**: https://ui.shadcn.com - **Recharts 문서**: https://recharts.org --- **문서 끝** 이 문서를 기반으로 완벽한 프론트엔드 애플리케이션을 구현하세요! 🚀