2522 lines
86 KiB
Markdown
2522 lines
86 KiB
Markdown
# 유전능력 컨설팅 서비스 프론트엔드 구현 가이드
|
||
|
||
**작성일**: 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 (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>전체 개체 수</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-3xl font-bold">{data?.totalCows || 0}두</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>분석 완료율</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-3xl font-bold">
|
||
{Math.round((data?.analysisSummary.geneAnalyzed / data?.totalCows) * 100)}%
|
||
</p>
|
||
<Progress value={...} className="mt-2" />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 평균 유전능력 점수, 농장 종합 등급 */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
**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 (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>연도별 비교 (3개년)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<LineChart
|
||
data={data?.years.map((year, idx) => ({
|
||
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' }
|
||
]}
|
||
/>
|
||
|
||
{/* 추세 분석 표시 */}
|
||
<div className="mt-4">
|
||
<Badge variant={data?.trendAnalysis.overall === 'improvement' ? 'default' : 'secondary'}>
|
||
{data?.trendAnalysis.insights}
|
||
</Badge>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
```
|
||
|
||
**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 (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>등급별 분포</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<BarChart
|
||
data={[
|
||
{ grade: 'A', count: data?.gradeDistribution.A || 0 },
|
||
{ grade: 'B', count: data?.gradeDistribution.B || 0 },
|
||
{ grade: 'C', count: data?.gradeDistribution.C || 0 },
|
||
{ grade: 'D', count: data?.gradeDistribution.D || 0 },
|
||
{ grade: 'E', count: data?.gradeDistribution.E || 0 }
|
||
]}
|
||
xKey="grade"
|
||
bars={[{ key: 'count', label: '개체 수', color: '#8884d8' }]}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
```
|
||
|
||
**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 (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>주요 유전자 보유 현황</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<RadarChart
|
||
data={data?.majorMarkers.map(marker => ({
|
||
marker: marker.markerNm,
|
||
possession: marker.possessionRate,
|
||
target: 80 // 목표 보유율
|
||
}))}
|
||
keys={['possession', 'target']}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
```
|
||
|
||
**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 (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* 우수 개체 TOP 3 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>우수 개체 TOP 3</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{excellentCows?.map((cow, idx) => (
|
||
<div key={cow.cowNo} className="flex items-center gap-3 p-3 border-b">
|
||
<Badge variant="default">{idx + 1}</Badge>
|
||
<div className="flex-1">
|
||
<p className="font-semibold">{cow.cowNo}</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{cow.grade}등급 | {cow.overallScore}점
|
||
</p>
|
||
</div>
|
||
<Button size="sm" onClick={() => router.push(`/cow/${cow.cowNo}`)}>
|
||
상세보기
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 도태 후보 추천 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>도태 후보 추천</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{cullCows?.map((cow) => (
|
||
<div key={cow.cowNo} className="p-3 border-b">
|
||
<p className="font-semibold">{cow.cowNo}</p>
|
||
<p className="text-sm text-muted-foreground">{cow.cullReason}</p>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 추천 KPN TOP 3 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>추천 KPN TOP 3</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{kpnAggregation?.slice(0, 3).map((kpn, idx) => (
|
||
<div key={kpn.kpnNo} className="flex items-center gap-3 p-3 border-b">
|
||
<Badge variant="default">{idx + 1}</Badge>
|
||
<div className="flex-1">
|
||
<p className="font-semibold">{kpn.kpnNo}</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{kpn.recommendedCount}두 추천 | 평균 {kpn.avgMatchingScore}점
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 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<GlobalFilterState>({
|
||
isActive: false,
|
||
viewMode: 'QUANTITY',
|
||
analysisIndex: 'GENE',
|
||
selectedGenes: [],
|
||
selectedTraits: [],
|
||
inbreedingThreshold: 12.5
|
||
})
|
||
|
||
return (
|
||
<GlobalFilterContext.Provider value={{ filters, setFilters }}>
|
||
{children}
|
||
</GlobalFilterContext.Provider>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 5.1.5 분석연도 선택
|
||
|
||
**AnalysisYearContext 구현**:
|
||
|
||
```tsx
|
||
// src/contexts/AnalysisYearContext.tsx
|
||
export const AnalysisYearProvider = ({ children, farmNo }) => {
|
||
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||
|
||
useEffect(() => {
|
||
// API 호출: GET /dashboard/analysis-years/:farmNo
|
||
dashboardApi.getAnalysisYears(farmNo).then(years => {
|
||
setAvailableYears(years)
|
||
setSelectedYear(years[0]) // 최신 연도 자동 선택
|
||
})
|
||
}, [farmNo])
|
||
|
||
return (
|
||
<AnalysisYearContext.Provider value={{ selectedYear, setSelectedYear, availableYears }}>
|
||
{children}
|
||
</AnalysisYearContext.Provider>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 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<string[]>([])
|
||
const [gradeFilter, setGradeFilter] = useState<string[]>([])
|
||
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 (
|
||
<div className="space-y-4">
|
||
{/* 필터 패널 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>필터 및 정렬</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{/* 유전자 선택 */}
|
||
<div>
|
||
<Label>유전자 선택</Label>
|
||
<GeneFilterModal
|
||
selectedGenes={selectedGenes}
|
||
onSelectionChange={setSelectedGenes}
|
||
/>
|
||
</div>
|
||
|
||
{/* 등급 필터 */}
|
||
<div>
|
||
<Label>등급 필터</Label>
|
||
<ToggleGroup type="multiple" value={gradeFilter} onValueChange={setGradeFilter}>
|
||
<ToggleGroupItem value="A">A</ToggleGroupItem>
|
||
<ToggleGroupItem value="B">B</ToggleGroupItem>
|
||
<ToggleGroupItem value="C">C</ToggleGroupItem>
|
||
<ToggleGroupItem value="D">D</ToggleGroupItem>
|
||
<ToggleGroupItem value="E">E</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
</div>
|
||
|
||
{/* 정렬 기준 */}
|
||
<div>
|
||
<Label>정렬 기준</Label>
|
||
<Select value={sortBy} onValueChange={setSortBy}>
|
||
<SelectItem value="gene-rank">유전자 순위</SelectItem>
|
||
<SelectItem value="grade">등급</SelectItem>
|
||
<SelectItem value="score">점수</SelectItem>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 개체 테이블 */}
|
||
<Card>
|
||
<CardContent>
|
||
<DataTable
|
||
columns={columns}
|
||
data={data?.items || []}
|
||
loading={isLoading}
|
||
onRowClick={(row) => router.push(`/cow/${row.entity.pkCowNo}`)}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 페이지네이션 */}
|
||
<Pagination
|
||
currentPage={page}
|
||
totalPages={Math.ceil((data?.total || 0) / limit)}
|
||
onPageChange={setPage}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 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<Cow | null>(null)
|
||
const [targetGenes, setTargetGenes] = useState<string[]>([])
|
||
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 (
|
||
<SidebarProvider>
|
||
<AppSidebar />
|
||
<SidebarInset>
|
||
<SiteHeader />
|
||
<div className="p-6 space-y-6">
|
||
{/* 1. 암소 선택 섹션 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>암소 선택</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{!selectedCow ? (
|
||
<div>
|
||
<Label>개체번호 또는 이름으로 검색</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="KOR002108023350 또는 소 이름"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
<Button onClick={handleSearch}>검색</Button>
|
||
</div>
|
||
|
||
{/* 검색 결과 */}
|
||
{searchResults && searchResults.length > 0 && (
|
||
<div className="mt-4 space-y-2">
|
||
{searchResults.map(cow => (
|
||
<div
|
||
key={cow.pkCowNo}
|
||
className="p-3 border rounded-lg cursor-pointer hover:bg-muted"
|
||
onClick={() => setSelectedCow(cow)}
|
||
>
|
||
<p className="font-semibold">{cow.pkCowNo}</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{cow.grade}등급 | {cow.cowSex === 'F' ? '암소' : '수소'}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="p-4 bg-muted rounded-lg">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-lg font-semibold">{selectedCow.pkCowNo}</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{selectedCow.grade}등급 | 생년월일: {selectedCow.cowBirthDt}
|
||
</p>
|
||
</div>
|
||
<Button variant="ghost" onClick={() => setSelectedCow(null)}>
|
||
변경
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 2. 필터 설정 */}
|
||
{selectedCow && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>추천 필터 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* 타겟 유전자 선택 */}
|
||
<div>
|
||
<Label>타겟 유전자 (다중 선택 가능)</Label>
|
||
<GeneFilterModal
|
||
selectedGenes={targetGenes}
|
||
onSelectionChange={setTargetGenes}
|
||
maxSelection={10}
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
{targetGenes.length}개 선택됨
|
||
</p>
|
||
</div>
|
||
|
||
{/* 근친도 임계값 */}
|
||
<div>
|
||
<Label>근친도 임계값: {inbreedingThreshold}% 이하</Label>
|
||
<Slider
|
||
min={5}
|
||
max={20}
|
||
step={0.5}
|
||
value={[inbreedingThreshold]}
|
||
onValueChange={(value) => setInbreedingThreshold(value[0])}
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
{inbreedingThreshold < 10 ? '엄격' : inbreedingThreshold < 15 ? '보통' : '관대'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 추천 개수 */}
|
||
<div>
|
||
<Label>추천 KPN 개수</Label>
|
||
<Select value={limit.toString()} onValueChange={(v) => setLimit(parseInt(v))}>
|
||
<SelectItem value="5">5개</SelectItem>
|
||
<SelectItem value="10">10개</SelectItem>
|
||
<SelectItem value="20">20개</SelectItem>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 추천 받기 버튼 */}
|
||
<Button
|
||
className="w-full"
|
||
onClick={handleRecommend}
|
||
disabled={isLoading}
|
||
>
|
||
{isLoading ? '추천 중...' : 'KPN 추천 받기'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 3. 추천 결과 */}
|
||
{recommendations && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>추천 결과 (총 {recommendations.totalCount}개)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{recommendations.recommendations.map((rec) => (
|
||
<KpnRecommendationCard
|
||
key={rec.kpn.kpnNo}
|
||
recommendation={rec}
|
||
onSave={() => handleSaveBreeding(rec)}
|
||
onViewDetail={() => router.push(`/kpn/${rec.kpn.kpnNo}?cowNo=${selectedCow.pkCowNo}`)}
|
||
/>
|
||
))}
|
||
|
||
{/* 제외된 KPN 정보 */}
|
||
{recommendations.excludedKpns && recommendations.excludedKpns.count > 0 && (
|
||
<div className="p-4 bg-muted rounded-lg">
|
||
<p className="text-sm font-semibold mb-2">
|
||
교배 이력으로 제외된 KPN ({recommendations.excludedKpns.count}개)
|
||
</p>
|
||
{recommendations.excludedKpns.list.map((excluded) => (
|
||
<div key={excluded.kpnNumber} className="text-sm text-muted-foreground">
|
||
{excluded.kpnNumber} - 마지막 사용: {excluded.lastUsedDate}
|
||
(재사용 가능: {excluded.reusableDate})
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</SidebarInset>
|
||
</SidebarProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 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 (
|
||
<div className="border rounded-lg p-4 space-y-3">
|
||
{/* 헤더 */}
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant="default" className="text-lg">순위 {rec.rank}</Badge>
|
||
<div>
|
||
<p className="text-xl font-bold">{rec.kpn.kpnNo}</p>
|
||
<p className="text-sm text-muted-foreground">{rec.kpn.kpnNm}</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-sm text-muted-foreground">매칭점수</p>
|
||
<p className="text-2xl font-bold text-primary">{rec.matchingScore}점</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 근친도 정보 */}
|
||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||
<div className="p-2 bg-muted rounded">
|
||
<p className="text-muted-foreground">1세대 근친도</p>
|
||
<p className={`font-semibold ${getRiskColor(rec.inbreeding.riskLevel)}`}>
|
||
{rec.inbreeding.generation1}%
|
||
</p>
|
||
</div>
|
||
<div className="p-2 bg-muted rounded">
|
||
<p className="text-muted-foreground">2세대</p>
|
||
<p className="font-semibold">{rec.inbreeding.generation2}%</p>
|
||
</div>
|
||
<div className="p-2 bg-muted rounded">
|
||
<p className="text-muted-foreground">3세대</p>
|
||
<p className="font-semibold">{rec.inbreeding.generation3}%</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 보유 여부 및 저장 여부 */}
|
||
<div className="flex gap-2">
|
||
{rec.isOwned && <Badge variant="secondary">보유 중</Badge>}
|
||
{rec.isSaved && <Badge variant="outline">저장됨</Badge>}
|
||
{rec.isExcludedByHistory && <Badge variant="destructive">이력 제외</Badge>}
|
||
</div>
|
||
|
||
{/* 추천 이유 */}
|
||
<div className="p-3 bg-primary/5 rounded-lg">
|
||
<p className="text-sm font-semibold mb-1">추천 이유</p>
|
||
<p className="text-sm">{rec.recommendationReason}</p>
|
||
<p className="text-xs text-muted-foreground mt-1">전략: {rec.strategy}</p>
|
||
</div>
|
||
|
||
{/* 유전자 매칭 상세 (토글) */}
|
||
<div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowGeneMatching(!showGeneMatching)}
|
||
>
|
||
{showGeneMatching ? '접기' : '유전자 매칭 상세 보기'}
|
||
</Button>
|
||
|
||
{showGeneMatching && (
|
||
<div className="mt-3 space-y-2">
|
||
{rec.geneMatching.map((match, idx) => (
|
||
<div key={idx} className="p-3 border rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="font-semibold">{match.markerNm}</p>
|
||
<Badge variant={match.favorableProbability > 0.5 ? 'default' : 'outline'}>
|
||
우량확률 {Math.round(match.favorableProbability * 100)}%
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
<div>
|
||
<p className="text-muted-foreground">암소 유전자형</p>
|
||
<p className="font-semibold">{match.cowGenotype}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-muted-foreground">KPN 유전자형</p>
|
||
<p className="font-semibold">{match.kpnGenotype}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2">
|
||
<p className="text-xs text-muted-foreground mb-1">자손 유전자형 확률</p>
|
||
<div className="flex gap-2">
|
||
{Object.entries(match.offspringProbability).map(([genotype, prob]) => (
|
||
<div key={genotype} className="flex-1 text-center">
|
||
<p className="text-xs text-muted-foreground">{genotype}</p>
|
||
<Progress value={prob * 100} className="h-2" />
|
||
<p className="text-xs font-semibold">{Math.round(prob * 100)}%</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{match.improvementReason && (
|
||
<p className="mt-2 text-xs text-primary">{match.improvementReason}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 액션 버튼 */}
|
||
<div className="flex gap-2">
|
||
<Button variant="default" onClick={onSave} disabled={rec.isSaved}>
|
||
{rec.isSaved ? '저장됨' : '교배 조합 저장'}
|
||
</Button>
|
||
<Button variant="outline" onClick={onViewDetail}>
|
||
KPN 상세보기
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 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 (
|
||
<SidebarProvider>
|
||
<AppSidebar />
|
||
<SidebarInset>
|
||
<SiteHeader />
|
||
<div className="p-6 space-y-6">
|
||
{/* 1. KPN 기본 정보 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>KPN 기본 정보</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">KPN 번호</p>
|
||
<p className="font-semibold">{kpn?.basicInfo.kpnNumber}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">출생지</p>
|
||
<p className="font-semibold">{kpn?.basicInfo.origin}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">생년월일</p>
|
||
<p className="font-semibold">{kpn?.basicInfo.birthDate}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">모색</p>
|
||
<p className="font-semibold">{kpn?.basicInfo.coatColor}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 보유 여부 */}
|
||
<div className="mt-4">
|
||
{kpn?.isOwned ? (
|
||
<Badge variant="default">보유 중</Badge>
|
||
) : (
|
||
<Badge variant="outline">미보유</Badge>
|
||
)}
|
||
{kpn?.isSaved && <Badge variant="secondary" className="ml-2">저장됨</Badge>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 2. 혈통 정보 (3대) */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>혈통 정보 (3대)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<PedigreeTree pedigree={kpn?.pedigree} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 3. 유전자 정보 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>보유 유전자</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Tabs defaultValue="quality">
|
||
<TabsList>
|
||
<TabsTrigger value="quality">육질형</TabsTrigger>
|
||
<TabsTrigger value="quantity">육량형</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="quality">
|
||
<div className="flex flex-wrap gap-2">
|
||
{kpn?.genes.meatQuality.map((gene) => (
|
||
<Badge key={gene.geneName} variant="default">
|
||
{gene.geneName}: {gene.genotype}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="quantity">
|
||
<div className="flex flex-wrap gap-2">
|
||
{kpn?.genes.meatQuantity.map((gene) => (
|
||
<Badge key={gene.geneName} variant="default">
|
||
{gene.geneName}: {gene.genotype}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 4. 유전능력 평가 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>유전능력 종합 평가</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="p-4 border rounded-lg">
|
||
<p className="text-sm text-muted-foreground">종합점수</p>
|
||
<p className="text-2xl font-bold">{kpn?.geneticAbility.compositeScore}</p>
|
||
</div>
|
||
<div className="p-4 border rounded-lg">
|
||
<p className="text-sm text-muted-foreground">등급</p>
|
||
<Badge variant="default" className="text-lg">
|
||
{kpn?.geneticAbility.grade}
|
||
</Badge>
|
||
</div>
|
||
{/* 경제형질 추가 */}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 5. 유전자 매칭 시뮬레이션 (cowNo 있을 때만) */}
|
||
{cowNo && kpn?.geneMatchingSimulation && (
|
||
<Card className="border-primary">
|
||
<CardHeader>
|
||
<CardTitle>유전자 매칭 시뮬레이션</CardTitle>
|
||
<CardDescription>
|
||
선택한 암소({cowNo})와의 교배 시 예상 결과
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{kpn.geneMatchingSimulation.detailedSimulation.map((sim, idx) => (
|
||
<AlleleSimulationChart key={idx} simulation={sim} />
|
||
))}
|
||
|
||
{/* 종합 요약 */}
|
||
<div className="p-4 bg-primary/5 rounded-lg">
|
||
<p className="font-semibold mb-2">종합 요약</p>
|
||
<p className="text-sm">
|
||
육질형: {kpn.geneMatchingSimulation.overallSummary.meatQuality}
|
||
</p>
|
||
<p className="text-sm">
|
||
육량형: {kpn.geneMatchingSimulation.overallSummary.meatQuantity}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</SidebarInset>
|
||
</SidebarProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 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 (
|
||
<SidebarProvider>
|
||
<AppSidebar />
|
||
<SidebarInset>
|
||
<SiteHeader />
|
||
<div className="p-6 space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>저장된 교배 조합</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{/* 검색 */}
|
||
<div className="mb-4">
|
||
<Input
|
||
placeholder="암소 번호, KPN 번호, 메모로 검색"
|
||
value={searchKeyword}
|
||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* 목록 */}
|
||
<div className="space-y-3">
|
||
{data?.items.map((breeding) => (
|
||
<BreedingCombinationCard
|
||
key={breeding.pkSaveNo}
|
||
breeding={breeding}
|
||
onDelete={() => handleDelete(breeding.pkSaveNo)}
|
||
onUpdateMemo={(memo) => handleUpdateMemo(breeding.pkSaveNo, memo)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 페이지네이션 */}
|
||
<Pagination
|
||
currentPage={page}
|
||
totalPages={Math.ceil((data?.total || 0) / limit)}
|
||
onPageChange={setPage}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</SidebarInset>
|
||
</SidebarProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 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<Cow | null>(null)
|
||
const [initialKpn, setInitialKpn] = useState<string>('')
|
||
const [alternativeKpns, setAlternativeKpns] = useState<string[]>([])
|
||
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 (
|
||
<SidebarProvider>
|
||
<AppSidebar />
|
||
<SidebarInset>
|
||
<SiteHeader />
|
||
<div className="p-6 space-y-6">
|
||
{/* 설정 패널 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>시뮬레이션 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* 암소 선택 */}
|
||
<div>
|
||
<Label>암소 선택</Label>
|
||
<CowSearchSelect
|
||
value={selectedCow}
|
||
onChange={setSelectedCow}
|
||
/>
|
||
</div>
|
||
|
||
{/* 초기 KPN */}
|
||
<div>
|
||
<Label>초기 KPN</Label>
|
||
<KpnSearchSelect
|
||
value={initialKpn}
|
||
onChange={setInitialKpn}
|
||
/>
|
||
</div>
|
||
|
||
{/* 대체 KPN (최대 5개) */}
|
||
<div>
|
||
<Label>대체 KPN (순환 시 사용, 최대 5개)</Label>
|
||
<MultiKpnSelect
|
||
value={alternativeKpns}
|
||
onChange={setAlternativeKpns}
|
||
maxSelection={5}
|
||
/>
|
||
</div>
|
||
|
||
{/* 세대 수 */}
|
||
<div>
|
||
<Label>세대 수: {generations}세대</Label>
|
||
<Slider
|
||
min={1}
|
||
max={20}
|
||
step={1}
|
||
value={[generations]}
|
||
onValueChange={(v) => setGenerations(v[0])}
|
||
/>
|
||
</div>
|
||
|
||
{/* 근친도 임계값 */}
|
||
<div>
|
||
<Label>근친도 임계값: {inbreedingThreshold}%</Label>
|
||
<Slider
|
||
min={5}
|
||
max={20}
|
||
step={0.5}
|
||
value={[inbreedingThreshold]}
|
||
onValueChange={(v) => setInbreedingThreshold(v[0])}
|
||
/>
|
||
</div>
|
||
|
||
<Button className="w-full" onClick={handleSimulate} disabled={isLoading}>
|
||
{isLoading ? '시뮬레이션 중...' : '시뮬레이션 실행'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 시뮬레이션 결과 */}
|
||
{result && (
|
||
<>
|
||
{/* 타임라인 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>세대별 KPN 배정 타임라인</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<MultiGenerationTimeline
|
||
generations={result.generationPlan}
|
||
threshold={inbreedingThreshold}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 근친도 그래프 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>세대별 근친도 변화</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<LineChart
|
||
data={result.generationPlan.map(gen => ({
|
||
generation: gen.generation,
|
||
inbreeding: gen.cumulativeInbreeding,
|
||
threshold: inbreedingThreshold
|
||
}))}
|
||
xKey="generation"
|
||
lines={[
|
||
{ key: 'inbreeding', label: '누적 근친도', color: '#8884d8' },
|
||
{ key: 'threshold', label: '임계값', color: '#ff0000', strokeDasharray: '5 5' }
|
||
]}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 인사이트 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>시뮬레이션 인사이트</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="default">순환 시작 세대</Badge>
|
||
<p>{result.rotationInsights.rotationStartGeneration}세대</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="secondary">권장 순환 주기</Badge>
|
||
<p>{result.rotationInsights.rotationCycle}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline">근친도 감소 원리</Badge>
|
||
<p>{result.rotationInsights.inbreedingReductionPrinciple}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 경고 */}
|
||
{result.warnings && result.warnings.length > 0 && (
|
||
<div className="mt-4 p-4 bg-destructive/10 rounded-lg">
|
||
<p className="font-semibold text-destructive mb-2">경고</p>
|
||
{result.warnings.map((warning, idx) => (
|
||
<p key={idx} className="text-sm text-destructive">{warning}</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
</SidebarInset>
|
||
</SidebarProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 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<number | null>(null)
|
||
const [targetGenes, setTargetGenes] = useState<string[]>([])
|
||
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 (
|
||
<SidebarProvider>
|
||
<AppSidebar />
|
||
<SidebarInset>
|
||
<SiteHeader />
|
||
<div className="p-6 space-y-6">
|
||
{/* 설정 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>농장 KPN 패키지 추천 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div>
|
||
<Label>타겟 유전자</Label>
|
||
<GeneFilterModal
|
||
selectedGenes={targetGenes}
|
||
onSelectionChange={setTargetGenes}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>근친도 임계값: {inbreedingThreshold}%</Label>
|
||
<Slider
|
||
min={5}
|
||
max={20}
|
||
step={0.5}
|
||
value={[inbreedingThreshold]}
|
||
onValueChange={(v) => setInbreedingThreshold(v[0])}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>패키지 크기 (추천 KPN 개수)</Label>
|
||
<Select value={maxPackageSize.toString()} onValueChange={(v) => setMaxPackageSize(parseInt(v))}>
|
||
<SelectItem value="3">3개</SelectItem>
|
||
<SelectItem value="5">5개</SelectItem>
|
||
<SelectItem value="10">10개</SelectItem>
|
||
</Select>
|
||
</div>
|
||
|
||
<Button className="w-full" onClick={handleRecommend} disabled={isLoading}>
|
||
패키지 추천 받기
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 추천 결과 */}
|
||
{result && (
|
||
<>
|
||
{/* 추천 KPN 패키지 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>추천 KPN 패키지</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{result.packageRecommendation.map((kpn, idx) => (
|
||
<div key={kpn.kpnId} className="border rounded-lg p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant="default" className="text-lg">{idx + 1}</Badge>
|
||
<div>
|
||
<p className="text-xl font-bold">{kpn.kpnNumber}</p>
|
||
<Badge variant={kpn.classification === 'essential' ? 'default' : 'secondary'}>
|
||
{kpn.classification === 'essential' ? '필수' : '추천'}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-sm text-muted-foreground">추천 두수</p>
|
||
<p className="text-2xl font-bold">{kpn.recommendedCount}두</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||
<div className="p-2 bg-muted rounded">
|
||
<p className="text-muted-foreground">평균 매칭점수</p>
|
||
<p className="font-semibold">{kpn.avgMatchingScore}점</p>
|
||
</div>
|
||
<div className="p-2 bg-muted rounded">
|
||
<p className="text-muted-foreground">1세대 수요</p>
|
||
<p className="font-semibold">{kpn.generationDemand.generation1}개</p>
|
||
</div>
|
||
<div className="p-2 bg-muted rounded">
|
||
<p className="text-muted-foreground">2세대 수요</p>
|
||
<p className="font-semibold">{kpn.generationDemand.generation2}개</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 적용 가능 암소 */}
|
||
<div className="mt-3">
|
||
<p className="text-sm font-semibold mb-2">
|
||
적용 가능 암소 ({kpn.applicableCows.length}두)
|
||
</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
{kpn.applicableCows.slice(0, 10).map((cow) => (
|
||
<Badge key={cow.cowId} variant="outline" className="text-xs">
|
||
{cow.cowNumber}
|
||
</Badge>
|
||
))}
|
||
{kpn.applicableCows.length > 10 && (
|
||
<Badge variant="outline" className="text-xs">
|
||
+{kpn.applicableCows.length - 10}두
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 보유 KPN 비교 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>보유 KPN 비교 및 구매 가이드</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground mb-1">보유 중인 KPN</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
{result.ownedComparison.ownedKpns.map((kpn) => (
|
||
<Badge key={kpn} variant="default">{kpn}</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-sm text-muted-foreground mb-1">구매 필요 KPN</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
{result.ownedComparison.missingKpns.map((kpn) => (
|
||
<Badge key={kpn} variant="destructive">{kpn}</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-3 bg-primary/5 rounded-lg">
|
||
<p className="text-sm font-semibold">구매 가이드</p>
|
||
<p className="text-sm">{result.ownedComparison.purchaseGuide}</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 패키지 효과 분석 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>패키지 적용 시 예상 효과</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div className="p-4 border rounded-lg">
|
||
<p className="text-sm text-muted-foreground mb-1">육질형 개선 두수</p>
|
||
<p className="text-3xl font-bold text-primary">
|
||
+{result.packageEffect.meatQualityImprovement}두
|
||
</p>
|
||
</div>
|
||
<div className="p-4 border rounded-lg">
|
||
<p className="text-sm text-muted-foreground mb-1">육량형 개선 두수</p>
|
||
<p className="text-3xl font-bold text-primary">
|
||
+{result.packageEffect.meatQuantityImprovement}두
|
||
</p>
|
||
</div>
|
||
<div className="p-4 border rounded-lg">
|
||
<p className="text-sm text-muted-foreground mb-1">평균 등급 상승</p>
|
||
<p className="text-3xl font-bold text-primary">
|
||
{result.packageEffect.expectedGradeIncrease}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
</SidebarInset>
|
||
</SidebarProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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 (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline">
|
||
유전자 선택 ({selectedGenes.length}/{maxSelection})
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||
<DialogHeader>
|
||
<DialogTitle>유전자 선택</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{/* 검색 */}
|
||
<Input
|
||
placeholder="유전자 이름 검색..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
|
||
{/* 타입 필터 */}
|
||
<ToggleGroup type="single" value={selectedType} onValueChange={setSelectedType}>
|
||
<ToggleGroupItem value="ALL">전체</ToggleGroupItem>
|
||
<ToggleGroupItem value="QTY">육량형</ToggleGroupItem>
|
||
<ToggleGroupItem value="QLT">육질형</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
|
||
{/* 유전자 목록 (가상 스크롤) */}
|
||
<ScrollArea className="h-[400px]">
|
||
<div className="space-y-2">
|
||
{genes?.items.map((gene) => (
|
||
<div
|
||
key={gene.pkMarkerNo}
|
||
className={`p-3 border rounded-lg cursor-pointer ${
|
||
selectedGenes.includes(gene.markerNm) ? 'bg-primary/10 border-primary' : ''
|
||
}`}
|
||
onClick={() => handleToggle(gene.markerNm)}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox checked={selectedGenes.includes(gene.markerNm)} />
|
||
<p className="font-semibold">{gene.markerNm}</p>
|
||
<Badge variant="outline">{gene.fkMarkerType}</Badge>
|
||
</div>
|
||
</div>
|
||
{gene.markerDesc && (
|
||
<p className="text-xs text-muted-foreground mt-1">{gene.markerDesc}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
{/* 선택된 유전자 */}
|
||
<div>
|
||
<p className="text-sm font-semibold mb-2">선택된 유전자 ({selectedGenes.length}개)</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
{selectedGenes.map((gene) => (
|
||
<Badge key={gene} variant="default">
|
||
{gene}
|
||
<button
|
||
className="ml-1 text-xs"
|
||
onClick={() => handleToggle(gene)}
|
||
>
|
||
×
|
||
</button>
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button onClick={() => setOpen(false)}>확인</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 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 <Skeleton className="h-[400px]" />
|
||
}
|
||
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{columns.map((col) => (
|
||
<TableHead key={col.key}>{col.label}</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.map((row, idx) => (
|
||
<TableRow
|
||
key={idx}
|
||
className={onRowClick ? 'cursor-pointer hover:bg-muted' : ''}
|
||
onClick={() => onRowClick?.(row)}
|
||
>
|
||
{columns.map((col) => (
|
||
<TableCell key={col.key}>
|
||
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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<AuthState>()(
|
||
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<FilterState>((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[]>('/cow'),
|
||
|
||
findOne: (cowNo: string) => apiClient.get<Cow>(`/cow/${cowNo}`),
|
||
|
||
search: (keyword: string, farmNo?: number, limit = 20) =>
|
||
apiClient.get<Cow[]>('/cow/search', {
|
||
params: { keyword, farmNo, limit }
|
||
}),
|
||
|
||
ranking: (payload: CowRankingRequest) =>
|
||
apiClient.post<RankingResult>('/cow/ranking', payload),
|
||
|
||
getRecommendations: (cowNo: string, payload: RecommendationRequest) =>
|
||
apiClient.post<RecommendationResponse>(`/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(<KpnRecommendationCard recommendation={mockRecommendation} />)
|
||
|
||
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
|
||
|
||
---
|
||
|
||
**문서 끝**
|
||
|
||
이 문서를 기반으로 완벽한 프론트엔드 애플리케이션을 구현하세요! 🚀
|