Files
genome2025/backend/doc/FRONTEND_IMPLEMENTATION_GUIDE.md
2025-12-09 17:02:27 +09:00

86 KiB
Raw Blame History

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

작성일: 2025-10-26 버전: 1.0 목적: 백엔드 API 완전 활용한 완벽한 프론트엔드 구현


목차

  1. 개요
  2. 기술 스택
  3. 프로젝트 구조
  4. 백엔드 API 매핑
  5. 페이지별 구현 가이드
  6. 컴포넌트 설계
  7. 상태 관리
  8. API 클라이언트
  9. 개발 우선순위
  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 토큰을 포함해야 합니다:

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 요청 예시:

{
  "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 요청 예시:

{
  "targetGenes": ["PLAG1", "CAPN1", "FASN"],
  "inbreedingThreshold": 12.5,
  "limit": 10
}

응답 예시:

{
  "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:

{
  "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:

{
  "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 요청 예시:

{
  "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-001011, SFR-FARM-001004

5.1.2 레이아웃

┌─────────────────────────────────────────────────────┐
│ 사이드바              │ 헤더 (분석연도 선택, 전역 필터) │
├─────────────────────────────────────────────────────┤
│                       │ 전역 필터 적용 현황 카드        │
│                       ├─────────────────────────────┤
│                       │ 기본 정보 카드 (4개)          │
│  네비게이션           │ - 전체 개체 수               │
│  - 대시보드           │ - 분석 완료율                │
│  - 개체 관리          │ - 평균 유전능력 점수          │
│  - KPN 추천           │ - 농장 종합 등급             │
│  - 교배 관리          ├─────────────────────────────┤
│  - 설정               │ 농장 현황 섹션                │
│                       │ ├─ 연도별 비교 차트          │
│                       │ └─ 등급별 분포 차트          │
│                       ├─────────────────────────────┤
│                       │ 유전자 보유 현황              │
│                       │ (주요 마커별 보유율 레이더 차트) │
│                       ├─────────────────────────────┤
│                       │ 주요 개체 및 추천             │
│                       │ ├─ 우수 개체 TOP 3          │
│                       │ ├─ 도태 후보 추천            │
│                       │ └─ 추천 KPN TOP 3           │
└─────────────────────────────────────────────────────┘

5.1.3 컴포넌트 구성

1. SectionCards (기본 정보 카드)

API: GET /dashboard/summary/:farmNo

// 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

// 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

// 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

// 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
// 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 구현:

// 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 구현:

// 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개씩 표시]      │
└─────────────────────────────────────────────────────┘

구현:

// 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 구현

// 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 추천 카드 컴포넌트

// 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 구현

// 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 구현

// 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 구현

// 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 구현

// 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 (유전자 선택 모달)

// 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 (재사용 가능한 테이블)

// 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

// 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

// 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 설정

// 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 함수 예시

// 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 단위 테스트

// __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)

// 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 타입 정의

// 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.local
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_APP_NAME=유전능력 컨설팅 서비스

부록 C: 참고 자료


문서 끝

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