필터 및 화면 수정사항 반영

This commit is contained in:
2025-12-18 17:01:24 +09:00
parent 4d0f8f3b6b
commit abc2f20495
19 changed files with 417 additions and 5574 deletions

File diff suppressed because it is too large Load Diff

View 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

View File

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

View File

@@ -59,6 +59,8 @@ export class AuthService {
}
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) {
this.logger.warn(`[LOGIN] 비밀번호 불일치 - userId: ${userId}`);
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');

View File

@@ -26,8 +26,12 @@ export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
export const EXCLUDED_COW_IDS = [
'KOR002191642861',
// 일치인데 정보가 없음 / 김정태님 유전체 내역 빠짐 1두
// 일치인데 정보가 없음
// 김정태님 유전체 내역 빠짐 1두
// 근데 유전자 검사내역은 있음
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
// 분석불가로 넣으면 유전자가 조회가 안됨
// 유전자가 조회될수 있는 조건은 불일치와 이력제부재만 가능 // 분석불가는 아예안되는듯
];
//=================================================================================================================
@@ -60,13 +64,7 @@ export function isValidGenomeAnalysis(
chipDamName: string | null | undefined,
cowId?: string | null,
): 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;
return true;
@@ -74,15 +72,15 @@ export function isValidGenomeAnalysis(
/**
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
* 주의: cowId 제외 목록은 SQL에 포함되지 않으므로 별도 필터링 필요
* 부/모 불일치여도 유전자 데이터 있으면 표시하므로 조건 제거
*
* @param alias - 테이블 별칭 (예: 'request', 'genome')
* @returns SQL 조건 문자열
* @returns SQL 조건 문자열 (항상 true)
*
* @example
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
*/
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';
}

View File

@@ -9,6 +9,32 @@
* @constant
*/
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)
* 단위: g/dL
@@ -17,7 +43,7 @@ export const MPT_NORMAL_RANGES = {
/**
* 총 글로불린 (Total Globulin)
* 단위: g/L
* 단위: g/dL
*/
totalGlobulin: { min: 9.1, max: 36.1 },
@@ -74,6 +100,13 @@ export const MPT_NORMAL_RANGES = {
* 단위: mg/dL
*/
magnesium: { min: 1.6, max: 3.3 },
// ========== 기타 카테고리 ==========
/**
* 크레아티닌 (Creatinine)
* 단위: mg/dL
*/
creatinine: { min: 1.0, max: 1.3 },
} as const;
/**

View File

@@ -542,10 +542,10 @@ export function CategoryEvaluationCard({
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
if (!selectedTrait) return null
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">
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
<div className="flex items-center justify-between mb-3">
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-sm font-bold rounded-full">
{selectedTrait.shortName}
</span>
<button
@@ -556,25 +556,25 @@ export function CategoryEvaluationCard({
</button>
</div>
{/* 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">
<span className="text-xs text-muted-foreground mb-1 font-medium"> </span>
<span className="text-lg font-bold text-emerald-600">
<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-slate-600 mb-1 font-semibold whitespace-nowrap"> </span>
<span className="text-xl font-bold text-emerald-600">
{selectedTrait.regionEpd?.toFixed(2) ?? '-'}
</span>
</div>
{/* 농가 카드 */}
<div className="flex flex-col items-center justify-center p-3 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-lg font-bold text-[#1F3A8F]">
<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-slate-600 mb-1 font-semibold whitespace-nowrap"> </span>
<span className="text-xl font-bold text-[#1F3A8F]">
{selectedTrait.farmEpd?.toFixed(2) ?? '-'}
</span>
</div>
{/* 개체 카드 */}
<div className="flex flex-col items-center justify-center p-3 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-lg font-bold text-[#1482B0]">
<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-slate-600 mb-1 font-semibold whitespace-nowrap"> </span>
<span className="text-xl font-bold text-[#1482B0]">
{selectedTrait.epd?.toFixed(2) ?? '-'}
</span>
</div>

View File

@@ -811,10 +811,10 @@ export function NormalDistributionChart({
return Math.max(chartX + halfWidth + 5, Math.min(x, chartX + chartWidth - halfWidth - 5))
}
// 배지 크기 (더 크게)
// 배지 크기 (더 크게) - 모바일에서 텍스트가 한 줄로 나오도록 너비 확보
const cowBadgeW = isMobile ? 105 : 135
const avgBadgeW = isMobile ? 100 : 135
const regionBadgeW = isMobile ? 105 : 145
const avgBadgeW = isMobile ? 118 : 135
const regionBadgeW = isMobile ? 125 : 145
const badgeH = isMobile ? 42 : 48
// Y 위치 계산 - 겹치지 않게 배치

View File

@@ -17,6 +17,7 @@ import { GenomeTrait } from "@/types/genome.types"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import {
ArrowLeft,
ArrowUp,
BarChart3,
CheckCircle2,
Download,
@@ -156,6 +157,7 @@ export default function CowOverviewPage() {
const [geneDataLoading, setGeneDataLoading] = useState(false) // 유전자 데이터 로딩 중
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<string>('genome')
const [showScrollTop, setShowScrollTop] = useState(false)
// 검사 상태
const [hasGenomeData, setHasGenomeData] = useState(false)
@@ -220,6 +222,20 @@ export default function CowOverviewPage() {
}
}, [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 [geneSearchKeyword, setGeneSearchKeyword] = useState('') // 디바운스된 검색값
@@ -1938,6 +1954,17 @@ export default function CowOverviewPage() {
</div>
</DialogContent>
</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>
</AuthGuard>
)

View File

@@ -973,7 +973,7 @@ function MyCowContent() {
})() : '-'}
</td>
<td className="cow-table-cell">
{cow.cowSex === "" ? "소" : "소"}
{cow.cowSex === "" ? "소" : "소"}
</td>
<td className="cow-table-cell">
{cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'}
@@ -1132,7 +1132,7 @@ function MyCowContent() {
<div className="md:hidden space-y-2.5">
{paginatedCows.map((cow) => {
const rank = getRank(cow)
const isFemale = cow.cowSex === ''
const isFemale = cow.cowSex !== ''
return (
<div

View 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>
)
}

View File

@@ -134,7 +134,7 @@ function SortableTraitItem({
<GripVertical className="w-4 h-4 text-slate-400" />
</button>
{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">
<Button
variant="outline"
@@ -221,6 +221,30 @@ const TRAIT_DESCRIPTIONS: Record<string, string> = {
'갈비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
interface GlobalFilterDialogProps {
@@ -973,7 +997,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
>
<Checkbox checked={isSelected} />
<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] && (
<span className="text-xs text-muted-foreground ml-2">{TRAIT_DESCRIPTIONS[trait]}</span>
)}

View File

@@ -266,7 +266,7 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
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)
toggleSidebar()
@@ -274,7 +274,7 @@ function SidebarTrigger({
{...props}
>
{isMobile ? (
<Menu className="h-6 w-6" />
<Menu className="h-7 w-7" />
) : (
<PanelLeftIcon />
)}

View File

@@ -16,25 +16,25 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
// 에너지 카테고리
glucose: {
name: '혈당',
upperLimit: 72,
lowerLimit: 46.9,
upperLimit: 84,
lowerLimit: 40,
unit: 'mg/dL',
category: '에너지',
description: '에너지 대사 상태 지표',
},
cholesterol: {
name: '콜레스테롤',
upperLimit: 169,
lowerLimit: 117,
upperLimit: 252,
lowerLimit: 74,
unit: 'mg/dL',
category: '에너지',
description: '혈액 내 콜레스테롤 수치',
},
nefa: {
name: '유리지방산(NEFA)',
upperLimit: 382,
lowerLimit: 118,
unit: 'uEq/L',
upperLimit: 660,
lowerLimit: 115,
unit: 'μEq/L',
category: '에너지',
description: '혈액 내 유리지방산 수치',
},
@@ -50,8 +50,8 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
// 단백질 카테고리
totalProtein: {
name: '총단백질',
upperLimit: 8.5,
lowerLimit: 6.5,
upperLimit: 7.7,
lowerLimit: 6.2,
unit: 'g/dL',
category: '단백질',
description: '혈액 내 총단백질 수치',
@@ -68,7 +68,7 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
name: '총글로불린',
upperLimit: 36.1,
lowerLimit: 9.1,
unit: 'g/L',
unit: 'g/dL',
category: '단백질',
description: '혈액 내 총글로불린 수치',
},
@@ -152,7 +152,7 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
// 기타 카테고리
creatinine: {
name: '크레아티닌',
upperLimit: 2.0,
upperLimit: 1.3,
lowerLimit: 1.0,
unit: 'mg/dL',
category: '기타',

View File

@@ -39,6 +39,19 @@ export interface GeneSummary {
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 = {
/**
* 개체식별번호로 유전자 상세 정보 조회
@@ -87,4 +100,34 @@ export const geneApi = {
createBulk: async (dataList: Partial<GeneDetail>[]): Promise<GeneDetail[]> => {
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 [];
}
},
};

View File

@@ -54,13 +54,7 @@ export function isValidGenomeAnalysis(
chipDamName: string | null | undefined,
cowId?: string | null,
): 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;
return true;

View File

@@ -128,7 +128,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
analysisIndex: "GENE",
selectedGenes: [],
pinnedGenes: [],
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "체장", "체", "흉위"],
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "등심weight", "체", "체고"],
pinnedTraits: [],
traitWeights: {
// 성장형질 (점수: 1 ~ 10, 미선택 시 0)
@@ -150,11 +150,11 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
요각폭: 0,
좌골폭: 0,
곤폭: 0,
흉위: 1,
흉위: 0,
// 부위별무게 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치
안심weight: 0,
등심weight: 0,
등심weight: 1,
채끝weight: 0,
목심weight: 0,
앞다리weight: 0,