필터 및 화면 수정사항 반영
This commit is contained in:
File diff suppressed because it is too large
Load Diff
78
backend/doc/mpt 카테고리 권장수치.md
Normal file
78
backend/doc/mpt 카테고리 권장수치.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 혈액대사판정시험(MPT) 검사항목 및 권장수치
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
번식능력 검사를 위한 혈액대사판정시험(MPT) 검사항목으로, 5개 카테고리 총 16개 항목으로 구성됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 에너지 카테고리
|
||||||
|
|
||||||
|
| 항목 | 권장수치 | 단위 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 혈당 | 40-84 | mg/dL |
|
||||||
|
| 콜레스테롤 | 74-252 | mg/dL |
|
||||||
|
| 유리지방산(NEFA) | 115-660 | μEq/L |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 단백질 카테고리
|
||||||
|
|
||||||
|
| 항목 | 권장수치 | 단위 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 총단백질 | 6.2-7.7 | g/dL |
|
||||||
|
| 알부민 | 3.3-4.3 | g/dL |
|
||||||
|
| 총글로블린 | 9.1-36.1 | g/dL |
|
||||||
|
| A/G | 0.1-0.4 | - |
|
||||||
|
| 요소태질소(BUN) | 11.7-18.9 | mg/dL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 간기능 카테고리
|
||||||
|
|
||||||
|
| 항목 | 권장수치 | 단위 |
|
||||||
|
|------|---------|------|
|
||||||
|
| AST | 47-92 | U/L |
|
||||||
|
| GGT | 11-32 | U/L |
|
||||||
|
| 지방간 지수 | -1.2 ~ 9.9 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 미네랄 카테고리
|
||||||
|
|
||||||
|
| 항목 | 권장수치 | 단위 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 칼슘 | 8.1-10.6 | mg/dL |
|
||||||
|
| 인 | 6.2-8.9 | mg/dL |
|
||||||
|
| 칼슘/인 | 1.2-1.3 | - |
|
||||||
|
| 마그네슘 | 1.6-3.3 | mg/dL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 별도 카테고리
|
||||||
|
|
||||||
|
| 항목 | 권장수치 | 단위 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 크레아틴 | 1.0-1.3 | mg/dL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 결과 판정 기준
|
||||||
|
|
||||||
|
| 판정 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 낮음 | 권장수치 하한 미만 |
|
||||||
|
| 권장범위 | 권장수치 범위 내 |
|
||||||
|
| 높음 | 권장수치 상한 초과 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시각화 방식
|
||||||
|
- **폴리곤(레이더) 차트**: 5개 카테고리를 5각형 구조로 표현
|
||||||
|
- **가로 막대 도표**: 각 항목별 낮음/권장범위/높음 표시
|
||||||
|
- **색상 구분**: 우수/적정/부족으로 구분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
> 혈액대사판정시험의 주요 5가지 항목에 대한 시군 및 농가 수치비교를 위해 표준화한 자료입니다.
|
||||||
|
> 본 결과자료를 통한 종합평가는 보은군 평균과 농가 평균을 비교하여 상대적 차이의 수준을 나타냅니다.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,719 +0,0 @@
|
|||||||
# 프론트엔드 API 연동 가이드
|
|
||||||
|
|
||||||
> **작성일**: 2025-10-26
|
|
||||||
> **버전**: 1.0
|
|
||||||
> **대상**: 한우 유전체 분석 시스템 프론트엔드 개발자
|
|
||||||
|
|
||||||
## 📋 목차
|
|
||||||
|
|
||||||
1. [개요](#1-개요)
|
|
||||||
2. [인증 흐름](#2-인증-흐름)
|
|
||||||
3. [사용자-농장-개체 관계](#3-사용자-농장-개체-관계)
|
|
||||||
4. [API 연동 방법](#4-api-연동-방법)
|
|
||||||
5. [주요 구현 사례](#5-주요-구현-사례)
|
|
||||||
6. [문제 해결](#6-문제-해결)
|
|
||||||
7. [추가 구현 권장사항](#7-추가-구현-권장사항)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 개요
|
|
||||||
|
|
||||||
### 1.1 시스템 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
사용자 (User) 1:N 농장 (Farm) 1:N 개체 (Cow)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **User**: 로그인한 사용자 (농가, 컨설턴트, 기관담당자)
|
|
||||||
- **Farm**: 사용자가 소유한 농장 (한 사용자가 여러 농장 소유 가능)
|
|
||||||
- **Cow**: 농장에 속한 개체 (한우)
|
|
||||||
|
|
||||||
### 1.2 주요 기술 스택
|
|
||||||
|
|
||||||
**백엔드**:
|
|
||||||
- NestJS 10.x
|
|
||||||
- TypeORM
|
|
||||||
- JWT 인증
|
|
||||||
- PostgreSQL
|
|
||||||
|
|
||||||
**프론트엔드**:
|
|
||||||
- Next.js 15.5.3 (App Router)
|
|
||||||
- TypeScript
|
|
||||||
- Zustand (상태관리)
|
|
||||||
- Axios (HTTP 클라이언트)
|
|
||||||
|
|
||||||
### 1.3 API Base URL
|
|
||||||
|
|
||||||
```
|
|
||||||
개발: http://localhost:4000
|
|
||||||
운영: 환경변수 NEXT_PUBLIC_API_URL 사용
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 인증 흐름
|
|
||||||
|
|
||||||
### 2.1 JWT 기반 인증
|
|
||||||
|
|
||||||
모든 API 요청은 JWT 토큰을 필요로 합니다 (일부 Public 엔드포인트 제외).
|
|
||||||
|
|
||||||
**전역 Guard 적용**:
|
|
||||||
```typescript
|
|
||||||
// backend/src/main.ts
|
|
||||||
app.useGlobalGuards(new JwtAuthGuard(reflector));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 로그인 프로세스
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. 사용자 로그인
|
|
||||||
const response = await authApi.login({
|
|
||||||
userId: 'user123',
|
|
||||||
userPassword: 'password123'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 응답 구조
|
|
||||||
{
|
|
||||||
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
|
||||||
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
|
||||||
user: {
|
|
||||||
userNo: 1,
|
|
||||||
userName: '홍길동',
|
|
||||||
userEmail: 'hong@example.com',
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 토큰 자동 저장 (auth-store.ts에서 처리)
|
|
||||||
localStorage.setItem('accessToken', response.accessToken);
|
|
||||||
localStorage.setItem('refreshToken', response.refreshToken);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 자동 토큰 주입
|
|
||||||
|
|
||||||
`apiClient`가 모든 요청에 자동으로 토큰을 추가합니다:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// frontend/src/lib/api-client.ts (자동 처리)
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
|
||||||
const token = localStorage.getItem('accessToken');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**개발자는 별도로 토큰을 관리할 필요 없음** ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 사용자-농장-개체 관계
|
|
||||||
|
|
||||||
### 3.1 데이터 모델
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// User Entity
|
|
||||||
{
|
|
||||||
pkUserNo: number; // 사용자 번호
|
|
||||||
userId: string; // 로그인 ID
|
|
||||||
userName: string; // 이름
|
|
||||||
userSe: 'FARM' | 'CNSLT' | 'ORGAN'; // 사용자 구분
|
|
||||||
farms: FarmModel[]; // 소유 농장 목록
|
|
||||||
}
|
|
||||||
|
|
||||||
// Farm Entity
|
|
||||||
{
|
|
||||||
pkFarmNo: number; // 농장 번호
|
|
||||||
fkUserNo: number; // 사용자 번호 (FK)
|
|
||||||
farmCode: string; // 농장 코드 (F000001)
|
|
||||||
farmName: string; // 농장명
|
|
||||||
farmAddress: string; // 농장 주소
|
|
||||||
cows: CowModel[]; // 농장의 개체 목록
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cow Entity
|
|
||||||
{
|
|
||||||
pkCowNo: string; // 개체번호 (12자리)
|
|
||||||
fkFarmNo: number; // 농장 번호 (FK)
|
|
||||||
cowSex: 'M' | 'F'; // 성별
|
|
||||||
cowBirthDt: Date; // 생년월일
|
|
||||||
cowStatus: string; // 개체 상태
|
|
||||||
delYn: 'Y' | 'N'; // 삭제 여부
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 관계 API 호출 순서
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 올바른 순서:
|
|
||||||
// 1. 로그인한 사용자의 농장 목록 조회
|
|
||||||
const farms = await farmApi.findAll(); // GET /farm
|
|
||||||
|
|
||||||
// 2. 특정 농장의 개체 조회
|
|
||||||
const cows = await cowApi.findByFarmNo(farms[0].pkFarmNo); // GET /cow/farm/:farmNo
|
|
||||||
```
|
|
||||||
|
|
||||||
**❌ 잘못된 방법**: farmNo를 하드코딩
|
|
||||||
```typescript
|
|
||||||
const cows = await cowApi.findByFarmNo(1); // ❌ 다른 사용자의 데이터 접근 불가
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API 연동 방법
|
|
||||||
|
|
||||||
### 4.1 API 모듈 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/lib/api/
|
|
||||||
├── api-client.ts # Axios 인스턴스 + 인터셉터
|
|
||||||
├── index.ts # 모든 API export
|
|
||||||
├── auth.api.ts # 인증 API
|
|
||||||
├── farm.api.ts # 농장 API
|
|
||||||
├── cow.api.ts # 개체 API
|
|
||||||
├── kpn.api.ts # KPN API
|
|
||||||
└── dashboard.api.ts # 대시보드 API
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Import 방법
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { cowApi, farmApi, authApi } from '@/lib/api';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 주요 Farm API
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. 현재 사용자의 농장 목록 조회
|
|
||||||
const farms = await farmApi.findAll();
|
|
||||||
// GET /farm
|
|
||||||
// 응답: FarmDto[]
|
|
||||||
|
|
||||||
// 2. 농장 상세 조회
|
|
||||||
const farm = await farmApi.findOne(farmNo);
|
|
||||||
// GET /farm/:id
|
|
||||||
// 응답: FarmDto
|
|
||||||
|
|
||||||
// 3. 농장 생성
|
|
||||||
const newFarm = await farmApi.create({
|
|
||||||
userNo: 1,
|
|
||||||
farmName: '행복농장',
|
|
||||||
farmAddress: '충청북도 보은군...',
|
|
||||||
farmBizNo: '123-45-67890'
|
|
||||||
});
|
|
||||||
// POST /farm
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 주요 Cow API
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. 특정 농장의 개체 목록 조회
|
|
||||||
const cows = await cowApi.findByFarmNo(farmNo);
|
|
||||||
// GET /cow/farm/:farmNo
|
|
||||||
// 응답: CowDto[]
|
|
||||||
|
|
||||||
// 2. 개체 상세 조회
|
|
||||||
const cow = await cowApi.findOne(cowNo);
|
|
||||||
// GET /cow/:cowNo
|
|
||||||
// 응답: CowDto
|
|
||||||
|
|
||||||
// 3. 개체 검색
|
|
||||||
const results = await cowApi.search('KOR001', farmNo, 20);
|
|
||||||
// GET /cow/search?keyword=KOR001&farmNo=1&limit=20
|
|
||||||
// 응답: CowDto[]
|
|
||||||
|
|
||||||
// 4. 개체 랭킹 조회 (필터 + 정렬)
|
|
||||||
const ranking = await cowApi.getRanking({
|
|
||||||
filterOptions: {
|
|
||||||
filters: [/* 필터 조건 */]
|
|
||||||
},
|
|
||||||
rankingOptions: {
|
|
||||||
criteria: 'GENE',
|
|
||||||
order: 'DESC'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// POST /cow/ranking
|
|
||||||
// 응답: RankingResult<CowDto>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 주요 구현 사례
|
|
||||||
|
|
||||||
### 5.1 개체 목록 페이지 (/cow/page.tsx)
|
|
||||||
|
|
||||||
**완전한 구현 예시** (하드코딩 없음):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { cowApi, farmApi } from '@/lib/api'
|
|
||||||
import type { Cow } from '@/types/cow.types'
|
|
||||||
|
|
||||||
export default function CowListPage() {
|
|
||||||
const [cows, setCows] = useState<Cow[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCows = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// 1단계: 사용자의 농장 목록 조회
|
|
||||||
const farms = await farmApi.findAll()
|
|
||||||
|
|
||||||
if (!farms || farms.length === 0) {
|
|
||||||
setError('등록된 농장이 없습니다. 농장을 먼저 등록해주세요.')
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2단계: 첫 번째 농장의 개체 조회
|
|
||||||
// TODO: 여러 농장이 있을 경우 사용자가 선택할 수 있도록 UI 추가
|
|
||||||
const farmNo = farms[0].pkFarmNo
|
|
||||||
|
|
||||||
const data = await cowApi.findByFarmNo(farmNo)
|
|
||||||
setCows(data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('개체 데이터 조회 실패:', err)
|
|
||||||
setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchCows()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (loading) return <div>로딩 중...</div>
|
|
||||||
if (error) return <div>에러: {error}</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>개체 목록</h1>
|
|
||||||
{cows.map(cow => (
|
|
||||||
<div key={cow.pkCowNo}>
|
|
||||||
<p>개체번호: {cow.pkCowNo}</p>
|
|
||||||
<p>성별: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 개체 상세 페이지 (/cow/[cowNo]/page.tsx)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { cowApi } from '@/lib/api'
|
|
||||||
import type { Cow } from '@/types/cow.types'
|
|
||||||
|
|
||||||
export default function CowDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const cowNo = params.cowNo as string
|
|
||||||
const [cow, setCow] = useState<Cow | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCow = async () => {
|
|
||||||
try {
|
|
||||||
const data = await cowApi.findOne(cowNo)
|
|
||||||
setCow(data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('개체 상세 조회 실패:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchCow()
|
|
||||||
}, [cowNo])
|
|
||||||
|
|
||||||
if (loading) return <div>로딩 중...</div>
|
|
||||||
if (!cow) return <div>개체를 찾을 수 없습니다</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>개체 상세: {cow.pkCowNo}</h1>
|
|
||||||
<p>농장번호: {cow.fkFarmNo}</p>
|
|
||||||
<p>성별: {cow.cowSex === 'M' ? '수소' : '암소'}</p>
|
|
||||||
<p>생년월일: {cow.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString() : '-'}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 Dashboard 통계 페이지
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { farmApi, cowApi } from '@/lib/api'
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const [stats, setStats] = useState({
|
|
||||||
totalFarms: 0,
|
|
||||||
totalCows: 0,
|
|
||||||
farms: []
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchStats = async () => {
|
|
||||||
try {
|
|
||||||
// 1. 사용자의 농장 목록 조회
|
|
||||||
const farms = await farmApi.findAll()
|
|
||||||
|
|
||||||
// 2. 각 농장의 개체 수 집계
|
|
||||||
let totalCows = 0
|
|
||||||
const farmsWithCows = await Promise.all(
|
|
||||||
farms.map(async (farm) => {
|
|
||||||
const cows = await cowApi.findByFarmNo(farm.pkFarmNo)
|
|
||||||
totalCows += cows.length
|
|
||||||
return {
|
|
||||||
...farm,
|
|
||||||
cowCount: cows.length
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
setStats({
|
|
||||||
totalFarms: farms.length,
|
|
||||||
totalCows,
|
|
||||||
farms: farmsWithCows
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('통계 조회 실패:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchStats()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>대시보드</h1>
|
|
||||||
<p>총 농장 수: {stats.totalFarms}개</p>
|
|
||||||
<p>총 개체 수: {stats.totalCows}마리</p>
|
|
||||||
{stats.farms.map(farm => (
|
|
||||||
<div key={farm.pkFarmNo}>
|
|
||||||
<p>{farm.farmName}: {farm.cowCount}마리</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 문제 해결
|
|
||||||
|
|
||||||
### 6.1 인증 에러 (401 Unauthorized)
|
|
||||||
|
|
||||||
**증상**:
|
|
||||||
```
|
|
||||||
{"statusCode":401,"message":["인증이 필요합니다. 로그인 후 이용해주세요."]}
|
|
||||||
```
|
|
||||||
|
|
||||||
**원인**:
|
|
||||||
- localStorage에 토큰이 없음
|
|
||||||
- 토큰 만료
|
|
||||||
|
|
||||||
**해결 방법**:
|
|
||||||
```typescript
|
|
||||||
// 1. 로그인 상태 확인
|
|
||||||
const { isAuthenticated } = useAuthStore()
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 토큰 갱신 (필요 시)
|
|
||||||
await authApi.refreshToken(refreshToken)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 농장이 없는 경우
|
|
||||||
|
|
||||||
**증상**:
|
|
||||||
```
|
|
||||||
등록된 농장이 없습니다.
|
|
||||||
```
|
|
||||||
|
|
||||||
**해결 방법**:
|
|
||||||
```typescript
|
|
||||||
if (!farms || farms.length === 0) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>등록된 농장이 없습니다.</p>
|
|
||||||
<button onClick={() => router.push('/farm/create')}>
|
|
||||||
농장 등록하기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 CORS 에러
|
|
||||||
|
|
||||||
**증상**:
|
|
||||||
```
|
|
||||||
Access to XMLHttpRequest has been blocked by CORS policy
|
|
||||||
```
|
|
||||||
|
|
||||||
**해결 방법**:
|
|
||||||
|
|
||||||
백엔드에서 CORS 설정 확인:
|
|
||||||
```typescript
|
|
||||||
// backend/src/main.ts
|
|
||||||
app.enableCors({
|
|
||||||
origin: 'http://localhost:3000',
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 추가 구현 권장사항
|
|
||||||
|
|
||||||
### 7.1 여러 농장 선택 UI
|
|
||||||
|
|
||||||
현재는 첫 번째 농장만 사용하지만, 사용자가 여러 농장을 소유한 경우 선택할 수 있어야 합니다.
|
|
||||||
|
|
||||||
**구현 예시**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { farmApi, cowApi } from '@/lib/api'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
|
|
||||||
export default function CowListPage() {
|
|
||||||
const [farms, setFarms] = useState([])
|
|
||||||
const [selectedFarmNo, setSelectedFarmNo] = useState<number | null>(null)
|
|
||||||
const [cows, setCows] = useState([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchFarms = async () => {
|
|
||||||
const data = await farmApi.findAll()
|
|
||||||
setFarms(data)
|
|
||||||
|
|
||||||
// 첫 번째 농장 자동 선택
|
|
||||||
if (data.length > 0) {
|
|
||||||
setSelectedFarmNo(data[0].pkFarmNo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchFarms()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedFarmNo) return
|
|
||||||
|
|
||||||
const fetchCows = async () => {
|
|
||||||
const data = await cowApi.findByFarmNo(selectedFarmNo)
|
|
||||||
setCows(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchCows()
|
|
||||||
}, [selectedFarmNo])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Select value={String(selectedFarmNo)} onValueChange={(val) => setSelectedFarmNo(Number(val))}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="농장 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{farms.map(farm => (
|
|
||||||
<SelectItem key={farm.pkFarmNo} value={String(farm.pkFarmNo)}>
|
|
||||||
{farm.farmName} ({farm.farmCode})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{cows.map(cow => (
|
|
||||||
<div key={cow.pkCowNo}>{cow.pkCowNo}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 유전자 정보 포함 API 사용
|
|
||||||
|
|
||||||
현재 `GET /cow/farm/:farmNo`는 기본 정보만 반환합니다.
|
|
||||||
유전자 정보가 필요한 경우 `POST /cow/ranking` API를 사용하세요.
|
|
||||||
|
|
||||||
**구현 예시**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 유전자 정보를 포함한 개체 조회
|
|
||||||
const ranking = await cowApi.getRanking({
|
|
||||||
filterOptions: {
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
field: 'cow.fkFarmNo',
|
|
||||||
operator: 'equals',
|
|
||||||
value: farmNo
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
rankingOptions: {
|
|
||||||
criteria: 'GENE',
|
|
||||||
order: 'DESC'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ranking.items에 SNP, Trait 등 모든 관계 데이터 포함됨
|
|
||||||
const cowsWithGenes = ranking.items
|
|
||||||
```
|
|
||||||
|
|
||||||
**백엔드 참조**:
|
|
||||||
- `backend/src/cow/cow.service.ts:122-140` - `createRankingQueryBuilder()`
|
|
||||||
- SNP, Trait, Repro, MPT 데이터를 모두 leftJoin으로 포함
|
|
||||||
|
|
||||||
### 7.3 에러 바운더리 추가
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// components/ErrorBoundary.tsx
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function ErrorBoundary({
|
|
||||||
error,
|
|
||||||
reset,
|
|
||||||
}: {
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
reset: () => void
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
|
||||||
console.error('에러 발생:', error)
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">문제가 발생했습니다</h2>
|
|
||||||
<p className="mb-4">{error.message}</p>
|
|
||||||
<button
|
|
||||||
onClick={reset}
|
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded"
|
|
||||||
>
|
|
||||||
다시 시도
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 로딩 스켈레톤 UI
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// components/CowListSkeleton.tsx
|
|
||||||
export default function CowListSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
|
||||||
<div key={i} className="border rounded p-4 animate-pulse">
|
|
||||||
<div className="h-6 bg-gray-200 rounded mb-2"></div>
|
|
||||||
<div className="h-4 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용
|
|
||||||
if (loading) return <CowListSkeleton />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 참고 자료
|
|
||||||
|
|
||||||
### 8.1 백엔드 API 문서
|
|
||||||
|
|
||||||
- **Cow API**: `backend/src/cow/cow.controller.ts`
|
|
||||||
- **Farm API**: `backend/src/farm/farm.controller.ts`
|
|
||||||
- **Auth API**: `backend/src/auth/auth.controller.ts`
|
|
||||||
|
|
||||||
### 8.2 프론트엔드 코드 위치
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
├── app/
|
|
||||||
│ ├── cow/
|
|
||||||
│ │ ├── page.tsx # 개체 목록 (완성)
|
|
||||||
│ │ └── [cowNo]/page.tsx # 개체 상세
|
|
||||||
│ └── dashboard/page.tsx # 대시보드
|
|
||||||
├── lib/api/
|
|
||||||
│ ├── cow.api.ts # Cow API
|
|
||||||
│ ├── farm.api.ts # Farm API (신규 추가)
|
|
||||||
│ └── index.ts # API export
|
|
||||||
├── types/
|
|
||||||
│ ├── cow.types.ts # Cow 타입
|
|
||||||
│ └── auth.types.ts # Auth 타입
|
|
||||||
└── store/
|
|
||||||
└── auth-store.ts # 인증 상태 관리
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 타입 정의 참조
|
|
||||||
|
|
||||||
**백엔드 Entity → 프론트엔드 Types 매핑**:
|
|
||||||
|
|
||||||
| 백엔드 Entity | 프론트엔드 Types | 설명 |
|
|
||||||
|--------------|-----------------|------|
|
|
||||||
| `UsersModel` | `UserDto` | 사용자 |
|
|
||||||
| `FarmModel` | `FarmDto` | 농장 |
|
|
||||||
| `CowModel` | `CowDto` | 개체 |
|
|
||||||
| `KpnModel` | `KpnDto` | KPN |
|
|
||||||
|
|
||||||
**필드명 주의사항**:
|
|
||||||
- 백엔드: `pkCowNo`, `fkFarmNo`, `cowBirthDt`, `cowSex`
|
|
||||||
- 프론트엔드도 동일하게 사용 (DTO 변환 없음)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 체크리스트
|
|
||||||
|
|
||||||
개발 시 확인사항:
|
|
||||||
|
|
||||||
- [ ] JWT 토큰이 localStorage에 저장되는가?
|
|
||||||
- [ ] API 호출 시 Authorization 헤더가 자동으로 추가되는가?
|
|
||||||
- [ ] farmNo를 하드코딩하지 않고 `farmApi.findAll()`로 조회하는가?
|
|
||||||
- [ ] 농장이 없는 경우를 처리했는가?
|
|
||||||
- [ ] 에러 발생 시 사용자에게 적절한 메시지를 보여주는가?
|
|
||||||
- [ ] 로딩 상태를 표시하는가?
|
|
||||||
- [ ] 여러 농장이 있는 경우를 고려했는가?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 문의
|
|
||||||
|
|
||||||
질문이나 문제가 있는 경우:
|
|
||||||
|
|
||||||
1. 백엔드 API 문서 확인: `backend/doc/기능요구사항전체정리.md`
|
|
||||||
2. PRD 문서 확인: `E:/repo5/prd/`
|
|
||||||
3. 코드 참조:
|
|
||||||
- 완성된 `/cow/page.tsx` 구현 참조
|
|
||||||
- `lib/api-client.ts` 인터셉터 참조
|
|
||||||
- `store/auth-store.ts` 인증 흐름 참조
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**문서 작성**: Claude Code
|
|
||||||
**최종 수정일**: 2025-10-26
|
|
||||||
@@ -59,6 +59,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
|
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
|
||||||
|
const inputHash = await bcrypt.hash(userPassword, 10);
|
||||||
|
this.logger.log(`[DEBUG] 입력 해시: ${inputHash}, DB 해시: ${user.userPw}`);
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
this.logger.warn(`[LOGIN] 비밀번호 불일치 - userId: ${userId}`);
|
this.logger.warn(`[LOGIN] 비밀번호 불일치 - userId: ${userId}`);
|
||||||
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
|
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
|
|||||||
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
|
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
|
||||||
export const EXCLUDED_COW_IDS = [
|
export const EXCLUDED_COW_IDS = [
|
||||||
'KOR002191642861',
|
'KOR002191642861',
|
||||||
// 일치인데 정보가 없음 / 김정태님 유전체 내역 빠짐 1두
|
// 일치인데 정보가 없음
|
||||||
|
// 김정태님 유전체 내역 빠짐 1두
|
||||||
|
// 근데 유전자 검사내역은 있음
|
||||||
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
|
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
|
||||||
|
// 분석불가로 넣으면 유전자가 조회가 안됨
|
||||||
|
// 유전자가 조회될수 있는 조건은 불일치와 이력제부재만 가능 // 분석불가는 아예안되는듯
|
||||||
|
|
||||||
];
|
];
|
||||||
//=================================================================================================================
|
//=================================================================================================================
|
||||||
@@ -60,13 +64,7 @@ export function isValidGenomeAnalysis(
|
|||||||
chipDamName: string | null | undefined,
|
chipDamName: string | null | undefined,
|
||||||
cowId?: string | null,
|
cowId?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
// 1. 아비 일치 확인
|
// 개별 제외 개체만 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
|
||||||
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
|
||||||
|
|
||||||
// 2. 어미 제외 조건 확인
|
|
||||||
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
|
|
||||||
|
|
||||||
// 3. 개별 제외 개체 확인
|
|
||||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -74,15 +72,15 @@ export function isValidGenomeAnalysis(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
|
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
|
||||||
* 주의: cowId 제외 목록은 SQL에 포함되지 않으므로 별도 필터링 필요
|
* 부/모 불일치여도 유전자 데이터 있으면 표시하므로 조건 제거
|
||||||
*
|
*
|
||||||
* @param alias - 테이블 별칭 (예: 'request', 'genome')
|
* @param alias - 테이블 별칭 (예: 'request', 'genome')
|
||||||
* @returns SQL 조건 문자열
|
* @returns SQL 조건 문자열 (항상 true)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
|
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
|
||||||
*/
|
*/
|
||||||
export function getValidGenomeConditionSQL(alias: string): string {
|
export function getValidGenomeConditionSQL(alias: string): string {
|
||||||
const damConditions = INVALID_CHIP_DAM_NAMES.map(name => `${alias}.chipDamName != '${name}'`).join(' AND ');
|
// 부/모 불일치 조건 제거 - 유전자 데이터 있으면 모두 표시
|
||||||
return `${alias}.chipSireName = '${VALID_CHIP_SIRE_NAME}' AND (${alias}.chipDamName IS NULL OR (${damConditions}))`;
|
return '1=1';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,32 @@
|
|||||||
* @constant
|
* @constant
|
||||||
*/
|
*/
|
||||||
export const MPT_NORMAL_RANGES = {
|
export const MPT_NORMAL_RANGES = {
|
||||||
|
// ========== 에너지 카테고리 ==========
|
||||||
|
/**
|
||||||
|
* 혈당 (Glucose)
|
||||||
|
* 단위: mg/dL
|
||||||
|
*/
|
||||||
|
glucose: { min: 40, max: 84 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜레스테롤 (Cholesterol)
|
||||||
|
* 단위: mg/dL
|
||||||
|
*/
|
||||||
|
cholesterol: { min: 74, max: 252 },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유리지방산 (NEFA)
|
||||||
|
* 단위: μEq/L
|
||||||
|
*/
|
||||||
|
nefa: { min: 115, max: 660 },
|
||||||
|
|
||||||
|
// ========== 단백질 카테고리 ==========
|
||||||
|
/**
|
||||||
|
* 총단백질 (Total Protein)
|
||||||
|
* 단위: g/dL
|
||||||
|
*/
|
||||||
|
totalProtein: { min: 6.2, max: 7.7 },
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 알부민 (Albumin)
|
* 알부민 (Albumin)
|
||||||
* 단위: g/dL
|
* 단위: g/dL
|
||||||
@@ -17,7 +43,7 @@ export const MPT_NORMAL_RANGES = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 총 글로불린 (Total Globulin)
|
* 총 글로불린 (Total Globulin)
|
||||||
* 단위: g/L
|
* 단위: g/dL
|
||||||
*/
|
*/
|
||||||
totalGlobulin: { min: 9.1, max: 36.1 },
|
totalGlobulin: { min: 9.1, max: 36.1 },
|
||||||
|
|
||||||
@@ -74,6 +100,13 @@ export const MPT_NORMAL_RANGES = {
|
|||||||
* 단위: mg/dL
|
* 단위: mg/dL
|
||||||
*/
|
*/
|
||||||
magnesium: { min: 1.6, max: 3.3 },
|
magnesium: { min: 1.6, max: 3.3 },
|
||||||
|
|
||||||
|
// ========== 기타 카테고리 ==========
|
||||||
|
/**
|
||||||
|
* 크레아티닌 (Creatinine)
|
||||||
|
* 단위: mg/dL
|
||||||
|
*/
|
||||||
|
creatinine: { min: 1.0, max: 1.3 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -542,10 +542,10 @@ export function CategoryEvaluationCard({
|
|||||||
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
|
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
|
||||||
if (!selectedTrait) return null
|
if (!selectedTrait) return null
|
||||||
return (
|
return (
|
||||||
<div className="mx-4 mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
|
<div className="mx-2 mb-4 p-3 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
|
||||||
{/* 헤더: 형질명 + 닫기 */}
|
{/* 헤더: 형질명 + 닫기 */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
|
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-sm font-bold rounded-full">
|
||||||
{selectedTrait.shortName} 조회 기준
|
{selectedTrait.shortName} 조회 기준
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -556,25 +556,25 @@ export function CategoryEvaluationCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 3개 카드 그리드 */}
|
{/* 3개 카드 그리드 */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{/* 보은군 카드 */}
|
{/* 보은군 카드 */}
|
||||||
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-emerald-300 shadow-sm">
|
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-emerald-300 shadow-sm">
|
||||||
<span className="text-xs text-muted-foreground mb-1 font-medium">보은군 평균</span>
|
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap">보은군 평균</span>
|
||||||
<span className="text-lg font-bold text-emerald-600">
|
<span className="text-xl font-bold text-emerald-600">
|
||||||
{selectedTrait.regionEpd?.toFixed(2) ?? '-'}
|
{selectedTrait.regionEpd?.toFixed(2) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 농가 카드 */}
|
{/* 농가 카드 */}
|
||||||
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
||||||
<span className="text-xs text-muted-foreground mb-1 font-medium">농가 평균</span>
|
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap">농가 평균</span>
|
||||||
<span className="text-lg font-bold text-[#1F3A8F]">
|
<span className="text-xl font-bold text-[#1F3A8F]">
|
||||||
{selectedTrait.farmEpd?.toFixed(2) ?? '-'}
|
{selectedTrait.farmEpd?.toFixed(2) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 개체 카드 */}
|
{/* 개체 카드 */}
|
||||||
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1482B0]/30 shadow-sm">
|
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-[#1482B0]/30 shadow-sm">
|
||||||
<span className="text-xs text-muted-foreground mb-1 font-medium">내 개체</span>
|
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap">내 개체</span>
|
||||||
<span className="text-lg font-bold text-[#1482B0]">
|
<span className="text-xl font-bold text-[#1482B0]">
|
||||||
{selectedTrait.epd?.toFixed(2) ?? '-'}
|
{selectedTrait.epd?.toFixed(2) ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -811,10 +811,10 @@ export function NormalDistributionChart({
|
|||||||
return Math.max(chartX + halfWidth + 5, Math.min(x, chartX + chartWidth - halfWidth - 5))
|
return Math.max(chartX + halfWidth + 5, Math.min(x, chartX + chartWidth - halfWidth - 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배지 크기 (더 크게)
|
// 배지 크기 (더 크게) - 모바일에서 텍스트가 한 줄로 나오도록 너비 확보
|
||||||
const cowBadgeW = isMobile ? 105 : 135
|
const cowBadgeW = isMobile ? 105 : 135
|
||||||
const avgBadgeW = isMobile ? 100 : 135
|
const avgBadgeW = isMobile ? 118 : 135
|
||||||
const regionBadgeW = isMobile ? 105 : 145
|
const regionBadgeW = isMobile ? 125 : 145
|
||||||
const badgeH = isMobile ? 42 : 48
|
const badgeH = isMobile ? 42 : 48
|
||||||
|
|
||||||
// Y 위치 계산 - 겹치지 않게 배치
|
// Y 위치 계산 - 겹치지 않게 배치
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { GenomeTrait } from "@/types/genome.types"
|
|||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowUp,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Download,
|
Download,
|
||||||
@@ -156,6 +157,7 @@ export default function CowOverviewPage() {
|
|||||||
const [geneDataLoading, setGeneDataLoading] = useState(false) // 유전자 데이터 로딩 중
|
const [geneDataLoading, setGeneDataLoading] = useState(false) // 유전자 데이터 로딩 중
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [activeTab, setActiveTab] = useState<string>('genome')
|
const [activeTab, setActiveTab] = useState<string>('genome')
|
||||||
|
const [showScrollTop, setShowScrollTop] = useState(false)
|
||||||
|
|
||||||
// 검사 상태
|
// 검사 상태
|
||||||
const [hasGenomeData, setHasGenomeData] = useState(false)
|
const [hasGenomeData, setHasGenomeData] = useState(false)
|
||||||
@@ -220,6 +222,20 @@ export default function CowOverviewPage() {
|
|||||||
}
|
}
|
||||||
}, [filters.isActive, firstPinnedTrait, chartFilterTrait])
|
}, [filters.isActive, firstPinnedTrait, chartFilterTrait])
|
||||||
|
|
||||||
|
// 스크롤 투 탑 버튼 표시 여부
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setShowScrollTop(window.scrollY > 400)
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 맨 위로 스크롤
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
// 유전자 탭 필터 상태
|
// 유전자 탭 필터 상태
|
||||||
const [geneSearchInput, setGeneSearchInput] = useState('') // 실시간 입력값
|
const [geneSearchInput, setGeneSearchInput] = useState('') // 실시간 입력값
|
||||||
const [geneSearchKeyword, setGeneSearchKeyword] = useState('') // 디바운스된 검색값
|
const [geneSearchKeyword, setGeneSearchKeyword] = useState('') // 디바운스된 검색값
|
||||||
@@ -1938,6 +1954,17 @@ export default function CowOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 플로팅 맨 위로 버튼 */}
|
||||||
|
{showScrollTop && (
|
||||||
|
<button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className="fixed bottom-6 right-6 z-50 w-12 h-12 bg-primary text-white rounded-full shadow-lg hover:bg-primary/90 transition-all duration-300 flex items-center justify-center hover:scale-110 active:scale-95"
|
||||||
|
aria-label="맨 위로"
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -973,7 +973,7 @@ function MyCowContent() {
|
|||||||
})() : '-'}
|
})() : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="cow-table-cell">
|
<td className="cow-table-cell">
|
||||||
{cow.cowSex === "암" ? "암소" : "수소"}
|
{cow.cowSex === "수" ? "수소" : "암소"}
|
||||||
</td>
|
</td>
|
||||||
<td className="cow-table-cell">
|
<td className="cow-table-cell">
|
||||||
{cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'}
|
{cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'}
|
||||||
@@ -1132,7 +1132,7 @@ function MyCowContent() {
|
|||||||
<div className="md:hidden space-y-2.5">
|
<div className="md:hidden space-y-2.5">
|
||||||
{paginatedCows.map((cow) => {
|
{paginatedCows.map((cow) => {
|
||||||
const rank = getRank(cow)
|
const rank = getRank(cow)
|
||||||
const isFemale = cow.cowSex === '암'
|
const isFemale = cow.cowSex !== '수'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
162
frontend/src/app/demo/floating-button/page.tsx
Normal file
162
frontend/src/app/demo/floating-button/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ArrowUp, ChevronUp, ChevronsUp, MoveUp } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function FloatingButtonDemo() {
|
||||||
|
const [selectedStyle, setSelectedStyle] = useState<number>(1)
|
||||||
|
|
||||||
|
const styles = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '기본 프라이머리',
|
||||||
|
className: 'bg-primary text-white shadow-lg hover:bg-primary/90',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '그라데이션 블루',
|
||||||
|
className: 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-xl shadow-blue-500/30 hover:shadow-blue-500/50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '글래스모피즘',
|
||||||
|
className: 'bg-white/80 backdrop-blur-md border border-white/50 text-slate-700 shadow-lg hover:bg-white/90',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '아웃라인',
|
||||||
|
className: 'bg-white border-2 border-primary text-primary hover:bg-primary hover:text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: '미니멀 다크',
|
||||||
|
className: 'bg-slate-800 text-white shadow-lg hover:bg-slate-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: '소프트 그레이',
|
||||||
|
className: 'bg-slate-100 text-slate-600 shadow-md hover:bg-slate-200 hover:text-slate-800',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: '그린 그라데이션',
|
||||||
|
className: 'bg-gradient-to-r from-emerald-500 to-teal-600 text-white shadow-xl shadow-emerald-500/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: '네온 퍼플',
|
||||||
|
className: 'bg-violet-600 text-white shadow-xl shadow-violet-500/40 hover:shadow-violet-500/60',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: '오렌지 웜',
|
||||||
|
className: 'bg-gradient-to-r from-orange-400 to-rose-500 text-white shadow-xl shadow-orange-500/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
name: '심플 화이트',
|
||||||
|
className: 'bg-white text-slate-500 shadow-xl border border-slate-200 hover:text-primary hover:border-primary',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const icons = [
|
||||||
|
{ id: 'arrow', icon: ArrowUp, name: 'ArrowUp' },
|
||||||
|
{ id: 'chevron', icon: ChevronUp, name: 'ChevronUp' },
|
||||||
|
{ id: 'chevrons', icon: ChevronsUp, name: 'ChevronsUp' },
|
||||||
|
{ id: 'move', icon: MoveUp, name: 'MoveUp' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const [selectedIcon, setSelectedIcon] = useState('arrow')
|
||||||
|
const SelectedIconComponent = icons.find(i => i.id === selectedIcon)?.icon || ArrowUp
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">플로팅 버튼 스타일 데모</h1>
|
||||||
|
<p className="text-slate-600 mb-8">원하는 스타일을 선택해보세요. 우하단에 실제 버튼이 표시됩니다.</p>
|
||||||
|
|
||||||
|
{/* 아이콘 선택 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">아이콘 선택</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{icons.map((icon) => {
|
||||||
|
const IconComp = icon.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={icon.id}
|
||||||
|
onClick={() => setSelectedIcon(icon.id)}
|
||||||
|
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
|
||||||
|
selectedIcon === icon.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-slate-200 bg-white hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconComp className="w-6 h-6" />
|
||||||
|
<span className="text-xs text-slate-600">{icon.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스타일 선택 그리드 */}
|
||||||
|
<h2 className="text-lg font-semibold mb-4">스타일 선택</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-12">
|
||||||
|
{styles.map((style) => (
|
||||||
|
<button
|
||||||
|
key={style.id}
|
||||||
|
onClick={() => setSelectedStyle(style.id)}
|
||||||
|
className={`p-4 rounded-xl border-2 transition-all ${
|
||||||
|
selectedStyle === style.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-slate-200 bg-white hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${style.className}`}
|
||||||
|
>
|
||||||
|
<SelectedIconComponent className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-center">{style.name}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 코드 표시 */}
|
||||||
|
<div className="bg-slate-900 rounded-xl p-6 mb-8">
|
||||||
|
<p className="text-slate-400 text-sm mb-2">선택한 스타일 코드:</p>
|
||||||
|
<code className="text-green-400 text-sm break-all">
|
||||||
|
{`className="${styles.find(s => s.id === selectedStyle)?.className}"`}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스크롤 테스트용 더미 콘텐츠 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">스크롤 테스트</h2>
|
||||||
|
<p className="text-slate-600">아래로 스크롤해서 버튼 동작을 확인하세요.</p>
|
||||||
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
|
<div key={i} className="p-6 bg-white rounded-xl border border-slate-200">
|
||||||
|
<h3 className="font-semibold mb-2">더미 콘텐츠 #{i + 1}</h3>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실제 플로팅 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
className={`fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full flex items-center justify-center transition-all duration-300 hover:scale-110 active:scale-95 ${
|
||||||
|
styles.find(s => s.id === selectedStyle)?.className
|
||||||
|
}`}
|
||||||
|
aria-label="맨 위로"
|
||||||
|
>
|
||||||
|
<SelectedIconComponent className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ function SortableTraitItem({
|
|||||||
<GripVertical className="w-4 h-4 text-slate-400" />
|
<GripVertical className="w-4 h-4 text-slate-400" />
|
||||||
</button>
|
</button>
|
||||||
{isPinned && <Pin className="w-3 h-3 text-amber-500" fill="currentColor" />}
|
{isPinned && <Pin className="w-3 h-3 text-amber-500" fill="currentColor" />}
|
||||||
<span className="font-medium text-sm min-w-0 truncate">{id}</span>
|
<span className="font-medium text-sm min-w-0 truncate">{TRAIT_DISPLAY_NAMES[id] || id}</span>
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -221,6 +221,30 @@ const TRAIT_DESCRIPTIONS: Record<string, string> = {
|
|||||||
'갈비rate': '전체 대비 갈비 비율',
|
'갈비rate': '전체 대비 갈비 비율',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 형질 표시 이름 (DB 키 -> 화면 표시용)
|
||||||
|
const TRAIT_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
'안심weight': '안심중량',
|
||||||
|
'등심weight': '등심중량',
|
||||||
|
'채끝weight': '채끝중량',
|
||||||
|
'목심weight': '목심중량',
|
||||||
|
'앞다리weight': '앞다리중량',
|
||||||
|
'우둔weight': '우둔중량',
|
||||||
|
'설도weight': '설도중량',
|
||||||
|
'사태weight': '사태중량',
|
||||||
|
'양지weight': '양지중량',
|
||||||
|
'갈비weight': '갈비중량',
|
||||||
|
'안심rate': '안심비율',
|
||||||
|
'등심rate': '등심비율',
|
||||||
|
'채끝rate': '채끝비율',
|
||||||
|
'목심rate': '목심비율',
|
||||||
|
'앞다리rate': '앞다리비율',
|
||||||
|
'우둔rate': '우둔비율',
|
||||||
|
'설도rate': '설도비율',
|
||||||
|
'사태rate': '사태비율',
|
||||||
|
'양지rate': '양지비율',
|
||||||
|
'갈비rate': '갈비비율',
|
||||||
|
}
|
||||||
|
|
||||||
type TraitName = keyof typeof DEFAULT_FILTER_SETTINGS.traitWeights
|
type TraitName = keyof typeof DEFAULT_FILTER_SETTINGS.traitWeights
|
||||||
|
|
||||||
interface GlobalFilterDialogProps {
|
interface GlobalFilterDialogProps {
|
||||||
@@ -973,7 +997,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
|||||||
>
|
>
|
||||||
<Checkbox checked={isSelected} />
|
<Checkbox checked={isSelected} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="font-medium text-sm">{trait}</span>
|
<span className="font-medium text-sm">{TRAIT_DISPLAY_NAMES[trait] || trait}</span>
|
||||||
{TRAIT_DESCRIPTIONS[trait] && (
|
{TRAIT_DESCRIPTIONS[trait] && (
|
||||||
<span className="text-xs text-muted-foreground ml-2">{TRAIT_DESCRIPTIONS[trait]}</span>
|
<span className="text-xs text-muted-foreground ml-2">{TRAIT_DESCRIPTIONS[trait]}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ function SidebarTrigger({
|
|||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn("size-7 md:size-7", isMobile && "size-9", className)}
|
className={cn("size-7 md:size-7", isMobile && "size-11", className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
toggleSidebar()
|
toggleSidebar()
|
||||||
@@ -274,7 +274,7 @@ function SidebarTrigger({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<Menu className="h-6 w-6" />
|
<Menu className="h-7 w-7" />
|
||||||
) : (
|
) : (
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,25 +16,25 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
|||||||
// 에너지 카테고리
|
// 에너지 카테고리
|
||||||
glucose: {
|
glucose: {
|
||||||
name: '혈당',
|
name: '혈당',
|
||||||
upperLimit: 72,
|
upperLimit: 84,
|
||||||
lowerLimit: 46.9,
|
lowerLimit: 40,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '에너지',
|
category: '에너지',
|
||||||
description: '에너지 대사 상태 지표',
|
description: '에너지 대사 상태 지표',
|
||||||
},
|
},
|
||||||
cholesterol: {
|
cholesterol: {
|
||||||
name: '콜레스테롤',
|
name: '콜레스테롤',
|
||||||
upperLimit: 169,
|
upperLimit: 252,
|
||||||
lowerLimit: 117,
|
lowerLimit: 74,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '에너지',
|
category: '에너지',
|
||||||
description: '혈액 내 콜레스테롤 수치',
|
description: '혈액 내 콜레스테롤 수치',
|
||||||
},
|
},
|
||||||
nefa: {
|
nefa: {
|
||||||
name: '유리지방산(NEFA)',
|
name: '유리지방산(NEFA)',
|
||||||
upperLimit: 382,
|
upperLimit: 660,
|
||||||
lowerLimit: 118,
|
lowerLimit: 115,
|
||||||
unit: 'uEq/L',
|
unit: 'μEq/L',
|
||||||
category: '에너지',
|
category: '에너지',
|
||||||
description: '혈액 내 유리지방산 수치',
|
description: '혈액 내 유리지방산 수치',
|
||||||
},
|
},
|
||||||
@@ -50,8 +50,8 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
|||||||
// 단백질 카테고리
|
// 단백질 카테고리
|
||||||
totalProtein: {
|
totalProtein: {
|
||||||
name: '총단백질',
|
name: '총단백질',
|
||||||
upperLimit: 8.5,
|
upperLimit: 7.7,
|
||||||
lowerLimit: 6.5,
|
lowerLimit: 6.2,
|
||||||
unit: 'g/dL',
|
unit: 'g/dL',
|
||||||
category: '단백질',
|
category: '단백질',
|
||||||
description: '혈액 내 총단백질 수치',
|
description: '혈액 내 총단백질 수치',
|
||||||
@@ -68,7 +68,7 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
|||||||
name: '총글로불린',
|
name: '총글로불린',
|
||||||
upperLimit: 36.1,
|
upperLimit: 36.1,
|
||||||
lowerLimit: 9.1,
|
lowerLimit: 9.1,
|
||||||
unit: 'g/L',
|
unit: 'g/dL',
|
||||||
category: '단백질',
|
category: '단백질',
|
||||||
description: '혈액 내 총글로불린 수치',
|
description: '혈액 내 총글로불린 수치',
|
||||||
},
|
},
|
||||||
@@ -152,7 +152,7 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
|||||||
// 기타 카테고리
|
// 기타 카테고리
|
||||||
creatinine: {
|
creatinine: {
|
||||||
name: '크레아티닌',
|
name: '크레아티닌',
|
||||||
upperLimit: 2.0,
|
upperLimit: 1.3,
|
||||||
lowerLimit: 1.0,
|
lowerLimit: 1.0,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '기타',
|
category: '기타',
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ export interface GeneSummary {
|
|||||||
heterozygousCount: number;
|
heterozygousCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 마커 정보 타입
|
||||||
|
*/
|
||||||
|
export interface MarkerModel {
|
||||||
|
markerNo: number;
|
||||||
|
markerNm: string;
|
||||||
|
markerType: string; // 'QTY' | 'QLT'
|
||||||
|
markerTypeCd?: string; // 'QTY' | 'QLT' (별칭)
|
||||||
|
markerDesc?: string;
|
||||||
|
description?: string;
|
||||||
|
relatedTrait?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const geneApi = {
|
export const geneApi = {
|
||||||
/**
|
/**
|
||||||
* 개체식별번호로 유전자 상세 정보 조회
|
* 개체식별번호로 유전자 상세 정보 조회
|
||||||
@@ -87,4 +100,34 @@ export const geneApi = {
|
|||||||
createBulk: async (dataList: Partial<GeneDetail>[]): Promise<GeneDetail[]> => {
|
createBulk: async (dataList: Partial<GeneDetail>[]): Promise<GeneDetail[]> => {
|
||||||
return await apiClient.post('/gene/bulk', dataList);
|
return await apiClient.post('/gene/bulk', dataList);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유전자 타입별 마커 목록 조회
|
||||||
|
* GET /gene/markers/:type
|
||||||
|
* TODO: 백엔드 API 구현 후 연동 필요
|
||||||
|
*/
|
||||||
|
getGenesByType: async (type: 'QTY' | 'QLT'): Promise<MarkerModel[]> => {
|
||||||
|
try {
|
||||||
|
return await apiClient.get(`/gene/markers/${type}`);
|
||||||
|
} catch {
|
||||||
|
// API 미구현 시 빈 배열 반환
|
||||||
|
console.warn(`[Gene API] getGenesByType(${type}) - API 미구현, 빈 배열 반환`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 마커 목록 조회
|
||||||
|
* GET /gene/markers
|
||||||
|
* TODO: 백엔드 API 구현 후 연동 필요
|
||||||
|
*/
|
||||||
|
getAllMarkers: async (): Promise<MarkerModel[]> => {
|
||||||
|
try {
|
||||||
|
return await apiClient.get('/gene/markers');
|
||||||
|
} catch {
|
||||||
|
// API 미구현 시 빈 배열 반환
|
||||||
|
console.warn('[Gene API] getAllMarkers() - API 미구현, 빈 배열 반환');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,13 +54,7 @@ export function isValidGenomeAnalysis(
|
|||||||
chipDamName: string | null | undefined,
|
chipDamName: string | null | undefined,
|
||||||
cowId?: string | null,
|
cowId?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
// 1. 아비 일치 확인
|
// 개별 제외 개체만 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
|
||||||
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
|
||||||
|
|
||||||
// 2. 어미 제외 조건 확인
|
|
||||||
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
|
|
||||||
|
|
||||||
// 3. 개별 제외 개체 확인
|
|
||||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
|
|||||||
analysisIndex: "GENE",
|
analysisIndex: "GENE",
|
||||||
selectedGenes: [],
|
selectedGenes: [],
|
||||||
pinnedGenes: [],
|
pinnedGenes: [],
|
||||||
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "체장", "체고", "흉위"],
|
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "등심weight", "체장", "체고"],
|
||||||
pinnedTraits: [],
|
pinnedTraits: [],
|
||||||
traitWeights: {
|
traitWeights: {
|
||||||
// 성장형질 (점수: 1 ~ 10, 미선택 시 0)
|
// 성장형질 (점수: 1 ~ 10, 미선택 시 0)
|
||||||
@@ -150,11 +150,11 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
|
|||||||
요각폭: 0,
|
요각폭: 0,
|
||||||
좌골폭: 0,
|
좌골폭: 0,
|
||||||
곤폭: 0,
|
곤폭: 0,
|
||||||
흉위: 1,
|
흉위: 0,
|
||||||
|
|
||||||
// 부위별무게 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치
|
// 부위별무게 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치
|
||||||
안심weight: 0,
|
안심weight: 0,
|
||||||
등심weight: 0,
|
등심weight: 1,
|
||||||
채끝weight: 0,
|
채끝weight: 0,
|
||||||
목심weight: 0,
|
목심weight: 0,
|
||||||
앞다리weight: 0,
|
앞다리weight: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user