# 유전능력 컨설팅 서비스 프론트엔드 구현 가이드

**작성일**: 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

---

**문서 끝**

이 문서를 기반으로 완벽한 프론트엔드 애플리케이션을 구현하세요! 🚀
