파일 정리

This commit is contained in:
2025-12-24 22:50:13 +09:00
parent 05d89fdfcd
commit 2877a474eb
22 changed files with 1274 additions and 646 deletions

View File

@@ -213,31 +213,31 @@ export const MPT_CATEGORIES: MptCategory[] = [
{ {
key: 'energy', key: 'energy',
name: '에너지 대사', name: '에너지 대사',
color: 'bg-orange-500', color: 'bg-muted/50',
items: ['glucose', 'cholesterol', 'nefa', 'bcs'], items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
}, },
{ {
key: 'protein', key: 'protein',
name: '단백질 대사', name: '단백질 대사',
color: 'bg-blue-500', color: 'bg-muted/50',
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
}, },
{ {
key: 'liver', key: 'liver',
name: '간기능', name: '간기능',
color: 'bg-green-500', color: 'bg-muted/50',
items: ['ast', 'ggt', 'fattyLiverIdx'], items: ['ast', 'ggt', 'fattyLiverIdx'],
}, },
{ {
key: 'mineral', key: 'mineral',
name: '미네랄', name: '미네랄',
color: 'bg-purple-500', color: 'bg-muted/50',
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'],
}, },
{ {
key: 'etc', key: 'etc',
name: '기타', name: '기타',
color: 'bg-gray-500', color: 'bg-muted/50',
items: ['creatine'], items: ['creatine'],
}, },
]; ];

View File

@@ -189,22 +189,14 @@ export class CowService {
.where('gene.delDt IS NULL') .where('gene.delDt IS NULL')
.getRawMany(), .getRawMany(),
// 번식능력(MPT) 데이터가 있는 cowId와 최신 검사일/월령 // 번식능력(MPT) 데이터가 있는 cowId와 최신 검사일/월령
// 서브쿼리로 최신 검사일 기준 데이터 가져오기 // cowId별 최신 검사일 기준으로 중복 제거 (GROUP BY)
this.mptRepository this.mptRepository
.createQueryBuilder('mpt') .createQueryBuilder('mpt')
.select('mpt.cowId', 'cowId') .select('mpt.cowId', 'cowId')
.addSelect('mpt.testDt', 'testDt') .addSelect('MAX(mpt.testDt)', 'testDt')
.addSelect('mpt.monthAge', 'monthAge') .addSelect('MAX(mpt.monthAge)', 'monthAge')
.where('mpt.delDt IS NULL') .where('mpt.delDt IS NULL')
.andWhere(qb => { .groupBy('mpt.cowId')
const subQuery = qb.subQuery()
.select('MAX(sub.testDt)')
.from('tb_mpt', 'sub')
.where('sub.cow_id = mpt.cowId')
.andWhere('sub.del_dt IS NULL')
.getQuery();
return `mpt.testDt = ${subQuery}`;
})
.getRawMany(), .getRawMany(),
]); ]);
@@ -223,8 +215,23 @@ export class CowService {
.map(c => [c.cowId, { testDt: c.testDt, monthAge: c.monthAge }]) .map(c => [c.cowId, { testDt: c.testDt, monthAge: c.monthAge }])
); );
// 데이터가 있는 개체가 없으면 빈 배열 반환 // 데이터가 있는 개체가 없으면 빈 배열 반환 (단, 테스트 농장 예외)
if (allCowIds.length === 0) { const TEST_FARM_NO = 26; // 코쿤 테스트 농장
// farmNo 체크: filterOptions.farmNo 또는 filterOptions.filters에서 추출
let isTestFarm = Number(filterOptions?.farmNo) === TEST_FARM_NO;
if (!isTestFarm && filterOptions?.filters) {
const farmFilter = filterOptions.filters.find(
(f: { field: string; value: number | number[] }) => f.field === 'cow.fkFarmNo'
);
if (farmFilter) {
const farmNos = Array.isArray(farmFilter.value) ? farmFilter.value : [farmFilter.value];
// 숫자/문자열 모두 처리 (프론트에서 문자열로 올 수 있음)
isTestFarm = farmNos.map(Number).includes(TEST_FARM_NO);
}
}
if (allCowIds.length === 0 && !isTestFarm) {
return { cows: [], mptCowIdMap }; return { cows: [], mptCowIdMap };
} }
@@ -232,8 +239,12 @@ export class CowService {
const queryBuilder = this.cowRepository const queryBuilder = this.cowRepository
.createQueryBuilder('cow') .createQueryBuilder('cow')
.leftJoinAndSelect('cow.farm', 'farm') .leftJoinAndSelect('cow.farm', 'farm')
.where('cow.cowId IN (:...cowIds)', { cowIds: allCowIds }) .where('cow.delDt IS NULL');
.andWhere('cow.delDt IS NULL');
// 테스트 농장(26번)은 tb_cow 전체 조회, 그 외는 데이터 있는 개체만
if (!isTestFarm && allCowIds.length > 0) {
queryBuilder.andWhere('cow.cowId IN (:...cowIds)', { cowIds: allCowIds });
}
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성) // farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
if (filterOptions?.farmNo) { if (filterOptions?.farmNo) {
@@ -242,18 +253,29 @@ export class CowService {
}); });
} }
// FilterEngine 사용하여 동적 필터 적용 // FilterEngine 사용하여 동적 필터 적용 (페이지네이션 없이 전체 조회)
if (filterOptions?.filters) { if (filterOptions?.filters) {
const result = await this.filterEngineService.executeFilteredQuery( const result = await this.filterEngineService.executeFilteredQuery(
queryBuilder, queryBuilder,
filterOptions, {
...filterOptions,
pagination: { page: 1, limit: 10000 }, // 전체 조회 (프론트에서 페이지네이션 처리)
},
); );
return { cows: result.data, mptCowIdMap }; // cowId 기준 중복 제거 (tb_cow에 같은 cowId가 여러 row일 수 있음)
const uniqueCows = Array.from(
new Map(result.data.map((cow: CowModel) => [cow.cowId, cow])).values()
);
return { cows: uniqueCows, mptCowIdMap };
} }
// 필터 없으면 전체 조회 (최신순) // 필터 없으면 전체 조회 (최신순)
const cows = await queryBuilder.orderBy('cow.regDt', 'DESC').getMany(); const cows = await queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
return { cows, mptCowIdMap }; // cowId 기준 중복 제거
const uniqueCows = Array.from(
new Map(cows.map(cow => [cow.cowId, cow])).values()
);
return { cows: uniqueCows, mptCowIdMap };
} }
// ============================================================ // ============================================================

View File

@@ -0,0 +1,25 @@
/**
* 카테고리별 평균 EBV 정보
*/
export interface CategoryAverageDto {
/** 카테고리명 (성장/생산/체형/무게/비율) */
category: string;
/** 평균 EBV 값 (표준화 육종가) */
avgEbv: number;
/** 평균 EPD 값 (원래 육종가) */
avgEpd: number;
/** 해당 카테고리의 데이터 개수 */
count: number;
}
/**
* 전국/지역/농가 비교 평균 데이터
*/
export interface ComparisonAveragesDto {
/** 전국 평균 */
nationwide: CategoryAverageDto[];
/** 지역 평균 */
region: CategoryAverageDto[];
/** 농가 평균 */
farm: CategoryAverageDto[];
}

View File

@@ -0,0 +1,102 @@
/**
* 대시보드 요약 정보 DTO
*/
export interface DashboardSummaryDto {
// 요약
summary: {
totalCows: number; // 검사 받은 전체 개체 수
genomeCowCount: number; // 유전체 분석 개체 수
geneCowCount: number; // 유전자검사 개체 수
mptCowCount: number; // 번식능력검사 개체 수
totalRequests: number; // 유전체 의뢰 건수
analyzedCount: number; // 분석 완료
pendingCount: number; // 대기
mismatchCount: number; // 불일치
maleCount: number; // 수컷 수
femaleCount: number; // 암컷 수
};
// 친자감별 결과 현황
paternityStats: {
analysisComplete: number; // 분석 완료
sireMismatch: number; // 부 불일치
damMismatch: number; // 모 불일치
damNoRecord: number; // 모 이력제부재
notAnalyzed: number; // 미분석
};
// 검사 종류별 현황
testTypeStats: {
snp: { total: number; completed: number };
ms: { total: number; completed: number };
};
}
/**
* 연도별 통계 DTO
*/
export interface YearlyStatsDto {
// 연도별 분석 현황
yearlyStats: {
year: number;
totalRequests: number;
analyzedCount: number;
pendingCount: number;
sireMatchCount: number;
analyzeRate: number;
sireMatchRate: number;
}[];
// 월별 접수 현황
monthlyStats: { month: number; count: number }[];
// 연도별 평균 EBV (농가 vs 보은군)
yearlyAvgEbv: {
year: number;
farmAvgEbv: number;
regionAvgEbv: number;
traitCount: number;
}[];
}
/**
* 형질 평균 DTO
*/
export interface TraitAveragesDto {
traitAverages: {
traitName: string;
category: string;
avgEbv: number;
avgEpd: number;
avgPercentile: number;
count: number;
rank: number | null;
totalFarms: number;
percentile: number | null;
regionAvgEpd?: number;
}[];
// 연도별 형질 평균 (차트용)
yearlyTraitAverages: {
year: number;
traits: { traitName: string; avgEbv: number | null }[];
}[];
}
/**
* 접수 내역 DTO
*/
export interface RequestHistoryDto {
requestHistory: {
pkRequestNo: number;
cowId: string;
cowRemarks: string | null;
requestDt: string | null;
chipSireName: string | null;
chipReportDt: string | null;
status: string;
}[];
}
/**
* 칩/모근 통계 DTO
*/
export interface ChipStatsDto {
chipTypeStats: { chipType: string; count: number }[];
sampleAmountStats: { sampleAmount: string; count: number }[];
}

View File

@@ -0,0 +1,19 @@
/**
* 형질별 평균 EBV 응답 DTO
*/
export interface TraitAverageDto {
traitName: string; // 형질명
category: string; // 카테고리
avgEbv: number; // 평균 EBV (표준화 육종가)
avgEpd: number; // 평균 EPD (육종가 원본값)
count: number; // 데이터 개수
}
/**
* 형질별 비교 평균 응답 DTO
*/
export interface TraitComparisonAveragesDto {
nationwide: TraitAverageDto[]; // 전국 평균
region: TraitAverageDto[]; // 지역(군) 평균
farm: TraitAverageDto[]; // 농장 평균
}

View File

@@ -1,11 +1,13 @@
import { BaseModel } from 'src/common/entities/base.entity'; import { BaseModel } from 'src/common/entities/base.entity';
import { CowModel } from 'src/cow/entities/cow.entity'; import { CowModel } from 'src/cow/entities/cow.entity';
import { FarmModel } from 'src/farm/entities/farm.entity'; import { FarmModel } from 'src/farm/entities/farm.entity';
import { GenomeTraitDetailModel } from './genome-trait-detail.entity';
import { import {
Column, Column,
Entity, Entity,
JoinColumn, JoinColumn,
ManyToOne, ManyToOne,
OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } from 'typeorm';
@@ -189,4 +191,7 @@ export class GenomeRequestModel extends BaseModel {
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' }) @ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'fk_farm_no' }) @JoinColumn({ name: 'fk_farm_no' })
farm: FarmModel; farm: FarmModel;
@OneToMany(() => GenomeTraitDetailModel, (trait) => trait.genomeRequest)
traitDetails: GenomeTraitDetailModel[];
} }

View File

@@ -1,17 +1,6 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common'; import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { GenomeService } from './genome.service'; import { GenomeService } from './genome.service';
import { ComparisonAveragesDto } from './dto/comparison-averages.dto';
export interface CategoryAverageDto {
category: string;
avgEbv: number;
count: number;
}
export interface ComparisonAveragesDto {
nationwide: CategoryAverageDto[];
region: CategoryAverageDto[];
farm: CategoryAverageDto[];
}
@Controller('genome') @Controller('genome')
export class GenomeController { export class GenomeController {
@@ -100,6 +89,16 @@ export class GenomeController {
} }
/**
* GET /genome/yearly-ebv-stats/:farmNo
* 연도별 EBV 통계 (개체상세 > 유전체 통합비교용)
* @param farmNo - 농장 번호
*/
@Get('yearly-ebv-stats/:farmNo')
getYearlyEbvStats(@Param('farmNo') farmNo: string) {
return this.genomeService.getYearlyEbvStats(+farmNo);
}
/** /**
* GET /genome/yearly-trait-trend/:farmNo * GET /genome/yearly-trait-trend/:farmNo
* 연도별 유전능력 추이 (형질별/카테고리별) * 연도별 유전능력 추이 (형질별/카테고리별)

View File

@@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm'; import { IsNull, Repository } from 'typeorm';
import { import {
isValidGenomeAnalysis, isValidGenomeAnalysis,
VALID_CHIP_SIRE_NAME
} from '../common/config/GenomeAnalysisConfig'; } from '../common/config/GenomeAnalysisConfig';
import { import {
ALL_TRAITS, ALL_TRAITS,
@@ -17,46 +16,8 @@ import { GenomeRequestModel } from './entities/genome-request.entity';
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity'; import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
import { MptModel } from '../mpt/entities/mpt.entity'; import { MptModel } from '../mpt/entities/mpt.entity';
import { GeneDetailModel } from '../gene/entities/gene-detail.entity'; import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
import { CategoryAverageDto, ComparisonAveragesDto } from './dto/comparison-averages.dto';
/** import { TraitAverageDto, TraitComparisonAveragesDto } from './dto/trait-comparison.dto';
* 카테고리별 평균 EBV(추정육종가) 응답 DTO
*/
interface CategoryAverageDto {
category: string; // 카테고리명 (성장/생산/체형/무게/비율)
avgEbv: number; // 평균 EBV 값 (표준화 육종가)
avgEpd: number; // 평균 EPD 값 (원래 육종가)
count: number; // 해당 카테고리의 데이터 개수
}
/**
* 비교 평균 응답 DTO
* 전국/지역/농장 3단계로 평균값 제공
*/
interface ComparisonAveragesDto {
nationwide: CategoryAverageDto[]; // 전국 평균
region: CategoryAverageDto[]; // 지역(군) 평균
farm: CategoryAverageDto[]; // 농장 평균
}
/**
* 형질별 평균 EBV 응답 DTO
*/
interface TraitAverageDto {
traitName: string; // 형질명
category: string; // 카테고리
avgEbv: number; // 평균 EBV (표준화 육종가)
avgEpd: number; // 평균 EPD (육종가 원본값)
count: number; // 데이터 개수
}
/**
* 형질별 비교 평균 응답 DTO
*/
export interface TraitComparisonAveragesDto {
nationwide: TraitAverageDto[]; // 전국 평균
region: TraitAverageDto[]; // 지역(군) 평균
farm: TraitAverageDto[]; // 농장 평균
}
/** /**
* 유전체 분석 서비스 * 유전체 분석 서비스
@@ -105,6 +66,8 @@ export class GenomeService {
* - 형질별 농장 평균 EBV * - 형질별 농장 평균 EBV
* - 접수 내역 목록 * - 접수 내역 목록
* *
* @usedBy /dashboard - 대시보드 페이지
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교
* @param farmNo - 농장 번호 * @param farmNo - 농장 번호
*/ */
async getDashboardStats(farmNo: number): Promise<{ async getDashboardStats(farmNo: number): Promise<{
@@ -164,7 +127,7 @@ export class GenomeService {
sireMismatch: number; // 부 불일치 sireMismatch: number; // 부 불일치
damMismatch: number; // 모 불일치 (부 일치 + 모 불일치) damMismatch: number; // 모 불일치 (부 일치 + 모 불일치)
damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재) damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재)
pending: number; // 대기 notAnalyzed: number; // 미분석
}; };
// 월별 접수 현황 // 월별 접수 현황
monthlyStats: { month: number; count: number }[]; monthlyStats: { month: number; count: number }[];
@@ -185,28 +148,13 @@ export class GenomeService {
traitCount: number; traitCount: number;
}[]; }[];
}> { }> {
// Step 1: 농장의 모든 분석 의뢰 조회 // Step 1: 농장의 모든 분석 의뢰 조회 (traitDetails 포함)
const requests = await this.genomeRequestRepository.find({ const requests = await this.genomeRequestRepository.find({
where: { fkFarmNo: farmNo, delDt: IsNull() }, where: { fkFarmNo: farmNo, delDt: IsNull() },
relations: ['cow'], relations: ['cow', 'traitDetails'],
order: { requestDt: 'DESC', regDt: 'DESC' }, order: { requestDt: 'DESC', regDt: 'DESC' },
}); });
// Step 1.5: 모든 형질 상세 데이터를 한 번에 조회 (N+1 문제 해결)
const allTraitDetails = await this.genomeTraitDetailRepository.find({
where: { delDt: IsNull() },
});
// cowId로 형질 데이터를 그룹화 (Map으로 빠른 조회)
const traitDetailsByCowId = new Map<string, typeof allTraitDetails>();
for (const detail of allTraitDetails) {
if (!detail.cowId) continue;
if (!traitDetailsByCowId.has(detail.cowId)) {
traitDetailsByCowId.set(detail.cowId, []);
}
traitDetailsByCowId.get(detail.cowId)!.push(detail);
}
// Step 2: 연도별 통계 계산 // Step 2: 연도별 통계 계산
const yearMap = new Map<number, { total: number; analyzed: number; pending: number; sireMatch: number }>(); const yearMap = new Map<number, { total: number; analyzed: number; pending: number; sireMatch: number }>();
@@ -243,7 +191,7 @@ export class GenomeService {
sireMatchRate: stat.total > 0 ? Math.round((stat.sireMatch / stat.total) * 100) : 0, sireMatchRate: stat.total > 0 ? Math.round((stat.sireMatch / stat.total) * 100) : 0,
})); }));
// Step 3: 분석 완료된 개체의 형질 데이터 수집 (메모리에서 처리) // Step 3: 분석 완료된 개체의 형질 데이터 수집
const validRequests = requests.filter(r => r.chipSireName === '일치'); const validRequests = requests.filter(r => r.chipSireName === '일치');
const traitDataMap = new Map<string, { sum: number; epdSum: number; percentileSum: number; count: number; category: string }>(); const traitDataMap = new Map<string, { sum: number; epdSum: number; percentileSum: number; count: number; category: string }>();
@@ -251,8 +199,8 @@ export class GenomeService {
const yearlyTraitMap = new Map<number, Map<string, { sum: number; count: number }>>(); const yearlyTraitMap = new Map<number, Map<string, { sum: number; count: number }>>();
for (const request of validRequests) { for (const request of validRequests) {
// Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) // relations로 조회된 traitDetails 사용
const details = traitDetailsByCowId.get(request.cow?.cowId || '') || []; const details = request.traitDetails || [];
if (details.length === 0) continue; if (details.length === 0) continue;
// 연도 추출 // 연도 추출
@@ -290,14 +238,15 @@ export class GenomeService {
} }
// 형질별 평균 계산 (순위 계산을 위해 보은군 내 모든 농가 데이터 필요) // 형질별 평균 계산 (순위 계산을 위해 보은군 내 모든 농가 데이터 필요)
// Step: 보은군 내 모든 농가의 형질별 평균 EBV 계산 (메모리에서 처리) // 보은군 내 모든 농가의 형질별 평균 EBV 계산
const allFarmsTraitMap = new Map<number, Map<string, { sum: number; count: number }>>(); const allFarmsTraitMap = new Map<number, Map<string, { sum: number; count: number }>>();
// 보은군 내 모든 분석 완료된 요청 조회 // 보은군 내 모든 분석 완료된 요청 조회 (traitDetails 포함)
const allRegionValidRequests = await this.genomeRequestRepository const allRegionValidRequests = await this.genomeRequestRepository
.createQueryBuilder('req') .createQueryBuilder('req')
.leftJoinAndSelect('req.cow', 'cow') .leftJoinAndSelect('req.cow', 'cow')
.leftJoinAndSelect('req.farm', 'farm') .leftJoinAndSelect('req.farm', 'farm')
.leftJoinAndSelect('req.traitDetails', 'traitDetails')
.where('req.delDt IS NULL') .where('req.delDt IS NULL')
.andWhere('req.chipSireName = :match', { match: '일치' }) .andWhere('req.chipSireName = :match', { match: '일치' })
.getMany(); .getMany();
@@ -306,8 +255,8 @@ export class GenomeService {
const reqFarmNo = req.fkFarmNo; const reqFarmNo = req.fkFarmNo;
if (!reqFarmNo) continue; if (!reqFarmNo) continue;
// Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) // relations로 조회된 traitDetails 사용
const details = traitDetailsByCowId.get(req.cow?.cowId || '') || []; const details = req.traitDetails || [];
if (details.length === 0) continue; if (details.length === 0) continue;
if (!allFarmsTraitMap.has(reqFarmNo)) { if (!allFarmsTraitMap.has(reqFarmNo)) {
@@ -350,7 +299,7 @@ export class GenomeService {
// 보은군 전체 형질별 평균 EPD 계산 (모든 농가의 모든 개체 데이터 사용) // 보은군 전체 형질별 평균 EPD 계산 (모든 농가의 모든 개체 데이터 사용)
const regionTraitEpdMap = new Map<string, { sum: number; count: number }>(); const regionTraitEpdMap = new Map<string, { sum: number; count: number }>();
for (const req of allRegionValidRequests) { for (const req of allRegionValidRequests) {
const details = traitDetailsByCowId.get(req.cow?.cowId || '') || []; const details = req.traitDetails || [];
for (const detail of details) { for (const detail of details) {
if (detail.traitVal !== null && detail.traitName) { if (detail.traitVal !== null && detail.traitName) {
const traitName = detail.traitName; const traitName = detail.traitName;
@@ -421,21 +370,21 @@ export class GenomeService {
})), })),
})); }));
// 보은군 전체 연도별 평균 계산을 위한 데이터 조회 // 보은군 전체 연도별 평균 계산을 위한 데이터 조회 (traitDetails 포함)
const allRegionRequests = await this.genomeRequestRepository.find({ const allRegionRequests = await this.genomeRequestRepository.find({
where: { delDt: IsNull() }, where: { delDt: IsNull() },
relations: ['cow'], relations: ['cow', 'traitDetails'],
}); });
// 보은군 연도별 형질 데이터 수집 (메모리에서 처리) // 보은군 연도별 형질 데이터 수집
const regionYearlyTraitMap = new Map<number, Map<string, { sum: number; count: number }>>(); const regionYearlyTraitMap = new Map<number, Map<string, { sum: number; count: number }>>();
for (const req of allRegionRequests) { for (const req of allRegionRequests) {
if (!isValidGenomeAnalysis(req.chipSireName, req.chipDamName, req.cow?.cowId)) continue; if (!isValidGenomeAnalysis(req.chipSireName, req.chipDamName, req.cow?.cowId)) continue;
const year = req.requestDt ? new Date(req.requestDt).getFullYear() : new Date().getFullYear(); const year = req.requestDt ? new Date(req.requestDt).getFullYear() : new Date().getFullYear();
// Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) // relations로 조회된 traitDetails 사용
const details = traitDetailsByCowId.get(req.cow?.cowId || '') || []; const details = req.traitDetails || [];
if (details.length === 0) continue; if (details.length === 0) continue;
if (!regionYearlyTraitMap.has(year)) { if (!regionYearlyTraitMap.has(year)) {
@@ -565,12 +514,18 @@ export class GenomeService {
const farmMptCowIds = mptCowIds.filter(id => farmCowIds.has(id)); const farmMptCowIds = mptCowIds.filter(id => farmCowIds.has(id));
// 합집합 계산 (중복 제외) - genomeRequest 포함 (리스트 페이지와 일치) // 합집합 계산 (중복 제외) - genomeRequest 포함 (리스트 페이지와 일치)
const allTestedCowIds = new Set([ const TEST_FARM_NO = 26; // 코쿤 테스트 농장
...farmGenomeRequestCowIds, const isTestFarm = farmNo === TEST_FARM_NO;
...farmGenomeCowIds,
...farmGeneCowIds, // 테스트 농장(26번)은 tb_cow 전체, 그 외는 검사 받은 개체만
...farmMptCowIds, const allTestedCowIds = isTestFarm
]); ? farmCowIds
: new Set([
...farmGenomeRequestCowIds,
...farmGenomeCowIds,
...farmGeneCowIds,
...farmMptCowIds,
]);
const totalCows = allTestedCowIds.size; const totalCows = allTestedCowIds.size;
const genomeCowCount = farmGenomeCowIds.length; const genomeCowCount = farmGenomeCowIds.length;
@@ -620,8 +575,8 @@ export class GenomeService {
damMismatch: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '불일치').length, damMismatch: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '불일치').length,
// 모 이력제부재 (부 일치 + 모 이력제부재) // 모 이력제부재 (부 일치 + 모 이력제부재)
damNoRecord: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '이력제부재').length, damNoRecord: requests.filter(r => r.chipSireName === '일치' && r.chipDamName === '이력제부재').length,
// 대기 (chipSireName이 없는 경우) // 미분석 (chipSireName이 없는 경우)
pending: requests.filter(r => !r.chipSireName).length, notAnalyzed: requests.filter(r => !r.chipSireName).length,
}; };
// Step 8: 월별 접수 현황 (올해 기준) // Step 8: 월별 접수 현황 (올해 기준)
@@ -694,6 +649,7 @@ export class GenomeService {
* 개체식별번호(cowId)로 유전체 데이터 조회 * 개체식별번호(cowId)로 유전체 데이터 조회
* 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환 * 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환
* *
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (유전체 데이터 조회)
* @param cowId - 개체식별번호 (예: KOR123456789) * @param cowId - 개체식별번호 (예: KOR123456789)
* @returns 유전체 분석 결과 배열 * @returns 유전체 분석 결과 배열
* - request: 분석 의뢰 정보 * - request: 분석 의뢰 정보
@@ -754,6 +710,7 @@ export class GenomeService {
/** /**
* 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회 * 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회
* *
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (분석 의뢰 정보 조회)
* @param cowId - 개체식별번호 (예: KOR002115897818) * @param cowId - 개체식별번호 (예: KOR002115897818)
* @returns 최신 분석 의뢰 정보 (없으면 null) * @returns 최신 분석 의뢰 정보 (없으면 null)
*/ */
@@ -787,6 +744,7 @@ export class GenomeService {
* 사용 목적: 특정 개체의 유전능력을 전국 평균, 같은 지역(군) 평균, * 사용 목적: 특정 개체의 유전능력을 전국 평균, 같은 지역(군) 평균,
* 같은 농장 평균과 비교하여 상대적 위치 파악 * 같은 농장 평균과 비교하여 상대적 위치 파악
* *
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (카테고리별 레이더 차트)
* @param cowId - 개체식별번호 (예: KOR123456789) * @param cowId - 개체식별번호 (예: KOR123456789)
* @returns 전국/지역/농장별 카테고리 평균 EBV * @returns 전국/지역/농장별 카테고리 평균 EBV
* @throws NotFoundException - 개체를 찾을 수 없는 경우 * @throws NotFoundException - 개체를 찾을 수 없는 경우
@@ -850,6 +808,7 @@ export class GenomeService {
* 사용 목적: 폴리곤 차트에서 형질별로 정확한 비교를 위해 * 사용 목적: 폴리곤 차트에서 형질별로 정확한 비교를 위해
* 전국/지역/농장 평균을 형질 단위로 제공 * 전국/지역/농장 평균을 형질 단위로 제공
* *
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (형질별 폴리곤 차트)
* @param cowId - 개체식별번호 (예: KOR123456789) * @param cowId - 개체식별번호 (예: KOR123456789)
* @returns 전국/지역/농장별 형질별 평균 EBV * @returns 전국/지역/농장별 형질별 평균 EBV
* @throws NotFoundException - 개체를 찾을 수 없는 경우 * @throws NotFoundException - 개체를 찾을 수 없는 경우
@@ -1036,6 +995,7 @@ export class GenomeService {
/** /**
* 단일 개체 선발지수(가중 평균) 계산 + 전국/지역 순위 * 단일 개체 선발지수(가중 평균) 계산 + 전국/지역 순위
* *
* @usedBy /cow/[cowNo] - 개체 상세 페이지 (선발지수 계산)
* @param cowId - 개체식별번호 (예: KOR002119144049) * @param cowId - 개체식별번호 (예: KOR002119144049)
* @param traitConditions - 형질별 가중치 조건 배열 * @param traitConditions - 형질별 가중치 조건 배열
* @returns 선발지수 점수, 순위, 상세 내역 * @returns 선발지수 점수, 순위, 상세 내역
@@ -1333,6 +1293,8 @@ export class GenomeService {
/** /**
* 개별 형질 기준 순위 조회 * 개별 형질 기준 순위 조회
*
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교, 정규분포 차트
* @param cowId - 개체식별번호 (KOR...) * @param cowId - 개체식별번호 (KOR...)
* @param traitName - 형질명 (도체중, 근내지방도 등) * @param traitName - 형질명 (도체중, 근내지방도 등)
*/ */
@@ -1480,7 +1442,9 @@ export class GenomeService {
/** /**
* 농가의 보은군 내 순위 조회 (대시보드용) * 농가의 보은군 내 순위 조회 (대시보드용)
* 성능 최적화: N+1 쿼리 문제 해결 - 모든 데이터를 한 번에 조회 후 메모리에서 처리 * JOIN으로 한 번에 조회
*
* @usedBy /dashboard - 대시보드 페이지 (농가 순위 카드)
* @param farmNo - 농장 번호 * @param farmNo - 농장 번호
*/ */
async getFarmRegionRanking( async getFarmRegionRanking(
@@ -1516,43 +1480,28 @@ export class GenomeService {
}; };
} }
// 2. 모든 유전체 분석 의뢰 조회 // 2. 모든 유전체 분석 의뢰 조회 (traitDetails 포함)
const allRequests = await this.genomeRequestRepository.find({ const allRequests = await this.genomeRequestRepository.find({
where: { delDt: IsNull() }, where: { delDt: IsNull() },
relations: ['cow', 'farm'], relations: ['cow', 'farm', 'traitDetails'],
}); });
// 3. 모든 형질 상세 데이터를 한 번에 조회 (N+1 문제 해결) // 3. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import)
const allTraitDetails = await this.genomeTraitDetailRepository.find({
where: { delDt: IsNull() },
});
// cowId로 형질 데이터를 그룹화 (Map으로 빠른 조회)
const traitDetailsByCowId = new Map<string, typeof allTraitDetails>();
for (const detail of allTraitDetails) {
if (!detail.cowId) continue;
if (!traitDetailsByCowId.has(detail.cowId)) {
traitDetailsByCowId.set(detail.cowId, []);
}
traitDetailsByCowId.get(detail.cowId)!.push(detail);
}
// 4. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import)
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0 const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
? inputTraitConditions // 프론트에서 보낸 형질사용 ? inputTraitConditions // 프론트에서 보낸 형질사용
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용 : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용
console.log('[getFarmRegionRanking] traitConditions:', traitConditions.length, 'traits'); console.log('[getFarmRegionRanking] traitConditions:', traitConditions.length, 'traits');
// 5. 각 개체별 점수 계산 (메모리에서 처리 - DB 쿼리 없음) // 4. 각 개체별 점수 계산
const allScores: { cowId: string; score: number; farmNo: number | null }[] = []; const allScores: { cowId: string; score: number; farmNo: number | null }[] = [];
for (const request of allRequests) { for (const request of allRequests) {
if (!request.cow?.cowId) continue; if (!request.cow?.cowId) continue;
if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue; if (!isValidGenomeAnalysis(request.chipSireName, request.chipDamName, request.cow?.cowId)) continue;
// Map에서 형질 데이터 조회 (DB 쿼리 없이 O(1) 조회) // relations로 조회된 traitDetails 사용
const traitDetails = traitDetailsByCowId.get(request.cow.cowId); const traitDetails = request.traitDetails;
if (!traitDetails || traitDetails.length === 0) continue; if (!traitDetails || traitDetails.length === 0) continue;
let weightedSum = 0; let weightedSum = 0;
@@ -1657,9 +1606,41 @@ export class GenomeService {
} }
/** /**
* 연도별 유전능력 추이 (형질별/카테고리별) * 연도별 EBV 통계 조회 (개체상세용)
* 최적화: N+1 쿼리 문제 해결 - 단일 쿼리로 모든 데이터 조회 * getDashboardStats의 yearlyStats와 yearlyAvgEbv 부분만 반환
* *
* @usedBy /cow/[cowNo]/genome - 개체 상세 > 유전체 통합 비교
* @param farmNo - 농장 번호
*/
async getYearlyEbvStats(farmNo: number): Promise<{
yearlyStats: {
year: number;
totalRequests: number;
analyzedCount: number;
pendingCount: number;
sireMatchCount: number;
analyzeRate: number;
sireMatchRate: number;
}[];
yearlyAvgEbv: {
year: number;
farmAvgEbv: number;
regionAvgEbv: number;
traitCount: number;
}[];
}> {
const dashboardStats = await this.getDashboardStats(farmNo);
return {
yearlyStats: dashboardStats.yearlyStats,
yearlyAvgEbv: dashboardStats.yearlyAvgEbv,
};
}
/**
* 연도별 유전능력 추이 (형질별/카테고리별)
* JOIN으로 한 번에 조회
*
* @usedBy /dashboard - 대시보드 페이지 (연도별 추이 차트)
* @param farmNo - 농장 번호 * @param farmNo - 농장 번호
* @param traitName - 형질명 (선택, 없으면 카테고리 전체) * @param traitName - 형질명 (선택, 없으면 카테고리 전체)
* @param category - 카테고리명 (성장/생산/체형/무게/비율) * @param category - 카테고리명 (성장/생산/체형/무게/비율)
@@ -1695,8 +1676,7 @@ export class GenomeService {
// 대상 형질 결정 // 대상 형질 결정
const targetTraits = traitName ? [traitName] : traitsInCategory; const targetTraits = traitName ? [traitName] : traitsInCategory;
// 단일 쿼리로 모든 데이터 조회 (N+1 문제 해결) // JOIN으로 한 번에 조회 (genome_request + cow + genome_trait_detail)
// genome_request + cow + genome_trait_detail을 한번에 조인
const allData = await this.genomeRequestRepository const allData = await this.genomeRequestRepository
.createQueryBuilder('r') .createQueryBuilder('r')
.innerJoin('r.cow', 'c') .innerJoin('r.cow', 'c')

View File

@@ -0,0 +1,20 @@
/**
* MPT 통계 응답 DTO
*/
export interface MptStatisticsDto {
totalMptCows: number;
latestTestDate: Date | null;
categories: {
energy: { safe: number; caution: number };
protein: { safe: number; caution: number };
liver: { safe: number; caution: number };
mineral: { safe: number; caution: number };
};
riskyCows: Array<{
cowId: string;
category: string;
itemName: string;
value: number;
status: 'high' | 'low';
}>;
}

View File

@@ -3,6 +3,7 @@ import { FarmModel } from 'src/farm/entities/farm.entity';
import { import {
Column, Column,
Entity, Entity,
Index,
JoinColumn, JoinColumn,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@@ -20,6 +21,7 @@ export class MptModel extends BaseModel {
}) })
pkMptNo: number; pkMptNo: number;
@Index('idx_mpt_cow_id')
@Column({ @Column({
name: 'cow_id', name: 'cow_id',
type: 'varchar', type: 'varchar',
@@ -38,6 +40,7 @@ export class MptModel extends BaseModel {
}) })
cowShortNo: string; cowShortNo: string;
@Index('idx_mpt_fk_farm_no')
@Column({ @Column({
name: 'fk_farm_no', name: 'fk_farm_no',
type: 'int', type: 'int',

View File

@@ -8,27 +8,7 @@ import {
MptReferenceRange, MptReferenceRange,
MptCategory, MptCategory,
} from '../common/const/MptReference'; } from '../common/const/MptReference';
import { MptStatisticsDto } from './dto/mpt-statistics.dto';
/**
* MPT 통계 응답 DTO
*/
export interface MptStatisticsDto {
totalMptCows: number;
latestTestDate: Date | null;
categories: {
energy: { safe: number; caution: number };
protein: { safe: number; caution: number };
liver: { safe: number; caution: number };
mineral: { safe: number; caution: number };
};
riskyCows: Array<{
cowId: string;
category: string;
itemName: string;
value: number;
status: 'high' | 'low';
}>;
}
@Injectable() @Injectable()
export class MptService { export class MptService {

View File

@@ -0,0 +1,16 @@
/**
* 시스템 헬스체크 응답 DTO
*/
export interface SystemHealthResponse {
status: 'ok' | 'error';
timestamp: string;
environment: string;
database: {
host: string;
port: number;
database: string;
user: string;
status: 'connected' | 'disconnected';
error?: string;
};
}

View File

@@ -0,0 +1,69 @@
/**
* 검사 집계 DTO
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
*/
// 개체별 검사 상세
export class CowTestDetailDto {
cowId: string; // 개체번호
cowBirthDt: string | null; // 생년월일
cowSex: string | null; // 성별
hasGenome: boolean; // 유전체 검사 여부
hasGene: boolean; // 유전자 검사 여부
hasMpt: boolean; // 번식능력 검사 여부
testCount: number; // 받은 검사 수 (1~3)
testTypes: string[]; // 검사 종류 목록
}
// 농가별 검사 집계
export class FarmTestSummaryDto {
farmNo: number;
farmerName: string | null;
regionSi: string | null;
// 검사별 개체수 (중복 허용)
genomeCowCount: number; // 유전체 검사 개체수
geneCowCount: number; // 유전자 검사 개체수
mptCowCount: number; // 번식능력 검사 개체수
// 중복 검사 조합별 개체수
genomeOnly: number; // 유전체만
geneOnly: number; // 유전자만
mptOnly: number; // 번식능력만
genomeAndGene: number; // 유전체 + 유전자
genomeAndMpt: number; // 유전체 + 번식능력
geneAndMpt: number; // 유전자 + 번식능력
allThree: number; // 유전체 + 유전자 + 번식능력
// 합계
totalCows: number; // 전체 개체수 (합집합, 중복 제외)
totalTests: number; // 총 검사 건수 (중복 포함)
// 개체별 상세 (선택적)
cows?: CowTestDetailDto[];
}
// 전체 검사 집계 (모든 농가 합산)
export class TestSummaryDto {
// 전체 집계
totalFarms: number; // 농가 수
totalCows: number; // 전체 개체수 (합집합)
totalTests: number; // 총 검사 건수 (중복 포함)
// 검사별 개체수 (중복 허용)
genomeCowCount: number;
geneCowCount: number;
mptCowCount: number;
// 중복 검사 조합별 개체수
genomeOnly: number;
geneOnly: number;
mptOnly: number;
genomeAndGene: number;
genomeAndMpt: number;
geneAndMpt: number;
allThree: number;
// 농가별 상세
farms: FarmTestSummaryDto[];
}

View File

@@ -1,5 +1,7 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { SystemService, SystemHealthResponse } from './system.service'; import { SystemService } from './system.service';
import { SystemHealthResponse } from './dto/system-health.dto';
import { TestSummaryDto } from './dto/test-summary.dto';
import { Public } from '../common/decorators/public.decorator'; import { Public } from '../common/decorators/public.decorator';
@Controller('system') @Controller('system')
@@ -11,4 +13,14 @@ export class SystemController {
async getHealth(): Promise<SystemHealthResponse> { async getHealth(): Promise<SystemHealthResponse> {
return this.systemService.getHealth(); return this.systemService.getHealth();
} }
/**
* 전체 검사 집계 조회
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
*/
@Public()
@Get('test-summary')
async getTestSummary(): Promise<TestSummaryDto> {
return this.systemService.getTestSummary();
}
} }

View File

@@ -1,8 +1,23 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SystemController } from './system.controller'; import { SystemController } from './system.controller';
import { SystemService } from './system.service'; import { SystemService } from './system.service';
import { CowModel } from '../cow/entities/cow.entity';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
import { MptModel } from '../mpt/entities/mpt.entity';
@Module({ @Module({
imports: [
TypeOrmModule.forFeature([
CowModel,
FarmModel,
GenomeTraitDetailModel,
GeneDetailModel,
MptModel,
]),
],
controllers: [SystemController], controllers: [SystemController],
providers: [SystemService], providers: [SystemService],
}) })

View File

@@ -1,27 +1,30 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource, IsNull, Repository } from 'typeorm';
import { SystemHealthResponse } from './dto/system-health.dto';
export interface SystemHealthResponse { import { TestSummaryDto, FarmTestSummaryDto, CowTestDetailDto } from './dto/test-summary.dto';
status: 'ok' | 'error'; import { CowModel } from '../cow/entities/cow.entity';
timestamp: string; import { FarmModel } from '../farm/entities/farm.entity';
environment: string; import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
database: { import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
host: string; import { MptModel } from '../mpt/entities/mpt.entity';
port: number;
database: string;
user: string;
status: 'connected' | 'disconnected';
error?: string;
};
}
@Injectable() @Injectable()
export class SystemService { export class SystemService {
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
@InjectDataSource() private dataSource: DataSource, @InjectDataSource() private dataSource: DataSource,
@InjectRepository(CowModel)
private readonly cowRepository: Repository<CowModel>,
@InjectRepository(FarmModel)
private readonly farmRepository: Repository<FarmModel>,
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
@InjectRepository(MptModel)
private readonly mptRepository: Repository<MptModel>,
) {} ) {}
async getHealth(): Promise<SystemHealthResponse> { async getHealth(): Promise<SystemHealthResponse> {
@@ -50,4 +53,233 @@ export class SystemService {
return { ...config, status: 'disconnected' as const, error: error.message }; return { ...config, status: 'disconnected' as const, error: error.message };
} }
} }
/**
* 전체 검사 집계 조회
* 농가별/개체별 유전체, 유전자, 번식능력 검사 현황
*/
async getTestSummary(): Promise<TestSummaryDto> {
// 1. 모든 농가 조회
const farms = await this.farmRepository.find({
where: { delDt: IsNull() },
order: { farmerName: 'ASC' },
});
// 2. 각 검사별 cowId 조회 (전체)
const [genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([
// 유전체 검사 개체 (형질 데이터 보유)
this.genomeTraitDetailRepository
.createQueryBuilder('trait')
.select('DISTINCT trait.cowId', 'cowId')
.where('trait.delDt IS NULL')
.getRawMany()
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
// 유전자검사 개체
this.geneDetailRepository
.createQueryBuilder('gene')
.select('DISTINCT gene.cowId', 'cowId')
.where('gene.delDt IS NULL')
.getRawMany()
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
// 번식능력검사 개체
this.mptRepository
.createQueryBuilder('mpt')
.select('DISTINCT mpt.cowId', 'cowId')
.where('mpt.delDt IS NULL')
.getRawMany()
.then(rows => rows.map((r: { cowId: string }) => r.cowId).filter(Boolean)),
]);
const genomeSet = new Set(genomeCowIds);
const geneSet = new Set(geneCowIds);
const mptSet = new Set(mptCowIds);
// 3. 모든 개체 정보 조회 (cowId로 농가 매핑)
const allCows = await this.cowRepository.find({
where: { delDt: IsNull() },
select: ['cowId', 'cowBirthDt', 'cowSex', 'fkFarmNo'],
});
const cowFarmMap = new Map<string, number>();
const cowInfoMap = new Map<string, { cowBirthDt: Date | null; cowSex: string | null }>();
for (const cow of allCows) {
if (cow.cowId && cow.fkFarmNo) {
cowFarmMap.set(cow.cowId, cow.fkFarmNo);
cowInfoMap.set(cow.cowId, { cowBirthDt: cow.cowBirthDt, cowSex: cow.cowSex });
}
}
// 4. 농가별 집계
const farmSummaries: FarmTestSummaryDto[] = [];
for (const farm of farms) {
const farmNo = farm.pkFarmNo;
const farmCowIds = new Set<string>();
// 해당 농가의 개체 필터링
for (const [cowId, fNo] of cowFarmMap.entries()) {
if (fNo === farmNo) {
// 검사 받은 개체만 추가
if (genomeSet.has(cowId) || geneSet.has(cowId) || mptSet.has(cowId)) {
farmCowIds.add(cowId);
}
}
}
// 개체별 검사 상세
const cows: CowTestDetailDto[] = [];
let genomeCowCount = 0;
let geneCowCount = 0;
let mptCowCount = 0;
let genomeOnly = 0;
let geneOnly = 0;
let mptOnly = 0;
let genomeAndGene = 0;
let genomeAndMpt = 0;
let geneAndMpt = 0;
let allThree = 0;
let totalTests = 0;
for (const cowId of farmCowIds) {
const hasGenome = genomeSet.has(cowId);
const hasGene = geneSet.has(cowId);
const hasMpt = mptSet.has(cowId);
const cowInfo = cowInfoMap.get(cowId);
const testTypes: string[] = [];
if (hasGenome) testTypes.push('유전체');
if (hasGene) testTypes.push('유전자');
if (hasMpt) testTypes.push('번식능력');
// cowBirthDt 포맷 처리 (Date 객체 또는 문자열)
let birthDtStr: string | null = null;
if (cowInfo?.cowBirthDt) {
if (cowInfo.cowBirthDt instanceof Date) {
birthDtStr = cowInfo.cowBirthDt.toISOString().split('T')[0];
} else {
birthDtStr = String(cowInfo.cowBirthDt).split('T')[0];
}
}
cows.push({
cowId,
cowBirthDt: birthDtStr,
cowSex: cowInfo?.cowSex || null,
hasGenome,
hasGene,
hasMpt,
testCount: testTypes.length,
testTypes,
});
// 검사별 카운트
if (hasGenome) genomeCowCount++;
if (hasGene) geneCowCount++;
if (hasMpt) mptCowCount++;
totalTests += testTypes.length;
// 중복 검사 조합별 카운트
if (hasGenome && hasGene && hasMpt) {
allThree++;
} else if (hasGenome && hasGene && !hasMpt) {
genomeAndGene++;
} else if (hasGenome && !hasGene && hasMpt) {
genomeAndMpt++;
} else if (!hasGenome && hasGene && hasMpt) {
geneAndMpt++;
} else if (hasGenome && !hasGene && !hasMpt) {
genomeOnly++;
} else if (!hasGenome && hasGene && !hasMpt) {
geneOnly++;
} else if (!hasGenome && !hasGene && hasMpt) {
mptOnly++;
}
}
// testCount 내림차순, cowId 오름차순 정렬
cows.sort((a, b) => {
if (b.testCount !== a.testCount) return b.testCount - a.testCount;
return a.cowId.localeCompare(b.cowId);
});
farmSummaries.push({
farmNo,
farmerName: farm.farmerName || null,
regionSi: farm.regionSi || null,
genomeCowCount,
geneCowCount,
mptCowCount,
genomeOnly,
geneOnly,
mptOnly,
genomeAndGene,
genomeAndMpt,
geneAndMpt,
allThree,
totalCows: farmCowIds.size,
totalTests,
cows,
});
}
// 검사 개체가 있는 농가만 필터링
const activeFarms = farmSummaries.filter(f => f.totalCows > 0);
// 5. 전체 집계 계산
const allTestedCowIds = new Set([...genomeCowIds, ...geneCowIds, ...mptCowIds]);
let totalGenomeOnly = 0;
let totalGeneOnly = 0;
let totalMptOnly = 0;
let totalGenomeAndGene = 0;
let totalGenomeAndMpt = 0;
let totalGeneAndMpt = 0;
let totalAllThree = 0;
let totalTestsSum = 0;
for (const cowId of allTestedCowIds) {
const hasGenome = genomeSet.has(cowId);
const hasGene = geneSet.has(cowId);
const hasMpt = mptSet.has(cowId);
let testCount = 0;
if (hasGenome) testCount++;
if (hasGene) testCount++;
if (hasMpt) testCount++;
totalTestsSum += testCount;
if (hasGenome && hasGene && hasMpt) {
totalAllThree++;
} else if (hasGenome && hasGene && !hasMpt) {
totalGenomeAndGene++;
} else if (hasGenome && !hasGene && hasMpt) {
totalGenomeAndMpt++;
} else if (!hasGenome && hasGene && hasMpt) {
totalGeneAndMpt++;
} else if (hasGenome && !hasGene && !hasMpt) {
totalGenomeOnly++;
} else if (!hasGenome && hasGene && !hasMpt) {
totalGeneOnly++;
} else if (!hasGenome && !hasGene && hasMpt) {
totalMptOnly++;
}
}
return {
totalFarms: activeFarms.length,
totalCows: allTestedCowIds.size,
totalTests: totalTestsSum,
genomeCowCount: genomeCowIds.length,
geneCowCount: geneCowIds.length,
mptCowCount: mptCowIds.length,
genomeOnly: totalGenomeOnly,
geneOnly: totalGeneOnly,
mptOnly: totalMptOnly,
genomeAndGene: totalGenomeAndGene,
genomeAndMpt: totalGenomeAndMpt,
geneAndMpt: totalGeneAndMpt,
allThree: totalAllThree,
farms: activeFarms,
};
}
} }

View File

@@ -463,11 +463,11 @@ export function GenomeIntegratedComparison({
setTrendLoading(true) setTrendLoading(true)
try { try {
const dashboardStats = await genomeApi.getDashboardStats(farmNo) const ebvStats = await genomeApi.getYearlyEbvStats(farmNo)
// yearlyStats와 yearlyAvgEbv 합치기 // yearlyStats와 yearlyAvgEbv 합치기
const yearlyStats = dashboardStats.yearlyStats || [] const yearlyStats = ebvStats.yearlyStats || []
const yearlyAvgEbv = dashboardStats.yearlyAvgEbv || [] const yearlyAvgEbv = ebvStats.yearlyAvgEbv || []
// 연도별 데이터 맵 생성 // 연도별 데이터 맵 생성
const yearMap = new Map<number, { analyzedCount: number; avgEbv: number }>() const yearMap = new Map<number, { analyzedCount: number; avgEbv: number }>()

View File

@@ -181,7 +181,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
</div> </div>
<div className="px-5 py-4"> <div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground"> <span className="text-2xl font-bold text-foreground">
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'} {cow?.cowBirthDt && selectedMpt.testDt
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span> </span>
</div> </div>
</div> </div>
@@ -217,7 +219,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
<div className="flex items-center"> <div className="flex items-center">
<span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground"></span> <span className="w-24 shrink-0 bg-muted/50 px-4 py-3.5 text-sm font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground"> <span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'} {cow?.cowBirthDt && selectedMpt.testDt
? `${Math.floor((new Date(selectedMpt.testDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span> </span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
@@ -242,7 +246,9 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{selectedMpt ? ( {selectedMpt ? (
<> <>
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3> <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">
{/* 데스크탑: 테이블 */}
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
@@ -310,6 +316,60 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
</CardContent> </CardContent>
</Card> </Card>
{/* 모바일: 카드 레이아웃 */}
<div className="lg:hidden space-y-4">
{categories.map((category) => (
<Card key={category.key} className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<div className="bg-muted/50 px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">{category.name}</span>
</div>
<CardContent className="p-0 divide-y divide-border">
{category.items.map((itemKey) => {
const ref = references[itemKey]
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
const status = getMptValueStatus(itemKey, value, references)
return (
<div key={itemKey} className="py-2">
<div className="flex items-center border-b border-border/50">
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground"></span>
<span className="flex-1 px-3 py-2 text-sm font-semibold text-foreground">{ref?.name || itemKey}</span>
</div>
<div className="flex items-center border-b border-border/50">
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground"></span>
<div className="flex-1 px-3 py-2 flex items-center justify-between">
<span className={`text-base font-bold ${
status === 'safe' ? 'text-green-600' :
status === 'caution' ? 'text-amber-600' :
'text-muted-foreground'
}`}>
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
</span>
{value !== null && value !== undefined ? (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
status === 'safe' ? 'bg-green-100 text-green-700' :
status === 'caution' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-500'
}`}>
{status === 'safe' ? '안전' : status === 'caution' ? '주의' : '-'}
</span>
) : null}
</div>
</div>
<div className="flex items-center">
<span className="w-20 shrink-0 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground"></span>
<span className="flex-1 px-3 py-2 text-sm text-muted-foreground">
{ref?.lowerLimit ?? '-'} ~ {ref?.upperLimit ?? '-'} {ref?.unit || ''}
</span>
</div>
</div>
)
})}
</CardContent>
</Card>
))}
</div>
{/* 검사 이력 (여러 검사 결과가 있을 경우) */} {/* 검사 이력 (여러 검사 결과가 있을 경우) */}
{mptData.length > 1 && ( {mptData.length > 1 && (
<> <>

View File

@@ -182,9 +182,6 @@ function MyCowContent() {
setError(null) setError(null)
// 마커 타입 정보 (gene.api 제거됨 - 추후 백엔드 구현 시 복구)
const currentMarkerTypes = markerTypes
// 전역 필터 + 랭킹 모드를 기반으로 랭킹 옵션 구성 // 전역 필터 + 랭킹 모드를 기반으로 랭킹 옵션 구성
// 타입을 any로 지정하여 백엔드 API와의 호환성 유지 // 타입을 any로 지정하여 백엔드 API와의 호환성 유지
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -279,94 +276,24 @@ function MyCowContent() {
ranking?: { traits?: { traitName: string; traitEbv: number | null; traitVal: number | null }[] }; // 랭킹 형질 ranking?: { traits?: { traitName: string; traitEbv: number | null; traitVal: number | null }[] }; // 랭킹 형질
} }
const cowsWithMockGenes = response.items.map((item: RankingItem) => { const cowsWithMockGenes = response.items.map((item: RankingItem) => {
// 백엔드에서 genes 객체를 배열로 변환
// genes 객체 형식: { "PLAG1": 2, "NCAPG": 1, ... }
// 배열 형식으로 변환: [{ name: "PLAG1", genotype: "AA", favorable: true }, ...]
let genesArray = []
if (item.entity.genes && typeof item.entity.genes === 'object') {
// 백엔드 genes 객체를 배열로 변환
genesArray = Object.entries(item.entity.genes).map(([markerName, count]) => {
const favorableCount = count as number
let genotype = 'N/A'
let favorable = false
// favorableCount에 따라 유전자형 결정
if (favorableCount === 2) {
genotype = 'AA' // 동형 접합 (유리)
favorable = true
} else if (favorableCount === 1) {
genotype = 'AG' // 이형 접합 (중간)
favorable = true
} else {
genotype = 'GG' // 동형 접합 (불리)
favorable = false
}
return {
name: markerName,
genotype,
favorable,
}
})
} else {
// 백엔드에서 genes 데이터가 없으면 mock 생성
genesArray = generateMockGenes()
}
// currentMarkerTypes를 사용하여 동적으로 육량형/육질형 개수 계산
// 동형접합(AA)과 이형접합(AG)을 구분하여 계산
const isHomozygous = (genotype: string) => genotype.length === 2 && genotype[0] === genotype[1]
const quantityHomoCount = genesArray.filter(g =>
currentMarkerTypes[g.name] === 'QTY' && g.favorable && isHomozygous(g.genotype)
).length
const quantityHeteroCount = genesArray.filter(g =>
currentMarkerTypes[g.name] === 'QTY' && g.favorable && !isHomozygous(g.genotype)
).length
const qualityHomoCount = genesArray.filter(g =>
currentMarkerTypes[g.name] === 'QLT' && g.favorable && isHomozygous(g.genotype)
).length
const qualityHeteroCount = genesArray.filter(g =>
currentMarkerTypes[g.name] === 'QLT' && g.favorable && !isHomozygous(g.genotype)
).length
return { return {
...item.entity, // 실제 cow 데이터 ...item.entity,
rank: item.rank, // 백엔드에서 계산한 랭킹 rank: item.rank,
rankScore: item.sortValue, // 백엔드에서 계산한 점수 rankScore: item.sortValue,
grade: item.grade, // 백엔드에서 계산한 등급 (A~E) grade: item.grade,
genes: genesArray,
quantityGeneCount: quantityHomoCount + quantityHeteroCount,
qualityGeneCount: qualityHomoCount + qualityHeteroCount,
quantityHomoCount,
quantityHeteroCount,
qualityHomoCount,
qualityHeteroCount,
// 유전체 점수는 sortValue에서 가져옴 (백엔드 랭킹 엔진이 계산한 값)
genomeScore: item.sortValue, genomeScore: item.sortValue,
geneScore: item.compositeScores?.geneScore, // 번식 정보
// 번식 정보 (백엔드에서 가져옴 - 암소만)
calvingCount: item.entity.calvingCount, calvingCount: item.entity.calvingCount,
bcs: item.entity.bcs, bcs: item.entity.bcs,
inseminationCount: item.entity.inseminationCount, inseminationCount: item.entity.inseminationCount,
// 근친도 (백엔드에서 계산된 근친계수 백분율)
inbreedingPercent: item.entity.inbreedingPercent ?? 0, inbreedingPercent: item.entity.inbreedingPercent ?? 0,
// 아비 KPN 번호 (genome trait에서 가져옴)
sireKpn: item.entity.sireKpn ?? null, sireKpn: item.entity.sireKpn ?? null,
// 분석일자
anlysDt: item.entity.anlysDt ?? null, anlysDt: item.entity.anlysDt ?? null,
// 분석불가 사유
unavailableReason: item.entity.unavailableReason ?? null, unavailableReason: item.entity.unavailableReason ?? null,
// 번식능력검사(MPT) 여부
hasMpt: item.entity.hasMpt ?? false, hasMpt: item.entity.hasMpt ?? false,
// MPT 검사일
mptTestDt: item.entity.mptTestDt ?? null, mptTestDt: item.entity.mptTestDt ?? null,
// MPT 월령
mptMonthAge: item.entity.mptMonthAge ?? null, mptMonthAge: item.entity.mptMonthAge ?? null,
//==================================================================================================================== // 형질 데이터
// 형질 데이터 (백엔드에서 계산됨, 형질명 → 표준화육종가 매핑)
// 백엔드 응답: { traitName: string, traitVal: number, traitEbv: number, traitPercentile: number }
traits: item.ranking?.traits?.reduce((acc: Record<string, traits: item.ranking?.traits?.reduce((acc: Record<string,
{ breedVal: number | null, traitVal: number | null }>, t: { traitName: string; traitEbv: number | null; traitVal: number | null }) => { { breedVal: number | null, traitVal: number | null }>, t: { traitName: string; traitEbv: number | null; traitVal: number | null }) => {
acc[t.traitName] = { breedVal: t.traitEbv, traitVal: t.traitVal }; acc[t.traitName] = { breedVal: t.traitEbv, traitVal: t.traitVal };
@@ -389,98 +316,6 @@ function MyCowContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, rankingMode, isFilterSet]) }, [filters, rankingMode, isFilterSet])
// Mock 유전자 생성 함수 (실제로는 API에서 가져와야 함)
const generateMockGenes = () => {
// 모든 소가 다양한 유전자를 가지도록 더 많은 유전자 풀 생성
const genePool = [
// 육량형 유전자
{ name: 'PLAG1', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
{ name: 'NCAPG', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
{ name: 'LCORL', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
{ name: 'MSTN', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA'] },
{ name: 'IGF1', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
{ name: 'GH1', genotypes: ['CC', 'CT', 'TT'], favorable: ['CC', 'CT'] },
{ name: 'LAP3', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA'] },
{ name: 'ARRDC3', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
// 육질형 유전자
{ name: 'CAPN1', genotypes: ['CC', 'CG', 'GG'], favorable: ['CC', 'CG'] },
{ name: 'CAST', genotypes: ['AA', 'AG', 'GG'], favorable: ['GG', 'AG'] },
{ name: 'FASN', genotypes: ['AA', 'AG', 'GG'], favorable: ['GG', 'AG'] },
{ name: 'SCD', genotypes: ['AA', 'AV', 'VV'], favorable: ['VV', 'AV'] },
{ name: 'FABP4', genotypes: ['AA', 'AG', 'GG'], favorable: ['AA', 'AG'] },
{ name: 'SREBP1', genotypes: ['CC', 'CT', 'TT'], favorable: ['CC', 'CT'] },
{ name: 'DGAT1', genotypes: ['AA', 'AK', 'KK'], favorable: ['KK', 'AK'] },
{ name: 'LEP', genotypes: ['CC', 'CT', 'TT'], favorable: ['TT', 'CT'] },
]
// 모든 유전자를 포함 (랜덤 유전자형)
return genePool.map(gene => {
const genotype = gene.genotypes[Math.floor(Math.random() * gene.genotypes.length)]
return {
name: gene.name,
genotype,
favorable: gene.favorable.includes(genotype),
}
})
}
// ============================================
// 유전자형 판단 및 스타일 정의
// ============================================
/**
* 동형접합 여부 판단
* AA, GG, CC, TT 등 → true
* AG, CT, AK 등 → false
*/
const isHomozygous = (genotype: string): boolean => {
return genotype.length === 2 && genotype[0] === genotype[1]
}
/**
* 유전자 뱃지 스타일 정의
* @param genotype 유전자형 (AA, AG, GG 등)
* @param favorable 우량 유전자 여부
* @param geneCategory 유전자 카테고리 ('QTY': 육량형, 'QLT': 육질형)
*/
type GeneBadgeStyle = {
className: string
icon: 'star' | 'circle' | 'double-circle' | 'minus' | 'none'
}
const getGeneBadgeStyle = (
genotype: string,
favorable: boolean,
geneCategory: 'QTY' | 'QLT'
): GeneBadgeStyle => {
const isHomo = isHomozygous(genotype)
// 1. 동형접합 우량 (AA형) → 진한 색 (육량: 파랑, 육질: 주황)
if (isHomo && favorable) {
return {
className: geneCategory === 'QTY'
? 'bg-blue-600 text-white border-blue-700'
: 'bg-orange-600 text-white border-orange-700',
icon: 'none',
}
}
// 2. 이형접합 우량 (AG형) → 중간 색 (육량: 파랑, 육질: 주황)
if (!isHomo && favorable) {
return {
className: geneCategory === 'QTY'
? 'bg-blue-400 text-white border-blue-500'
: 'bg-orange-400 text-white border-orange-500',
icon: 'none',
}
}
// 3. 불량형 (GG형) → 연한 회색
return {
className: 'bg-gray-300 text-gray-600 border-gray-400',
icon: 'none',
}
}
// ============================================ // ============================================
// 컬럼 스타일은 globals.css의 CSS 변수로 관리됨 // 컬럼 스타일은 globals.css의 CSS 변수로 관리됨
@@ -962,10 +797,10 @@ function MyCowContent() {
</th> </th>
{selectedDisplayGenes.length > 0 && ( {selectedDisplayGenes.length > 0 && (
<th className="cow-table-header bg-blue-50" style={{ width: '140px' }}></th> <th className="cow-table-header" style={{ width: '140px' }}></th>
)} )}
{selectedDisplayTraits.length > 0 && ( {selectedDisplayTraits.length > 0 && (
<th className="cow-table-header bg-teal-50" style={{ width: '140px' }}></th> <th className="cow-table-header" style={{ width: '140px' }}></th>
)} )}
</tr> </tr>
</thead> </thead>
@@ -1022,15 +857,20 @@ function MyCowContent() {
{(() => { {(() => {
// 번식능력만 있는 개체 판단 // 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용 // 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
if (analysisFilter === 'mptOnly' || hasMptOnly) { if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-' if (cow.cowBirthDt && cow.mptTestDt) {
const birthDate = new Date(cow.cowBirthDt)
const refDate = new Date(cow.mptTestDt)
return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
}
return '-'
} }
// 유전체 분석일 기준 월령
if (cow.cowBirthDt && cow.anlysDt) { if (cow.cowBirthDt && cow.anlysDt) {
const birthDate = new Date(cow.cowBirthDt) const birthDate = new Date(cow.cowBirthDt)
const refDate = new Date(cow.anlysDt) const refDate = new Date(cow.anlysDt)
const ageInMonths = Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)) return `${Math.floor((refDate.getTime() - birthDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
return `${ageInMonths}개월`
} }
return '-' return '-'
})()} })()}
@@ -1079,7 +919,7 @@ function MyCowContent() {
</td> </td>
{selectedDisplayGenes.length > 0 && ( {selectedDisplayGenes.length > 0 && (
<td <td
className="py-2 px-2 text-sm bg-blue-50/30" className="py-2 px-2 text-sm"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{(() => { {(() => {
@@ -1092,15 +932,17 @@ function MyCowContent() {
{displayGenes.map((geneName) => { {displayGenes.map((geneName) => {
const gene = cow.genes?.find(g => g.name === geneName) const gene = cow.genes?.find(g => g.name === geneName)
const genotype = gene?.genotype || '-' const genotype = gene?.genotype || '-'
const favorable = gene?.favorable || false
const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT' const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT'
const badgeStyle = gene ? getGeneBadgeStyle(genotype, favorable, geneCategory) : null // 육량형: 파랑, 육질형: 주황
const badgeClass = geneCategory === 'QTY'
? 'bg-blue-500 text-white'
: 'bg-orange-500 text-white'
return ( return (
<div key={geneName} className="flex items-center gap-2"> <div key={geneName} className="flex items-center gap-2">
<span className="text-xs text-slate-600 min-w-[60px]">{geneName}</span> <span className="text-xs text-slate-600 min-w-[60px]">{geneName}</span>
{gene ? ( {gene ? (
<Badge className={`text-xs font-semibold ${badgeStyle?.className}`}> <Badge className={`text-xs font-semibold ${badgeClass}`}>
{genotype} {genotype}
</Badge> </Badge>
) : ( ) : (
@@ -1134,7 +976,7 @@ function MyCowContent() {
)} )}
{selectedDisplayTraits.length > 0 && ( {selectedDisplayTraits.length > 0 && (
<td <td
className="py-2 px-2 text-sm bg-teal-50/30" className="py-2 px-2 text-sm"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex flex-col items-start gap-1.5"> <div className="flex flex-col items-start gap-1.5">
@@ -1264,10 +1106,14 @@ function MyCowContent() {
{(() => { {(() => {
// 번식능력만 있는 개체 판단 // 번식능력만 있는 개체 판단
const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt const hasMptOnly = cow.hasMpt && !cow.genomeScore && !cow.anlysDt
// 번식능력 탭이거나 번식능력만 있는 개체: MPT 월령 사용 // 번식능력 탭이거나 번식능력만 있는 개체: MPT 검사일 기준 월령
if (analysisFilter === 'mptOnly' || hasMptOnly) { if (analysisFilter === 'mptOnly' || hasMptOnly) {
return cow.mptMonthAge ? `${cow.mptMonthAge}개월` : '-' if (cow.cowBirthDt && cow.mptTestDt) {
return `${Math.floor((new Date(cow.mptTestDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
}
return '-'
} }
// 유전체 분석일 기준 월령
if (cow.cowBirthDt && cow.anlysDt) { if (cow.cowBirthDt && cow.anlysDt) {
return `${Math.floor((new Date(cow.anlysDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` return `${Math.floor((new Date(cow.anlysDt).getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
} }
@@ -1344,12 +1190,14 @@ function MyCowContent() {
{displayGenes.map((geneName) => { {displayGenes.map((geneName) => {
const gene = cow.genes?.find(g => g.name === geneName) const gene = cow.genes?.find(g => g.name === geneName)
const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT' const geneCategory = markerTypes[geneName] as 'QTY' | 'QLT'
const genotype = gene?.genotype || 'GG' const genotype = gene?.genotype || '-'
const favorable = gene?.favorable || false // 육량형: 파랑, 육질형: 주황
const badgeStyle = getGeneBadgeStyle(genotype, favorable, geneCategory) const badgeClass = geneCategory === 'QTY'
? 'bg-blue-500 text-white'
: 'bg-orange-500 text-white'
return ( return (
<Badge key={geneName} className={`text-xs px-1.5 py-0.5 font-medium ${badgeStyle.className}`}> <Badge key={geneName} className={`text-xs px-1.5 py-0.5 font-medium ${badgeClass}`}>
{geneName} {genotype} {geneName} {genotype}
</Badge> </Badge>
) )

View File

@@ -0,0 +1,448 @@
'use client'
import { AppSidebar } from "@/components/layout/app-sidebar"
import { SiteHeader } from "@/components/layout/site-header"
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar"
import { useEffect, useState } from "react"
import apiClient from "@/lib/api-client"
import { ChevronDown, ChevronRight, Check, X, Dna, TestTube, Baby } from "lucide-react"
// 타입 정의
interface CowTestDetail {
cowId: string
cowBirthDt: string | null
cowSex: string | null
hasGenome: boolean
hasGene: boolean
hasMpt: boolean
testCount: number
testTypes: string[]
}
interface FarmTestSummary {
farmNo: number
farmerName: string | null
regionSi: string | null
genomeCowCount: number
geneCowCount: number
mptCowCount: number
genomeOnly: number
geneOnly: number
mptOnly: number
genomeAndGene: number
genomeAndMpt: number
geneAndMpt: number
allThree: number
totalCows: number
totalTests: number
cows?: CowTestDetail[]
}
interface TestSummary {
totalFarms: number
totalCows: number
totalTests: number
genomeCowCount: number
geneCowCount: number
mptCowCount: number
genomeOnly: number
geneOnly: number
mptOnly: number
genomeAndGene: number
genomeAndMpt: number
geneAndMpt: number
allThree: number
farms: FarmTestSummary[]
}
export default function TestSummaryPage() {
const [data, setData] = useState<TestSummary | null>(null)
const [loading, setLoading] = useState(true)
const [expandedFarms, setExpandedFarms] = useState<Set<number>>(new Set())
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiClient.get('/system/test-summary') as TestSummary
setData(response)
} catch (error) {
console.error('데이터 로드 실패:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const toggleFarm = (farmNo: number) => {
setExpandedFarms(prev => {
const next = new Set(prev)
if (next.has(farmNo)) {
next.delete(farmNo)
} else {
next.add(farmNo)
}
return next
})
}
const formatCowId = (cowId: string) => {
const digits = cowId.replace(/\D/g, '')
if (digits.length === 12) {
return `${digits.slice(0, 3)} ${digits.slice(3, 7)} ${digits.slice(7, 11)} ${digits.slice(11)}`
}
return cowId
}
if (loading) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-2 border-blue-500 border-t-transparent" />
</div>
</SidebarInset>
</SidebarProvider>
)
}
if (!data) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 items-center justify-center text-red-500">
</div>
</SidebarInset>
</SidebarProvider>
)
}
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col gap-6 p-6 bg-slate-50 min-h-screen">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-sm text-slate-500 mt-1">
/ , ,
</p>
</div>
{/* 전체 요약 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border p-4 shadow-sm">
<p className="text-sm text-slate-500"> </p>
<p className="text-3xl font-bold text-slate-900">{data.totalFarms}</p>
</div>
<div className="bg-white rounded-xl border p-4 shadow-sm">
<p className="text-sm text-slate-500"> </p>
<p className="text-3xl font-bold text-blue-600">{data.totalCows}</p>
</div>
<div className="bg-white rounded-xl border p-4 shadow-sm">
<p className="text-sm text-slate-500"> </p>
<p className="text-3xl font-bold text-emerald-600">{data.totalTests}</p>
</div>
<div className="bg-white rounded-xl border p-4 shadow-sm">
<p className="text-sm text-slate-500"> /</p>
<p className="text-3xl font-bold text-amber-600">
{data.totalCows > 0 ? (data.totalTests / data.totalCows).toFixed(1) : 0}
</p>
</div>
</div>
{/* 검사별 집계 */}
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="p-4 border-b bg-slate-50">
<h2 className="font-semibold text-slate-900"> </h2>
</div>
<div className="p-4">
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-lg">
<Dna className="w-8 h-8 text-blue-600" />
<div>
<p className="text-sm text-blue-600 font-medium"></p>
<p className="text-2xl font-bold text-blue-700">{data.genomeCowCount}</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-purple-50 rounded-lg">
<TestTube className="w-8 h-8 text-purple-600" />
<div>
<p className="text-sm text-purple-600 font-medium"></p>
<p className="text-2xl font-bold text-purple-700">{data.geneCowCount}</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-pink-50 rounded-lg">
<Baby className="w-8 h-8 text-pink-600" />
<div>
<p className="text-sm text-pink-600 font-medium"></p>
<p className="text-2xl font-bold text-pink-700">{data.mptCowCount}</p>
</div>
</div>
</div>
{/* 중복 검사 조합 */}
<h3 className="font-medium text-slate-700 mb-3"> </h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-100">
<th className="px-4 py-2 text-left font-medium text-slate-600"></th>
<th className="px-4 py-2 text-center font-medium text-slate-600"></th>
<th className="px-4 py-2 text-center font-medium text-slate-600"></th>
<th className="px-4 py-2 text-center font-medium text-slate-600"></th>
<th className="px-4 py-2 text-right font-medium text-slate-600"> </th>
</tr>
</thead>
<tbody>
<tr className="border-b">
<td className="px-4 py-2 text-slate-700"></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-right font-semibold">{data.genomeOnly}</td>
</tr>
<tr className="border-b">
<td className="px-4 py-2 text-slate-700"></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-right font-semibold">{data.geneOnly}</td>
</tr>
<tr className="border-b">
<td className="px-4 py-2 text-slate-700"></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
<td className="px-4 py-2 text-right font-semibold">{data.mptOnly}</td>
</tr>
<tr className="border-b bg-blue-50/30">
<td className="px-4 py-2 text-slate-700"> + </td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-right font-semibold">{data.genomeAndGene}</td>
</tr>
<tr className="border-b bg-blue-50/30">
<td className="px-4 py-2 text-slate-700"> + </td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
<td className="px-4 py-2 text-right font-semibold">{data.genomeAndMpt}</td>
</tr>
<tr className="border-b bg-purple-50/30">
<td className="px-4 py-2 text-slate-700"> + </td>
<td className="px-4 py-2 text-center"><X className="w-5 h-5 text-slate-300 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
<td className="px-4 py-2 text-right font-semibold">{data.geneAndMpt}</td>
</tr>
<tr className="bg-emerald-50">
<td className="px-4 py-2 text-emerald-700 font-medium">3 </td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-blue-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-purple-600 mx-auto" /></td>
<td className="px-4 py-2 text-center"><Check className="w-5 h-5 text-pink-600 mx-auto" /></td>
<td className="px-4 py-2 text-right font-bold text-emerald-700">{data.allThree}</td>
</tr>
<tr className="bg-slate-100 font-semibold">
<td className="px-4 py-2 text-slate-900" colSpan={4}> ( )</td>
<td className="px-4 py-2 text-right text-slate-900">{data.totalCows}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{/* 농가별 집계 */}
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="p-4 border-b bg-slate-50">
<h2 className="font-semibold text-slate-900"> </h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-100 border-b">
<th className="px-4 py-3 text-left font-medium text-slate-600 w-8"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600"></th>
<th className="px-4 py-3 text-center font-medium text-slate-600"></th>
<th className="px-4 py-3 text-center font-medium text-slate-600"></th>
<th className="px-4 py-3 text-center font-medium text-slate-600"></th>
<th className="px-4 py-3 text-center font-medium text-slate-600"> </th>
<th className="px-4 py-3 text-center font-medium text-slate-600"> </th>
</tr>
</thead>
<tbody>
{data.farms.map((farm) => (
<>
<tr
key={farm.farmNo}
className="border-b hover:bg-slate-50 cursor-pointer"
onClick={() => toggleFarm(farm.farmNo)}
>
<td className="px-4 py-3">
{expandedFarms.has(farm.farmNo) ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</td>
<td className="px-4 py-3">
<span className="font-medium text-slate-900">{farm.farmerName || `농가 ${farm.farmNo}`}</span>
{farm.regionSi && (
<span className="text-slate-400 text-xs ml-2">{farm.regionSi}</span>
)}
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
{farm.genomeCowCount}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
{farm.geneCowCount}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-0.5 rounded-full bg-pink-100 text-pink-700 font-medium">
{farm.mptCowCount}
</span>
</td>
<td className="px-4 py-3 text-center font-semibold text-slate-900">
{farm.totalCows}
</td>
<td className="px-4 py-3 text-center font-semibold text-emerald-600">
{farm.totalTests}
</td>
</tr>
{/* 펼쳐진 개체 목록 */}
{expandedFarms.has(farm.farmNo) && farm.cows && farm.cows.length > 0 && (
<tr key={`${farm.farmNo}-detail`}>
<td colSpan={7} className="bg-slate-50 px-4 py-2">
<div className="ml-6 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="bg-white">
<th className="px-3 py-2 text-left font-medium text-slate-500"></th>
<th className="px-3 py-2 text-center font-medium text-slate-500"></th>
<th className="px-3 py-2 text-center font-medium text-slate-500"></th>
<th className="px-3 py-2 text-center font-medium text-slate-500"></th>
<th className="px-3 py-2 text-center font-medium text-slate-500"></th>
<th className="px-3 py-2 text-center font-medium text-slate-500"></th>
<th className="px-3 py-2 text-center font-medium text-slate-500"> </th>
</tr>
</thead>
<tbody>
{farm.cows.map((cow) => (
<tr key={cow.cowId} className="border-t border-slate-100">
<td className="px-3 py-2 font-mono text-slate-700">
{formatCowId(cow.cowId)}
</td>
<td className="px-3 py-2 text-center text-slate-600">
{cow.cowBirthDt || '-'}
</td>
<td className="px-3 py-2 text-center">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
cow.cowSex === '암' || cow.cowSex === 'F'
? 'bg-pink-100 text-pink-700'
: 'bg-blue-100 text-blue-700'
}`}>
{cow.cowSex === '암' || cow.cowSex === 'F' ? '암' : '수'}
</span>
</td>
<td className="px-3 py-2 text-center">
{cow.hasGenome ? (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white font-bold">O</span>
) : (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
)}
</td>
<td className="px-3 py-2 text-center">
{cow.hasGene ? (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-500 text-white font-bold">O</span>
) : (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
)}
</td>
<td className="px-3 py-2 text-center">
{cow.hasMpt ? (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-pink-500 text-white font-bold">O</span>
) : (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-400">X</span>
)}
</td>
<td className="px-3 py-2 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${
cow.testCount === 3 ? 'bg-emerald-100 text-emerald-700' :
cow.testCount === 2 ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-600'
}`}>
{cow.testCount}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 농가별 중복 검사 요약 */}
<div className="ml-6 mt-3 p-3 bg-white rounded-lg border text-xs">
<div className="flex flex-wrap gap-3">
{farm.genomeOnly > 0 && (
<span className="text-slate-600">: <span className="font-bold">{farm.genomeOnly}</span></span>
)}
{farm.geneOnly > 0 && (
<span className="text-slate-600">: <span className="font-bold">{farm.geneOnly}</span></span>
)}
{farm.mptOnly > 0 && (
<span className="text-slate-600">: <span className="font-bold">{farm.mptOnly}</span></span>
)}
{farm.genomeAndGene > 0 && (
<span className="text-blue-600">+: <span className="font-bold">{farm.genomeAndGene}</span></span>
)}
{farm.genomeAndMpt > 0 && (
<span className="text-blue-600">+: <span className="font-bold">{farm.genomeAndMpt}</span></span>
)}
{farm.geneAndMpt > 0 && (
<span className="text-purple-600">+: <span className="font-bold">{farm.geneAndMpt}</span></span>
)}
{farm.allThree > 0 && (
<span className="text-emerald-600">3 : <span className="font-bold">{farm.allThree}</span></span>
)}
</div>
</div>
</td>
</tr>
)}
</>
))}
</tbody>
<tfoot>
<tr className="bg-slate-200 font-semibold">
<td className="px-4 py-3"></td>
<td className="px-4 py-3 text-slate-900"></td>
<td className="px-4 py-3 text-center text-blue-700">{data.genomeCowCount}</td>
<td className="px-4 py-3 text-center text-purple-700">{data.geneCowCount}</td>
<td className="px-4 py-3 text-center text-pink-700">{data.mptCowCount}</td>
<td className="px-4 py-3 text-center text-slate-900">{data.totalCows}</td>
<td className="px-4 py-3 text-center text-emerald-700">{data.totalTests}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -1,206 +0,0 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useEffect, useState } from "react"
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
import { useFilterStore } from "@/store/filter-store"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
interface GeneData {
geneName: string
geneType: '육량' | '육질' // 유전자 분류
farmRate: number // 우리 농장 우량형(AA) 보유율
regionAvgRate: number // 지역 평균
}
interface GenePossessionStatusProps {
farmNo: number | null
}
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
const { selectedYear } = useAnalysisYear()
const { filters } = useFilterStore()
const [allGenes, setAllGenes] = useState<GeneData[]>([])
const [loading, setLoading] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
// 선택된 유전자 확인
const selectedGenes = filters.selectedGenes || []
const hasFilter = selectedGenes.length > 0
useEffect(() => {
const fetchData = async () => {
setLoading(true)
// TODO: 백엔드 API 연동 시 실제 데이터 fetch
// 현재는 목업 데이터 사용 (전체 유전자 리스트)
const mockAllGenes: GeneData[] = [
// 육량 관련
{ geneName: 'PLAG1', geneType: '육량', farmRate: 85, regionAvgRate: 72 },
{ geneName: 'NCAPG', geneType: '육량', farmRate: 82, regionAvgRate: 75 },
{ geneName: 'LCORL', geneType: '육량', farmRate: 78, regionAvgRate: 68 },
{ geneName: 'LAP3', geneType: '육량', farmRate: 65, regionAvgRate: 58 },
// 육질 관련
{ geneName: 'FABP4', geneType: '육질', farmRate: 88, regionAvgRate: 70 },
{ geneName: 'SCD', geneType: '육질', farmRate: 80, regionAvgRate: 72 },
{ geneName: 'DGAT1', geneType: '육질', farmRate: 75, regionAvgRate: 65 },
{ geneName: 'FASN', geneType: '육질', farmRate: 70, regionAvgRate: 62 },
{ geneName: 'CAPN1', geneType: '육질', farmRate: 82, regionAvgRate: 68 },
{ geneName: 'CAST', geneType: '육질', farmRate: 77, regionAvgRate: 64 },
]
// 선택된 유전자 중 목업 데이터에 없는 유전자가 있다면 추가
if (selectedGenes.length > 0) {
selectedGenes.forEach(geneName => {
if (!mockAllGenes.find(g => g.geneName === geneName)) {
// 선택된 유전자가 목업 데이터에 없으면 기본값으로 추가
mockAllGenes.push({
geneName: geneName,
geneType: geneName.includes('PLAG') || geneName.includes('NCAPG') || geneName.includes('LCORL') || geneName.includes('LAP') ? '육량' : '육질',
farmRate: Math.floor(Math.random() * 30) + 60, // 60-90 사이 랜덤값
regionAvgRate: Math.floor(Math.random() * 20) + 55, // 55-75 사이 랜덤값
})
}
})
}
setAllGenes(mockAllGenes)
setLoading(false)
}
fetchData()
}, [selectedYear, farmNo, selectedGenes])
if (loading) {
return (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"> ...</p>
</div>
)
}
if (!farmNo) {
return (
<div className="h-[300px] flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)
}
// 필터에 따라 표시할 유전자 선택
const allDisplayGenes = hasFilter
? allGenes.filter(g => selectedGenes.includes(g.geneName))
: allGenes.slice(0, 6) // TOP 6 (보유율 높은 순으로 이미 정렬됨)
// 접기/펼치기 적용 (4개 기준)
// 단, 선택된 유전자가 있을 때는 모두 표시
const DISPLAY_LIMIT = 4
const displayGenes = hasFilter || isExpanded ? allDisplayGenes : allDisplayGenes.slice(0, DISPLAY_LIMIT)
const hasMore = !hasFilter && allDisplayGenes.length > DISPLAY_LIMIT
return (
<div className="space-y-4">
{/* 필터 배지 표시 */}
{hasFilter && (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<Filter className="h-3.5 w-3.5" />
<span className="font-medium"> :</span>
</div>
{selectedGenes.map(gene => (
<Badge
key={gene}
variant="secondary"
className="text-xs font-medium bg-blue-50 text-blue-700 border-blue-200"
>
{gene}
</Badge>
))}
</div>
)}
{/* 유전자별 바 차트 */}
<div className="space-y-2.5">
{displayGenes.map((gene, index) => (
<div key={gene.geneName} className="space-y-1">
{/* 유전자명 + 타입 배지 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800 min-w-[60px]">
{gene.geneName}
</span>
<Badge
variant="outline"
className={`text-xs px-2 py-0 ${
gene.geneType === '육량'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-purple-50 text-purple-700 border-purple-200'
}`}
>
{gene.geneType}
</Badge>
</div>
<span className="text-sm font-bold text-gray-900">
{gene.farmRate}%
</span>
</div>
{/* 프로그레스 바 */}
<div className="relative h-7 bg-gray-100 rounded-full overflow-hidden">
{/* 우리 농장 */}
<div
className={`absolute h-full transition-all duration-800 ${
gene.geneType === '육량' ? 'bg-blue-500' : 'bg-purple-500'
}`}
style={{ width: `${gene.farmRate}%` }}
/>
{/* 지역 평균 표시 (점선) */}
<div
className="absolute h-full border-l-2 border-dashed border-gray-400"
style={{ left: `${gene.regionAvgRate}%` }}
title={`지역 평균: ${gene.regionAvgRate}%`}
/>
</div>
{/* 지역 평균 레이블 */}
<div className="flex justify-end">
<span className="text-xs text-gray-500">
: {gene.regionAvgRate}%
</span>
</div>
</div>
))}
</div>
{/* 더보기/접기 버튼 */}
{hasMore && (
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{allDisplayGenes.length - DISPLAY_LIMIT}
</>
)}
</Button>
</div>
)}
</div>
)
}

View File

@@ -131,6 +131,13 @@ export const genomeApi = {
return await apiClient.get(`/genome/dashboard-stats/${farmNo}`); return await apiClient.get(`/genome/dashboard-stats/${farmNo}`);
}, },
/**
* GET /genome/yearly-ebv-stats/:farmNo - 연도별 EBV 통계 (개체상세용)
*/
getYearlyEbvStats: async (farmNo: number): Promise<YearlyEbvStatsDto> => {
return await apiClient.get(`/genome/yearly-ebv-stats/${farmNo}`);
},
/** /**
* GET /genome/farm-region-ranking/:farmNo - 농가의 보은군 내 순위 조회 (대시보드용) * GET /genome/farm-region-ranking/:farmNo - 농가의 보은군 내 순위 조회 (대시보드용)
*/ */
@@ -222,7 +229,7 @@ export interface FarmRegionRankingDto {
} }
/** /**
* 대시보드 통계 데이터 타입 * 대시보드 통계 데이터 타입 (필수 4개만)
*/ */
export interface DashboardStatsDto { export interface DashboardStatsDto {
// 연도별 분석 현황 // 연도별 분석 현황
@@ -231,91 +238,63 @@ export interface DashboardStatsDto {
totalRequests: number; totalRequests: number;
analyzedCount: number; analyzedCount: number;
pendingCount: number; pendingCount: number;
sireMatchCount: number; // 친자 일치 수 sireMatchCount: number;
analyzeRate: number; // 분석 완료율 (%) analyzeRate: number;
sireMatchRate: number; // 친자 일치율 (%) sireMatchRate: number;
}[]; }[];
// 형질별 농장 평균 // 형질별 농장 평균
traitAverages: { traitAverages: {
traitName: string; traitName: string;
category: string; category: string;
avgEbv: number; avgEbv: number;
avgEpd: number; // 농가 육종가(EPD) 평균 avgEpd: number;
regionAvgEpd: number; // 보은군 육종가(EPD) 평균 regionAvgEpd?: number;
avgPercentile: number; avgPercentile: number;
count: number; count: number;
rank: number | null; // 보은군 내 농가 순위 rank: number | null;
totalFarms: number; // 보은군 내 총 농가 수 totalFarms: number;
percentile: number | null; // 상위 백분율 percentile: number | null;
}[];
// 접수 내역 목록
requestHistory: {
pkRequestNo: number;
cowId: string;
cowRemarks: string | null;
requestDt: string | null;
chipSireName: string | null;
chipReportDt: string | null;
status: string;
}[]; }[];
// 요약 // 요약
summary: { summary: {
totalCows: number; // 검사 받은 전체 개체 수 (합집합, 중복 제외) totalCows: number;
genomeCowCount: number; // 유전체 분석 개체 수 genomeCowCount: number;
geneCowCount: number; // 유전자검사 개체 수 geneCowCount: number;
mptCowCount: number; // 번식능력검사 개체 수 mptCowCount: number;
totalRequests: number; // 유전체 의뢰 건수 (기존 호환성) totalRequests: number;
analyzedCount: number; analyzedCount: number;
pendingCount: number; pendingCount: number;
mismatchCount: number; mismatchCount: number;
maleCount: number; // 수컷 수 maleCount: number;
femaleCount: number; // 암컷 수 femaleCount: number;
}; };
// 검사 종류별 현황 // 친자감별 결과 현황
testTypeStats: {
snp: { total: number; completed: number };
ms: { total: number; completed: number };
};
// 친자감별 결과 현황 (상호 배타적 분류)
paternityStats: { paternityStats: {
analysisComplete: number; // 분석 완료 (부 일치 + 모 일치/null/정보없음) analysisComplete: number;
sireMismatch: number; // 부 불일치 sireMismatch: number;
damMismatch: number; // 모 불일치 (부 일치 + 모 불일치) damMismatch: number;
damNoRecord: number; // 모 이력제부재 (부 일치 + 모 이력제부재) damNoRecord: number;
pending: number; // 대기 notAnalyzed: number;
}; };
// 월별 접수 현황 }
monthlyStats: {
month: number; /**
count: number; * 연도별 EBV 통계 (개체상세용)
}[]; */
// 칩 종류별 분포 export interface YearlyEbvStatsDto {
chipTypeStats: { yearlyStats: {
chipType: string;
count: number;
}[];
// 모근량별 분포
sampleAmountStats: {
sampleAmount: string;
count: number;
}[];
// 연도별 주요 형질 평균 (차트용)
yearlyTraitAverages: {
year: number; year: number;
traits: { traitName: string; avgEbv: number | null }[]; totalRequests: number;
analyzedCount: number;
pendingCount: number;
sireMatchCount: number;
analyzeRate: number;
sireMatchRate: number;
}[]; }[];
// 연도별 평균 표준화육종가 (농가 vs 보은군 비교)
yearlyAvgEbv: { yearlyAvgEbv: {
year: number; year: number;
farmAvgEbv: number; // 농가 평균 farmAvgEbv: number;
regionAvgEbv: number; // 보은군 평균 regionAvgEbv: number;
traitCount: number; traitCount: number;
}[]; }[];
// 우수 개체 TOP 5 (옵션)
topAnimals?: {
animalId?: string;
identNo?: string;
birthDt?: string;
avgEbv?: number;
}[];
} }