86 KiB
유전능력 컨설팅 서비스 프론트엔드 구현 가이드
작성일: 2025-10-26 버전: 1.0 목적: 백엔드 API 완전 활용한 완벽한 프론트엔드 구현
목차
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=3GET /dashboard/cull-cows/:farmNoGET /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])
이미 잘 구현되어 있음! 추가 개선 사항:
-
유전자 전체 보기 페이지 (
/cow/[cowNo]/genes/[category])- API:
GET /gene/cow-profile/:cowNo - 육량형/육질형 전체 유전자 목록 (2500개)
- 가상 스크롤링 적용
- API:
-
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주
-
대시보드 완성 (3일)
- 모든 컴포넌트 백엔드 API 연동
- 차트 및 시각화 개선
-
개체 목록 고도화 (2일)
- 랭킹 시스템 구현
- 동적 컬럼 생성
-
KPN 추천 페이지 (5일)
- 암소→KPN 추천
- 추천 결과 상세 표시
- 유전자 매칭 시뮬레이션
-
교배 조합 저장/관리 (2일)
- CRUD 기능 완성
9.2 Phase 2: 고급 기능 (우선순위: 중간)
기간: 2-3주
-
다세대 시뮬레이터 (5일)
- 타임라인 UI
- 근친도 그래프
-
농장 KPN 패키지 추천 (3일)
- 패키지 추천 로직
- 효과 분석 UI
-
보유 KPN 관리 (2일)
- 등록/수정/삭제
9.3 Phase 3: UX 개선 (우선순위: 낮음)
기간: 1-2주
- 로딩 상태 개선
- 에러 핸들링 강화
- 반응형 디자인 최적화
- 애니메이션 및 트랜지션
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: 참고 자료
- 백엔드 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
문서 끝
이 문서를 기반으로 완벽한 프론트엔드 애플리케이션을 구현하세요! 🚀