Compare commits

...

11 Commits

Author SHA1 Message Date
NYD
d8e7121b1a fix_text_align 2026-01-08 19:18:52 +09:00
NYD
65d56ecc85 fix_loop_loading 2026-01-08 18:59:56 +09:00
NYD
fce5dcc283 fix_unlimit_loading 2026-01-08 18:55:56 +09:00
c3ccab75c8 Update backend/.env 2026-01-08 09:40:28 +00:00
NYD
dabee8666c fix_cow_detail_page 2026-01-08 18:30:03 +09:00
NYD
f8ff86e4ea update_cow_detail_page 2026-01-08 16:04:01 +09:00
NYD
9e5ffb2c15 update_cow_list_detail_page 2026-01-07 17:56:22 +09:00
NYD
f5b52df26f update_cow_list_ui_2 2026-01-07 15:14:24 +09:00
NYD
dae3808221 update_cow_list_ui 2026-01-07 15:13:42 +09:00
NYD
0780f2e47c Merge branch 'main' of http://gitea.turbosoft.kr:80/turbosoft/genome2025 2026-01-06 17:26:25 +09:00
NYD
261bc4f91f add_cow_data_batch_insert 2026-01-06 17:23:53 +09:00
30 changed files with 4294 additions and 1777 deletions

View File

@@ -4,6 +4,7 @@
# DATABASE
POSTGRES_HOST=192.168.11.46
# POSTGRES_HOST=localhost
POSTGRES_USER=genome
POSTGRES_PASSWORD=genome1@3
POSTGRES_DB=genome_db

View File

@@ -0,0 +1,117 @@
import { BadRequestException, Body, Controller, Get, Post, UploadedFile, UseInterceptors, Logger } from "@nestjs/common";
import { AdminService } from "./admin.service";
import { FileInterceptor } from "@nestjs/platform-express";
import { basename, extname, join } from "path";
import { BaseResultDto } from "src/common/dto/base.result.dto";
import { diskStorage } from "multer";
import * as fs from 'fs/promises';
import { randomUUID } from "crypto";
import { tmpdir } from "os";
/**
※업로드 관련 기능 추후 공통화 처리 필요.
**/
const ALLOWED_EXTENSIONS = ['.xlsx', '.txt', '.csv', '.xls']; // 파일 업로드 허용 확장자
const ALLOWED_MIME_TYPES = [ // 파일 업로드 허용 MIME 타입
'text/plain',
// XLSX
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// XLS (구형 + CSV도 섞여 나옴)
'application/vnd.ms-excel',
// CSV 계열
'text/csv',
'application/csv',
'text/plain',
// 한컴
'application/haansoftxls',
];
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
private readonly logger = new Logger(AdminController.name);
@Get('dashboard')
getDashboard() {
return null;
}
@Post('batchUpload')
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: async (req, file, callback) => {
// 환경 변수가 없으면 시스템 임시 디렉터리 사용
const uploadDir = process.env.UPLOAD_DESTINATION
? join(process.env.UPLOAD_DESTINATION, 'tmp')
: join(tmpdir(), 'genome2025-uploads');
try {
// 디렉터리가 없으면 생성
await fs.mkdir(uploadDir, { recursive: true });
callback(null, uploadDir);
} catch (error) {
callback(error, null);
}
},
filename: (req, file, callback) => {
const ext = extname(file.originalname).toLowerCase();
callback(null, `${randomUUID()}-${Date.now()}${ext}`);
},
}),
fileFilter: (req, file, callback) => { // 파일 업로드 필터링
const ext = extname(file.originalname).toLowerCase();
const mime = file.mimetype;
if (!ALLOWED_EXTENSIONS.includes(ext)) { // 허용되지 않은 확장자 필터링
return callback(
new BadRequestException(`허용되지 않은 확장자: ${ext}`),
false,
);
}
if (!ALLOWED_MIME_TYPES.includes(mime)) { // 허용되지 않은 MIME 타입 필터링
return callback(
new BadRequestException(`허용되지 않은 MIME 타입: ${mime}`),
false,
);
}
callback(null, true);
}
}))
async batchUpload(@UploadedFile() file: Express.Multer.File, @Body('div') div: string) {
let divName = '';
try {
if (!file?.path){
throw new BadRequestException('파일 업로드 실패')
};
if (div === 'genome-result') { // 유전체 분석 결과(DGV)
divName = '유전체 분석 결과(DGV)';
await this.adminService.batchInsertGenomeResult(file);
}else if (div === 'snp-typing') { // 개체별 SNP 타이핑 결과(유전자 타이핑 결과)
divName = '개체별 SNP 타이핑 결과(유전자 타이핑 결과)';
await this.adminService.batchInsertSnpTyping(file);
}else if (div === 'mpt-result') { // MPT 분석결과(종합혈액화학검사결과서)
divName = 'MPT 분석결과(종합혈액화학검사결과서)';
await this.adminService.batchInsertMptResult(file);
}
// else if (div === 'animal-info') { // 소 정보 입력은 어디서 처리?
// divName = '소 정보 입력';
// return this.adminService.batchUploadAnimalInfo(file);
// }
return BaseResultDto.ok(`${divName} 파일 업로드 성공.\n데이터 입력 중...`, 'SUCCESS', 'OK');
} catch (error) {
return BaseResultDto.fail(`${divName} 파일 업로드 실패.\n${error.message}`, 'FAIL');
} finally {
await fs.unlink(file.path).catch(() => {}); // 파일 삭제
this.logger.log(`[batchUpload] ${divName} 파일 업로드 완료`);
}
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { GenomeRequestModel } from "../genome/entities/genome-request.entity";
import { CowModel } from "../cow/entities/cow.entity";
import { GenomeTraitDetailModel } from "../genome/entities/genome-trait-detail.entity";
import { MptModel } from "src/mpt/entities/mpt.entity";
import { FarmModel } from "src/farm/entities/farm.entity";
import { GeneDetailModel } from "src/gene/entities/gene-detail.entity";
@Module({
imports: [
TypeOrmModule.forFeature([
GenomeRequestModel,
CowModel,
GenomeTraitDetailModel,
MptModel,
FarmModel,
GeneDetailModel,
]),
],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,847 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { GenomeTraitDetailModel } from "src/genome/entities/genome-trait-detail.entity";
import { GenomeRequestModel } from "src/genome/entities/genome-request.entity";
import { CowModel } from "src/cow/entities/cow.entity";
import { Repository, IsNull, In } from "typeorm";
import { MptModel } from "src/mpt/entities/mpt.entity";
import { MptDto } from "src/mpt/dto/mpt.dto";
import { parseNumber, parseDate } from "src/common/utils";
import { ExcelUtil } from "src/common/excel/excel.util";
import { createReadStream } from "fs";
import * as readline from 'readline';
import { GeneDetailModel } from "src/gene/entities/gene-detail.entity";
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
// 유전체 분석 결과 Repository
@InjectRepository(GenomeTraitDetailModel)
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
// 유전체 분석 의뢰 Repository
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
// 소 개체 Repository
@InjectRepository(CowModel)
private readonly cowRepository: Repository<CowModel>,
// 혈액화학검사 결과 Repository
@InjectRepository(MptModel)
private readonly mptRepository: Repository<MptModel>,
// 유전자 상세 정보 Repository
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
@Inject(ExcelUtil)
private readonly excelUtil: ExcelUtil,
) {}
async batchInsertGenomeResult(file: Express.Multer.File) {
this.logger.log(`[배치업로드] 유전체 분석 결과 파일 처리 시작: ${file.originalname}`);
try {
// 엑셀파일 로드
const rawData = this.excelUtil.parseExcelData(file);
const headerRow = rawData[0];
const dataRows = rawData.slice(1);
// 헤더에서 형질명 추출 및 인덱스 매핑
// 컬럼 구조: A(samplename), B, C, D~DD(형질 데이터)
// 형질 패턴: "XXXXX", "XXXXX_표준화육종가", "XXXXX_백분율"
const traitMap = new Map<string, {
name: string;
valIndex: number | null;
ebvIndex: number | null;
percentileIndex: number | null;
}>();
// 헤더 분석 (인덱스 3부터 시작, D열부터)
for (let colIdx = 3; colIdx < headerRow.length && colIdx <= 107; colIdx++) {
const headerValue = headerRow[colIdx];
if (!headerValue || typeof headerValue !== 'string') {
continue;
}
const trimmedHeader = headerValue.trim();
// 형질명 추출 (접미사 제거)
if (trimmedHeader.endsWith('_표준화육종가')) {
const traitName = trimmedHeader.replace('_표준화육종가', '');
if (!traitMap.has(traitName)) {
traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null });
}
traitMap.get(traitName)!.ebvIndex = colIdx;
} else if (trimmedHeader.endsWith('_백분율')) {
const traitName = trimmedHeader.replace('_백분율', '');
if (!traitMap.has(traitName)) {
traitMap.set(traitName, { name: traitName, valIndex: null, ebvIndex: null, percentileIndex: null });
}
traitMap.get(traitName)!.percentileIndex = colIdx;
} else {
// 형질명 단독 (trait_val)
// 이미 존재하는 형질인지 확인 (접미사가 있는 경우 이미 추가됨)
if (!traitMap.has(trimmedHeader)) {
traitMap.set(trimmedHeader, { name: trimmedHeader, valIndex: null, ebvIndex: null, percentileIndex: null });
}
traitMap.get(trimmedHeader)!.valIndex = colIdx;
}
}
this.logger.log(`[배치업로드] 형질 개수: ${traitMap.size}`);
// 데이터 행 처리
const traitDataArray: Array<{
cowId: string;
traitName: string;
traitVal: number | null;
traitEbv: number | null;
traitPercentile: number | null;
}> = [];
for (let rowIdx = 0; rowIdx < dataRows.length; rowIdx++) {
const row = dataRows[rowIdx];
// A열(인덱스 0): cowId (samplename)
const cowId = row[0];
if (!cowId || typeof cowId !== 'string' || !cowId.trim()) {
this.logger.warn(`[배치업로드] ${rowIdx + 2}행: cowId가 없어 건너뜀`);
continue;
}
const trimmedCowId = cowId.trim();
// 각 형질별로 데이터 추출
for (const [traitName, indices] of traitMap.entries()) {
const traitVal = indices.valIndex !== null && row[indices.valIndex] !== null && row[indices.valIndex] !== undefined
? parseNumber(row[indices.valIndex])
: null;
const traitEbv = indices.ebvIndex !== null && row[indices.ebvIndex] !== null && row[indices.ebvIndex] !== undefined
? parseNumber(row[indices.ebvIndex])
: null;
const traitPercentile = indices.percentileIndex !== null && row[indices.percentileIndex] !== null && row[indices.percentileIndex] !== undefined
? parseNumber(row[indices.percentileIndex])
: null;
// 값이 하나라도 있으면 추가
if (traitVal !== null || traitEbv !== null || traitPercentile !== null) {
traitDataArray.push({
cowId: trimmedCowId,
traitName: traitName,
traitVal: traitVal,
traitEbv: traitEbv,
traitPercentile: traitPercentile,
});
}
}
}
this.logger.log(`[배치업로드] 파싱 완료: ${traitDataArray.length}개 형질 데이터`);
// ============================================
// 3단계: Json Array 각 객체 fk_request_no 추가
// - fk_request_no 추가 방법 : 소 식별번호(cowId)로 조회 후 fk_request_no 추가
// ============================================
this.logger.log('[배치업로드] 3단계: fk_request_no 조회 중...');
// 고유한 cowId 목록 추출
const uniqueCowIds = [...new Set(traitDataArray.map(item => item.cowId))];
this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}`);
// cowId별로 Cow와 GenomeRequest 조회
const cowIdToRequestNoMap = new Map<string, number | null>();
for (const cowId of uniqueCowIds) {
try {
// Step 1: cowId로 개체 조회
const cow = await this.cowRepository.findOne({
where: { cowId: cowId, delDt: IsNull() },
});
if (!cow) {
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`);
cowIdToRequestNoMap.set(cowId, null);
continue;
}
// Step 2: 해당 개체의 최신 분석 의뢰 조회
const request = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
order: { requestDt: 'DESC', regDt: 'DESC' },
});
if (!request) {
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 유전체 분석 의뢰를 찾을 수 없습니다.`);
cowIdToRequestNoMap.set(cowId, null);
continue;
}
cowIdToRequestNoMap.set(cowId, request.pkRequestNo);
} catch (error) {
this.logger.error(`[배치업로드] cowId "${cowId}" 조회 중 오류: ${error.message}`);
cowIdToRequestNoMap.set(cowId, null);
}
}
// fk_request_no 추가
const traitDataWithRequestNo = traitDataArray.map(item => {
const fkRequestNo = cowIdToRequestNoMap.get(item.cowId);
return {
...item,
fkRequestNo: fkRequestNo,
};
});
// fk_request_no가 없는 데이터 필터링
const validTraitData = traitDataWithRequestNo.filter(item => item.fkRequestNo !== null);
const invalidCount = traitDataWithRequestNo.length - validTraitData.length;
if (invalidCount > 0) {
this.logger.warn(`[배치업로드] fk_request_no가 없는 데이터 ${invalidCount}건 제외`);
}
this.logger.log(`[배치업로드] 유효한 형질 데이터: ${validTraitData.length}`);
// ============================================
// 4단계: 데이터 DB Insert : 비동기 batchInsert(upsert)
// ============================================
this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...');
let successCount = 0;
let errorCount = 0;
// 배치 크기 설정 (한 번에 처리할 데이터 수)
const BATCH_SIZE = 100;
for (let i = 0; i < validTraitData.length; i += BATCH_SIZE) {
const batch = validTraitData.slice(i, i + BATCH_SIZE);
try {
// 각 배치를 upsert 처리
const insertPromises = batch.map(async (item) => {
try {
// 기존 데이터 조회 (cowId와 traitName 기준)
const existing = await this.genomeTraitDetailRepository.findOne({
where: {
cowId: item.cowId,
traitName: item.traitName,
delDt: IsNull(),
},
});
if (existing) {
// 업데이트
existing.fkRequestNo = item.fkRequestNo!;
existing.traitVal = item.traitVal;
existing.traitEbv = item.traitEbv;
existing.traitPercentile = item.traitPercentile;
await this.genomeTraitDetailRepository.save(existing);
} else {
// 삽입
const newTraitDetail = this.genomeTraitDetailRepository.create({
fkRequestNo: item.fkRequestNo!,
cowId: item.cowId,
traitName: item.traitName,
traitVal: item.traitVal,
traitEbv: item.traitEbv,
traitPercentile: item.traitPercentile,
});
await this.genomeTraitDetailRepository.save(newTraitDetail);
}
return true;
} catch (error) {
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, traitName: ${item.traitName}): ${error.message}`);
return false;
}
});
const results = await Promise.all(insertPromises);
successCount += results.filter(r => r === true).length;
errorCount += results.filter(r => r === false).length;
this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validTraitData.length)}/${validTraitData.length}`);
} catch (error) {
this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`);
errorCount += batch.length;
}
}
// ============================================
// 5단계: 결과 로깅
// ============================================
this.logger.log(`[배치업로드] 처리 완료`);
this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}`);
return {
success: true,
total: traitDataArray.length,
valid: validTraitData.length,
successCount: successCount,
errorCount: errorCount,
excludedCount: invalidCount,
};
} catch (error) {
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
}
}
async batchInsertSnpTyping(file: Express.Multer.File) {
this.logger.log(`[배치업로드] SNP 타이핑 결과 파일 처리 시작: ${file.originalname}`);
try {
// ============================================
// 1단계: 텍스트 파일 스트림 읽기
// ============================================
this.logger.log('[배치업로드] 1단계: 텍스트 파일 스트림 읽기 중...');
const stream = createReadStream(file.path, {
encoding: 'utf-8',
highWaterMark: 64 * 1024, // 64KB 버퍼
});
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let section: 'NONE' | 'HEADER' | 'DATA_WAIT_HEADER' | 'DATA' = 'NONE';
let dataHeader: string[] | null = null;
// 컬럼 인덱스 매핑
const COLUMN_MAPPING = {
SNP_NAME: 'SNP Name',
SAMPLE_ID: 'Sample ID',
ALLELE1: 'Allele1 - Top',
ALLELE2: 'Allele2 - Top',
CHR: 'Chr',
POSITION: 'Position',
SNP: 'SNP',
};
// 컬럼 인덱스 캐시 (헤더 읽은 후 한 번만 계산)
let columnIndices: {
snpNameIdx: number;
sampleIdIdx: number;
allele1Idx: number;
allele2Idx: number;
chrIdx: number;
positionIdx: number;
snpIdx: number;
} | null = null;
// ============================================
// 2단계: 스트림 방식으로 파일 파싱 및 배치 단위 DB 저장
// ============================================
this.logger.log('[배치업로드] 2단계: 스트림 방식 파일 파싱 및 배치 저장 중...');
// 배치 버퍼 (메모리에 일정 크기만 유지)
const BATCH_SIZE = 5000; // 배치 크기 (메모리 사용량 제어)
const batchBuffer: Array<{
cowId: string;
snpName: string;
chromosome: string | null;
position: string | null;
snpType: string | null;
allele1: string | null;
allele2: string | null;
}> = [];
let totalRows = 0;
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
// 배치 저장 함수
const flushBatch = async () => {
if (batchBuffer.length === 0) return;
try {
// 배치 단위로 upsert 처리
const insertPromises = batchBuffer.map(async (item) => {
try {
// 기존 데이터 조회 (cowId와 snpName 기준)
const existing = await this.geneDetailRepository.findOne({
where: {
cowId: item.cowId,
snpName: item.snpName,
delDt: IsNull(),
},
});
if (existing) {
// 업데이트
existing.fkRequestNo = null; // cowId 조회 제거로 null 처리
existing.chromosome = item.chromosome;
existing.position = item.position;
existing.snpType = item.snpType;
existing.allele1 = item.allele1;
existing.allele2 = item.allele2;
await this.geneDetailRepository.save(existing);
} else {
// 삽입
const newGeneDetail = this.geneDetailRepository.create({
cowId: item.cowId,
snpName: item.snpName,
fkRequestNo: null, // cowId 조회 제거로 null 처리
chromosome: item.chromosome,
position: item.position,
snpType: item.snpType,
allele1: item.allele1,
allele2: item.allele2,
});
await this.geneDetailRepository.save(newGeneDetail);
}
return true;
} catch (error) {
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, snpName: ${item.snpName}): ${error.message}`);
return false;
}
});
const results = await Promise.all(insertPromises);
const batchSuccess = results.filter(r => r === true).length;
const batchError = results.filter(r => r === false).length;
successCount += batchSuccess;
errorCount += batchError;
this.logger.log(`[배치업로드] 배치 저장 완료: ${batchSuccess}건 성공, ${batchError}건 실패 (총 처리: ${totalRows}건)`);
} catch (error) {
this.logger.error(`[배치업로드] 배치 저장 중 오류: ${error.message}`);
errorCount += batchBuffer.length;
} finally {
// 배치 버퍼 초기화 (메모리 해제)
batchBuffer.length = 0;
}
};
// 파일 라인별 처리
for await (const rawLine of rl) {
const line = rawLine.trim();
if (!line) continue;
// 섹션 전환
if (line === '[Header]') {
section = 'HEADER';
continue;
}
if (line === '[Data]') {
section = 'DATA_WAIT_HEADER';
continue;
}
// [Header] 섹션은 무시
if (section === 'HEADER') {
continue;
}
// [Data] 다음 줄 = 컬럼 헤더
if (section === 'DATA_WAIT_HEADER') {
dataHeader = rawLine.split('\t').map(s => s.trim());
if (dataHeader.length < 2) {
throw new Error('[Data] 헤더 라인이 비정상입니다. (탭 구분 여부 확인 필요)');
}
// 컬럼 인덱스 계산 및 캐시
columnIndices = {
snpNameIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP_NAME),
sampleIdIdx: dataHeader.indexOf(COLUMN_MAPPING.SAMPLE_ID),
allele1Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE1),
allele2Idx: dataHeader.indexOf(COLUMN_MAPPING.ALLELE2),
chrIdx: dataHeader.indexOf(COLUMN_MAPPING.CHR),
positionIdx: dataHeader.indexOf(COLUMN_MAPPING.POSITION),
snpIdx: dataHeader.indexOf(COLUMN_MAPPING.SNP),
};
if (columnIndices.snpNameIdx === -1 || columnIndices.sampleIdIdx === -1) {
throw new Error(`필수 컬럼이 없습니다. (SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx})`);
}
this.logger.log(`[배치업로드] Data 헤더 컬럼 수: ${dataHeader.length}`);
this.logger.log(`[배치업로드] 컬럼 인덱스 - SNP Name: ${columnIndices.snpNameIdx}, Sample ID: ${columnIndices.sampleIdIdx}, Chr: ${columnIndices.chrIdx}, Position: ${columnIndices.positionIdx}, SNP: ${columnIndices.snpIdx}`);
section = 'DATA';
continue;
}
// 데이터 라인 처리
if (section === 'DATA') {
if (!dataHeader || !columnIndices) {
throw new Error('dataHeader 또는 columnIndices가 초기화되지 않았습니다.');
}
const values = rawLine.split('\t');
// 컬럼 수 불일치 시 스킵
if (values.length !== dataHeader.length) {
this.logger.warn(
`[배치업로드] 컬럼 수 불일치: header=${dataHeader.length}, values=${values.length} / line=${rawLine.slice(0, 120)}...`,
);
skippedCount++;
continue;
}
// 필수 필드 검증
const cowId = values[columnIndices.sampleIdIdx]?.trim();
const snpName = values[columnIndices.snpNameIdx]?.trim();
if (!cowId || !snpName) {
this.logger.warn(`[배치업로드] 필수 필드 누락: cowId=${cowId}, snpName=${snpName}`);
skippedCount++;
continue;
}
// 배치 버퍼에 추가
batchBuffer.push({
cowId,
snpName,
chromosome: columnIndices.chrIdx !== -1 ? (values[columnIndices.chrIdx]?.trim() || null) : null,
position: columnIndices.positionIdx !== -1 ? (values[columnIndices.positionIdx]?.trim() || null) : null,
snpType: columnIndices.snpIdx !== -1 ? (values[columnIndices.snpIdx]?.trim() || null) : null,
allele1: columnIndices.allele1Idx !== -1 ? (values[columnIndices.allele1Idx]?.trim() || null) : null,
allele2: columnIndices.allele2Idx !== -1 ? (values[columnIndices.allele2Idx]?.trim() || null) : null,
});
totalRows++;
// 배치 크기에 도달하면 즉시 DB에 저장
if (batchBuffer.length >= BATCH_SIZE) {
await flushBatch();
}
}
}
// 마지막 남은 배치 처리
await flushBatch();
// ============================================
// 3단계: 결과 로깅
// ============================================
this.logger.log(`[배치업로드] 처리 완료`);
this.logger.log(`[배치업로드] 총 처리: ${totalRows}건, 성공: ${successCount}건, 실패: ${errorCount}건, 스킵: ${skippedCount}`);
return {
success: true,
total: totalRows,
successCount: successCount,
errorCount: errorCount,
skippedCount: skippedCount,
};
} catch (error) {
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
}
}
/**
* 혈액화학검사 결과 배치 삽입
* @param file - 파일
* @returns 성공 여부
*/
async batchInsertMptResult(file: Express.Multer.File) {
this.logger.log(`[배치업로드] 혈액화학검사 결과 파일 처리 시작: ${file.originalname}`);
try {
// ============================================
// 1단계: 엑셀파일 로드
// ============================================
this.logger.log('[배치업로드] 1단계: 엑셀 파일 로드 중...');
const rawData = this.excelUtil.parseExcelData(file);
if (rawData.length < 6) {
throw new Error('엑셀 파일에 데이터가 부족합니다. (최소 6행 필요: 헤더 5행 + 데이터 1행)');
}
// ============================================
// 2단계: 데이터 파싱 및 검증
// ============================================
this.logger.log('[배치업로드] 2단계: 데이터 파싱 중...');
// MptDto를 기반으로 하되, 파싱 단계에서는 null 허용
const mptDataArray: Array<Partial<MptDto> & {
cowId: string;
fkFarmNo: number | null;
}> = [];
// 5행부터 데이터 시작 (인덱스 5)
for (let rowIdx = 5; rowIdx < rawData.length; rowIdx++) {
const row = rawData[rowIdx];
// cowId 검증 (A열, 인덱스 0)
const cowId = row[0];
if (!cowId || typeof cowId !== 'string' || !cowId.trim()) {
this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId가 없어 건너뜀`);
continue;
}
const trimmedCowId = cowId.trim();
// cowShortNo 추출 (길이 검증)
let cowShortNo: string;
if (trimmedCowId.length >= 11) {
cowShortNo = trimmedCowId.slice(7, 11);
} else {
this.logger.warn(`[배치업로드] ${rowIdx + 1}행: cowId 길이가 부족하여 cowShortNo 추출 실패 (cowId: ${trimmedCowId})`);
cowShortNo = trimmedCowId.slice(-4) || trimmedCowId; // 최소한 뒤 4자리 또는 전체
}
// 날짜 파싱 (C열, 인덱스 2)
const testDt = parseDate(row[2]);
// 숫자 필드 파싱
const monthAge = parseNumber(row[3]);
const milkYield = parseNumber(row[4]);
const parity = parseNumber(row[5]);
const glucose = parseNumber(row[6]);
const cholesterol = parseNumber(row[7]);
const nefa = parseNumber(row[8]);
const bcs = parseNumber(row[9]);
const totalProtein = parseNumber(row[10]);
const albumin = parseNumber(row[11]);
const globulin = parseNumber(row[12]);
const agRatio = parseNumber(row[13]);
const bun = parseNumber(row[14]);
const ast = parseNumber(row[15]);
const ggt = parseNumber(row[16]);
const fattyLiverIdx = parseNumber(row[17]);
const calcium = parseNumber(row[18]);
const phosphorus = parseNumber(row[19]);
const caPRatio = parseNumber(row[20]);
const magnesium = parseNumber(row[21]);
const creatine = parseNumber(row[22]);
mptDataArray.push({
cowId: `KOR${trimmedCowId}`,
cowShortNo,
fkFarmNo: null, // 3단계에서 채움
testDt,
monthAge,
milkYield,
parity,
glucose,
cholesterol,
nefa,
bcs,
totalProtein,
albumin,
globulin,
agRatio,
bun,
ast,
ggt,
fattyLiverIdx,
calcium,
phosphorus,
caPRatio,
magnesium,
creatine,
});
}
this.logger.log(`[배치업로드] 파싱 완료: ${mptDataArray.length}`);
// ============================================
// 3단계: cowId별 fkFarmNo 조회
// ============================================
this.logger.log('[배치업로드] 3단계: fkFarmNo 조회 중...');
// 고유한 cowId 목록 추출
const uniqueCowIds = [...new Set(mptDataArray.map(item => item.cowId))];
this.logger.log(`[배치업로드] 고유 cowId 개수: ${uniqueCowIds.length}`);
// cowId별 fkFarmNo 매핑 생성 (일괄 조회로 성능 최적화)
const cowIdToFarmNoMap = new Map<string, number | null>();
if (uniqueCowIds.length > 0) {
try {
// 모든 cowId를 한 번에 조회 (IN 쿼리)
const cows = await this.cowRepository.find({
where: {
cowId: In(uniqueCowIds),
delDt: IsNull(),
},
});
// 조회된 결과를 Map으로 변환
const foundCowIdSet = new Set<string>();
for (const cow of cows) {
if (cow.cowId) {
cowIdToFarmNoMap.set(cow.cowId, cow.fkFarmNo || null);
foundCowIdSet.add(cow.cowId);
}
}
// 조회되지 않은 cowId는 null로 설정
for (const cowId of uniqueCowIds) {
if (!foundCowIdSet.has(cowId)) {
this.logger.warn(`[배치업로드] cowId "${cowId}"에 해당하는 개체를 찾을 수 없습니다.`);
cowIdToFarmNoMap.set(cowId, null);
}
}
this.logger.log(`[배치업로드] 조회 성공: ${cows.length}/${uniqueCowIds.length}`);
} catch (error) {
this.logger.error(`[배치업로드] cowId 일괄 조회 중 오류: ${error.message}`);
// 에러 발생 시 모든 cowId를 null로 설정
for (const cowId of uniqueCowIds) {
cowIdToFarmNoMap.set(cowId, null);
}
}
}
// fkFarmNo 추가
const mptDataWithFarmNo = mptDataArray.map(item => ({
...item,
fkFarmNo: cowIdToFarmNoMap.get(item.cowId) || null,
}));
// fkFarmNo가 있는 유효한 데이터만 필터링
const validMptData = mptDataWithFarmNo.filter(item => item.fkFarmNo !== null);
const invalidCount = mptDataWithFarmNo.length - validMptData.length;
if (invalidCount > 0) {
this.logger.warn(`[배치업로드] fkFarmNo가 없는 데이터 ${invalidCount}건 제외`);
}
this.logger.log(`[배치업로드] 유효한 MPT 데이터: ${validMptData.length}`);
// ============================================
// 4단계: 데이터 DB Insert (Upsert)
// ============================================
this.logger.log('[배치업로드] 4단계: DB 배치 삽입(upsert) 중...');
let successCount = 0;
let errorCount = 0;
// 배치 크기 설정 (한 번에 처리할 데이터 수)
const BATCH_SIZE = 100;
for (let i = 0; i < validMptData.length; i += BATCH_SIZE) {
const batch = validMptData.slice(i, i + BATCH_SIZE);
try {
// 각 배치를 upsert 처리
const insertPromises = batch.map(async (item) => {
try {
// 기존 데이터 조회 (cowId와 testDt 기준)
const existing = await this.mptRepository.findOne({
where: {
cowId: item.cowId,
testDt: item.testDt,
delDt: IsNull(),
},
});
if (existing) {
// 업데이트
existing.fkFarmNo = item.fkFarmNo!;
existing.cowShortNo = item.cowShortNo;
existing.monthAge = item.monthAge;
existing.milkYield = item.milkYield;
existing.parity = item.parity;
existing.glucose = item.glucose;
existing.cholesterol = item.cholesterol;
existing.nefa = item.nefa;
existing.bcs = item.bcs;
existing.totalProtein = item.totalProtein;
existing.albumin = item.albumin;
existing.globulin = item.globulin;
existing.agRatio = item.agRatio;
existing.bun = item.bun;
existing.ast = item.ast;
existing.ggt = item.ggt;
existing.fattyLiverIdx = item.fattyLiverIdx;
existing.calcium = item.calcium;
existing.phosphorus = item.phosphorus;
existing.caPRatio = item.caPRatio;
existing.magnesium = item.magnesium;
existing.creatine = item.creatine;
await this.mptRepository.save(existing);
} else {
// 삽입
const newMpt = this.mptRepository.create({
cowId: item.cowId,
cowShortNo: item.cowShortNo,
fkFarmNo: item.fkFarmNo!,
testDt: item.testDt,
monthAge: item.monthAge,
milkYield: item.milkYield,
parity: item.parity,
glucose: item.glucose,
cholesterol: item.cholesterol,
nefa: item.nefa,
bcs: item.bcs,
totalProtein: item.totalProtein,
albumin: item.albumin,
globulin: item.globulin,
agRatio: item.agRatio,
bun: item.bun,
ast: item.ast,
ggt: item.ggt,
fattyLiverIdx: item.fattyLiverIdx,
calcium: item.calcium,
phosphorus: item.phosphorus,
caPRatio: item.caPRatio,
magnesium: item.magnesium,
creatine: item.creatine,
});
await this.mptRepository.save(newMpt);
}
return true;
} catch (error) {
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, ${error.message}`);
return false;
}
});
const results = await Promise.all(insertPromises);
successCount += results.filter(r => r === true).length;
errorCount += results.filter(r => r === false).length;
this.logger.log(`[배치업로드] 진행률: ${Math.min(i + BATCH_SIZE, validMptData.length)}/${validMptData.length}`);
} catch (error) {
this.logger.error(`[배치업로드] 배치 처리 중 오류: ${error.message}`);
errorCount += batch.length;
}
}
// ============================================
// 5단계: 결과 로깅
// ============================================
this.logger.log(`[배치업로드] 처리 완료`);
this.logger.log(`[배치업로드] 성공: ${successCount}건, 실패: ${errorCount}건, 제외: ${invalidCount}`);
return {
success: true,
total: mptDataArray.length,
valid: validMptData.length,
successCount: successCount,
errorCount: errorCount,
excludedCount: invalidCount,
};
} catch (error) {
this.logger.error(`[배치업로드] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
}
}
}

View File

@@ -15,6 +15,8 @@ import { GenomeModule } from './genome/genome.module';
import { MptModule } from './mpt/mpt.module';
import { GeneModule } from './gene/gene.module';
import { SystemModule } from './system/system.module';
import { AdminModule } from './admin/admin.module';
import { ExcelModule } from './common/excel/excel.module';
@Module({
imports: [
@@ -53,8 +55,12 @@ import { SystemModule } from './system/system.module';
GeneModule,
MptModule,
// 관리자 모듈
AdminModule,
// 기타
SystemModule,
ExcelModule,
],
controllers: [AppController],
providers: [AppService, JwtStrategy],

View File

@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModel } from '../user/entities/user.entity';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
import { JwtModule } from 'src/common/jwt/jwt.module';
import { EmailModule } from 'src/shared/email/email.module';
import { VerificationModule } from 'src/shared/verification/verification.module';
@@ -13,7 +15,7 @@ import { VerificationModule } from 'src/shared/verification/verification.module'
*/
@Module({
imports: [
TypeOrmModule.forFeature([UserModel]),
TypeOrmModule.forFeature([UserModel, FarmModel, GenomeRequestModel]),
JwtModule,
EmailModule,
VerificationModule,

View File

@@ -7,7 +7,9 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserModel } from '../user/entities/user.entity';
import { Repository } from 'typeorm';
import { FarmModel } from '../farm/entities/farm.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
import { Repository, IsNull } from 'typeorm';
import { LoginDto } from './dto/login.dto';
import { LoginResponseDto } from './dto/login-response.dto';
import { SignupDto } from './dto/signup.dto';
@@ -36,6 +38,10 @@ export class AuthService {
constructor(
@InjectRepository(UserModel)
private readonly userRepository: Repository<UserModel>,
@InjectRepository(FarmModel)
private readonly farmRepository: Repository<FarmModel>,
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
private readonly emailService: EmailService,
private readonly verificationService: VerificationService,
private readonly jwtService: JwtService,
@@ -78,7 +84,10 @@ export class AuthService {
const accessToken = this.jwtService.sign(payload as any);
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`);
// 최근 검사 년도 조회
const defaultAnalysisYear = await this.getDefaultAnalysisYear(user.pkUserNo);
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}, defaultAnalysisYear: ${defaultAnalysisYear}`);
return {
message: '로그인 성공',
@@ -90,9 +99,61 @@ export class AuthService {
userEmail: user.userEmail,
userRole: user.userRole || 'USER',
},
defaultAnalysisYear,
};
}
/**
* 사용자의 최근 검사 년도 조회
* @param userNo - 사용자 번호
* @returns 최근 검사 년도 (없으면 현재 년도)
*/
private async getDefaultAnalysisYear(userNo: number): Promise<number> {
try {
// 1. 사용자의 농장 번호 조회
const farm = await this.farmRepository.findOne({
where: { fkUserNo: userNo, delDt: IsNull() },
select: ['pkFarmNo'],
});
if (!farm) {
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, No farm found, returning current year`);
return new Date().getFullYear();
}
// 2. 농장의 검사 이력에서 최신 날짜 조회
const result = await this.genomeRequestRepository
.createQueryBuilder('request')
.select('MAX(request.chipReportDt)', 'maxChipDt')
.addSelect('MAX(request.msReportDt)', 'maxMsDt')
.where('request.fkFarmNo = :farmNo', { farmNo: farm.pkFarmNo })
.andWhere('request.delDt IS NULL')
.getRawOne();
const maxChipDt = result?.maxChipDt ? new Date(result.maxChipDt) : null;
const maxMsDt = result?.maxMsDt ? new Date(result.maxMsDt) : null;
// 둘 중 최신 날짜 선택
let latestDate: Date | null = null;
if (maxChipDt && maxMsDt) {
latestDate = maxChipDt > maxMsDt ? maxChipDt : maxMsDt;
} else if (maxChipDt) {
latestDate = maxChipDt;
} else if (maxMsDt) {
latestDate = maxMsDt;
}
const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear();
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, farmNo: ${farm.pkFarmNo}, maxChipDt: ${maxChipDt?.toISOString()}, maxMsDt: ${maxMsDt?.toISOString()}, year: ${year}`);
return year;
} catch (error) {
this.logger.error(`[getDefaultAnalysisYear] Error: ${error.message}`);
return new Date().getFullYear();
}
}
/**
* 회원가입
*/

View File

@@ -11,4 +11,5 @@ export class LoginResponseDto {
userEmail: string;
userRole: 'USER' | 'ADMIN';
};
defaultAnalysisYear: number; // 최근 검사 년도
}

View File

@@ -0,0 +1,37 @@
// common/dto/base-result.dto.ts
export class BaseResultDto<T = any> {
success: boolean;
code: string;
message: string;
data?: T;
timestamp: string;
constructor(
success: boolean,
code: string,
message: string,
data?: T,
) {
this.success = success;
this.code = code;
this.message = message;
this.data = data;
this.timestamp = new Date().toISOString();
}
static ok<T>(
data?: T,
message = 'SUCCESS',
code = 'OK',
): BaseResultDto<T> {
return new BaseResultDto<T>(true, code, message, data);
}
static fail(
message: string,
code = 'FAIL',
): BaseResultDto<null> {
return new BaseResultDto<null>(false, code, message);
}
}

View File

@@ -0,0 +1,14 @@
import { ExcelUtil } from "./excel.util";
import { Global, Module } from "@nestjs/common";
/**
* Excel 모듈
* Excel 파일 유틸리티를 제공하는 모듈입니다.
* 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성
*/
@Global()
@Module({
providers: [ExcelUtil],
exports: [ExcelUtil],
})
export class ExcelModule {}

View File

@@ -0,0 +1,70 @@
import * as XLSX from "xlsx";
import { Injectable, Logger } from "@nestjs/common";
/**
* Excel 파일 유틸리티
* Lib활용 등 서비스에 가까운 공통 유틸이므로, 서비스 클래스 형태로 생성
* 각 서비스 클래스에서 사용 가능하도록 공통 모듈로 생성
*/
@Injectable()
export class ExcelUtil {
private readonly logger = new Logger(ExcelUtil.name);
/**
* file 파일 데이터를 파싱하여 json 배열로 변환 - 추후 공통으로 처리 가능하면 정리해서 처리
* @param file - 파싱할 데이터
* @returns json 배열
*/
parseExcelData(file: Express.Multer.File): any[] {
try {
// ============================================
// 1단계: 엑셀파일 로드
// ============================================
this.logger.log('[parseExcelData] 1단계: 엑셀 파일 로드 중...');
let workbook: XLSX.WorkBook;
if(file?.buffer) {
workbook = XLSX.read(file.buffer, { type: 'buffer', cellDates: true });
}else if (file?.path){
workbook = XLSX.readFile(file.path, { cellDates: true });
}else{
throw new Error('file.buffer/file.path가 모두 없습니다.');
}
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
throw new Error('엑셀 파일에 시트가 없습니다.');
}
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
throw new Error(`시트 "${sheetName}"를 읽을 수 없습니다.`);
}
// 시트를 JSON 배열로 변환 (헤더 포함)
const rawData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: null,
raw: false
}) as any[][];
if (rawData.length < 2) {
throw new Error('엑셀 파일에 데이터가 없습니다. (헤더 포함 최소 2행 필요)');
}
this.logger.log(`[parseExcelData] 엑셀 파일 로드 완료: ${rawData.length}`);
return rawData;
} catch (error) {
this.logger.error(`[parseExcelData] 처리 중 오류 발생: ${error.message}`);
this.logger.error(error.stack);
throw error;
} finally {
this.logger.log(`[parseExcelData] 처리 완료`);
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* 날짜 문자열을 Date 객체로 변환
* @param value - 변환할 값
* @returns Date 객체 또는 null
*/
export function parseDate(value: any): Date | null {
if (value === null || value === undefined || value === '') {
return null;
}
// 이미 Date 객체인 경우
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
// 숫자인 경우 (엑셀 날짜 시리얼 번호)
if (typeof value === 'number') {
// Excel 날짜는 1900-01-01부터의 일수
// 하지만 XLSX 라이브러리가 이미 변환해줄 수 있으므로 일반 Date로 처리
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
// 문자열인 경우
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
// 다양한 날짜 형식 시도
// YYYY-MM-DD, YYYY/MM/DD, YYYYMMDD 등
const date = new Date(trimmed);
if (!isNaN(date.getTime())) {
return date;
}
// YYYYMMDD 형식 처리
if (/^\d{8}$/.test(trimmed)) {
const year = parseInt(trimmed.substring(0, 4), 10);
const month = parseInt(trimmed.substring(4, 6), 10) - 1; // 월은 0부터 시작
const day = parseInt(trimmed.substring(6, 8), 10);
const date = new Date(year, month, day);
return isNaN(date.getTime()) ? null : date;
}
}
return null;
}
/**
* 숫자 문자열을 숫자로 변환
* @param value - 변환할 값
* @returns 숫자 또는 null
*/
export function parseNumber(value: any): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value === 'number') {
return isNaN(value) ? null : value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
const parsed = parseFloat(trimmed);
return isNaN(parsed) ? null : parsed;
}
return null;
}

View File

@@ -115,6 +115,16 @@ export class GenomeController {
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
}
/**
* GET /genome/latest-analysis-year/:farmNo
* 농장의 가장 최근 분석 연도 조회 (chip_report_dt 또는 ms_report_dt 기준)
* @param farmNo - 농장 번호
*/
@Get('latest-analysis-year/:farmNo')
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
return this.genomeService.getLatestAnalysisYear(+farmNo);
}
/**
* GET /genome/:cowId
* cowId(개체식별번호)로 유전체 데이터 조회

View File

@@ -1044,6 +1044,7 @@ export class GenomeService {
farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
details: { traitNm: string; ebv: number; weight: number; contribution: number }[];
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포
message?: string;
}> {
// Step 1: cowId로 개체 조회
@@ -1067,7 +1068,7 @@ export class GenomeService {
farmRank: null, farmTotal: 0,
regionRank: null, regionTotal: 0, regionName: null, farmerName: null,
farmAvgScore: null, regionAvgScore: null,
details: [], message: '유전체 분석 데이터 없음'
details: [], histogram: [], message: '유전체 분석 데이터 없음'
};
}
@@ -1082,7 +1083,7 @@ export class GenomeService {
farmRank: null, farmTotal: 0,
regionRank: null, regionTotal: 0, regionName: null, farmerName: null,
farmAvgScore: null, regionAvgScore: null,
details: [], message: '형질 데이터 없음'
details: [], histogram: [], message: '형질 데이터 없음'
};
}
@@ -1138,7 +1139,7 @@ export class GenomeService {
// Step 7: 현재 개체의 농장/지역 정보 조회
let regionName: string | null = null;
let farmerName: string | null = null;
let farmNo: number | null = latestRequest.fkFarmNo;
const farmNo: number | null = latestRequest.fkFarmNo;
if (farmNo) {
const farm = await this.farmRepository.findOne({
@@ -1162,12 +1163,13 @@ export class GenomeService {
farmAvgScore: null,
regionAvgScore: null,
details,
histogram: [],
message: '선택한 형질 중 일부 데이터가 없습니다',
};
}
// Step 9: 농가/지역 순위 및 평균 선발지수 계산
const { farmRank, farmTotal, regionRank, regionTotal, farmAvgScore, regionAvgScore } =
const { farmRank, farmTotal, regionRank, regionTotal, farmAvgScore, regionAvgScore, histogram } =
await this.calculateRanks(cowId, score, traitConditions, farmNo, regionName);
return {
@@ -1182,6 +1184,7 @@ export class GenomeService {
farmAvgScore,
regionAvgScore,
details,
histogram,
};
}
@@ -1207,10 +1210,11 @@ export class GenomeService {
regionTotal: number;
farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포
}> {
// 점수가 없으면 순위 계산 불가
if (currentScore === null) {
return { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgScore: null, regionAvgScore: null };
return { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgScore: null, regionAvgScore: null, histogram: [] };
}
// 모든 유전체 분석 의뢰 조회 (유전체 데이터가 있는 모든 개체)
@@ -1297,7 +1301,7 @@ export class GenomeService {
// 지역(보은군) 순위 및 평균 선발지수 계산 - 전체 유전체 데이터가 보은군이므로 필터링 없이 전체 사용
let regionRank: number | null = null;
let regionTotal = allScores.length;
const regionTotal = allScores.length;
let regionAvgScore: number | null = null;
const regionIndex = allScores.findIndex(s => s.cowId === currentCowId);
@@ -1309,6 +1313,38 @@ export class GenomeService {
regionAvgScore = Math.round((regionScoreSum / allScores.length) * 100) / 100;
}
// 히스토그램 생성 (선발지수 실제 분포)
const histogram: { bin: number; count: number; farmCount: number }[] = [];
if (allScores.length > 0) {
// 최소/최대값 찾기
const scores = allScores.map(s => s.score);
const minScore = Math.min(...scores);
const maxScore = Math.max(...scores);
const range = maxScore - minScore;
// 구간 크기 결정 (전체 범위를 약 20-30개 구간으로 나눔)
const binSize = range > 0 ? Math.ceil(range / 25) : 1;
// 구간별 집계
const binMap = new Map<number, { count: number; farmCount: number }>();
allScores.forEach(({ score, farmNo: scoreFarmNo }) => {
const binStart = Math.floor(score / binSize) * binSize;
const existing = binMap.get(binStart) || { count: 0, farmCount: 0 };
existing.count += 1;
if (scoreFarmNo === farmNo) {
existing.farmCount += 1;
}
binMap.set(binStart, existing);
});
// Map을 배열로 변환 및 정렬
histogram.push(...Array.from(binMap.entries())
.map(([bin, data]) => ({ bin, count: data.count, farmCount: data.farmCount }))
.sort((a, b) => a.bin - b.bin)
);
}
return {
farmRank,
farmTotal,
@@ -1316,6 +1352,7 @@ export class GenomeService {
regionTotal,
farmAvgScore,
regionAvgScore,
histogram,
};
}
@@ -1338,6 +1375,7 @@ export class GenomeService {
regionAvgEbv: number | null;
farmAvgEpd: number | null; // 농가 평균 육종가(EPD)
regionAvgEpd: number | null; // 보은군 평균 육종가(EPD)
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 데이터 분포
}> {
// 1. 현재 개체의 의뢰 정보 조회
const cow = await this.cowRepository.findOne({
@@ -1357,6 +1395,7 @@ export class GenomeService {
regionAvgEbv: null,
farmAvgEpd: null,
regionAvgEpd: null,
histogram: [],
};
}
@@ -1378,6 +1417,7 @@ export class GenomeService {
regionAvgEbv: null,
farmAvgEpd: null,
regionAvgEpd: null,
histogram: [],
};
}
@@ -1459,6 +1499,77 @@ export class GenomeService {
? Math.round((farmEpdValues.reduce((sum, v) => sum + v, 0) / farmEpdValues.length) * 100) / 100
: null;
// 8. 실제 데이터 분포 히스토그램 생성 (EPD 기준)
const histogram: { bin: number; count: number; farmCount: number }[] = [];
if (allScores.length > 0) {
// EPD 값들 수집 (EPD가 실제 육종가 값)
const epdValues = allScores.filter(s => s.epd !== null).map(s => ({ epd: s.epd as number, farmNo: s.farmNo }));
if (epdValues.length > 0) {
// 최소/최대값 찾기
const minEpd = Math.min(...epdValues.map(v => v.epd));
const maxEpd = Math.max(...epdValues.map(v => v.epd));
const range = maxEpd - minEpd;
// rate 형질 여부 확인 (형질명에 'rate' 또는 'Rate' 포함)
const isRateTrait = traitName.toLowerCase().includes('rate');
// 구간 크기 결정
let binSize: number;
if (isRateTrait) {
// rate 형질: 소수점 binSize 사용 (더 촘촘한 구간)
binSize = range > 0 ? range / 25 : 0.1;
// 너무 작으면 최소값 보장
if (binSize < 0.1) binSize = 0.1;
// 소수점 둘째자리까지 반올림
binSize = Math.round(binSize * 100) / 100;
console.log(`📊 [${traitName}] rate 형질 히스토그램 생성:`, {
: `${minEpd.toFixed(2)} ~ ${maxEpd.toFixed(2)}`,
range: range.toFixed(2),
binSize: binSize.toFixed(2),
: '소수점'
});
} else {
// 일반 형질: 기존 로직 (정수 binSize)
binSize = range > 0 ? Math.ceil(range / 25) : 1;
}
// 구간별 집계
const binMap = new Map<number, { count: number; farmCount: number }>();
epdValues.forEach(({ epd, farmNo: scoreFarmNo }) => {
// rate 형질은 소수점 구간, 일반 형질은 정수 구간
const binStart = isRateTrait
? Math.round((Math.floor(epd / binSize) * binSize) * 100) / 100 // 소수점 둘째자리까지
: Math.floor(epd / binSize) * binSize;
const existing = binMap.get(binStart) || { count: 0, farmCount: 0 };
existing.count += 1;
if (scoreFarmNo === farmNo) {
existing.farmCount += 1;
}
binMap.set(binStart, existing);
});
// Map을 배열로 변환 및 정렬
const sortedHistogram = Array.from(binMap.entries())
.map(([bin, data]) => ({ bin, count: data.count, farmCount: data.farmCount }))
.sort((a, b) => a.bin - b.bin);
histogram.push(...sortedHistogram);
// rate 형질일 때만 로그 출력
if (isRateTrait && sortedHistogram.length > 0) {
console.log(`📊 [${traitName}] 최종 히스토그램:`, {
구간수: sortedHistogram.length,
첫구간: sortedHistogram[0].bin,
마지막구간: sortedHistogram[sortedHistogram.length - 1].bin,
샘플: sortedHistogram.slice(0, 5).map(h => `${h.bin.toFixed(2)}(${h.count}마리)`)
});
}
}
}
return {
traitName,
cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null,
@@ -1471,6 +1582,7 @@ export class GenomeService {
regionAvgEbv,
farmAvgEpd,
regionAvgEpd,
histogram, // 실제 데이터 분포 추가
};
}
@@ -1874,4 +1986,69 @@ export class GenomeService {
},
};
}
/**
* 농장의 가장 최근 분석 연도 조회
* chip_report_dt 또는 ms_report_dt 중 가장 최근 날짜의 년도 반환
* 둘 다 없으면 현재 년도 반환
*
* @param farmNo - 농장 번호
* @returns { year: number } - 가장 최근 분석 연도
*/
async getLatestAnalysisYear(farmNo: number): Promise<{ year: number }> {
console.log(`[getLatestAnalysisYear] farmNo: ${farmNo}`);
// 농장의 모든 분석 의뢰 조회
const requests = await this.genomeRequestRepository.find({
where: { fkFarmNo: farmNo, delDt: IsNull() },
select: ['chipReportDt', 'msReportDt'],
});
console.log(`[getLatestAnalysisYear] Found ${requests?.length || 0} requests`);
if (!requests || requests.length === 0) {
console.log('[getLatestAnalysisYear] No requests found, returning current year');
return { year: new Date().getFullYear() };
}
// chip_report_dt와 ms_report_dt 중 가장 최근 날짜 찾기
let latestDate: Date | null = null;
let latestChipDate: Date | null = null;
let latestMsDate: Date | null = null;
for (const request of requests) {
// chip_report_dt 확인
if (request.chipReportDt) {
const chipDate = new Date(request.chipReportDt);
if (!latestChipDate || chipDate > latestChipDate) {
latestChipDate = chipDate;
}
if (!latestDate || chipDate > latestDate) {
latestDate = chipDate;
}
}
// ms_report_dt 확인
if (request.msReportDt) {
const msDate = new Date(request.msReportDt);
if (!latestMsDate || msDate > latestMsDate) {
latestMsDate = msDate;
}
if (!latestDate || msDate > latestDate) {
latestDate = msDate;
}
}
}
console.log(`[getLatestAnalysisYear] Latest chip_report_dt: ${latestChipDate?.toISOString()}`);
console.log(`[getLatestAnalysisYear] Latest ms_report_dt: ${latestMsDate?.toISOString()}`);
console.log(`[getLatestAnalysisYear] Latest date overall: ${latestDate?.toISOString()}`);
// 가장 최근 날짜가 있으면 그 연도, 없으면 현재 연도
const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear();
console.log(`[getLatestAnalysisYear] Returning year: ${year}`);
return { year };
}
}

View File

@@ -0,0 +1,29 @@
/**
* MPT 결과 DTO
*/
export interface MptDto {
cowId: string;
cowShortNo: string;
fkFarmNo: number;
testDt: Date;
monthAge: number;
milkYield: number;
parity: number;
glucose: number;
cholesterol: number;
nefa: number;
bcs: number;
totalProtein: number;
albumin: number;
globulin: number;
agRatio: number;
bun: number;
ast: number;
ggt: number;
fattyLiverIdx: number;
calcium: number;
phosphorus: number;
caPRatio: number;
magnesium: number;
creatine: number;
}

View File

@@ -73,6 +73,7 @@ interface UploadState {
status: "idle" | "uploading" | "success" | "error"
message: string
isDragging: boolean
fileType: FileType
}
function FileUploadCard({ fileType }: { fileType: FileType }) {
@@ -81,6 +82,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
status: "idle",
message: "",
isDragging: false,
fileType: fileType,
})
const fileInputRef = React.useRef<HTMLInputElement>(null)
@@ -109,7 +111,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
const files = e.dataTransfer.files
if (files && files.length > 0) {
const file = files[0]
if (isValidExcelFile(file)) {
if (isValidFile(file)) {
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
} else {
setState(prev => ({
@@ -125,7 +127,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
if (isValidExcelFile(file)) {
if (isValidFile(file)) {
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
} else {
setState(prev => ({
@@ -137,18 +139,21 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
}
}
const isValidExcelFile = (file: File): boolean => {
const isValidFile = (file: File): boolean => {
const validExtensions = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
"text/csv",
"application/csv",
"application/haansoftxls",
"text/plain",
]
return (
validExtensions.includes(file.type) ||
file.name.endsWith(".xlsx") ||
file.name.endsWith(".xls") ||
file.name.endsWith(".csv")
file.name.endsWith(".csv") ||
file.name.endsWith(".txt")
)
}
@@ -167,16 +172,28 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
try {
const formData = new FormData()
formData.append("file", state.file)
formData.append("fileType", fileType.fileType)
formData.append("div", state.fileType.id)
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploadfile`, {
// const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploadfile`, {
// method: "POST",
// body: formData,
// headers: {
// 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
// },
// })
// 배치 파일 업로드 테스트
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/admin/batchUpload`, {
method: "POST",
body: formData,
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
'Authorization': `Bearer ${useAuthStore.getState().accessToken}`,
},
})
if (!response.ok) {
throw new Error("파일 업로드에 실패했습니다.")
}
@@ -195,6 +212,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
status: "idle",
message: "",
isDragging: false,
fileType: fileType,
})
if (fileInputRef.current) {
fileInputRef.current.value = ""
@@ -218,6 +236,7 @@ function FileUploadCard({ fileType }: { fileType: FileType }) {
status: "idle",
message: "",
isDragging: false,
fileType: fileType,
})
if (fileInputRef.current) {
fileInputRef.current.value = ""

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -74,19 +74,53 @@ export function CategoryEvaluationCard({
// 차트에 표시할 형질 목록 (커스텀 가능)
const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS])
// 활성화된 형질 목록 (차트에 표시할 형질)
const [activeTraits, setActiveTraits] = useState<Set<string>>(new Set([...DEFAULT_TRAITS]))
// 형질 추가 모달/드로어 상태
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
// 선택된 형질 (터치/클릭 시 정보 표시용)
const [selectedTraitName, setSelectedTraitName] = useState<string | null>(null)
// 차트 로딩 상태
const [isChartLoading, setIsChartLoading] = useState(false)
// 모바일 여부 확인
const isDesktop = useMediaQuery("(min-width: 768px)")
// 형질 활성화/비활성화 토글
const toggleTraitActive = (traitName: string) => {
setActiveTraits(prev => {
const newSet = new Set(prev)
if (newSet.has(traitName)) {
// 비활성화 시 제한 없음 (2개 이하일 때 차트 비활성화로 처리)
newSet.delete(traitName)
} else {
newSet.add(traitName)
}
return newSet
})
}
// 차트 데이터 변경 시 로딩 처리
useEffect(() => {
setIsChartLoading(true)
const timer = setTimeout(() => {
setIsChartLoading(false)
}, 300) // 차트 렌더링 시뮬레이션
return () => clearTimeout(timer)
}, [activeTraits])
// 형질 제거
const removeTrait = (traitName: string) => {
if (chartTraits.length > 3) { // 최소 3개는 유지
setChartTraits(prev => prev.filter(t => t !== traitName))
setActiveTraits(prev => {
const newSet = new Set(prev)
newSet.delete(traitName)
return newSet
})
}
}
@@ -94,16 +128,21 @@ export function CategoryEvaluationCard({
const addTrait = (traitName: string) => {
if (chartTraits.length < 7 && !chartTraits.includes(traitName)) { // 최대 7개
setChartTraits(prev => [...prev, traitName])
setActiveTraits(prev => new Set([...prev, traitName]))
}
}
// 기본값으로 초기화
const resetToDefault = () => {
setChartTraits([...DEFAULT_TRAITS])
setActiveTraits(new Set([...DEFAULT_TRAITS]))
}
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
const traitChartData = chartTraits.map(traitName => {
// 폴리곤 차트용 데이터 생성 (활성화된 형질만 포함) - 보은군, 농가, 이 개체 비교
const traitChartData = useMemo(() => {
return chartTraits
.filter(traitName => activeTraits.has(traitName))
.map(traitName => {
const trait = allTraits.find((t: GenomeCowTraitDto) => t.traitName === traitName)
// 형질별 평균 데이터에서 해당 형질 찾기
@@ -131,17 +170,50 @@ export function CategoryEvaluationCard({
diff: trait?.breedVal ?? 0,
hasData: !!trait
}
})
})
}, [chartTraits, activeTraits, allTraits, traitComparisonAverages])
// 가장 높은 형질 찾기 (이 개체 기준)
const bestTraitName = traitChartData.reduce((best, current) =>
current.breedVal > best.breedVal ? current : best
, traitChartData[0])?.shortName
// 동적 스케일 계산 (모든 값의 최대 절대값 기준)
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
const maxAbsValue = Math.max(...allValues.map(Math.abs), 0.3) // 최소 0.3
const dynamicDomain = Math.ceil(maxAbsValue * 1.2 * 10) / 10 // 20% 여유
// 동적 스케일 계산 (실제 데이터 범위를 기반으로, min/max 각각에 5% 여유분만 추가)
// useMemo를 사용하는 이유: traitChartData가 변경될 때만 재계산하여 성능 최적화
// - traitChartData는 activeTraits, chartTraits, allTraits, traitComparisonAverages에 의존
// - 이 값들이 변경될 때마다 스케일을 다시 계산해야 함
// - useMemo를 사용하면 의존성이 변경되지 않으면 이전 계산 결과를 재사용
const dynamicDomain = useMemo(() => {
if (traitChartData.length === 0) return [-0.3, 0.3]
// 모든 값 수집 (breedVal, regionVal, farmVal)
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
// 실제 데이터의 최소값과 최대값 찾기
const minValue = Math.min(...allValues)
const maxValue = Math.max(...allValues)
// 데이터 범위 계산
const dataRange = maxValue - minValue
// 데이터 범위가 너무 작으면 최소 범위 보장 (0.3)
const effectiveRange = Math.max(dataRange, 0.3)
// min/max 각각에 범위의 10%만큼 여유분 추가 (대칭 처리하지 않음)
const padding = effectiveRange * 0.10
let domainMin = minValue - padding
let domainMax = maxValue + padding
// 소수점 첫째자리까지 반올림
domainMin = Math.floor(domainMin * 10) / 10
domainMax = Math.ceil(domainMax * 10) / 10
return [domainMin, domainMax]
}, [traitChartData])
// 활성화된 형질 개수
const activeTraitsCount = activeTraits.size
const hasEnoughTraits = activeTraitsCount >= 3
// 형질 이름으로 원본 형질명 찾기 (shortName -> name)
const findTraitNameByShortName = (shortName: string) => {
@@ -189,7 +261,7 @@ export function CategoryEvaluationCard({
y={0}
dy={5}
textAnchor="middle"
fontSize={15}
fontSize={isDesktop ? 17 : 15}
fontWeight={isSelected ? 700 : 600}
fill={isSelected ? '#ffffff' : '#334155'}
>
@@ -227,11 +299,12 @@ export function CategoryEvaluationCard({
}`}
>
{getTraitDisplayName(trait)}
{traitData && traitData.breedVal !== undefined && (
{/* 육종가(EBV) 값 표시 (주석 처리) */}
{/* {traitData && traitData.breedVal !== undefined && (
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
</span>
)}
)} */}
</button>
)
})}
@@ -282,38 +355,52 @@ export function CategoryEvaluationCard({
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
{/* 2열 레이아웃: 왼쪽 차트 + 오른쪽 카드 */}
<div className="p-4 lg:p-6">
<div className="p-4 lg:p-6 lg:pb-0">
{/* 형질 선택 칩 영역 */}
<div className="mb-4 lg:mb-6">
<div className="flex items-center justify-between mb-2 lg:mb-3">
<span className="text-sm lg:text-base font-medium text-muted-foreground"> </span>
<button
onClick={() => setIsTraitSelectorOpen(true)}
className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<div className="mb-4 lg:mb-2">
<div className="flex items-center justify-between mb-2 lg:mb-0">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-lg lg:text-base font-medium text-muted-foreground"> :</div>
<div className="flex flex-wrap gap-1.5 lg:gap-2">
{chartTraits.map(trait => {
const isActive = activeTraits.has(trait)
return (
<div
key={trait}
className={`inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 rounded-full text-sm lg:text-base font-medium transition-all cursor-pointer ${
isActive
? 'bg-primary text-primary-foreground'
: 'bg-primary/10 text-primary opacity-50'
}`}
onClick={() => toggleTraitActive(trait)}
>
<span className="text-md font-bold">{getTraitDisplayName(trait)}</span>
{chartTraits.length > 3 && (
<span
onClick={(e) => {
e.stopPropagation()
removeTrait(trait)
setActiveTraits(prev => {
const newSet = new Set(prev)
newSet.delete(trait)
return newSet
})
}}
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
>
<X className="w-3 h-3 lg:w-4 lg:h-4" />
</span>
)}
</div>
)
})}
</div>
</div>
<button onClick={() => setIsTraitSelectorOpen(true)} className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors">
<Pencil className="w-4 h-4 lg:w-5 lg:h-5" />
</button>
</div>
<div className="flex flex-wrap gap-1.5 lg:gap-2">
{chartTraits.map(trait => (
<span
key={trait}
className="inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 bg-primary/10 text-primary rounded-full text-sm lg:text-base font-medium group"
>
{getTraitDisplayName(trait)}
{chartTraits.length > 3 && (
<button
onClick={() => removeTrait(trait)}
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3 lg:w-4 lg:h-4" />
</button>
)}
</span>
))}
</div>
{/* 형질 편집 모달 - 웹: Dialog, 모바일: Drawer */}
{isDesktop ? (
@@ -342,18 +429,47 @@ export function CategoryEvaluationCard({
<div className={`flex flex-col ${hideTraitCards ? '' : 'lg:flex-row'} gap-6`}>
{/* 폴리곤 차트 */}
<div className={hideTraitCards ? 'w-full' : 'lg:w-1/2'}>
<div className="bg-muted/20 rounded-xl h-full">
<div className="bg-muted/20 rounded-xl h-full relative">
<div className={hideTraitCards ? 'h-[95vw] max-h-[520px] sm:h-[420px]' : 'h-[95vw] max-h-[520px] sm:h-[440px]'}>
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={traitChartData} margin={{ top: 40, right: 45, bottom: 40, left: 45 }}>
{/* 범례 - 좌측 상단 */}
<div className="absolute top-2 left-2 z-20 flex items-center gap-2 sm:gap-3 flex-wrap">
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#10b981' }}></div>
<span className="text-lg sm:text-base font-medium text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
<span className="text-lg sm:text-base font-medium text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1482B0' }}></div>
<span className="text-lg sm:text-base font-medium text-foreground">{formatCowNoShort(cowNo)} </span>
</div>
</div>
{/* 로딩 상태 또는 최소 형질 개수 미달 */}
{(isChartLoading || !hasEnoughTraits) ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
{isChartLoading ? (
<>
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"></div>
<p className="text-sm text-muted-foreground font-medium"> ...</p>
</>
) : (
<p className="text-lg text-muted-foreground font-bold"> 3 .</p>
)}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={traitChartData} margin={{ top: 40, right: 0, bottom: 0, left: 0 }}>
<PolarGrid
stroke="#e2e8f0"
strokeWidth={1}
/>
<PolarRadiusAxis
angle={90}
domain={[-dynamicDomain, dynamicDomain]}
tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }}
domain={dynamicDomain}
tick={{ fontSize: isDesktop ? 16 : 15, fill: '#64748b', fontWeight: 700 }}
tickCount={5}
axisLine={false}
/>
@@ -362,8 +478,9 @@ export function CategoryEvaluationCard({
name="보은군 평균"
dataKey="regionVal"
stroke="#10b981"
fill="#10b981"
fillOpacity={0.2}
// fill="#10b981"
// fillOpacity={0.2}
fill="transparent"
strokeWidth={2}
dot={false}
/>
@@ -372,8 +489,9 @@ export function CategoryEvaluationCard({
name="농가 평균"
dataKey="farmVal"
stroke="#1F3A8F"
fill="#1F3A8F"
fillOpacity={0.3}
// fill="#1F3A8F"
// fillOpacity={0.3}
fill="transparent"
strokeWidth={2.5}
dot={false}
/>
@@ -382,8 +500,9 @@ export function CategoryEvaluationCard({
name={formatCowNo(cowNo)}
dataKey="breedVal"
stroke="#1482B0"
fill="#1482B0"
fillOpacity={0.35}
// fill="#1482B0"
// fillOpacity={0.35}
fill="transparent"
strokeWidth={isDesktop ? 3 : 2}
dot={{
fill: '#1482B0',
@@ -399,6 +518,7 @@ export function CategoryEvaluationCard({
tickLine={false}
/>
<RechartsTooltip
animationDuration={0}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0]?.payload
@@ -408,8 +528,8 @@ export function CategoryEvaluationCard({
return (
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
<p className="text-white font-bold mb-2">{item?.name}</p>
<div className="space-y-1.5 text-xs">
<p className="text-white font-bold mb-2 text-lg">{item?.name}</p>
<div className="space-y-1.5 text-lg">
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
@@ -440,22 +560,7 @@ export function CategoryEvaluationCard({
/>
</RadarChart>
</ResponsiveContainer>
</div>
{/* 범례 */}
<div className="flex items-center justify-center gap-4 sm:gap-6 py-3 border-t border-border">
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#10b981' }}></div>
<span className="text-sm text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
<span className="text-sm text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#1482B0' }}></div>
<span className="text-sm font-medium text-foreground">{formatCowNoShort(cowNo)} </span>
</div>
)}
</div>
{/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */}

View File

@@ -13,6 +13,7 @@ import {
Customized,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis
} from 'recharts'
@@ -97,6 +98,8 @@ interface NormalDistributionChartProps {
// 차트 필터 형질 선택 콜백 (외부 연동용)
chartFilterTrait?: string
onChartFilterTraitChange?: (trait: string) => void
// 전체 선발지수 히스토그램 (실제 분포 데이터)
selectionIndexHistogram?: { bin: number; count: number; farmCount: number }[]
}
export function NormalDistributionChart({
@@ -134,7 +137,8 @@ export function NormalDistributionChart({
highlightMode = null,
onHighlightModeChange,
chartFilterTrait: externalChartFilterTrait,
onChartFilterTraitChange
onChartFilterTraitChange,
selectionIndexHistogram = []
}: NormalDistributionChartProps) {
const { filters } = useFilterStore()
@@ -262,16 +266,95 @@ export function NormalDistributionChart({
}
}, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ])
// X축 범위 및 간격 계산 (내 개체 중심 방식)
// X축 범위 및 간격 계산 (실제 데이터에 맞게 조정, 중앙 정렬)
const xAxisConfig = useMemo(() => {
const cowScore = chartDisplayValues.originalScore
// 전체 선발지수: selectionIndexHistogram 사용
if (chartFilterTrait === 'overall' && selectionIndexHistogram.length > 0) {
const bins = selectionIndexHistogram.map(item => item.bin - cowScore)
// 내 개체(0), 농가 평균, 보은군 평균도 범위에 포함
const allValues = [...bins, 0, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
const minData = Math.min(...allValues)
const maxData = Math.max(...allValues)
// 데이터의 중심점 계산
const center = (minData + maxData) / 2
// 데이터 범위에 20% 여유 추가
const dataRange = maxData - minData
const padding = dataRange * 0.2
// 중심점 기준으로 좌우 대칭 범위 설정
const halfRange = (dataRange / 2) + padding
const min = Math.floor(center - halfRange)
const max = Math.ceil(center + halfRange)
const range = max - min
let step: number
if (range <= 5) {
step = 0.5
} else if (range <= 20) {
step = 2
} else if (range <= 50) {
step = 5
} else if (range <= 100) {
step = 10
} else {
step = 20
}
return { min, max, step }
}
// 형질별: traitRankData.histogram 사용
if (chartFilterTrait !== 'overall' && traitRankData?.histogram && traitRankData.histogram.length > 0) {
const bins = traitRankData.histogram.map(item => item.bin - cowScore)
// 내 개체(0), 농가 평균, 보은군 평균도 범위에 포함
const allValues = [...bins, 0, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
const minData = Math.min(...allValues)
const maxData = Math.max(...allValues)
console.log(`[${chartFilterTrait}] X축 범위 계산:`, {
bins: `${bins[0].toFixed(2)} ~ ${bins[bins.length-1].toFixed(2)}`,
내개체: 0,
농가평균위치: chartDisplayValues.farmScore.toFixed(2),
보은군평균위치: chartDisplayValues.regionScore.toFixed(2),
allValues범위: `${minData.toFixed(2)} ~ ${maxData.toFixed(2)}`,
})
// 데이터의 중심점 계산
const center = (minData + maxData) / 2
// 데이터 범위에 20% 여유 추가
const dataRange = maxData - minData
const padding = dataRange * 0.2
// 중심점 기준으로 좌우 대칭 범위 설정
const halfRange = (dataRange / 2) + padding
const min = Math.floor(center - halfRange)
const max = Math.ceil(center + halfRange)
const range = max - min
let step: number
if (range <= 5) {
step = 0.5
} else if (range <= 20) {
step = 2
} else if (range <= 50) {
step = 5
} else if (range <= 100) {
step = 10
} else {
step = 20
}
return { min, max, step }
}
// 히스토그램 데이터가 없으면 평균 대비 차이로 범위 계산 (폴백)
const { cowVsFarm, cowVsRegion } = chartDisplayValues
const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion))
// 데이터가 차트의 약 70% 영역에 들어오도록 범위 계산
// maxDiff가 range의 70%가 되도록: range = maxDiff / 0.7
const targetRange = maxDiff / 0.7
// step 계산: 범위에 따라 적절한 간격 선택
let step: number
if (targetRange <= 1) {
step = 0.2
@@ -285,12 +368,11 @@ export function NormalDistributionChart({
step = 10
}
// 범위를 step 단위로 올림 (최소값 보장)
const minRange = step * 3 // 최소 3개의 step
const minRange = step * 3
const range = Math.max(minRange, Math.ceil(targetRange / step) * step)
return { min: -range, max: range, step }
}, [chartDisplayValues])
}, [chartFilterTrait, selectionIndexHistogram, traitRankData, chartDisplayValues])
// X축 틱 계산 (동적 간격)
const xTicks = useMemo(() => {
@@ -302,22 +384,118 @@ export function NormalDistributionChart({
return ticks
}, [xAxisConfig])
// 히스토그램 데이터 생성 (내 개체 중심, 정규분포 곡선)
// 히스토그램 데이터 생성 (실제 데이터 분포 사용)
const histogramData = useMemo(() => {
// X축 범위에 맞게 표준편차 조정 (범위의 약 1/4)
// 전체 선발지수: selectionIndexHistogram 사용
if (chartFilterTrait === 'overall' && selectionIndexHistogram.length > 0) {
const histogram = selectionIndexHistogram
const totalCount = histogram.reduce((sum, item) => sum + item.count, 0)
const bins = histogram.map(item => {
const cowScore = chartDisplayValues.originalScore
const relativeBin = item.bin - cowScore
const percent = (item.count / totalCount) * 100
const farmPercent = totalCount > 0 ? (item.farmCount / totalCount) * 100 : 0
return {
midPoint: relativeBin,
regionPercent: percent,
percent: percent,
farmPercent: farmPercent,
count: item.count,
farmCount: item.farmCount
}
})
// 🔍 실제 히스토그램 데이터 콘솔 로그
const dataMinMax = { min: Math.min(...bins.map(b => b.midPoint)), max: Math.max(...bins.map(b => b.midPoint)) }
const percentMinMax = { min: Math.min(...bins.map(b => b.percent)), max: Math.max(...bins.map(b => b.percent)) }
const calculatedYMax = Math.max(5, Math.ceil(percentMinMax.max * 1.2))
console.log('📊 [전체 선발지수 - 차트 범위 자동 조정]', {
: '전체 선발지수',
전체개체수: totalCount,
'📏 X축': {
: `${dataMinMax.min.toFixed(1)} ~ ${dataMinMax.max.toFixed(1)}`,
: `${xAxisConfig.min} ~ ${xAxisConfig.max}`,
: `${(xAxisConfig.max - xAxisConfig.min).toFixed(1)}`
},
'📏 Y축': {
: `${percentMinMax.max.toFixed(1)}%`,
: `${calculatedYMax}%`,
: `${((calculatedYMax - percentMinMax.max) / percentMinMax.max * 100).toFixed(0)}%`
},
총데이터개수: bins.length,
: { 첫5개: bins.slice(0, 5), 마지막5개: bins.slice(-5) }
})
return bins
}
// 형질별 데이터가 있으면 실제 히스토그램 사용
if (chartFilterTrait !== 'overall' && traitRankData?.histogram && traitRankData.histogram.length > 0) {
const histogram = traitRankData.histogram
const totalCount = histogram.reduce((sum, item) => sum + item.count, 0)
// 백엔드에서 받은 히스토그램을 차트 데이터로 변환
const bins = histogram.map(item => {
// bin 값은 구간의 시작값 (예: 110, 115, 120...)
// 개체 점수 대비 상대 위치로 변환 (내 개체 = 0 기준)
const cowScore = chartDisplayValues.originalScore
const relativeBin = item.bin - cowScore
// 백분율 계산
const percent = (item.count / totalCount) * 100
const farmPercent = totalCount > 0 ? (item.farmCount / totalCount) * 100 : 0
return {
midPoint: relativeBin,
regionPercent: percent,
percent: percent,
farmPercent: farmPercent,
count: item.count,
farmCount: item.farmCount
}
})
// 🔍 실제 히스토그램 데이터 콘솔 로그
const dataMinMax = { min: Math.min(...bins.map(b => b.midPoint)), max: Math.max(...bins.map(b => b.midPoint)) }
const percentMinMax = { min: Math.min(...bins.map(b => b.percent)), max: Math.max(...bins.map(b => b.percent)) }
const calculatedYMax = Math.max(5, Math.ceil(percentMinMax.max * 1.2))
console.log(`📊 [${chartFilterTrait} - 차트 범위 자동 조정]`, {
형질명: chartFilterTrait,
전체개체수: totalCount,
'📏 X축': {
: `${dataMinMax.min.toFixed(1)} ~ ${dataMinMax.max.toFixed(1)}`,
: `${xAxisConfig.min} ~ ${xAxisConfig.max}`,
: `${(xAxisConfig.max - xAxisConfig.min).toFixed(1)}`
},
'📏 Y축': {
: `${percentMinMax.max.toFixed(1)}%`,
: `${calculatedYMax}%`,
: `${((calculatedYMax - percentMinMax.max) / percentMinMax.max * 100).toFixed(0)}%`
},
총데이터개수: bins.length,
: { 첫5개: bins.slice(0, 5), 마지막5개: bins.slice(-5) }
})
return bins
}
// 히스토그램 데이터가 없을 때만 정규분포 곡선 사용 (폴백)
const range = xAxisConfig.max - xAxisConfig.min
const std = range / 4
// 정규분포 PDF 계산 함수 (0~1 범위로 정규화)
const normalPDF = (x: number, mean: number = 0) => {
const exponent = -Math.pow(x - mean, 2) / (2 * Math.pow(std, 2))
return Math.exp(exponent) // 0~1 범위
return Math.exp(exponent)
}
const bins = []
const stepSize = range / 100 // 100개의 점으로 부드러운 곡선
const stepSize = range / 100
for (let x = xAxisConfig.min; x <= xAxisConfig.max; x += stepSize) {
const pdfValue = normalPDF(x) * 40 // 최대 40%로 스케일링
const pdfValue = normalPDF(x) * 40
bins.push({
midPoint: x,
regionPercent: pdfValue,
@@ -325,11 +503,30 @@ export function NormalDistributionChart({
})
}
return bins
}, [xAxisConfig])
// 🔍 정규분포 곡선 데이터 콘솔 로그
console.log('📊 [정규분포 곡선 데이터 - 폴백]', {
총데이터개수: bins.length,
X축범위: `${xAxisConfig.min} ~ ${xAxisConfig.max}`,
표준편차: std,
첫5개: bins.slice(0, 5),
마지막5개: bins.slice(-5)
})
// 최대 % (Y축 범위용) - 항상 40으로 고정
const maxPercent = 40
return bins
}, [xAxisConfig, chartFilterTrait, traitRankData, chartDisplayValues.originalScore, selectionIndexHistogram])
// Y축 범위 (실제 데이터에 맞게 조정 - 개체수 기준)
const maxCount = useMemo(() => {
if (histogramData.length === 0) return 100
const maxValue = Math.max(...histogramData.map(d => ('count' in d ? d.count : 0) || 0))
// 실제 최대값에 20% 여유만 추가 (너무 빡빡하지 않게)
const calculatedMax = Math.ceil(maxValue * 1.2)
// 최소 10개체 보장 (데이터가 너무 작을 때만)
return Math.max(10, calculatedMax)
}, [histogramData])
return (
@@ -387,14 +584,13 @@ export function NormalDistributionChart({
</Select>
</div>
{/* 확대 버튼 */}
<button
{/* <button
onClick={onOpenChartModal}
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
aria-label="차트 확대"
>
aria-label="차트 확대">
<Maximize2 className="w-4 h-4" />
<span className="hidden sm:inline">확대</span>
</button>
</button> */}
</div>
{/* 순위 및 평균 대비 요약 (차트 위에 배치) */}
@@ -511,16 +707,16 @@ export function NormalDistributionChart({
{/* 데스크탑: 기존 레이아웃 */}
<div className="hidden sm:block">
{/* 현재 보고 있는 조회 기준 표시 */}
<div className="flex items-center justify-center mb-4">
{/* <div className="flex items-center justify-center mb-4">
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 조회 기준
</span>
</div>
</div> */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* 농가 내 순위 */}
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span>
<span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -550,7 +746,7 @@ export function NormalDistributionChart({
{/* 보은군 내 순위 */}
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-300 shadow-sm">
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span>
<span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -580,7 +776,7 @@ export function NormalDistributionChart({
{/* 농가 평균 대비 */}
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalFarmScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span>
<span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -600,7 +796,7 @@ export function NormalDistributionChart({
{/* 보은군 평균 대비 */}
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalRegionScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span>
<span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -622,11 +818,20 @@ export function NormalDistributionChart({
</div>
<div className="h-[400px] sm:h-[440px] md:h-[470px] bg-gradient-to-b from-slate-50 to-slate-100 rounded-xl p-2 sm:p-4 relative" role="img" aria-label="농가 및 보은군 내 개체 위치">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={histogramData}
margin={isMobileView ? { top: 110, right: 10, left: 5, bottom: 25 } : { top: 110, right: 20, left: 10, bottom: 45 }}
>
{/* 로딩 상태 */}
{(traitRankLoading && chartFilterTrait !== 'overall') || histogramData.length === 0 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"></div>
<p className="text-sm text-muted-foreground font-medium">
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} ...
</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={histogramData}
margin={isMobileView ? { top: 110, right: 10, left: 5, bottom: 25 } : { top: 110, right: 20, left: 10, bottom: 45 }}
>
<defs>
{/* 보은군 - Blue */}
<linearGradient id="regionGradient" x1="0" y1="0" x2="0" y2="1">
@@ -660,31 +865,68 @@ export function NormalDistributionChart({
type="number"
domain={[xAxisConfig.min, xAxisConfig.max]}
ticks={xTicks}
tick={{ fontSize: isMobileView ? 11 : 13, fill: '#64748b', fontWeight: 600 }}
tick={{ fontSize: isMobileView ? 16 : 18, fill: '#64748b', fontWeight: 700 }}
tickLine={false}
axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }}
tickFormatter={(value) => {
if (value === 0) return '내 개체'
if (value === 0) {
// cowNo의 뒤에서 5번째부터 2번째까지 4자리 추출 (예: KOR002203259861 -> 5986)
const shortId = cowNo ? cowNo.slice(-5, -1) : ''
return shortId || '0'
}
return value > 0 ? `+${value}` : `${value}`
}}
/>
<YAxis
axisLine={{ stroke: '#cbd5e1', strokeWidth: 1 }}
tickLine={false}
tick={{ fontSize: isMobileView ? 10 : 11, fill: '#64748b' }}
width={isMobileView ? 35 : 45}
domain={[0, Math.ceil(maxPercent)]}
tickFormatter={(value) => `${Math.round(value)}%`}
tick={{ fontSize: isMobileView ? 15 : 17, fill: '#64748b', fontWeight: 700 }}
width={isMobileView ? 45 : 60}
domain={[0, Math.ceil(maxCount)]}
tickFormatter={(value) => `${Math.round(value)}`}
/>
{/* 정규분포 곡선 */}
{/* Tooltip */}
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null
const data = payload[0].payload
const cowScore = chartDisplayValues.originalScore
const binStart = Math.round((data.midPoint + cowScore) * 100) / 100
return (
<div className="bg-white p-3 border border-border rounded-lg shadow-lg">
<p className="text-sm font-semibold mb-2">
: {binStart >= 0 ? '+' : ''}{binStart.toFixed(2)}
</p>
<p className="text-sm text-muted-foreground">
: <span className="font-bold text-foreground">{data.count || 0}</span>
</p>
<p className="text-sm text-muted-foreground">
: <span className="font-bold text-foreground">{data.percent?.toFixed(1) || 0}%</span>
</p>
{data.farmCount !== undefined && (
<p className="text-sm text-blue-600 mt-1">
: <span className="font-bold">{data.farmCount}</span>
</p>
)}
</div>
)
}}
cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }}
/>
{/* 실제 데이터 분포 (Area 그래프 + 점 표시) */}
<Area
type="natural"
dataKey="percent"
type="linear"
dataKey="count"
stroke="#64748b"
strokeWidth={2.5}
fill="url(#areaFillGradient)"
dot={false}
dot={{ r: 4, fill: '#64748b', strokeWidth: 2, stroke: '#fff' }}
activeDot={{ r: 6, fill: '#3b82f6', strokeWidth: 2, stroke: '#fff' }}
isAnimationActive={false}
/>
{/* 보은군 평균 위치 */}
@@ -833,7 +1075,7 @@ export function NormalDistributionChart({
fontSize={isMobile ? 13 : 15}
fontWeight={600}
>
{cowNo ? cowNo.slice(-5, -1) : '0'}
</text>
<text
x={clamp(cowX, cowBadgeW / 2)}
@@ -1048,10 +1290,11 @@ export function NormalDistributionChart({
/>
</ComposedChart>
</ResponsiveContainer>
)}
</div>
{/* 범례 */}
<div className="flex items-center justify-center gap-4 sm:gap-6 mt-4 pt-4 border-t border-border">
{/* <div className="flex items-center justify-center gap-4 sm:gap-6 mt-4 pt-4 border-t border-border">
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-slate-500"></div>
<span className="text-sm sm:text-sm text-muted-foreground">보은군 평균</span>
@@ -1064,7 +1307,7 @@ export function NormalDistributionChart({
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-[#1482B0]"></div>
<span className="text-sm sm:text-sm font-medium text-foreground">{displayCowNumber.slice(-4)} 개체</span>
</div>
</div>
</div> */}
</CardContent>
</Card>

View File

@@ -1,9 +1,10 @@
'use client'
import { useMemo } from 'react'
import { useMemo, useEffect, useState } from 'react'
import { Card, CardContent } from "@/components/ui/card"
import { DEFAULT_TRAITS, NEGATIVE_TRAITS, getTraitDisplayName } from "@/constants/traits"
import { GenomeCowTraitDto } from "@/types/genome.types"
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
// 카테고리별 배지 스타일 (진한 톤)
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
@@ -23,13 +24,14 @@ interface TraitDistributionChartsProps {
regionAvgZ: number
farmAvgZ: number
cowName?: string
cowNo?: string // API 호출용 개체번호
totalCowCount?: number
selectedTraits?: GenomeCowTraitDto[]
traitWeights?: Record<string, number>
}
// 리스트 뷰 컴포넌트
function TraitListView({ traits, cowName }: {
// 테이블 뷰 컴포넌트 (데스크탑)
function TraitTableView({ traits, traitRanks }: {
traits: Array<{
traitName?: string;
shortName: string;
@@ -39,62 +41,69 @@ function TraitListView({ traits, cowName }: {
traitVal?: number;
hasData?: boolean;
}>;
cowName: string
traitRanks: Record<string, TraitRankDto>
}) {
return (
<Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md">
<Card className="hidden lg:block bg-white border border-border rounded-xl overflow-hidden shadow-md">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<table className="w-full text-[1.5rem]">
<thead>
<tr className="border-b-2 border-border bg-muted/70">
<th className="px-3 sm:px-5 py-4 text-center text-sm sm:text-base font-bold text-foreground"></th>
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground"></th>
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground"></th>
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground"> </th>
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"></th>
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
</tr>
</thead>
<tbody>
{traits.map((trait, idx) => (
<tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
<td className="px-3 sm:px-5 py-4 text-center">
<span className="text-sm sm:text-lg font-semibold text-foreground">{trait.shortName}</span>
</td>
<td className="px-3 sm:px-5 py-4 text-left">
{trait.traitCategory && (
<span
className={`inline-flex items-center text-xs sm:text-sm font-bold px-3 sm:px-4 py-1.5 rounded-full whitespace-nowrap border-2 ${CATEGORY_STYLES[trait.traitCategory]?.bg || 'bg-slate-50'} ${CATEGORY_STYLES[trait.traitCategory]?.text || 'text-slate-700'} ${CATEGORY_STYLES[trait.traitCategory]?.border || 'border-slate-200'}`}
>
{trait.traitCategory}
{traits.map((trait, idx) => {
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
return (
<tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
<td className="px-3 sm:px-5 py-4 text-center">
<span className="font-medium text-foreground">{trait.shortName}</span>
</td>
<td className="px-3 sm:px-5 py-4 text-center">
<div className="flex items-center justify-center gap-2">
<span className={`font-bold ${(() => {
const value = trait.traitVal ?? 0
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
if (value === 0) return 'text-muted-foreground'
if (isNegativeTrait) {
return value < 0 ? 'text-green-600' : 'text-red-600'
}
return value > 0 ? 'text-green-600' : 'text-red-600'
})()}`}>
{trait.traitVal !== undefined ? (
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
) : '-'}
</span>
</div>
</td>
<td className="px-3 sm:px-5 py-4 text-center">
<span className="font-bold text-foreground">
{(trait.percentile || 0).toFixed(0)}%
</span>
)}
</td>
<td className="px-3 sm:px-5 py-4 text-left">
<div className="flex items-center gap-2">
<span className={`text-base sm:text-xl font-bold ${(() => {
const value = trait.traitVal ?? 0
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
// 등지방두께: 음수가 좋음(녹색), 양수가 나쁨(빨간색)
// 나머지: 양수가 좋음(녹색), 음수가 나쁨(빨간색)
if (value === 0) return 'text-muted-foreground'
if (isNegativeTrait) {
return value < 0 ? 'text-green-600' : 'text-red-600'
}
return value > 0 ? 'text-green-600' : 'text-red-600'
})()}`}>
{trait.traitVal !== undefined ? (
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
</td>
<td className="px-3 sm:px-5 py-4 text-center">
<span className="font-bold text-foreground">
{rankData?.farmRank && rankData.farmTotal ? (
`${rankData.farmRank}위/${rankData.farmTotal}`
) : '-'}
</span>
</div>
</td>
<td className="px-3 sm:px-5 py-4 text-left">
<span className="text-base sm:text-xl font-bold text-foreground">
{(trait.percentile || 0).toFixed(0)}%
</span>
</td>
</tr>
))}
</td>
<td className="px-3 sm:px-5 py-4 text-center">
<span className="font-bold text-foreground">
{rankData?.regionRank && rankData.regionTotal ? (
`${rankData.regionRank}위/${rankData.regionTotal}`
) : '-'}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
@@ -103,12 +112,96 @@ function TraitListView({ traits, cowName }: {
)
}
// 카드 뷰 컴포넌트 (모바일)
function TraitCardView({ traits, traitRanks }: {
traits: Array<{
traitName?: string;
shortName: string;
breedVal: number;
percentile?: number;
traitCategory?: string;
traitVal?: number;
hasData?: boolean;
}>;
traitRanks: Record<string, TraitRankDto>
}) {
return (
<div className="lg:hidden space-y-3">
{traits.map((trait, idx) => {
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
const value = trait.traitVal ?? 0
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
const valueColor = (() => {
if (value === 0) return 'text-muted-foreground'
if (isNegativeTrait) {
return value < 0 ? 'text-green-600' : 'text-red-600'
}
return value > 0 ? 'text-green-600' : 'text-red-600'
})()
return (
<Card key={trait.traitName || idx} className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
<CardContent className="p-4">
<div className="space-y-3">
{/* 형질명 */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<span className="text-sm font-medium text-muted-foreground"></span>
<span className="text-base font-bold text-foreground">{trait.shortName}</span>
</div>
{/* 유전체 육종가 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className={`text-base font-bold ${valueColor}`}>
{trait.traitVal !== undefined ? (
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
) : '-'}
</span>
</div>
{/* 전국 백분위 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base font-bold text-foreground">
{(trait.percentile || 0).toFixed(0)}%
</span>
</div>
{/* 농가 내 순위 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base font-bold text-foreground">
{rankData?.farmRank && rankData.farmTotal ? (
`${rankData.farmRank}위/${rankData.farmTotal}`
) : '-'}
</span>
</div>
{/* 보은군 내 순위 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base font-bold text-foreground">
{rankData?.regionRank && rankData.regionTotal ? (
`${rankData.regionRank}위/${rankData.regionTotal}`
) : '-'}
</span>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
// 메인 컴포넌트
export function TraitDistributionCharts({
allTraits,
regionAvgZ,
farmAvgZ,
cowName = '개체',
cowNo,
totalCowCount = 100,
selectedTraits = [],
traitWeights = {}
@@ -153,6 +246,53 @@ export function TraitDistributionCharts({
})
}, [allTraits, selectedTraits, traitWeights])
// 표시할 형질명 목록 (순위 조회용)
const traitNames = useMemo(() => {
return displayTraits
.filter(trait => trait.traitName && trait.hasData)
.map(trait => trait.traitName!)
.sort() // 정렬하여 안정적인 키 생성
}, [displayTraits])
// 형질명 목록의 안정적인 키 (dependency용)
const traitNamesKey = useMemo(() => {
return traitNames.join(',')
}, [traitNames])
// 각 형질의 순위 정보 가져오기
const [traitRanks, setTraitRanks] = useState<Record<string, TraitRankDto>>({})
const [loadingRanks, setLoadingRanks] = useState(false)
useEffect(() => {
if (!cowNo || traitNames.length === 0) return
const fetchRanks = async () => {
setLoadingRanks(true)
try {
const rankPromises = traitNames.map(traitName =>
genomeApi.getTraitRank(cowNo, traitName)
.then(rank => ({ traitName, rank }))
.catch(() => null)
)
const results = await Promise.all(rankPromises)
const ranksMap: Record<string, TraitRankDto> = {}
results.forEach(result => {
if (result) {
ranksMap[result.traitName] = result.rank
}
})
setTraitRanks(ranksMap)
} catch (error) {
console.error('순위 정보 로드 실패:', error)
} finally {
setLoadingRanks(false)
}
}
fetchRanks()
}, [cowNo, traitNamesKey])
return (
<>
{/* 헤더 */}
@@ -166,8 +306,11 @@ export function TraitDistributionCharts({
</div>
</div>
{/* 리스트 뷰 */}
<TraitListView traits={displayTraits} cowName={displayCowNumber} />
{/* 테이블 뷰 (데스크탑) */}
<TraitTableView traits={displayTraits} traitRanks={traitRanks} />
{/* 카드 뷰 (모바일) */}
<TraitCardView traits={displayTraits} traitRanks={traitRanks} />
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
return (
<div className="space-y-6">
{/* 개체 정보 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* 데스크탑: 가로 그리드 */}
@@ -161,7 +161,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{/* 검사 정보 */}
{selectedMpt && (
<>
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<h3 className="text-lg lg:!text-[1.5rem] 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">
@@ -245,22 +245,22 @@ 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-[1.5rem] font-bold text-foreground"> </h3>
{/* 데스크탑: 테이블 */}
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<table className="w-full text-[1.5rem]">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground" style={{ width: '18%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '15%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '15%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '16%' }}></th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-left font-semibold text-muted-foreground" style={{ width: '18%' }}></th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}></th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}></th>
<th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '16%' }}></th>
</tr>
</thead>
<tbody>
@@ -275,14 +275,14 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{itemIdx === 0 && (
<td
rowSpan={category.items.length}
className={`px-4 py-3 text-sm font-semibold text-foreground ${category.color} align-middle text-center`}
className={`px-4 py-3 font-semibold text-foreground ${category.color} align-middle text-center`}
>
{category.name}
</td>
)}
<td className="px-4 py-3 text-sm font-medium text-foreground">{ref?.name || itemKey}</td>
<td className="px-4 py-3 font-medium text-foreground">{ref?.name || itemKey}</td>
<td className="px-4 py-3 text-center">
<span className={`text-lg font-bold ${
<span className={`font-bold ${
status === 'safe' ? 'text-green-600' :
status === 'caution' ? 'text-amber-600' :
'text-muted-foreground'
@@ -290,12 +290,12 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
</span>
</td>
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.lowerLimit ?? '-'}</td>
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.upperLimit ?? '-'}</td>
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.unit || '-'}</td>
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.lowerLimit ?? '-'}</td>
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.upperLimit ?? '-'}</td>
<td className="px-4 py-3 text-center text-muted-foreground">{ref?.unit || '-'}</td>
<td className="px-4 py-3 text-center">
{value !== null && value !== undefined ? (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold ${
<span className={`inline-flex items-center px-4 py-1 rounded-full font-semibold ${
status === 'safe' ? 'bg-green-100 text-green-700' :
status === 'caution' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-500'

File diff suppressed because it is too large Load Diff

View File

@@ -641,9 +641,9 @@ export default function DashboardPage() {
<div className="bg-white rounded-xl border border-slate-200 p-5 max-sm:p-4 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-4">
<p className="text-base max-sm:text-sm font-semibold text-slate-700"> </p>
<span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-pink-50 text-pink-700 font-medium">
{/* <span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-pink-50 text-pink-700 font-medium">
검사 {mptStats.totalMptCows}두
</span>
</span> */}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-sm:gap-3">
{/* 에너지 균형 */}

View File

@@ -110,7 +110,7 @@
}
.text-base {
font-size: 1rem; /* 16px */
font-size: 1.0rem; /* 16px */
line-height: 1.6;
}
@@ -404,14 +404,30 @@
/* 테이블 헤더 셀 */
.cow-table-header {
@apply text-center py-3 px-3 font-semibold;
font-size: 0.9375rem; /* 15px */
@apply text-center font-bold;
padding: 0.5rem 0.5rem; /* py-2 px-2 */
font-size: 1.25rem; /* 20px */
}
@media (min-width: 640px) {
.cow-table-header {
padding: 0.625rem 0.625rem; /* py-2.5 px-2.5 */
font-size: 1.375rem; /* 22px */
}
}
@media (min-width: 768px) {
.cow-table-header {
padding: 0.75rem 0.75rem; /* py-3 px-3 */
font-size: 1.5rem; /* 24px */
}
}
/* 테이블 바디 셀 */
.cow-table-cell {
@apply text-center py-3 px-3;
font-size: 0.9375rem; /* 15px */
@apply text-center;
padding: 0.5rem 0.375rem; /* py-2 px-1.5 */
font-size: 1.35rem; /* 21.6px */
}
/* 분석불가 행 - 각 td에 오버레이 */

View File

@@ -31,12 +31,12 @@ const userNavMain = [
},
{
title: "개체 조회",
url: "/cow",
url: "/cow?reset=true",
icon: IconListDetails,
items: [
{
title: "개체 목록",
url: "/cow",
url: "/cow?reset=true",
},
],
},

View File

@@ -42,6 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"cursor-pointer",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}

View File

@@ -6,6 +6,7 @@
* 기능:
* - 현재 연도부터 5년 전까지 선택 가능 (예: 2025~2020)
* - URL 파라미터 ?year=2024 와 동기화
* - 농장의 가장 최근 분석 연도를 기본값으로 사용
*
* 사용처:
* - site-header.tsx: 헤더 연도 선택 드롭다운
@@ -33,28 +34,81 @@ function AnalysisYearProviderInner({ children }: { children: React.ReactNode })
const currentYear = new Date().getFullYear()
const availableYears = Array.from({ length: 6 }, (_, i) => currentYear - i)
// URL 파라미터에서 연도 가져오기, 없으면 현재 연도 사용
const yearFromUrl = searchParams.get('year')
const initialYear = yearFromUrl && !isNaN(Number(yearFromUrl))
? Number(yearFromUrl)
: currentYear
// 초기 년도는 현재 년도로 설정 (클라이언트에서 useEffect로 업데이트)
const [selectedYear, setSelectedYearState] = useState<number>(currentYear)
const [isInitialized, setIsInitialized] = useState<boolean>(false)
const [selectedYear, setSelectedYearState] = useState<number>(initialYear)
// URL 파라미터와 동기화
// 클라이언트 사이드에서만 실행: localStorage와 URL에서 초기 년도 설정
useEffect(() => {
if (isInitialized || typeof window === 'undefined') return
const yearFromUrl = searchParams.get('year')
if (yearFromUrl && !isNaN(Number(yearFromUrl))) {
console.log('[AnalysisYear] Initial year from URL:', yearFromUrl)
const year = Number(yearFromUrl)
// availableYears에 포함된 년도만 사용
const validYear = availableYears.includes(year) ? year : currentYear
setSelectedYearState(validYear)
setIsInitialized(true)
// URL에 유효하지 않은 연도가 있으면 제거
if (!availableYears.includes(year) && pathname !== '/') {
const params = new URLSearchParams(searchParams.toString())
params.delete('year')
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
}
return
}
const savedYear = localStorage.getItem('defaultAnalysisYear')
if (savedYear && !isNaN(Number(savedYear))) {
console.log('[AnalysisYear] Initial year from localStorage:', savedYear)
const year = Number(savedYear)
// availableYears에 포함된 년도만 사용 (없으면 현재 연도 사용)
const validYear = availableYears.includes(year) ? year : currentYear
setSelectedYearState(validYear)
// URL에 year 파라미터 추가 (유효한 년도만, 루트 페이지 제외)
if (pathname !== '/') {
const params = new URLSearchParams(searchParams.toString())
params.set('year', validYear.toString())
router.replace(`${pathname}?${params.toString()}`)
}
}
setIsInitialized(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 의도적으로 빈 배열 사용 (최초 1회만 실행)
// URL 파라미터와 동기화 (초기화 이후에만 실행)
useEffect(() => {
if (!isInitialized || pathname === '/') return // 루트 페이지에서는 실행 안 함
const yearParam = searchParams.get('year')
if (yearParam && !isNaN(Number(yearParam))) {
const year = Number(yearParam)
if (availableYears.includes(year)) {
setSelectedYearState(year)
// 유효한 년도면 상태 업데이트
if (year !== selectedYear) {
setSelectedYearState(year)
}
} else {
// 유효하지 않은 년도면 URL에서 제거하고 현재 연도로 설정
const params = new URLSearchParams(searchParams.toString())
params.delete('year')
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
setSelectedYearState(currentYear)
}
}
}, [searchParams, availableYears])
}, [searchParams, availableYears, isInitialized, selectedYear, currentYear, pathname, router])
const setSelectedYear = (year: number) => {
console.log('[AnalysisYear] setSelectedYear:', year)
setSelectedYearState(year)
// localStorage 업데이트 (사용자가 선택한 년도를 디폴트로 저장)
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAnalysisYear', year.toString())
}
// URL 파라미터 업데이트
const params = new URLSearchParams(searchParams.toString())
params.set('year', year.toString())

View File

@@ -119,6 +119,7 @@ export const genomeApi = {
farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
details: { traitNm: string; ebv: number; weight: number; contribution: number }[];
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포
message?: string;
}> => {
return await apiClient.post(`/genome/selection-index/${cowId}`, { traitConditions });
@@ -211,6 +212,7 @@ export interface TraitRankDto {
regionAvgEbv: number | null;
farmAvgEpd: number | null; // 농가 평균 육종가(EPD)
regionAvgEpd: number | null; // 보은군 평균 육종가(EPD)
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 데이터 분포
}
/**

View File

@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { authApi } from '@/lib/api/auth.api';
import { UserDto, LoginDto, SignupDto } from '@/types/auth.types';
import apiClient from '@/lib/api-client';
/**
* 인증 Store 상태 - 인증 관련 값 안올때 확인
@@ -59,6 +60,12 @@ export const useAuthStore = create<AuthState>()(
accessToken: response.accessToken,
isAuthenticated: true,
});
// 최근 검사 년도를 localStorage에 저장
if (response.defaultAnalysisYear) {
localStorage.setItem('defaultAnalysisYear', response.defaultAnalysisYear.toString());
console.log('[AuthStore] defaultAnalysisYear saved:', response.defaultAnalysisYear);
}
} catch (error) {
console.error('로그인 실패:', error);
throw error;

View File

@@ -122,6 +122,7 @@ export interface AuthResponseDto {
message?: string // 결과 메시지
accessToken: string // JWT 액세스 토큰
user: UserDto // 사용자 정보
defaultAnalysisYear: number // 최근 검사 년도
}
// ========================================