개체분석 상태 값 수정
This commit is contained in:
146
backend/doc/검사가능조건요약.md
Normal file
146
backend/doc/검사가능조건요약.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 유전체/유전자 검사 가능 조건 요약
|
||||||
|
|
||||||
|
## 1. DB 상태값 정의
|
||||||
|
|
||||||
|
### chipSireName (아비명)
|
||||||
|
|
||||||
|
| DB 값 | 의미 | 분석 가능 여부 |
|
||||||
|
|-------|------|----------------|
|
||||||
|
| `일치` | 친자감별 일치 | 가능 |
|
||||||
|
| `불일치` | 친자감별 불일치 | 유전체 불가 / 유전자 가능 |
|
||||||
|
| `분석불가` | 모근 오염/불량 등 기타 사유 | 불가 |
|
||||||
|
| `정보없음` | 개체 식별번호/형식 오류 | 불가 |
|
||||||
|
| `null` | 미분석 (의뢰 없음) | - 표시 |
|
||||||
|
|
||||||
|
## - 아비명 가능한 개체에 대해서 어미명 판단 진행
|
||||||
|
|
||||||
|
### chipDamName (어미명)
|
||||||
|
|
||||||
|
| DB 값 | 의미 | 분석 가능 여부 |
|
||||||
|
|-------|------|----------------|
|
||||||
|
| `일치` | 친자감별 일치 | 통과 |
|
||||||
|
| `불일치` | 친자감별 불일치 | 유전체 불가 / 유전자 가능 |
|
||||||
|
| `이력제부재` | 모 이력제 정보 없음 | 유전체 불가 / 유전자 가능 |
|
||||||
|
| `정보없음` | 정보 없음 | 통과 |
|
||||||
|
| `null` | 정보 없음 | 통과 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 탭별 검사 가능 조건
|
||||||
|
|
||||||
|
### 유전체 탭
|
||||||
|
```
|
||||||
|
유효 조건 (모두 충족해야 함):
|
||||||
|
1. chipSireName === '일치'
|
||||||
|
2. chipDamName !== '불일치'
|
||||||
|
3. chipDamName !== '이력제부재'
|
||||||
|
4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 유전자 탭
|
||||||
|
```
|
||||||
|
유효 조건:
|
||||||
|
1. chipSireName !== '분석불가'
|
||||||
|
2. chipSireName !== '정보없음'
|
||||||
|
3. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
||||||
|
|
||||||
|
※ 불일치/이력제부재도 유전자 데이터가 있으면 표시 가능
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 개체 목록 배지 표시 (unavailableReason)
|
||||||
|
|
||||||
|
### 분석일자 컬럼
|
||||||
|
|
||||||
|
| unavailableReason | 배지 색상 | 표시 텍스트 |
|
||||||
|
|-------------------|-----------|-------------|
|
||||||
|
| `null` | - | `-` |
|
||||||
|
| `분석불가` | 회색 | 분석불가 |
|
||||||
|
| `부 불일치` | 빨간색 | 부 불일치 |
|
||||||
|
| `모 불일치` | 주황색 | 모 불일치 |
|
||||||
|
| `모 이력제부재` | 주황색 | 모 이력제부재 |
|
||||||
|
| `형질정보없음` | 회색 | 형질정보없음 |
|
||||||
|
|
||||||
|
### unavailableReason 결정 로직 (cow.service.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!latestRequest || !latestRequest.chipSireName) {
|
||||||
|
unavailableReason = null; // '-' 표시
|
||||||
|
} else if (chipSireName === '분석불가' || chipSireName === '정보없음') {
|
||||||
|
unavailableReason = '분석불가';
|
||||||
|
} else if (chipSireName !== '일치') {
|
||||||
|
unavailableReason = '부 불일치';
|
||||||
|
} else if (chipDamName === '불일치') {
|
||||||
|
unavailableReason = '모 불일치';
|
||||||
|
} else if (chipDamName === '이력제부재') {
|
||||||
|
unavailableReason = '모 이력제부재';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 형질 데이터 없으면
|
||||||
|
unavailableReason = '형질정보없음';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 개체 상세 페이지 배지
|
||||||
|
|
||||||
|
### 부 KPN 배지 (renderSireBadge)
|
||||||
|
|
||||||
|
| 조건 | 배지 색상 | 표시 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `EXCLUDED_COW_IDS` 포함 | 회색 | 분석불가 |
|
||||||
|
| `chipSireName === '분석불가'` | 회색 | 분석불가 |
|
||||||
|
| `chipSireName === '정보없음'` | 회색 | 분석불가 |
|
||||||
|
| `chipSireName === '일치'` | 초록색 | 일치 |
|
||||||
|
| 그 외 | 빨간색 | 불일치 |
|
||||||
|
| `null` | - | 표시 안 함 |
|
||||||
|
|
||||||
|
### 모 개체 배지 (renderDamBadge)
|
||||||
|
|
||||||
|
| 조건 | 배지 색상 | 표시 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `chipDamName === '일치'` | 초록색 | 일치 |
|
||||||
|
| `chipDamName === '불일치'` | 빨간색 | 불일치 |
|
||||||
|
| `chipDamName === '이력제부재'` | 주황색 | 이력제부재 |
|
||||||
|
| 그 외/null | - | 표시 안 함 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 분석불가 안내 문구
|
||||||
|
|
||||||
|
| 상태 | 안내 문구 |
|
||||||
|
|------|-----------|
|
||||||
|
| `분석불가` (DB) | 모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다. |
|
||||||
|
| `정보없음` (DB) | 개체 식별번호 및 형식오류로 유전체 분석 보고서를 제공할 수 없습니다. |
|
||||||
|
| `부 불일치` | 부 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다. |
|
||||||
|
| `모 불일치` | 모 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다. |
|
||||||
|
| `모 이력제부재` | 모 이력제 정보가 부재하여 유전체 분석 보고서를 제공할 수 없습니다. |
|
||||||
|
| `EXCLUDED_COW_IDS` | 모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 관련 파일
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
- `backend/src/common/config/GenomeAnalysisConfig.ts` - 유효성 검사 함수
|
||||||
|
- `backend/src/cow/cow.service.ts` - unavailableReason 결정 로직
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
- `frontend/src/lib/utils/genome-analysis-config.ts` - 유효성 검사, 메시지 함수
|
||||||
|
- `frontend/src/app/cow/page.tsx` - 개체 목록 배지
|
||||||
|
- `frontend/src/app/cow/[cowNo]/page.tsx` - 개체 상세 배지, 탭 조건
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 제외 개체 목록 (EXCLUDED_COW_IDS)
|
||||||
|
|
||||||
|
특수 사유로 분석 불가한 개체를 하드코딩으로 관리:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const EXCLUDED_COW_IDS = [
|
||||||
|
'KOR002191642861', // 모근상태 불량으로 인한 DNA분해
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
> 이 목록에 포함된 개체는 유전체/유전자 탭 모두 분석불가로 처리됨
|
||||||
@@ -64,9 +64,15 @@ export function isValidGenomeAnalysis(
|
|||||||
chipDamName: string | null | undefined,
|
chipDamName: string | null | undefined,
|
||||||
cowId?: string | null,
|
cowId?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
// 개별 제외 개체만 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
|
// 1. 개별 제외 개체 확인
|
||||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
||||||
|
|
||||||
|
// 2. 아비명이 '일치'가 아니면 무효 (null, 불일치, 분석불가, 정보없음 등)
|
||||||
|
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
||||||
|
|
||||||
|
// 3. 어미명이 '불일치' 또는 '이력제부재'면 무효
|
||||||
|
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const MPT_NORMAL_RANGES = {
|
|||||||
* 총 글로불린 (Total Globulin)
|
* 총 글로불린 (Total Globulin)
|
||||||
* 단위: g/dL
|
* 단위: g/dL
|
||||||
*/
|
*/
|
||||||
totalGlobulin: { min: 9.1, max: 36.1 },
|
globulin: { min: 9.1, max: 36.1 },
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A/G 비율 (Albumin/Globulin Ratio)
|
* A/G 비율 (Albumin/Globulin Ratio)
|
||||||
@@ -75,7 +75,7 @@ export const MPT_NORMAL_RANGES = {
|
|||||||
* 지방간 지수 (Fatty Liver Index)
|
* 지방간 지수 (Fatty Liver Index)
|
||||||
* 단위: 지수
|
* 단위: 지수
|
||||||
*/
|
*/
|
||||||
fattyLiverIndex: { min: -1.2, max: 9.9 },
|
fattyLiverIdx: { min: -1.2, max: 9.9 },
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 칼슘 (Calcium)
|
* 칼슘 (Calcium)
|
||||||
@@ -103,10 +103,10 @@ export const MPT_NORMAL_RANGES = {
|
|||||||
|
|
||||||
// ========== 기타 카테고리 ==========
|
// ========== 기타 카테고리 ==========
|
||||||
/**
|
/**
|
||||||
* 크레아티닌 (Creatinine)
|
* 크레아틴 (Creatine)
|
||||||
* 단위: mg/dL
|
* 단위: mg/dL
|
||||||
*/
|
*/
|
||||||
creatinine: { min: 1.0, max: 1.3 },
|
creatine: { min: 1.0, max: 1.3 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* 추천 시스템 설정 상수
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* KPN 추천, 개체 추천, 패키지 추천 등 추천 시스템 관련 설정값
|
|
||||||
*
|
|
||||||
* @source PRD 기능요구사항20.md SFR-COW-016, SFR-COW-037
|
|
||||||
*/
|
|
||||||
export const RECOMMENDATION_CONFIG = {
|
|
||||||
/**
|
|
||||||
* 유전자 매칭 점수 관련
|
|
||||||
*/
|
|
||||||
GENE_SCORE: {
|
|
||||||
/**
|
|
||||||
* 점수 차이 임계값
|
|
||||||
* 유전자 매칭 점수 차이가 이 값보다 작으면 근친도를 우선 고려
|
|
||||||
*/
|
|
||||||
DIFF_THRESHOLD: 5,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본값
|
|
||||||
*/
|
|
||||||
DEFAULTS: {
|
|
||||||
/**
|
|
||||||
* 근친도 임계값 (%)
|
|
||||||
* Wright's Coefficient 기준
|
|
||||||
*/
|
|
||||||
INBREEDING_THRESHOLD: 12.5,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 추천 개수
|
|
||||||
* 상위 N개의 KPN/개체를 추천
|
|
||||||
*/
|
|
||||||
RECOMMENDATION_LIMIT: 10,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 세대제약 기준
|
|
||||||
* 최근 N세대 이내 사용된 KPN을 추천에서 제외
|
|
||||||
*/
|
|
||||||
GENERATION_THRESHOLD: 3,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KPN 패키지 설정
|
|
||||||
*/
|
|
||||||
PACKAGE: {
|
|
||||||
/**
|
|
||||||
* 기본 패키지 크기
|
|
||||||
* 추천할 KPN 세트 개수
|
|
||||||
*/
|
|
||||||
DEFAULT_SIZE: 5,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최소 패키지 크기
|
|
||||||
*/
|
|
||||||
MIN_SIZE: 3,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최대 패키지 크기
|
|
||||||
*/
|
|
||||||
MAX_SIZE: 10,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 커버리지 기준 (%)
|
|
||||||
* 유전자 목표 달성률 평가 기준
|
|
||||||
*/
|
|
||||||
COVERAGE: {
|
|
||||||
/**
|
|
||||||
* 우수 기준
|
|
||||||
* 50% 이상 커버리지
|
|
||||||
*/
|
|
||||||
EXCELLENT: 50,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 양호 기준
|
|
||||||
* 30% 이상 커버리지
|
|
||||||
*/
|
|
||||||
GOOD: 30,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최소 기준
|
|
||||||
* 20% 이상 커버리지
|
|
||||||
*/
|
|
||||||
MINIMUM: 20,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KPN 순환 전략
|
|
||||||
*/
|
|
||||||
ROTATION: {
|
|
||||||
/**
|
|
||||||
* 최소 KPN 개수
|
|
||||||
* 순환 전략 적용 최소 개수
|
|
||||||
*/
|
|
||||||
MIN_KPN_COUNT: 3,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 재사용 안전 세대
|
|
||||||
* 동일 KPN을 이 세대 이후에 재사용 가능
|
|
||||||
*/
|
|
||||||
SAFE_REUSE_GENERATION: 4,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
@@ -19,6 +19,7 @@ import { CowService } from './cow.service';
|
|||||||
import { CowModel } from './entities/cow.entity';
|
import { CowModel } from './entities/cow.entity';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
|
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
||||||
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -27,6 +28,7 @@ import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
|||||||
CowModel, // 개체 기본 정보 (tb_cow)
|
CowModel, // 개체 기본 정보 (tb_cow)
|
||||||
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
|
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
|
||||||
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
|
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
|
||||||
|
GeneDetailModel, // 유전자 상세 (tb_gene_detail)
|
||||||
]),
|
]),
|
||||||
FilterEngineModule, // 필터 엔진 모듈
|
FilterEngineModule, // 필터 엔진 모듈
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Repository, IsNull } from 'typeorm';
|
|||||||
import { CowModel } from './entities/cow.entity';
|
import { CowModel } from './entities/cow.entity';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||||
|
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
||||||
import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
||||||
import {
|
import {
|
||||||
RankingRequestDto,
|
RankingRequestDto,
|
||||||
@@ -57,6 +58,10 @@ export class CowService {
|
|||||||
@InjectRepository(GenomeTraitDetailModel)
|
@InjectRepository(GenomeTraitDetailModel)
|
||||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||||
|
|
||||||
|
// 유전자 상세 Repository (SNP 데이터 접근용)
|
||||||
|
@InjectRepository(GeneDetailModel)
|
||||||
|
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
||||||
|
|
||||||
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
||||||
private readonly filterEngineService: FilterEngineService,
|
private readonly filterEngineService: FilterEngineService,
|
||||||
) { }
|
) { }
|
||||||
@@ -116,10 +121,10 @@ export class CowService {
|
|||||||
* 개체식별번호(cowId)로 단건 조회
|
* 개체식별번호(cowId)로 단건 조회
|
||||||
*
|
*
|
||||||
* @param cowId - 개체식별번호 (예: KOR002119144049)
|
* @param cowId - 개체식별번호 (예: KOR002119144049)
|
||||||
* @returns 개체 정보 (farm 포함)
|
* @returns 개체 정보 (farm 포함) + dataStatus (데이터 존재 여부)
|
||||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||||
*/
|
*/
|
||||||
async findByCowId(cowId: string): Promise<CowModel> {
|
async findByCowId(cowId: string): Promise<CowModel & { dataStatus: { hasGenomeData: boolean; hasGeneData: boolean } }> {
|
||||||
const cow = await this.cowRepository.findOne({
|
const cow = await this.cowRepository.findOne({
|
||||||
where: { cowId: cowId, delDt: IsNull() },
|
where: { cowId: cowId, delDt: IsNull() },
|
||||||
relations: ['farm'],
|
relations: ['farm'],
|
||||||
@@ -127,7 +132,26 @@ export class CowService {
|
|||||||
if (!cow) {
|
if (!cow) {
|
||||||
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
|
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
|
||||||
}
|
}
|
||||||
return cow;
|
|
||||||
|
// 데이터 존재 여부 확인 (가벼운 COUNT 쿼리)
|
||||||
|
const [genomeCount, geneCount] = await Promise.all([
|
||||||
|
this.genomeTraitDetailRepository.count({
|
||||||
|
where: { cowId, delDt: IsNull() },
|
||||||
|
take: 1,
|
||||||
|
}),
|
||||||
|
this.geneDetailRepository.count({
|
||||||
|
where: { cowId, delDt: IsNull() },
|
||||||
|
take: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cow,
|
||||||
|
dataStatus: {
|
||||||
|
hasGenomeData: genomeCount > 0,
|
||||||
|
hasGeneData: geneCount > 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -260,16 +284,23 @@ export class CowService {
|
|||||||
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
||||||
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
||||||
// 분석불가 사유 결정
|
// 분석불가 사유 결정
|
||||||
let unavailableReason = '분석불가';
|
let unavailableReason: string | null = null;
|
||||||
if (latestRequest) {
|
|
||||||
if (latestRequest.chipSireName !== '일치') {
|
if (!latestRequest || !latestRequest.chipSireName) {
|
||||||
unavailableReason = '부 불일치';
|
// latestRequest 없거나 chipSireName이 null → '-' 표시 (프론트에서 null은 '-'로 표시)
|
||||||
} else if (latestRequest.chipDamName === '불일치') {
|
unavailableReason = null;
|
||||||
unavailableReason = '모 불일치';
|
} else if (latestRequest.chipSireName === '분석불가' || latestRequest.chipSireName === '정보없음') {
|
||||||
} else if (latestRequest.chipDamName === '이력제부재') {
|
// 분석불가, 정보없음 → 분석불가
|
||||||
unavailableReason = '모 이력제부재';
|
unavailableReason = '분석불가';
|
||||||
}
|
} else if (latestRequest.chipSireName !== '일치') {
|
||||||
|
// 불일치 등 그 외 → 부 불일치
|
||||||
|
unavailableReason = '부 불일치';
|
||||||
|
} else if (latestRequest.chipDamName === '불일치') {
|
||||||
|
unavailableReason = '모 불일치';
|
||||||
|
} else if (latestRequest.chipDamName === '이력제부재') {
|
||||||
|
unavailableReason = '모 이력제부재';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] };
|
return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,14 +232,14 @@ export class MptModel extends BaseModel {
|
|||||||
magnesium: number;
|
magnesium: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'creatinine',
|
name: 'creatine',
|
||||||
type: 'decimal',
|
type: 'decimal',
|
||||||
precision: 10,
|
precision: 10,
|
||||||
scale: 2,
|
scale: 2,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
comment: '크레아틴',
|
comment: '크레아틴',
|
||||||
})
|
})
|
||||||
creatinine: number;
|
creatine: number;
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||||
|
|||||||
@@ -1,43 +1,40 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useSearchParams, useParams, useRouter } from "next/navigation"
|
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||||
|
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||||||
import { SiteHeader } from "@/components/layout/site-header"
|
import { SiteHeader } from "@/components/layout/site-header"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
|
||||||
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail, GenomeRequestDto, mptApi, MptDto } from "@/lib/api"
|
|
||||||
import { CowDetail } from "@/types/cow.types"
|
|
||||||
import { GenomeTrait } from "@/types/genome.types"
|
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowUp,
|
|
||||||
BarChart3,
|
|
||||||
CheckCircle2,
|
|
||||||
Download,
|
|
||||||
Dna,
|
|
||||||
Activity,
|
|
||||||
X,
|
|
||||||
XCircle,
|
|
||||||
Search,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { ComparisonAveragesDto, cowApi, geneApi, GeneDetail, genomeApi, GenomeRequestDto, TraitComparisonAveragesDto } from "@/lib/api"
|
||||||
|
import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config"
|
||||||
|
import { CowDetail } from "@/types/cow.types"
|
||||||
|
import { GenomeTrait } from "@/types/genome.types"
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowLeft,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronUp,
|
||||||
|
Dna,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
XCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
|
import { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
|
||||||
|
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
|
||||||
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
|
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
|
||||||
import { TraitDistributionCharts } from "./genome/_components/trait-distribution-charts"
|
import { TraitDistributionCharts } from "./genome/_components/trait-distribution-charts"
|
||||||
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
|
|
||||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
|
||||||
import { isValidGenomeAnalysis, getInvalidReason, getInvalidMessage } from "@/lib/utils/genome-analysis-config"
|
|
||||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
|
||||||
import { MptTable } from "./reproduction/_components/mpt-table"
|
|
||||||
|
|
||||||
// 형질명 → 카테고리 매핑 (한우 35개 형질)
|
// 형질명 → 카테고리 매핑 (한우 35개 형질)
|
||||||
const TRAIT_CATEGORY_MAP: Record<string, string> = {
|
const TRAIT_CATEGORY_MAP: Record<string, string> = {
|
||||||
@@ -254,6 +251,74 @@ export default function CowOverviewPage() {
|
|||||||
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
|
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
|
||||||
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
|
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
// 부 KPN 배지 렌더링 (분석불가/일치/불일치)
|
||||||
|
const renderSireBadge = (chipSireName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => {
|
||||||
|
const sizeClasses = size === 'lg'
|
||||||
|
? 'gap-1.5 text-sm px-3 py-1.5'
|
||||||
|
: 'gap-1 text-xs px-2 py-1'
|
||||||
|
const iconSize = size === 'lg' ? 'w-4 h-4' : 'w-3 h-3'
|
||||||
|
|
||||||
|
// 분석불가 개체 먼저 체크 (EXCLUDED_COW_IDS 또는 DB에서 '분석불가'/'정보없음'으로 저장된 경우)
|
||||||
|
if (isExcludedCow(cow?.cowId) || chipSireName === '분석불가' || chipSireName === '정보없음') {
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center ${sizeClasses} bg-slate-400 text-white font-semibold rounded-full shrink-0`}>
|
||||||
|
<XCircle className={iconSize} />
|
||||||
|
<span>분석불가</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipSireName === '일치') {
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center ${sizeClasses} bg-primary text-primary-foreground font-semibold rounded-full shrink-0`}>
|
||||||
|
<CheckCircle2 className={iconSize} />
|
||||||
|
<span>일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipSireName && chipSireName !== '일치') {
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center ${sizeClasses} bg-red-500 text-white font-semibold rounded-full shrink-0`}>
|
||||||
|
<XCircle className={iconSize} />
|
||||||
|
<span>불일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모 개체 배지 렌더링 (일치/불일치/이력제부재)
|
||||||
|
const renderDamBadge = (chipDamName: string | null | undefined, size: 'sm' | 'lg' = 'lg') => {
|
||||||
|
// 분석불가 개체는 어미 배지 표시 안 함
|
||||||
|
if (isExcludedCow(cow?.cowId)) return null
|
||||||
|
|
||||||
|
const sizeClasses = size === 'lg'
|
||||||
|
? 'gap-1.5 text-sm px-3 py-1.5'
|
||||||
|
: 'gap-1 text-xs px-2 py-1'
|
||||||
|
const iconSize = size === 'lg' ? 'w-4 h-4' : 'w-3 h-3'
|
||||||
|
|
||||||
|
if (chipDamName === '일치') {
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center ${sizeClasses} bg-primary text-primary-foreground font-semibold rounded-full shrink-0`}>
|
||||||
|
<CheckCircle2 className={iconSize} />
|
||||||
|
<span>일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipDamName === '불일치') {
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center ${sizeClasses} bg-red-500 text-white font-semibold rounded-full shrink-0`}>
|
||||||
|
<XCircle className={iconSize} />
|
||||||
|
<span>불일치</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (chipDamName === '이력제부재') {
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center ${sizeClasses} bg-amber-500 text-white font-semibold rounded-full shrink-0`}>
|
||||||
|
<XCircle className={iconSize} />
|
||||||
|
<span>이력제부재</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 유전자 데이터 지연 로드 함수
|
// 유전자 데이터 지연 로드 함수
|
||||||
const loadGeneData = async () => {
|
const loadGeneData = async () => {
|
||||||
if (geneDataLoaded || geneDataLoading) return // 이미 로드했거나 로딩 중이면 스킵
|
if (geneDataLoaded || geneDataLoading) return // 이미 로드했거나 로딩 중이면 스킵
|
||||||
@@ -264,10 +329,12 @@ export default function CowOverviewPage() {
|
|||||||
const geneList = geneDataResult || []
|
const geneList = geneDataResult || []
|
||||||
setGeneData(geneList)
|
setGeneData(geneList)
|
||||||
setGeneDataLoaded(true)
|
setGeneDataLoaded(true)
|
||||||
|
setHasGeneData(geneList.length > 0)
|
||||||
} catch (geneErr) {
|
} catch (geneErr) {
|
||||||
console.error('유전자 데이터 조회 실패:', geneErr)
|
console.error('유전자 데이터 조회 실패:', geneErr)
|
||||||
setGeneData([])
|
setGeneData([])
|
||||||
setGeneDataLoaded(true)
|
setGeneDataLoaded(true)
|
||||||
|
setHasGeneData(false)
|
||||||
} finally {
|
} finally {
|
||||||
setGeneDataLoading(false)
|
setGeneDataLoading(false)
|
||||||
}
|
}
|
||||||
@@ -318,6 +385,11 @@ export default function CowOverviewPage() {
|
|||||||
}
|
}
|
||||||
setCow(cowDetail)
|
setCow(cowDetail)
|
||||||
|
|
||||||
|
// dataStatus에서 데이터 존재 여부 설정 (백엔드에서 가벼운 COUNT 쿼리로 확인)
|
||||||
|
if (cowData.dataStatus) {
|
||||||
|
setHasGeneData(cowData.dataStatus.hasGeneData)
|
||||||
|
}
|
||||||
|
|
||||||
// 유전체 데이터 가져오기
|
// 유전체 데이터 가져오기
|
||||||
const genomeDataResult = await genomeApi.findByCowNo(cowNo)
|
const genomeDataResult = await genomeApi.findByCowNo(cowNo)
|
||||||
setGenomeData(genomeDataResult)
|
setGenomeData(genomeDataResult)
|
||||||
@@ -333,9 +405,6 @@ export default function CowOverviewPage() {
|
|||||||
setGenomeRequest(null)
|
setGenomeRequest(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유전자(SNP) 데이터는 탭 클릭 시 로드 (지연 로딩)
|
|
||||||
setHasGeneData(true) // 탭은 보여주되, 데이터는 나중에 로드
|
|
||||||
|
|
||||||
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
|
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
|
||||||
// TODO: 번식능력 API 연동
|
// TODO: 번식능력 API 연동
|
||||||
setHasReproductionData(false)
|
setHasReproductionData(false)
|
||||||
@@ -579,8 +648,8 @@ export default function CowOverviewPage() {
|
|||||||
>
|
>
|
||||||
<Dna className="hidden sm:block h-6 w-6 shrink-0" />
|
<Dna className="hidden sm:block h-6 w-6 shrink-0" />
|
||||||
<span className="font-bold text-sm sm:text-xl">유전자</span>
|
<span className="font-bold text-sm sm:text-xl">유전자</span>
|
||||||
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
|
||||||
{hasGeneData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? '완료' : '미검사'}
|
{hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? '완료' : '미검사'}
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -695,30 +764,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-2xl font-bold text-foreground break-all">
|
<span className="text-2xl font-bold text-foreground break-all">
|
||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
||||||
const chipSireName = genomeRequest?.chipSireName
|
|
||||||
if (chipSireName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipSireName && chipSireName !== '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<span>분석불가</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -731,34 +777,7 @@ export default function CowOverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-2xl font-bold text-foreground">-</span>
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
||||||
const chipDamName = genomeRequest?.chipDamName
|
|
||||||
if (chipDamName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '불일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '이력제부재') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>이력제부재</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -770,30 +789,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-base font-bold text-foreground break-all">
|
<span className="text-base font-bold text-foreground break-all">
|
||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
||||||
const chipSireName = genomeRequest?.chipSireName
|
|
||||||
if (chipSireName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipSireName && chipSireName !== '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<span>분석불가</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -804,34 +800,7 @@ export default function CowOverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-base font-bold text-foreground">-</span>
|
<span className="text-base font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
||||||
const chipDamName = genomeRequest?.chipDamName
|
|
||||||
if (chipDamName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '불일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '이력제부재') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>이력제부재</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1062,30 +1031,7 @@ export default function CowOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||||
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
||||||
{(() => {
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
||||||
const chipSireName = genomeRequest?.chipSireName
|
|
||||||
if (chipSireName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipSireName && chipSireName !== '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<span>분석불가</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1098,34 +1044,7 @@ export default function CowOverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-2xl font-bold text-foreground">-</span>
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
||||||
const chipDamName = genomeRequest?.chipDamName
|
|
||||||
if (chipDamName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '불일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '이력제부재') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>이력제부재</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1135,30 +1054,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN</span>
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN</span>
|
||||||
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
||||||
<span className="text-base font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
<span className="text-base font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
||||||
{(() => {
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
||||||
const chipSireName = genomeRequest?.chipSireName
|
|
||||||
if (chipSireName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipSireName && chipSireName !== '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<span>분석불가</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -1169,34 +1065,7 @@ export default function CowOverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-base font-bold text-foreground">-</span>
|
<span className="text-base font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
||||||
const chipDamName = genomeRequest?.chipDamName
|
|
||||||
if (chipDamName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '불일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '이력제부재') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>이력제부재</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 정보없음(null/undefined)일 때는 배지 표시 안함
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1333,30 +1202,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-2xl font-bold text-foreground break-all">
|
<span className="text-2xl font-bold text-foreground break-all">
|
||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
||||||
const chipSireName = genomeRequest?.chipSireName
|
|
||||||
if (chipSireName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipSireName && chipSireName !== '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<span>분석불가</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1369,33 +1215,7 @@ export default function CowOverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-2xl font-bold text-foreground">-</span>
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
||||||
const chipDamName = genomeRequest?.chipDamName
|
|
||||||
if (chipDamName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '불일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '이력제부재') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
<span>이력제부재</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1407,30 +1227,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-base font-bold text-foreground break-all">
|
<span className="text-base font-bold text-foreground break-all">
|
||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
||||||
const chipSireName = genomeRequest?.chipSireName
|
|
||||||
if (chipSireName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipSireName && chipSireName !== '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<span>분석불가</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -1441,33 +1238,7 @@ export default function CowOverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-base font-bold text-foreground">-</span>
|
<span className="text-base font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
||||||
const chipDamName = genomeRequest?.chipDamName
|
|
||||||
if (chipDamName === '일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
<span>일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '불일치') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>불일치</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else if (chipDamName === '이력제부재') {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
<span>이력제부재</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1477,8 +1248,8 @@ export default function CowOverviewPage() {
|
|||||||
{/* 유전자 검색 및 필터 섹션 */}
|
{/* 유전자 검색 및 필터 섹션 */}
|
||||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전자 분석 결과</h3>
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전자 분석 결과</h3>
|
||||||
|
|
||||||
{/* 친자확인 결과에 따른 분기 */}
|
{/* 유전자 탭 분기: 분석불가/정보없음만 차단, 불일치/이력제부재는 유전자 데이터 표시 */}
|
||||||
{isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? (
|
{!(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-3 sm:gap-3 p-3.5 max-sm:p-3 sm:px-4 sm:py-3 rounded-xl bg-slate-50/50 border border-slate-200/50">
|
<div className="flex flex-col gap-3 sm:gap-3 p-3.5 max-sm:p-3 sm:px-4 sm:py-3 rounded-xl bg-slate-50/50 border border-slate-200/50">
|
||||||
{/* 검색창 */}
|
{/* 검색창 */}
|
||||||
@@ -1867,15 +1638,154 @@ export default function CowOverviewPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card className="bg-slate-50 border border-border rounded-2xl">
|
<>
|
||||||
<CardContent className="p-8 text-center">
|
{/* 개체 정보 섹션 */}
|
||||||
<Dna className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">개체 정보</h3>
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">유전자 분석 데이터 없음</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||||
이 개체는 아직 유전자(SNP) 분석이 완료되지 않았습니다.
|
<CardContent className="p-0">
|
||||||
</p>
|
{/* 데스크탑: 가로 그리드 */}
|
||||||
</CardContent>
|
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
||||||
</Card>
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">생년월일</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<span className="text-2xl font-bold text-foreground">
|
||||||
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">월령</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<span className="text-2xl font-bold text-foreground">
|
||||||
|
{cow?.cowBirthDt
|
||||||
|
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">분석일자</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 모바일: 좌우 배치 리스트 */}
|
||||||
|
<div className="lg:hidden divide-y divide-border">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">개체번호</span>
|
||||||
|
<div className="flex-1 px-4 py-3.5">
|
||||||
|
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">생년월일</span>
|
||||||
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||||
|
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">월령</span>
|
||||||
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||||
|
{cow?.cowBirthDt
|
||||||
|
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">분석일자</span>
|
||||||
|
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 혈통정보 섹션 */}
|
||||||
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">혈통정보</h3>
|
||||||
|
|
||||||
|
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* 데스크탑: 가로 그리드 */}
|
||||||
|
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">부 KPN번호</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
||||||
|
{renderSireBadge(genomeRequest?.chipSireName)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-base font-semibold text-muted-foreground">모 개체번호</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||||
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
||||||
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
|
)}
|
||||||
|
{renderDamBadge(genomeRequest?.chipDamName)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 모바일: 세로 리스트 */}
|
||||||
|
<div className="lg:hidden divide-y divide-border">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">부 KPN</span>
|
||||||
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-base font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
||||||
|
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">모 개체번호</span>
|
||||||
|
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
|
||||||
|
{cow?.damCowId && cow.damCowId !== '0' ? (
|
||||||
|
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-base font-bold text-foreground">-</span>
|
||||||
|
)}
|
||||||
|
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 유전자 분석 결과 섹션 */}
|
||||||
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전자 분석 결과</h3>
|
||||||
|
<Card className="bg-slate-100 border border-slate-300 rounded-2xl">
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<Dna className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
||||||
|
{genomeRequest ? '유전자 분석 불가' : '유전자 분석불가'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{genomeRequest
|
||||||
|
? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId).replace('유전체', '유전자')
|
||||||
|
: '이 개체는 아직 유전자(SNP) 분석이 진행되지 않았습니다.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -1955,14 +1865,14 @@ export default function CowOverviewPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 플로팅 맨 위로 버튼 */}
|
{/* 플로팅 맨 위로 버튼 - 글래스모피즘 */}
|
||||||
{showScrollTop && (
|
{showScrollTop && (
|
||||||
<button
|
<button
|
||||||
onClick={scrollToTop}
|
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"
|
className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-50 w-10 h-10 sm:w-12 sm:h-12 bg-white/80 backdrop-blur-md border border-white/50 text-slate-700 rounded-full shadow-lg hover:bg-white/90 transition-all duration-300 flex items-center justify-center hover:scale-110 active:scale-95"
|
||||||
aria-label="맨 위로"
|
aria-label="맨 위로"
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-6 h-6" />
|
<ChevronUp className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const MPT_CATEGORIES = [
|
|||||||
{ name: '단백질 대사', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' },
|
{ name: '단백질 대사', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' },
|
||||||
{ name: '간기능', items: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' },
|
{ name: '간기능', items: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' },
|
||||||
{ name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' },
|
{ name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' },
|
||||||
{ name: '기타', items: ['creatinine'], color: 'bg-muted/50' },
|
{ name: '기타', items: ['creatine'], color: 'bg-muted/50' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 측정값 상태 판정
|
// 측정값 상태 판정
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { cowApi, reproApi } from "@/lib/api"
|
import { cowApi, reproApi } from "@/lib/api"
|
||||||
import { CowDetail } from "@/types/cow.types"
|
import { CowDetail } from "@/types/cow.types"
|
||||||
import { ReproMpt } from "@/types/reprompt.types"
|
import { ReproMpt } from "@/types/mpt.types"
|
||||||
import { Activity, AlertCircle, CheckCircle } from "lucide-react"
|
import { Activity, AlertCircle, CheckCircle } from "lucide-react"
|
||||||
import { CowNavigation } from "../_components/navigation"
|
import { CowNavigation } from "../_components/navigation"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
@@ -140,7 +140,7 @@ export default function ReproductionPage() {
|
|||||||
{ name: '인', value: reproMpt[0].phosphorus, fieldName: 'phosphorus' },
|
{ name: '인', value: reproMpt[0].phosphorus, fieldName: 'phosphorus' },
|
||||||
{ name: '칼슘/인', value: reproMpt[0].caPRatio, fieldName: 'caPRatio' },
|
{ name: '칼슘/인', value: reproMpt[0].caPRatio, fieldName: 'caPRatio' },
|
||||||
{ name: '마그네슘', value: reproMpt[0].magnesium, fieldName: 'magnesium' },
|
{ name: '마그네슘', value: reproMpt[0].magnesium, fieldName: 'magnesium' },
|
||||||
{ name: '크레아틴', value: reproMpt[0].creatinine, fieldName: 'creatinine' },
|
{ name: '크레아틴', value: reproMpt[0].creatine, fieldName: 'creatine' },
|
||||||
] : []
|
] : []
|
||||||
|
|
||||||
const normalItems = mptItems.filter(item => {
|
const normalItems = mptItems.filter(item => {
|
||||||
|
|||||||
@@ -680,7 +680,7 @@ function MyCowContent() {
|
|||||||
<div className="flex flex-col gap-3 sm:gap-4">
|
<div className="flex flex-col gap-3 sm:gap-4">
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl max-sm:text-2xl font-bold text-slate-900">개체 목록</h1>
|
<h1 className="text-3xl sm:text-3xl font-bold text-slate-900">개체 목록</h1>
|
||||||
<p className="text-base max-sm:text-sm text-slate-500 mt-1">{'농장'} 보유 개체 현황</p>
|
<p className="text-base max-sm:text-sm text-slate-500 mt-1">{'농장'} 보유 개체 현황</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -737,12 +737,12 @@ function MyCowContent() {
|
|||||||
{/* 필터 옵션들 - 모바일: 2행, 데스크톱: 1행 */}
|
{/* 필터 옵션들 - 모바일: 2행, 데스크톱: 1행 */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3">
|
||||||
{/* 랭킹/정렬 그룹 */}
|
{/* 랭킹/정렬 그룹 */}
|
||||||
<div className="grid grid-cols-3 sm:flex sm:items-center gap-2.5 max-sm:gap-2 sm:gap-2">
|
<div className="grid grid-cols-3 sm:flex sm:items-center gap-2.5 max-sm:gap-1.5 sm:gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={rankingMode}
|
value={rankingMode}
|
||||||
onValueChange={(value: 'gene' | 'genome') => setRankingMode(value)}
|
onValueChange={(value: 'gene' | 'genome') => setRankingMode(value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-sm border-slate-200 bg-white">
|
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 border-slate-200 bg-white">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -751,7 +751,7 @@ function MyCowContent() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
<SelectTrigger className="w-full sm:w-[110px] h-10 sm:h-9 text-sm border-slate-200 bg-white">
|
<SelectTrigger className="w-full sm:w-[110px] h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 border-slate-200 bg-white">
|
||||||
<SelectValue placeholder="정렬" />
|
<SelectValue placeholder="정렬" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -765,7 +765,7 @@ function MyCowContent() {
|
|||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onValueChange={(value) => setSortOrder(value as 'asc' | 'desc')}
|
onValueChange={(value) => setSortOrder(value as 'asc' | 'desc')}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-sm border-slate-200 bg-white">
|
<SelectTrigger className="w-full sm:w-[120px] h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 border-slate-200 bg-white">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -776,10 +776,10 @@ function MyCowContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표시항목 그룹 */}
|
{/* 표시항목 그룹 */}
|
||||||
<div className="grid grid-cols-2 sm:flex sm:items-center gap-2.5 max-sm:gap-2 sm:gap-2">
|
<div className="grid grid-cols-2 sm:flex sm:items-center gap-2.5 max-sm:gap-1.5 sm:gap-2">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="h-10 sm:h-9 text-sm justify-between w-full sm:w-[100px] border-slate-200 bg-white">
|
<Button variant="outline" className="h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 justify-between w-full sm:w-[100px] border-slate-200 bg-white">
|
||||||
<span>유전자</span>
|
<span>유전자</span>
|
||||||
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -843,7 +843,7 @@ function MyCowContent() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="h-10 sm:h-9 text-sm justify-between w-full sm:w-[100px] border-slate-200 bg-white">
|
<Button variant="outline" className="h-10 sm:h-9 text-xs sm:text-sm px-2 sm:px-3 justify-between w-full sm:w-[100px] border-slate-200 bg-white">
|
||||||
<span>형질</span>
|
<span>형질</span>
|
||||||
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -986,15 +986,15 @@ function MyCowContent() {
|
|||||||
year: '2-digit',
|
year: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit'
|
day: '2-digit'
|
||||||
}) : (
|
}) : cow.unavailableReason ? (
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
cow.unavailableReason?.includes('부') ? 'bg-red-100 text-red-700' :
|
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
|
||||||
cow.unavailableReason?.includes('모') ? 'bg-orange-100 text-orange-700' :
|
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
|
||||||
'bg-slate-100 text-slate-600'
|
'bg-slate-100 text-slate-600'
|
||||||
}`}>
|
}`}>
|
||||||
{cow.unavailableReason || '분석불가'}
|
{cow.unavailableReason}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5">
|
<td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5">
|
||||||
{(cow.genomeScore !== undefined && cow.genomeScore !== null) ? (
|
{(cow.genomeScore !== undefined && cow.genomeScore !== null) ? (
|
||||||
@@ -1197,15 +1197,15 @@ function MyCowContent() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">{cow.anlysDt ? '분석일' : '분석결과'}</span>
|
<span className="text-muted-foreground">{cow.anlysDt ? '분석일' : '분석결과'}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : (
|
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : cow.unavailableReason ? (
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
cow.unavailableReason?.includes('부') ? 'bg-red-100 text-red-700' :
|
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
|
||||||
cow.unavailableReason?.includes('모') ? 'bg-orange-100 text-orange-700' :
|
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
|
||||||
'bg-slate-100 text-slate-600'
|
'bg-slate-100 text-slate-600'
|
||||||
}`}>
|
}`}>
|
||||||
{cow.unavailableReason || '분석불가'}
|
{cow.unavailableReason}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const MPT_REFERENCE_VALUES: Record<string, { min: number; max: number; unit: str
|
|||||||
phosphorus: { min: 4.0, max: 7.5, unit: 'mg/dL', name: '인' },
|
phosphorus: { min: 4.0, max: 7.5, unit: 'mg/dL', name: '인' },
|
||||||
caPRatio: { min: 1.0, max: 2.0, unit: '', name: 'Ca/P 비율' },
|
caPRatio: { min: 1.0, max: 2.0, unit: '', name: 'Ca/P 비율' },
|
||||||
magnesium: { min: 1.8, max: 2.5, unit: 'mg/dL', name: '마그네슘' },
|
magnesium: { min: 1.8, max: 2.5, unit: 'mg/dL', name: '마그네슘' },
|
||||||
creatinine: { min: 1.0, max: 2.0, unit: 'mg/dL', name: '크레아틴' },
|
creatine: { min: 1.0, max: 2.0, unit: 'mg/dL', name: '크레아틴' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리별 항목 그룹핑
|
// 카테고리별 항목 그룹핑
|
||||||
@@ -63,7 +63,7 @@ const MPT_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '미네랄',
|
name: '미네랄',
|
||||||
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatinine'],
|
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatine'],
|
||||||
color: 'bg-purple-500',
|
color: 'bg-purple-500',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function AnimatedDesktop() {
|
|
||||||
const [svgContent, setSvgContent] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// SVG 파일을 fetch해서 내용을 가져옴
|
|
||||||
fetch('/images/Desktop_SVG.svg')
|
|
||||||
.then(res => res.text())
|
|
||||||
.then(text => {
|
|
||||||
// SVG 내용에 CSS 애니메이션을 추가
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const svgDoc = parser.parseFromString(text, 'image/svg+xml');
|
|
||||||
const svg = svgDoc.documentElement;
|
|
||||||
|
|
||||||
// style 태그 추가
|
|
||||||
const style = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes rotate-circle {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.rotating-element {
|
|
||||||
animation: rotate-circle 4s linear infinite;
|
|
||||||
transform-origin: center;
|
|
||||||
transform-box: fill-box;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
svg.insertBefore(style, svg.firstChild);
|
|
||||||
|
|
||||||
// 회전시킬 요소 찾기 (fill="#38BDD4" and transform="translate(813,386)")
|
|
||||||
const paths = svg.querySelectorAll('path');
|
|
||||||
paths.forEach(path => {
|
|
||||||
const fill = path.getAttribute('fill');
|
|
||||||
const transform = path.getAttribute('transform');
|
|
||||||
if (fill === '#38BDD4' && transform && transform.includes('translate(813,386)')) {
|
|
||||||
path.classList.add('rotating-element');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 수정된 SVG를 문자열로 변환
|
|
||||||
const serializer = new XMLSerializer();
|
|
||||||
const modifiedSvg = serializer.serializeToString(svg);
|
|
||||||
setSvgContent(modifiedSvg);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative w-full h-full flex items-center justify-center"
|
|
||||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,807 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
type UniqueIdentifier,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import {
|
|
||||||
IconChevronDown,
|
|
||||||
IconChevronLeft,
|
|
||||||
IconChevronRight,
|
|
||||||
IconChevronsLeft,
|
|
||||||
IconChevronsRight,
|
|
||||||
IconCircleCheckFilled,
|
|
||||||
IconDotsVertical,
|
|
||||||
IconGripVertical,
|
|
||||||
IconLayoutColumns,
|
|
||||||
IconLoader,
|
|
||||||
IconPlus,
|
|
||||||
IconTrendingUp,
|
|
||||||
} from "@tabler/icons-react"
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
ColumnFiltersState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFacetedRowModel,
|
|
||||||
getFacetedUniqueValues,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
Row,
|
|
||||||
SortingState,
|
|
||||||
useReactTable,
|
|
||||||
VisibilityState,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger,
|
|
||||||
} from "@/components/ui/drawer"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@/components/ui/tabs"
|
|
||||||
|
|
||||||
export const schema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
header: z.string(),
|
|
||||||
type: z.string(),
|
|
||||||
status: z.string(),
|
|
||||||
target: z.string(),
|
|
||||||
limit: z.string(),
|
|
||||||
reviewer: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a separate component for the drag handle
|
|
||||||
function DragHandle({ id }: { id: number }) {
|
|
||||||
const { attributes, listeners } = useSortable({
|
|
||||||
id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-muted-foreground size-7 hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<IconGripVertical className="text-muted-foreground size-3" />
|
|
||||||
<span className="sr-only">Drag to reorder</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|
||||||
{
|
|
||||||
id: "drag",
|
|
||||||
header: () => null,
|
|
||||||
cell: ({ row }) => <DragHandle id={row.original.id} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllPageRowsSelected() ||
|
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "header",
|
|
||||||
header: "Header",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <TableCellViewer item={row.original} />
|
|
||||||
},
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "type",
|
|
||||||
header: "Section Type",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="w-32">
|
|
||||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
|
||||||
{row.original.type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
|
||||||
{row.original.status === "Done" ? (
|
|
||||||
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
|
|
||||||
) : (
|
|
||||||
<IconLoader />
|
|
||||||
)}
|
|
||||||
{row.original.status}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "target",
|
|
||||||
header: () => <div className="w-full text-right">Target</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
|
||||||
loading: `Saving ${row.original.header}`,
|
|
||||||
success: "Done",
|
|
||||||
error: "Error",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
|
|
||||||
Target
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
|
||||||
defaultValue={row.original.target}
|
|
||||||
id={`${row.original.id}-target`}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "limit",
|
|
||||||
header: () => <div className="w-full text-right">Limit</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
|
||||||
loading: `Saving ${row.original.header}`,
|
|
||||||
success: "Done",
|
|
||||||
error: "Error",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
|
|
||||||
Limit
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
|
||||||
defaultValue={row.original.limit}
|
|
||||||
id={`${row.original.id}-limit`}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "reviewer",
|
|
||||||
header: "Reviewer",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const isAssigned = row.original.reviewer !== "Assign reviewer"
|
|
||||||
|
|
||||||
if (isAssigned) {
|
|
||||||
return row.original.reviewer
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
|
|
||||||
Reviewer
|
|
||||||
</Label>
|
|
||||||
<Select>
|
|
||||||
<SelectTrigger
|
|
||||||
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
|
|
||||||
size="sm"
|
|
||||||
id={`${row.original.id}-reviewer`}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Assign reviewer" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent align="end">
|
|
||||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
|
||||||
<SelectItem value="Jamik Tashpulatov">
|
|
||||||
Jamik Tashpulatov
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
cell: () => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
|
||||||
size="icon"
|
|
||||||
>
|
|
||||||
<IconDotsVertical />
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-32">
|
|
||||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Make a copy</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Favorite</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
|
||||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
|
||||||
id: row.original.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
data-dragging={isDragging}
|
|
||||||
ref={setNodeRef}
|
|
||||||
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
|
||||||
style={{
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition: transition,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTable({
|
|
||||||
data: initialData,
|
|
||||||
}: {
|
|
||||||
data: z.infer<typeof schema>[]
|
|
||||||
}) {
|
|
||||||
const [data, setData] = React.useState(() => initialData)
|
|
||||||
const [rowSelection, setRowSelection] = React.useState({})
|
|
||||||
const [columnVisibility, setColumnVisibility] =
|
|
||||||
React.useState<VisibilityState>({})
|
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
|
||||||
const [pagination, setPagination] = React.useState({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
})
|
|
||||||
const sortableId = React.useId()
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
useSensor(KeyboardSensor, {})
|
|
||||||
)
|
|
||||||
|
|
||||||
const dataIds = React.useMemo<UniqueIdentifier[]>(
|
|
||||||
() => data?.map(({ id }) => id) || [],
|
|
||||||
[data]
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnVisibility,
|
|
||||||
rowSelection,
|
|
||||||
columnFilters,
|
|
||||||
pagination,
|
|
||||||
},
|
|
||||||
getRowId: (row) => row.id.toString(),
|
|
||||||
enableRowSelection: true,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFacetedRowModel: getFacetedRowModel(),
|
|
||||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleDragEnd(event: DragEndEvent) {
|
|
||||||
const { active, over } = event
|
|
||||||
if (active && over && active.id !== over.id) {
|
|
||||||
setData((data) => {
|
|
||||||
const oldIndex = dataIds.indexOf(active.id)
|
|
||||||
const newIndex = dataIds.indexOf(over.id)
|
|
||||||
return arrayMove(data, oldIndex, newIndex)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
defaultValue="outline"
|
|
||||||
className="w-full flex-col justify-start gap-6"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
|
||||||
<Label htmlFor="view-selector" className="sr-only">
|
|
||||||
View
|
|
||||||
</Label>
|
|
||||||
<Select defaultValue="outline">
|
|
||||||
<SelectTrigger
|
|
||||||
className="flex w-fit @4xl/main:hidden"
|
|
||||||
size="sm"
|
|
||||||
id="view-selector"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select a view" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="outline">Outline</SelectItem>
|
|
||||||
<SelectItem value="past-performance">Past Performance</SelectItem>
|
|
||||||
<SelectItem value="key-personnel">Key Personnel</SelectItem>
|
|
||||||
<SelectItem value="focus-documents">Focus Documents</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
|
|
||||||
<TabsTrigger value="outline">Outline</TabsTrigger>
|
|
||||||
<TabsTrigger value="past-performance">
|
|
||||||
Past Performance <Badge variant="secondary">3</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="key-personnel">
|
|
||||||
Key Personnel <Badge variant="secondary">2</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<IconLayoutColumns />
|
|
||||||
<span className="hidden lg:inline">Customize Columns</span>
|
|
||||||
<span className="lg:hidden">Columns</span>
|
|
||||||
<IconChevronDown />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter(
|
|
||||||
(column) =>
|
|
||||||
typeof column.accessorFn !== "undefined" &&
|
|
||||||
column.getCanHide()
|
|
||||||
)
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
column.toggleVisibility(!!value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column.id}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<IconPlus />
|
|
||||||
<span className="hidden lg:inline">Add Section</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TabsContent
|
|
||||||
value="outline"
|
|
||||||
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
|
|
||||||
>
|
|
||||||
<div className="overflow-hidden rounded-lg border">
|
|
||||||
<DndContext
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
modifiers={[restrictToVerticalAxis]}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
sensors={sensors}
|
|
||||||
id={sortableId}
|
|
||||||
>
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="bg-muted sticky top-0 z-10">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="**:data-[slot=table-cell]:first:w-8">
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
<SortableContext
|
|
||||||
items={dataIds}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<DraggableRow key={row.id} row={row} />
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
No results.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between px-4">
|
|
||||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
|
||||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
|
||||||
<div className="hidden items-center gap-2 lg:flex">
|
|
||||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
|
||||||
Rows per page
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
table.setPageSize(Number(value))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={table.getState().pagination.pageSize}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top">
|
|
||||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
|
||||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
|
||||||
{pageSize}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
|
||||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
|
||||||
{table.getPageCount()}
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="hidden h-8 w-8 p-0 lg:flex"
|
|
||||||
onClick={() => table.setPageIndex(0)}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to first page</span>
|
|
||||||
<IconChevronsLeft />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="size-8"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to previous page</span>
|
|
||||||
<IconChevronLeft />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="size-8"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to next page</span>
|
|
||||||
<IconChevronRight />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="hidden size-8 lg:flex"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to last page</span>
|
|
||||||
<IconChevronsRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent
|
|
||||||
value="past-performance"
|
|
||||||
className="flex flex-col px-4 lg:px-6"
|
|
||||||
>
|
|
||||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
|
|
||||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent
|
|
||||||
value="focus-documents"
|
|
||||||
className="flex flex-col px-4 lg:px-6"
|
|
||||||
>
|
|
||||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ month: "January", desktop: 186, mobile: 80 },
|
|
||||||
{ month: "February", desktop: 305, mobile: 200 },
|
|
||||||
{ month: "March", desktop: 237, mobile: 120 },
|
|
||||||
{ month: "April", desktop: 73, mobile: 190 },
|
|
||||||
{ month: "May", desktop: 209, mobile: 130 },
|
|
||||||
{ month: "June", desktop: 214, mobile: 140 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
desktop: {
|
|
||||||
label: "Desktop",
|
|
||||||
color: "var(--primary)",
|
|
||||||
},
|
|
||||||
mobile: {
|
|
||||||
label: "Mobile",
|
|
||||||
color: "var(--primary)",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer direction={isMobile ? "bottom" : "right"}>
|
|
||||||
<DrawerTrigger asChild>
|
|
||||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
|
||||||
{item.header}
|
|
||||||
</Button>
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
|
||||||
<DrawerHeader className="gap-1">
|
|
||||||
<DrawerTitle>{item.header}</DrawerTitle>
|
|
||||||
<DrawerDescription>
|
|
||||||
Showing total visitors for the last 6 months
|
|
||||||
</DrawerDescription>
|
|
||||||
</DrawerHeader>
|
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
|
|
||||||
{!isMobile && (
|
|
||||||
<>
|
|
||||||
<ChartContainer config={chartConfig}>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={chartData}
|
|
||||||
margin={{
|
|
||||||
left: 0,
|
|
||||||
right: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="month"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickMargin={8}
|
|
||||||
tickFormatter={(value) => value.slice(0, 3)}
|
|
||||||
hide
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
cursor={false}
|
|
||||||
content={<ChartTooltipContent indicator="dot" />}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="mobile"
|
|
||||||
type="natural"
|
|
||||||
fill="var(--color-mobile)"
|
|
||||||
fillOpacity={0.6}
|
|
||||||
stroke="var(--color-mobile)"
|
|
||||||
stackId="a"
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="desktop"
|
|
||||||
type="natural"
|
|
||||||
fill="var(--color-desktop)"
|
|
||||||
fillOpacity={0.4}
|
|
||||||
stroke="var(--color-desktop)"
|
|
||||||
stackId="a"
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
<Separator />
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex gap-2 leading-none font-medium">
|
|
||||||
Trending up by 5.2% this month{" "}
|
|
||||||
<IconTrendingUp className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Showing total visitors for the last 6 months. This is just
|
|
||||||
some random text to test the layout. It spans multiple lines
|
|
||||||
and should wrap around.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<form className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label htmlFor="header">Header</Label>
|
|
||||||
<Input id="header" defaultValue={item.header} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label htmlFor="type">Type</Label>
|
|
||||||
<Select defaultValue={item.type}>
|
|
||||||
<SelectTrigger id="type" className="w-full">
|
|
||||||
<SelectValue placeholder="Select a type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Table of Contents">
|
|
||||||
Table of Contents
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Executive Summary">
|
|
||||||
Executive Summary
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Technical Approach">
|
|
||||||
Technical Approach
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Design">Design</SelectItem>
|
|
||||||
<SelectItem value="Capabilities">Capabilities</SelectItem>
|
|
||||||
<SelectItem value="Focus Documents">
|
|
||||||
Focus Documents
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Narrative">Narrative</SelectItem>
|
|
||||||
<SelectItem value="Cover Page">Cover Page</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Select defaultValue={item.status}>
|
|
||||||
<SelectTrigger id="status" className="w-full">
|
|
||||||
<SelectValue placeholder="Select a status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Done">Done</SelectItem>
|
|
||||||
<SelectItem value="In Progress">In Progress</SelectItem>
|
|
||||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label htmlFor="target">Target</Label>
|
|
||||||
<Input id="target" defaultValue={item.target} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label htmlFor="limit">Limit</Label>
|
|
||||||
<Input id="limit" defaultValue={item.limit} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label htmlFor="reviewer">Reviewer</Label>
|
|
||||||
<Select defaultValue={item.reviewer}>
|
|
||||||
<SelectTrigger id="reviewer" className="w-full">
|
|
||||||
<SelectValue placeholder="Select a reviewer" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
|
||||||
<SelectItem value="Jamik Tashpulatov">
|
|
||||||
Jamik Tashpulatov
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<DrawerFooter>
|
|
||||||
<Button>Submit</Button>
|
|
||||||
<DrawerClose asChild>
|
|
||||||
<Button variant="outline">Done</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -295,21 +295,22 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
|||||||
}, [open, filters])
|
}, [open, filters])
|
||||||
|
|
||||||
// 전체 유전자 로드
|
// 전체 유전자 로드
|
||||||
|
// TODO: 백엔드 /gene/markers API 구현 후 활성화
|
||||||
const loadAllGenes = async () => {
|
const loadAllGenes = async () => {
|
||||||
try {
|
// try {
|
||||||
setLoadingGenes(true)
|
// setLoadingGenes(true)
|
||||||
const [qtyGenes, qltGenes] = await Promise.all([
|
// const [qtyGenes, qltGenes] = await Promise.all([
|
||||||
geneApi.getGenesByType('QTY'),
|
// geneApi.getGenesByType('QTY'),
|
||||||
geneApi.getGenesByType('QLT'),
|
// geneApi.getGenesByType('QLT'),
|
||||||
])
|
// ])
|
||||||
setQuantityGenes(qtyGenes)
|
// setQuantityGenes(qtyGenes)
|
||||||
setQualityGenes(qltGenes)
|
// setQualityGenes(qltGenes)
|
||||||
} catch {
|
// } catch {
|
||||||
setQuantityGenes([])
|
// setQuantityGenes([])
|
||||||
setQualityGenes([])
|
// setQualityGenes([])
|
||||||
} finally {
|
// } finally {
|
||||||
setLoadingGenes(false)
|
// setLoadingGenes(false)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 활성화 여부
|
// 필터 활성화 여부
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts'
|
|
||||||
|
|
||||||
// 벤치마크 데이터
|
|
||||||
const benchmarkData = [
|
|
||||||
{ category: '유전체 점수', myFarm: 79.8, regional: 65.2, top10: 88.5 },
|
|
||||||
{ category: 'MPT 충족률', myFarm: 74.6, regional: 72.0, top10: 85.2 },
|
|
||||||
{ category: 'A등급 비율', myFarm: 37.5, regional: 18.8, top10: 45.0 },
|
|
||||||
{ category: '번식능력', myFarm: 72, regional: 70, top10: 82 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function RegionBenchmark() {
|
|
||||||
return (
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardHeader className="pb-2 md:pb-3">
|
|
||||||
<CardTitle className="text-xs md:text-sm font-semibold">보은군 비교 벤치마크</CardTitle>
|
|
||||||
<CardDescription className="text-[11px] md:text-xs mt-0.5">
|
|
||||||
내농장 vs 보은군 평균 vs 상위 10%
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 pb-3 md:pb-4">
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<BarChart data={benchmarkData} barGap={4}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="category"
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
stroke="#6b7280"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
stroke="#6b7280"
|
|
||||||
domain={[0, 100]}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '11px'
|
|
||||||
}}
|
|
||||||
formatter={(value: number) => `${value.toFixed(1)}`}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
|
||||||
|
|
||||||
<Bar dataKey="regional" fill="#9ca3af" name="보은군 평균" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="myFarm" fill="#2563eb" name="내 농장" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="top10" fill="#10b981" name="상위 10%" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -61,27 +61,28 @@ export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
// TODO: 백엔드 /gene/markers API 구현 후 활성화
|
||||||
const fetchMarkers = async () => {
|
const fetchMarkers = async () => {
|
||||||
try {
|
// try {
|
||||||
setLoading(true)
|
// setLoading(true)
|
||||||
setError(null)
|
// setError(null)
|
||||||
const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
|
// const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
|
||||||
|
|
||||||
// API 데이터를 GeneOption 형식으로 변환
|
// // API 데이터를 GeneOption 형식으로 변환
|
||||||
const geneOptions: GeneOption[] = markers.map(marker => ({
|
// const geneOptions: GeneOption[] = markers.map(marker => ({
|
||||||
name: marker.markerNm,
|
// name: marker.markerNm,
|
||||||
description: marker.relatedTrait || marker.markerDesc || '',
|
// description: marker.relatedTrait || marker.markerDesc || '',
|
||||||
type: marker.markerTypeCd as 'QTY' | 'QLT',
|
// type: marker.markerTypeCd as 'QTY' | 'QLT',
|
||||||
relatedTrait: marker.relatedTrait || ''
|
// relatedTrait: marker.relatedTrait || ''
|
||||||
}))
|
// }))
|
||||||
|
|
||||||
setAllMarkers(geneOptions)
|
// setAllMarkers(geneOptions)
|
||||||
} catch (err) {
|
// } catch (err) {
|
||||||
console.error('Failed to fetch markers:', err)
|
// console.error('Failed to fetch markers:', err)
|
||||||
setError('유전자 목록을 불러오는데 실패했습니다.')
|
// setError('유전자 목록을 불러오는데 실패했습니다.')
|
||||||
} finally {
|
// } finally {
|
||||||
setLoading(false)
|
// setLoading(false)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 육량형/육질형 필터링
|
// 육량형/육질형 필터링
|
||||||
|
|||||||
@@ -30,16 +30,17 @@ export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChan
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
// TODO: 백엔드 /gene/markers API 구현 후 활성화
|
||||||
const loadAllGenes = async () => {
|
const loadAllGenes = async () => {
|
||||||
try {
|
// try {
|
||||||
setLoading(true)
|
// setLoading(true)
|
||||||
const genes = await geneApi.getAllMarkers()
|
// const genes = await geneApi.getAllMarkers()
|
||||||
setAllGenes(genes)
|
// setAllGenes(genes)
|
||||||
} catch {
|
// } catch {
|
||||||
// 유전자 로드 실패 시 빈 배열 유지
|
// // 유전자 로드 실패 시 빈 배열 유지
|
||||||
} finally {
|
// } finally {
|
||||||
setLoading(false)
|
// setLoading(false)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 및 필터링
|
// 검색 및 필터링
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
|||||||
category: '에너지',
|
category: '에너지',
|
||||||
description: '혈액 내 유리지방산 수치',
|
description: '혈액 내 유리지방산 수치',
|
||||||
},
|
},
|
||||||
bcs: {
|
|
||||||
name: 'BCS',
|
|
||||||
upperLimit: 3.5,
|
|
||||||
lowerLimit: 2.5,
|
|
||||||
unit: '-',
|
|
||||||
category: '에너지',
|
|
||||||
description: '혈액 내 BCS 수치',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 단백질 카테고리
|
// 단백질 카테고리
|
||||||
totalProtein: {
|
totalProtein: {
|
||||||
name: '총단백질',
|
name: '총단백질',
|
||||||
@@ -150,13 +141,13 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 기타 카테고리
|
// 기타 카테고리
|
||||||
creatinine: {
|
creatine: {
|
||||||
name: '크레아티닌',
|
name: '크레아틴',
|
||||||
upperLimit: 1.3,
|
upperLimit: 1.3,
|
||||||
lowerLimit: 1.0,
|
lowerLimit: 1.0,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '기타',
|
category: '기타',
|
||||||
description: '혈액 내 크레아티닌 수치',
|
description: '혈액 내 크레아틴 수치',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface MptDto {
|
|||||||
phosphorus: number;
|
phosphorus: number;
|
||||||
caPRatio: number;
|
caPRatio: number;
|
||||||
magnesium: number;
|
magnesium: number;
|
||||||
creatinine: number;
|
creatine: number;
|
||||||
// Relations
|
// Relations
|
||||||
farm?: {
|
farm?: {
|
||||||
pkFarmNo: number;
|
pkFarmNo: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from '../api-client';
|
import apiClient from '../api-client';
|
||||||
import { ReproMpt } from '@/types/reprompt.types';
|
import { ReproMpt } from '@/types/mpt.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 번식정보(Reproduction) 관련 API - MPT(혈액검사) 전용
|
* 번식정보(Reproduction) 관련 API - MPT(혈액검사) 전용
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
* 유전체 분석 데이터가 유효한지 판단하는 조건 정의
|
* 유전체 분석 데이터가 유효한지 판단하는 조건 정의
|
||||||
* 백엔드 GenomeAnalysisConfig.ts와 동일한 로직 유지
|
* 백엔드 GenomeAnalysisConfig.ts와 동일한 로직 유지
|
||||||
*
|
*
|
||||||
* =================유효 조건=======================
|
* ====================유효 조건====================
|
||||||
* 1. chipSireName === '일치' (아비 칩 데이터 일치)
|
* 1. chipSireName === '일치' (아비 칩 데이터 일치)
|
||||||
* 2. chipDamName !== '불일치' (어미 칩 데이터 불일치가 아님)
|
* 2. chipDamName !== '불일치' (어미 칩 데이터 불일치가 아님)
|
||||||
* 3. chipDamName !== '이력제부재' (어미 이력제 부재가 아님)
|
* 3. chipDamName !== '이력제부재' (어미 이력제 부재가 아님)
|
||||||
* 4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
* 4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
||||||
*
|
*
|
||||||
* 제외되는 경우:
|
* ====================제외되는 경우====================
|
||||||
* - chipSireName !== '일치' (아비 불일치, 이력제부재 등)
|
* - chipSireName !== '일치' (아비 불일치, 이력제부재 등)
|
||||||
* - chipDamName === '불일치' (어미 불일치)
|
* - chipDamName === '불일치' (어미 불일치)
|
||||||
* - chipDamName === '이력제부재' (어미 이력제 부재)
|
* - chipDamName === '이력제부재' (어미 이력제 부재)
|
||||||
@@ -29,6 +29,17 @@ export const EXCLUDED_COW_IDS = [
|
|||||||
'KOR002191642861', // 모근량 갯수 적은데 1회분은 가능 아비명도 일치하는데 유전체 분석 정보가 없음
|
'KOR002191642861', // 모근량 갯수 적은데 1회분은 가능 아비명도 일치하는데 유전체 분석 정보가 없음
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분석불가 개체인지 확인 (모근 오염/불량 등 특수 사유)
|
||||||
|
*
|
||||||
|
* @param cowId - 개체식별번호
|
||||||
|
* @returns 분석불가 개체 여부
|
||||||
|
*/
|
||||||
|
export function isExcludedCow(cowId?: string | null): boolean {
|
||||||
|
if (!cowId) return false;
|
||||||
|
return EXCLUDED_COW_IDS.includes(cowId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 유전체 분석 데이터 유효성 검사
|
* 유전체 분석 데이터 유효성 검사
|
||||||
*
|
*
|
||||||
@@ -54,9 +65,15 @@ export function isValidGenomeAnalysis(
|
|||||||
chipDamName: string | null | undefined,
|
chipDamName: string | null | undefined,
|
||||||
cowId?: string | null,
|
cowId?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
// 개별 제외 개체만 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
|
// 1. 분석불가 개체 (모근 오염/불량 등 특수 사유)
|
||||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
||||||
|
|
||||||
|
// 2. 아비명 일치 필수
|
||||||
|
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
||||||
|
|
||||||
|
// 3. 어미명 불일치/이력제부재 제외
|
||||||
|
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,18 +90,23 @@ export function getInvalidReason(
|
|||||||
chipDamName: string | null | undefined,
|
chipDamName: string | null | undefined,
|
||||||
cowId?: string | null,
|
cowId?: string | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
|
// 1. 개별 제외 개체
|
||||||
if (!chipSireName) return '친자확인 정보 없음';
|
|
||||||
return '부 KPN 친자 불일치';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
|
|
||||||
if (chipDamName === '이력제부재') return '모 이력제 부재';
|
|
||||||
return '모 친자 불일치';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
|
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
|
||||||
return '분석 불가 개체';
|
return '분석불가';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 아비명 상태별 분류
|
||||||
|
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
|
||||||
|
if (chipSireName === '분석불가') return '분석불가';
|
||||||
|
if (chipSireName === '정보없음') return '분석불가';
|
||||||
|
if (!chipSireName) return null; // null은 '-' 표시
|
||||||
|
return '부 불일치'; // 불일치 등
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 어미명 상태별 분류
|
||||||
|
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
|
||||||
|
if (chipDamName === '이력제부재') return '모 이력제부재';
|
||||||
|
return '모 불일치';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -103,19 +125,24 @@ export function getInvalidMessage(
|
|||||||
chipDamName: string | null | undefined,
|
chipDamName: string | null | undefined,
|
||||||
cowId?: string | null,
|
cowId?: string | null,
|
||||||
): string {
|
): string {
|
||||||
|
// 1. 개별 제외 개체
|
||||||
|
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
|
||||||
|
return '모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 아비명 상태별 분류
|
||||||
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
|
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
|
||||||
if (!chipSireName) return '친자확인 정보가 없어 유전체 분석 보고서를 제공할 수 없습니다.';
|
if (chipSireName === '분석불가') return '모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다.';
|
||||||
|
if (chipSireName === '정보없음') return '개체 식별번호 및 형식오류로 유전체 분석 보고서를 제공할 수 없습니다.';
|
||||||
|
if (!chipSireName) return ''; // null은 '-' 표시
|
||||||
return '부 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
|
return '부 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 어미명 상태별 분류
|
||||||
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
|
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
|
||||||
if (chipDamName === '이력제부재') return '모 이력제 정보가 부재하여 유전체 분석 보고서를 제공할 수 없습니다.';
|
if (chipDamName === '이력제부재') return '모 이력제 정보가 부재하여 유전체 분석 보고서를 제공할 수 없습니다.';
|
||||||
return '모 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
|
return '모 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
|
|
||||||
return '해당 개체는 분석 불가 사유로 인해 유전체 분석 보고서를 제공할 수 없습니다.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '유전체 분석 보고서를 제공할 수 없습니다.';
|
return '유전체 분석 보고서를 제공할 수 없습니다.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export interface CowDetailResponseDto extends CowDto {
|
|||||||
totalCows?: number; // 농장 총 개체 수
|
totalCows?: number; // 농장 총 개체 수
|
||||||
inbreedingCoef?: number; // 근친계수 (0.0~1.0)
|
inbreedingCoef?: number; // 근친계수 (0.0~1.0)
|
||||||
calvingCount?: number; // 분만회차
|
calvingCount?: number; // 분만회차
|
||||||
|
|
||||||
|
// 데이터 상태 (백엔드에서 조회)
|
||||||
|
dataStatus?: {
|
||||||
|
hasGenomeData: boolean; // 유전체 데이터 존재 여부
|
||||||
|
hasGeneData: boolean; // 유전자 데이터 존재 여부
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface MptDto extends BaseFields {
|
|||||||
phosphorus?: number; // 인 (mg/dL)
|
phosphorus?: number; // 인 (mg/dL)
|
||||||
caPRatio?: number; // 칼슘/인 비율
|
caPRatio?: number; // 칼슘/인 비율
|
||||||
magnesium?: number; // 마그네슘 (mg/dL)
|
magnesium?: number; // 마그네슘 (mg/dL)
|
||||||
creatinine?: number; // 크레아틴 (mg/dL)
|
creatine?: number; // 크레아틴 (mg/dL)
|
||||||
|
|
||||||
delDt?: string; // 삭제일시 (Soft Delete)
|
delDt?: string; // 삭제일시 (Soft Delete)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ export interface ReproMpt {
|
|||||||
phosphorus?: number;
|
phosphorus?: number;
|
||||||
caPRatio?: number;
|
caPRatio?: number;
|
||||||
magnesium?: number;
|
magnesium?: number;
|
||||||
creatinine?: number;
|
creatine?: number;
|
||||||
|
|
||||||
reproMptNote?: string;
|
reproMptNote?: string;
|
||||||
regDt?: string;
|
regDt?: string;
|
||||||
Reference in New Issue
Block a user