개체분석 상태 값 수정

This commit is contained in:
2025-12-19 15:19:50 +09:00
parent abc2f20495
commit c8bd04f124
24 changed files with 596 additions and 1499 deletions

View 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분해
];
```
> 이 목록에 포함된 개체는 유전체/유전자 탭 모두 분석불가로 처리됨

View File

@@ -64,9 +64,15 @@ export function isValidGenomeAnalysis(
chipDamName: string | null | undefined,
cowId?: string | null,
): boolean {
// 개별 제외 개체 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
// 1. 개별 제외 개체 확인
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;
}

View File

@@ -45,7 +45,7 @@ export const MPT_NORMAL_RANGES = {
* 총 글로불린 (Total Globulin)
* 단위: g/dL
*/
totalGlobulin: { min: 9.1, max: 36.1 },
globulin: { min: 9.1, max: 36.1 },
/**
* A/G 비율 (Albumin/Globulin Ratio)
@@ -75,7 +75,7 @@ export const MPT_NORMAL_RANGES = {
* 지방간 지수 (Fatty Liver Index)
* 단위: 지수
*/
fattyLiverIndex: { min: -1.2, max: 9.9 },
fattyLiverIdx: { min: -1.2, max: 9.9 },
/**
* 칼슘 (Calcium)
@@ -103,10 +103,10 @@ export const MPT_NORMAL_RANGES = {
// ========== 기타 카테고리 ==========
/**
* 크레아티닌 (Creatinine)
* 크레아 (Creatine)
* 단위: mg/dL
*/
creatinine: { min: 1.0, max: 1.3 },
creatine: { min: 1.0, max: 1.3 },
} as const;
/**

View File

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

View File

@@ -19,6 +19,7 @@ import { CowService } from './cow.service';
import { CowModel } from './entities/cow.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.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';
@Module({
@@ -27,6 +28,7 @@ import { FilterEngineModule } from '../shared/filter/filter-engine.module';
CowModel, // 개체 기본 정보 (tb_cow)
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
GeneDetailModel, // 유전자 상세 (tb_gene_detail)
]),
FilterEngineModule, // 필터 엔진 모듈
],

View File

@@ -19,6 +19,7 @@ import { Repository, IsNull } from 'typeorm';
import { CowModel } from './entities/cow.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.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 {
RankingRequestDto,
@@ -57,6 +58,10 @@ export class CowService {
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
// 유전자 상세 Repository (SNP 데이터 접근용)
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
private readonly filterEngineService: FilterEngineService,
) { }
@@ -116,10 +121,10 @@ export class CowService {
* 개체식별번호(cowId)로 단건 조회
*
* @param cowId - 개체식별번호 (예: KOR002119144049)
* @returns 개체 정보 (farm 포함)
* @returns 개체 정보 (farm 포함) + dataStatus (데이터 존재 여부)
* @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({
where: { cowId: cowId, delDt: IsNull() },
relations: ['farm'],
@@ -127,7 +132,26 @@ export class CowService {
if (!cow) {
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: 친자감별 확인 - 유효하지 않으면 분석 불가
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
// 분석불가 사유 결정
let unavailableReason = '분석불가';
if (latestRequest) {
if (latestRequest.chipSireName !== '일치') {
let unavailableReason: string | null = null;
if (!latestRequest || !latestRequest.chipSireName) {
// latestRequest 없거나 chipSireName이 null → '-' 표시 (프론트에서 null은 '-'로 표시)
unavailableReason = null;
} else if (latestRequest.chipSireName === '분석불가' || latestRequest.chipSireName === '정보없음') {
// 분석불가, 정보없음 → 분석불가
unavailableReason = '분석불가';
} else if (latestRequest.chipSireName !== '일치') {
// 불일치 등 그 외 → 부 불일치
unavailableReason = '부 불일치';
} else if (latestRequest.chipDamName === '불일치') {
unavailableReason = '모 불일치';
} else if (latestRequest.chipDamName === '이력제부재') {
unavailableReason = '모 이력제부재';
}
}
return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] };
}

View File

@@ -232,14 +232,14 @@ export class MptModel extends BaseModel {
magnesium: number;
@Column({
name: 'creatinine',
name: 'creatine',
type: 'decimal',
precision: 10,
scale: 2,
nullable: true,
comment: '크레아틴',
})
creatinine: number;
creatine: number;
// Relations
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })

View File

@@ -1,43 +1,40 @@
'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 { SiteHeader } from "@/components/layout/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
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 { 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 { CategoryEvaluationCard } from "./genome/_components/category-evaluation-card"
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
import { NormalDistributionChart } from "./genome/_components/normal-distribution-chart"
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개 형질)
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 [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 () => {
if (geneDataLoaded || geneDataLoading) return // 이미 로드했거나 로딩 중이면 스킵
@@ -264,10 +329,12 @@ export default function CowOverviewPage() {
const geneList = geneDataResult || []
setGeneData(geneList)
setGeneDataLoaded(true)
setHasGeneData(geneList.length > 0)
} catch (geneErr) {
console.error('유전자 데이터 조회 실패:', geneErr)
setGeneData([])
setGeneDataLoaded(true)
setHasGeneData(false)
} finally {
setGeneDataLoading(false)
}
@@ -318,6 +385,11 @@ export default function CowOverviewPage() {
}
setCow(cowDetail)
// dataStatus에서 데이터 존재 여부 설정 (백엔드에서 가벼운 COUNT 쿼리로 확인)
if (cowData.dataStatus) {
setHasGeneData(cowData.dataStatus.hasGeneData)
}
// 유전체 데이터 가져오기
const genomeDataResult = await genomeApi.findByCowNo(cowNo)
setGenomeData(genomeDataResult)
@@ -333,9 +405,6 @@ export default function CowOverviewPage() {
setGenomeRequest(null)
}
// 유전자(SNP) 데이터는 탭 클릭 시 로드 (지연 로딩)
setHasGeneData(true) // 탭은 보여주되, 데이터는 나중에 로드
// 번식능력 데이터 (현재는 목업 - 추후 API 연동)
// TODO: 번식능력 API 연동
setHasReproductionData(false)
@@ -579,8 +648,8 @@ export default function CowOverviewPage() {
>
<Dna className="hidden sm:block h-6 w-6 shrink-0" />
<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'}`}>
{hasGeneData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? '완료' : '미검사'}
<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 && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? '완료' : '미검사'}
</span>
</TabsTrigger>
<TabsTrigger
@@ -695,30 +764,7 @@ export default function CowOverviewPage() {
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
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>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName)}
</div>
</div>
<div>
@@ -731,34 +777,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
{(() => {
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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName)}
</div>
</div>
</div>
@@ -770,30 +789,7 @@ export default function CowOverviewPage() {
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
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>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
</div>
</div>
<div className="flex items-center">
@@ -804,34 +800,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
</div>
</div>
</div>
@@ -1062,30 +1031,7 @@ export default function CowOverviewPage() {
</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>
{(() => {
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>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName)}
</div>
</div>
<div>
@@ -1098,34 +1044,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
{(() => {
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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName)}
</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>
<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>
{(() => {
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>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
</div>
</div>
<div className="flex items-center">
@@ -1169,34 +1065,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
</div>
</div>
</div>
@@ -1333,30 +1202,7 @@ export default function CowOverviewPage() {
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
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>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName)}
</div>
</div>
<div>
@@ -1369,33 +1215,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
{(() => {
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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName)}
</div>
</div>
</div>
@@ -1407,30 +1227,7 @@ export default function CowOverviewPage() {
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
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>
)
}
})()}
{renderSireBadge(genomeRequest?.chipSireName, 'sm')}
</div>
</div>
<div className="flex items-center">
@@ -1441,33 +1238,7 @@ export default function CowOverviewPage() {
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
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
}
})()}
{renderDamBadge(genomeRequest?.chipDamName, 'sm')}
</div>
</div>
</div>
@@ -1477,8 +1248,8 @@ export default function CowOverviewPage() {
{/* 유전자 검색 및 필터 섹션 */}
<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">
{/* 검색창 */}
@@ -1867,15 +1638,154 @@ export default function CowOverviewPage() {
)}
</>
) : (
<Card className="bg-slate-50 border border-border rounded-2xl">
<>
{/* 개체 정보 섹션 */}
<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-4 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"></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-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground">
(SNP) .
<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>
@@ -1955,14 +1865,14 @@ export default function CowOverviewPage() {
</DialogContent>
</Dialog>
{/* 플로팅 맨 위로 버튼 */}
{/* 플로팅 맨 위로 버튼 - 글래스모피즘 */}
{showScrollTop && (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 z-50 w-12 h-12 bg-primary text-white rounded-full shadow-lg hover:bg-primary/90 transition-all duration-300 flex items-center justify-center hover:scale-110 active:scale-95"
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="맨 위로"
>
<ArrowUp className="w-6 h-6" />
<ChevronUp className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
)}
</SidebarProvider>

View File

@@ -16,7 +16,7 @@ const MPT_CATEGORIES = [
{ name: '단백질 대사', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], 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: ['creatinine'], color: 'bg-muted/50' },
{ name: '기타', items: ['creatine'], color: 'bg-muted/50' },
]
// 측정값 상태 판정

View File

@@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { cowApi, reproApi } from "@/lib/api"
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 { CowNavigation } from "../_components/navigation"
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].caPRatio, fieldName: 'caPRatio' },
{ 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 => {

View File

@@ -680,7 +680,7 @@ function MyCowContent() {
<div className="flex flex-col gap-3 sm:gap-4">
{/* 제목 */}
<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>
</div>
@@ -737,12 +737,12 @@ function MyCowContent() {
{/* 필터 옵션들 - 모바일: 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="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
value={rankingMode}
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 />
</SelectTrigger>
<SelectContent>
@@ -751,7 +751,7 @@ function MyCowContent() {
</SelectContent>
</Select>
<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="정렬" />
</SelectTrigger>
<SelectContent>
@@ -765,7 +765,7 @@ function MyCowContent() {
value={sortOrder}
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 />
</SelectTrigger>
<SelectContent>
@@ -776,10 +776,10 @@ function MyCowContent() {
</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>
<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>
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -843,7 +843,7 @@ function MyCowContent() {
</Popover>
<Popover>
<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>
<ChevronsUpDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -986,15 +986,15 @@ function MyCowContent() {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}) : (
}) : cow.unavailableReason ? (
<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-orange-100 text-orange-700' :
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason || '분석불가'}
{cow.unavailableReason}
</span>
)}
) : '-'}
</td>
<td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5">
{(cow.genomeScore !== undefined && cow.genomeScore !== null) ? (
@@ -1197,15 +1197,15 @@ function MyCowContent() {
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{cow.anlysDt ? '분석일' : '분석결과'}</span>
<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 ${
cow.unavailableReason?.includes('부') ? 'bg-red-100 text-red-700' :
cow.unavailableReason?.includes('모') ? 'bg-orange-100 text-orange-700' :
cow.unavailableReason.includes('부') ? 'bg-red-100 text-red-700' :
cow.unavailableReason.includes('모') ? 'bg-orange-100 text-orange-700' :
'bg-slate-100 text-slate-600'
}`}>
{cow.unavailableReason || '분석불가'}
{cow.unavailableReason}
</span>
)}
) : '-'}
</span>
</div>
</div>

View File

@@ -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: '인' },
caPRatio: { min: 1.0, max: 2.0, unit: '', name: 'Ca/P 비율' },
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: '미네랄',
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatinine'],
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatine'],
color: 'bg-purple-500',
},
]

View File

@@ -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 }}
/>
);
}

View File

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

View File

@@ -295,21 +295,22 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
}, [open, filters])
// 전체 유전자 로드
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const loadAllGenes = async () => {
try {
setLoadingGenes(true)
const [qtyGenes, qltGenes] = await Promise.all([
geneApi.getGenesByType('QTY'),
geneApi.getGenesByType('QLT'),
])
setQuantityGenes(qtyGenes)
setQualityGenes(qltGenes)
} catch {
setQuantityGenes([])
setQualityGenes([])
} finally {
setLoadingGenes(false)
}
// try {
// setLoadingGenes(true)
// const [qtyGenes, qltGenes] = await Promise.all([
// geneApi.getGenesByType('QTY'),
// geneApi.getGenesByType('QLT'),
// ])
// setQuantityGenes(qtyGenes)
// setQualityGenes(qltGenes)
// } catch {
// setQuantityGenes([])
// setQualityGenes([])
// } finally {
// setLoadingGenes(false)
// }
}
// 필터 활성화 여부

View File

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

View File

@@ -61,27 +61,28 @@ export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }
}
}, [open])
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const fetchMarkers = async () => {
try {
setLoading(true)
setError(null)
const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
// try {
// setLoading(true)
// setError(null)
// const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
// API 데이터를 GeneOption 형식으로 변환
const geneOptions: GeneOption[] = markers.map(marker => ({
name: marker.markerNm,
description: marker.relatedTrait || marker.markerDesc || '',
type: marker.markerTypeCd as 'QTY' | 'QLT',
relatedTrait: marker.relatedTrait || ''
}))
// // API 데이터를 GeneOption 형식으로 변환
// const geneOptions: GeneOption[] = markers.map(marker => ({
// name: marker.markerNm,
// description: marker.relatedTrait || marker.markerDesc || '',
// type: marker.markerTypeCd as 'QTY' | 'QLT',
// relatedTrait: marker.relatedTrait || ''
// }))
setAllMarkers(geneOptions)
} catch (err) {
console.error('Failed to fetch markers:', err)
setError('유전자 목록을 불러오는데 실패했습니다.')
} finally {
setLoading(false)
}
// setAllMarkers(geneOptions)
// } catch (err) {
// console.error('Failed to fetch markers:', err)
// setError('유전자 목록을 불러오는데 실패했습니다.')
// } finally {
// setLoading(false)
// }
}
// 육량형/육질형 필터링

View File

@@ -30,16 +30,17 @@ export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChan
}
}, [open])
// TODO: 백엔드 /gene/markers API 구현 후 활성화
const loadAllGenes = async () => {
try {
setLoading(true)
const genes = await geneApi.getAllMarkers()
setAllGenes(genes)
} catch {
// 유전자 로드 실패 시 빈 배열 유지
} finally {
setLoading(false)
}
// try {
// setLoading(true)
// const genes = await geneApi.getAllMarkers()
// setAllGenes(genes)
// } catch {
// // 유전자 로드 실패 시 빈 배열 유지
// } finally {
// setLoading(false)
// }
}
// 검색 및 필터링

View File

@@ -38,15 +38,6 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
category: '에너지',
description: '혈액 내 유리지방산 수치',
},
bcs: {
name: 'BCS',
upperLimit: 3.5,
lowerLimit: 2.5,
unit: '-',
category: '에너지',
description: '혈액 내 BCS 수치',
},
// 단백질 카테고리
totalProtein: {
name: '총단백질',
@@ -150,13 +141,13 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
},
// 기타 카테고리
creatinine: {
name: '크레아티닌',
creatine: {
name: '크레아',
upperLimit: 1.3,
lowerLimit: 1.0,
unit: 'mg/dL',
category: '기타',
description: '혈액 내 크레아티닌 수치',
description: '혈액 내 크레아 수치',
},
};

View File

@@ -31,7 +31,7 @@ export interface MptDto {
phosphorus: number;
caPRatio: number;
magnesium: number;
creatinine: number;
creatine: number;
// Relations
farm?: {
pkFarmNo: number;

View File

@@ -1,5 +1,5 @@
import apiClient from '../api-client';
import { ReproMpt } from '@/types/reprompt.types';
import { ReproMpt } from '@/types/mpt.types';
/**
* 번식정보(Reproduction) 관련 API - MPT(혈액검사) 전용

View File

@@ -5,13 +5,13 @@
* 유전체 분석 데이터가 유효한지 판단하는 조건 정의
* 백엔드 GenomeAnalysisConfig.ts와 동일한 로직 유지
*
* =================유효 조건=======================
* ====================유효 조건====================
* 1. chipSireName === '일치' (아비 칩 데이터 일치)
* 2. chipDamName !== '불일치' (어미 칩 데이터 불일치가 아님)
* 3. chipDamName !== '이력제부재' (어미 이력제 부재가 아님)
* 4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
*
* 제외되는 경우:
* ====================제외되는 경우====================
* - chipSireName !== '일치' (아비 불일치, 이력제부재 등)
* - chipDamName === '불일치' (어미 불일치)
* - chipDamName === '이력제부재' (어미 이력제 부재)
@@ -29,6 +29,17 @@ export const EXCLUDED_COW_IDS = [
'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,
cowId?: string | null,
): boolean {
// 개별 제외 개체만 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
// 1. 분석불가 개체 (모근 오염/불량 등 특수 사유)
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;
}
@@ -73,18 +90,23 @@ export function getInvalidReason(
chipDamName: string | null | undefined,
cowId?: string | null,
): string | null {
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
if (!chipSireName) return '친자확인 정보 없음';
return '부 KPN 친자 불일치';
// 1. 개별 제외 개체
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
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 '모 친자 불일치';
}
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
return '분석 불가 개체';
return '모 불일치';
}
return null;
@@ -103,19 +125,24 @@ export function getInvalidMessage(
chipDamName: string | null | undefined,
cowId?: string | null,
): string {
// 1. 개별 제외 개체
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
return '모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다.';
}
// 2. 아비명 상태별 분류
if (chipSireName !== VALID_CHIP_SIRE_NAME) {
if (!chipSireName) return '친자확인 정보가 없어 유전체 분석 보고서를 제공할 수 없습니다.';
if (chipSireName === '분석불가') return '모근 오염 및 불량 등 기타 사유로 유전체 분석 보고서를 제공할 수 없습니다.';
if (chipSireName === '정보없음') return '개체 식별번호 및 형식오류로 유전체 분석 보고서를 제공할 수 없습니다.';
if (!chipSireName) return ''; // null은 '-' 표시
return '부 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
}
// 3. 어미명 상태별 분류
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) {
if (chipDamName === '이력제부재') return '모 이력제 정보가 부재하여 유전체 분석 보고서를 제공할 수 없습니다.';
return '모 친자감별 결과가 불일치하여 유전체 분석 보고서를 제공할 수 없습니다.';
}
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) {
return '해당 개체는 분석 불가 사유로 인해 유전체 분석 보고서를 제공할 수 없습니다.';
}
return '유전체 분석 보고서를 제공할 수 없습니다.';
}

View File

@@ -49,6 +49,12 @@ export interface CowDetailResponseDto extends CowDto {
totalCows?: number; // 농장 총 개체 수
inbreedingCoef?: number; // 근친계수 (0.0~1.0)
calvingCount?: number; // 분만회차
// 데이터 상태 (백엔드에서 조회)
dataStatus?: {
hasGenomeData: boolean; // 유전체 데이터 존재 여부
hasGeneData: boolean; // 유전자 데이터 존재 여부
};
}
/**

View File

@@ -41,7 +41,7 @@ export interface MptDto extends BaseFields {
phosphorus?: number; // 인 (mg/dL)
caPRatio?: number; // 칼슘/인 비율
magnesium?: number; // 마그네슘 (mg/dL)
creatinine?: number; // 크레아틴 (mg/dL)
creatine?: number; // 크레아틴 (mg/dL)
delDt?: string; // 삭제일시 (Soft Delete)
}
@@ -98,7 +98,7 @@ export interface ReproMpt {
phosphorus?: number;
caPRatio?: number;
magnesium?: number;
creatinine?: number;
creatine?: number;
reproMptNote?: string;
regDt?: string;