페이지 수정사항 반영

This commit is contained in:
2025-12-12 08:01:59 +09:00
parent 7d15c9be7c
commit dce58470b6
20 changed files with 1080 additions and 155 deletions

95
backend/check-data.js Normal file
View File

@@ -0,0 +1,95 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
// 1. 해당 개체의 형질 데이터 확인
const cowTraitsResult = await conn.query(
"SELECT trait_name, trait_ebv FROM tb_genome_trait_detail WHERE cow_id = 'KOR002191643715' AND del_dt IS NULL ORDER BY trait_name"
);
const cowTraits = cowTraitsResult.rows;
console.log('=== 개체 KOR002191643715 형질 데이터 ===');
console.log('형질수:', cowTraits.length);
let totalEbv = 0;
cowTraits.forEach(t => {
console.log(t.trait_name + ': ' + t.trait_ebv);
totalEbv += Number(t.trait_ebv || 0);
});
console.log('\n*** 내 개체 EBV 합계(선발지수):', totalEbv.toFixed(2));
// 2. 해당 개체의 농가 확인
const cowInfoResult = await conn.query(
"SELECT gr.fk_farm_no, f.farmer_name FROM tb_genome_request gr JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no LEFT JOIN tb_farm f ON gr.fk_farm_no = f.pk_farm_no WHERE c.cow_id = 'KOR002191643715' AND gr.del_dt IS NULL LIMIT 1"
);
const cowInfo = cowInfoResult.rows;
console.log('\n=== 농가 정보 ===');
console.log('농가번호:', cowInfo[0]?.fk_farm_no, '농장주:', cowInfo[0]?.farmer_name);
const farmNo = cowInfo[0]?.fk_farm_no;
// 3. 같은 농가의 모든 개체 EBV 합계
if (farmNo) {
const farmCowsResult = await conn.query(
`SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv, COUNT(*) as trait_count
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
ORDER BY total_ebv DESC`,
[farmNo]
);
const farmCows = farmCowsResult.rows;
console.log('\n=== 같은 농가 개체들 EBV 합계 (35형질 전체) ===');
console.log('개체수:', farmCows.length);
let farmSum = 0;
farmCows.forEach(c => {
console.log(c.cow_id + ': ' + Number(c.total_ebv).toFixed(2));
farmSum += Number(c.total_ebv);
});
if (farmCows.length > 0) {
console.log('\n*** 농가 평균:', (farmSum / farmCows.length).toFixed(2));
}
}
// 4. 전체 보은군 평균
const allCowsResult = await conn.query(
`SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35`
);
const allCows = allCowsResult.rows;
console.log('\n=== 보은군 전체 통계 ===');
console.log('개체수:', allCows.length);
let regionSum = 0;
allCows.forEach(c => regionSum += Number(c.total_ebv));
if (allCows.length > 0) {
console.log('*** 보은군 평균:', (regionSum / allCows.length).toFixed(2));
}
// 5. 최대/최소 확인
if (allCows.length > 0) {
const maxCow = allCows.reduce((max, c) => Number(c.total_ebv) > Number(max.total_ebv) ? c : max, allCows[0]);
const minCow = allCows.reduce((min, c) => Number(c.total_ebv) < Number(min.total_ebv) ? c : min, allCows[0]);
console.log('\n최대:', maxCow?.cow_id, Number(maxCow?.total_ebv).toFixed(2));
console.log('최소:', minCow?.cow_id, Number(minCow?.total_ebv).toFixed(2));
}
await conn.end();
}
main().catch(console.error);

97
backend/check-data2.js Normal file
View File

@@ -0,0 +1,97 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
// getComparisonAverages가 계산하는 방식 확인
// 농가 1번의 카테고리별 평균 EBV 계산
const farmNo = 1;
// 카테고리 매핑 (백엔드와 동일)
const TRAIT_CATEGORY_MAP = {
'12개월령체중': '성장',
'도체중': '생산', '등심단면적': '생산', '등지방두께': '생산', '근내지방도': '생산',
'체고': '체형', '십자': '체형', '체장': '체형', '흉심': '체형', '흉폭': '체형',
'고장': '체형', '요각폭': '체형', '곤폭': '체형', '좌골폭': '체형', '흉위': '체형',
'안심weight': '무게', '등심weight': '무게', '채끝weight': '무게', '목심weight': '무게',
'앞다리weight': '무게', '우둔weight': '무게', '설도weight': '무게', '사태weight': '무게',
'양지weight': '무게', '갈비weight': '무게',
'안심rate': '비율', '등심rate': '비율', '채끝rate': '비율', '목심rate': '비율',
'앞다리rate': '비율', '우둔rate': '비율', '설도rate': '비율', '사태rate': '비율',
'양지rate': '비율', '갈비rate': '비율',
};
// 농가 1번의 모든 형질 데이터 조회
const result = await conn.query(`
SELECT gtd.trait_name, gtd.trait_ebv
FROM tb_genome_trait_detail gtd
JOIN tb_genome_request gr ON gtd.fk_request_no = gr.pk_request_no
WHERE gr.fk_farm_no = $1
AND gtd.del_dt IS NULL
AND gtd.trait_ebv IS NOT NULL
`, [farmNo]);
const details = result.rows;
console.log('농가 1번 전체 형질 데이터 수:', details.length);
// 카테고리별로 합계 계산
const categoryMap = {};
for (const d of details) {
const category = TRAIT_CATEGORY_MAP[d.trait_name] || '기타';
if (!categoryMap[category]) {
categoryMap[category] = { sum: 0, count: 0 };
}
categoryMap[category].sum += Number(d.trait_ebv);
categoryMap[category].count += 1;
}
console.log('\n=== getComparisonAverages 방식 (카테고리별 평균) ===');
const categories = ['성장', '생산', '체형', '무게', '비율'];
let totalAvgEbv = 0;
for (const cat of categories) {
const data = categoryMap[cat];
const avgEbv = data ? data.sum / data.count : 0;
console.log(`${cat}: 합계=${data?.sum?.toFixed(2)} / 개수=${data?.count} = 평균 ${avgEbv.toFixed(2)}`);
totalAvgEbv += avgEbv;
}
console.log('\n*** farmAvgZ (카테고리 평균의 합/5):', (totalAvgEbv / categories.length).toFixed(2));
// getSelectionIndex 방식 비교
console.log('\n=== getSelectionIndex 방식 (개체별 합계의 평균) ===');
const farmCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as total_ebv, COUNT(*) as trait_count
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
ORDER BY total_ebv DESC
`, [farmNo]);
const farmCows = farmCowsResult.rows;
let farmSum = 0;
farmCows.forEach(c => farmSum += Number(c.total_ebv));
const farmAvgScore = farmCows.length > 0 ? farmSum / farmCows.length : 0;
console.log(`개체수: ${farmCows.length}`);
console.log(`*** farmAvgScore (개체별 합계의 평균): ${farmAvgScore.toFixed(2)}`);
console.log('\n=================================================');
console.log('farmAvgZ (카테고리 방식):', (totalAvgEbv / categories.length).toFixed(2));
console.log('farmAvgScore (선발지수 방식):', farmAvgScore.toFixed(2));
console.log('=================================================');
await conn.end();
}
main().catch(console.error);

114
backend/check-data3.js Normal file
View File

@@ -0,0 +1,114 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const farmNo = 1;
const cowId = 'KOR002191643715';
console.log('=======================================================');
console.log('대시보드 vs 개체상세 차트 비교');
console.log('=======================================================\n');
// =====================================================
// 1. 대시보드: getFarmRegionRanking API
// - 농가 평균 = 농가 내 개체들의 선발지수 평균
// - 보은군 평균 = 전체 개체들의 선발지수 평균
// =====================================================
console.log('=== 1. 대시보드 (getFarmRegionRanking) ===');
console.log('보은군 내 농가 위치 차트\n');
// 모든 개체별 선발지수 (35개 형질 EBV 합계)
const allCowsResult = await conn.query(`
SELECT c.cow_id, gr.fk_farm_no, SUM(gtd.trait_ebv) as total_ebv
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id, gr.fk_farm_no
HAVING COUNT(*) = 35
`);
const allCows = allCowsResult.rows;
// 농가별 평균 계산
const farmScoresMap = new Map();
for (const cow of allCows) {
const fNo = cow.fk_farm_no;
if (!farmScoresMap.has(fNo)) {
farmScoresMap.set(fNo, []);
}
farmScoresMap.set(fNo, [...farmScoresMap.get(fNo), Number(cow.total_ebv)]);
}
// 농가별 평균
const farmAverages = [];
for (const [fNo, scores] of farmScoresMap.entries()) {
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
farmAverages.push({ farmNo: fNo, avgScore: avg, cowCount: scores.length });
}
farmAverages.sort((a, b) => b.avgScore - a.avgScore);
// 보은군 전체 평균 (개체별 합계의 평균)
const regionAvgScore_dashboard = allCows.reduce((sum, c) => sum + Number(c.total_ebv), 0) / allCows.length;
// 농가 1번 평균
const myFarm = farmAverages.find(f => f.farmNo === farmNo);
const farmAvgScore_dashboard = myFarm?.avgScore || 0;
console.log('농가 평균 (개체 선발지수 평균):', farmAvgScore_dashboard.toFixed(2));
console.log('보은군 평균 (개체 선발지수 평균):', regionAvgScore_dashboard.toFixed(2));
console.log('차이 (농가 - 보은군):', (farmAvgScore_dashboard - regionAvgScore_dashboard).toFixed(2));
// =====================================================
// 2. 개체 상세: getSelectionIndex API
// - 내 개체 = 개체의 선발지수 (35개 형질 EBV 합계)
// - 농가 평균 = 같은 농가 개체들의 선발지수 평균
// - 보은군 평균 = 전체 개체들의 선발지수 평균
// =====================================================
console.log('\n=== 2. 개체 상세 (getSelectionIndex) ===');
console.log('농가 및 보은군 내 개체 위치 차트\n');
// 내 개체 선발지수
const myCow = allCows.find(c => c.cow_id === cowId);
const myScore = myCow ? Number(myCow.total_ebv) : 0;
// 같은 농가 개체들의 평균
const farmCows = allCows.filter(c => c.fk_farm_no === farmNo);
const farmAvgScore_detail = farmCows.reduce((sum, c) => sum + Number(c.total_ebv), 0) / farmCows.length;
// 보은군 전체 평균
const regionAvgScore_detail = regionAvgScore_dashboard; // 동일
console.log('내 개체 선발지수:', myScore.toFixed(2));
console.log('농가 평균:', farmAvgScore_detail.toFixed(2));
console.log('보은군 평균:', regionAvgScore_detail.toFixed(2));
console.log('');
console.log('내 개체 vs 농가평균:', (myScore - farmAvgScore_detail).toFixed(2));
console.log('내 개체 vs 보은군평균:', (myScore - regionAvgScore_detail).toFixed(2));
// =====================================================
// 3. 비교 요약
// =====================================================
console.log('\n=======================================================');
console.log('비교 요약');
console.log('=======================================================');
console.log('');
console.log('[대시보드] 농가 vs 보은군 차이:', (farmAvgScore_dashboard - regionAvgScore_dashboard).toFixed(2));
console.log('[개체상세] 개체 vs 농가 차이:', (myScore - farmAvgScore_detail).toFixed(2));
console.log('[개체상세] 개체 vs 보은군 차이:', (myScore - regionAvgScore_detail).toFixed(2));
console.log('');
console.log('=> 대시보드는 농가평균 vs 보은군평균 비교 (차이 작음)');
console.log('=> 개체상세는 개별개체 vs 평균 비교 (개체가 우수하면 차이 큼)');
await conn.end();
}
main().catch(console.error);

126
backend/check-data4.js Normal file
View File

@@ -0,0 +1,126 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const cowId = 'KOR002191643715';
const farmNo = 1;
console.log('=======================================================');
console.log('선발지수 계산 방식 비교 분석');
console.log('=======================================================\n');
// 1. 해당 개체의 35개 형질 EBV 확인
const traitsResult = await conn.query(`
SELECT trait_name, trait_ebv
FROM tb_genome_trait_detail
WHERE cow_id = $1 AND del_dt IS NULL
ORDER BY trait_name
`, [cowId]);
const traits = traitsResult.rows;
console.log('=== 개체 형질 데이터 ===');
console.log('형질 수:', traits.length);
// EBV 합계
const ebvSum = traits.reduce((sum, t) => sum + Number(t.trait_ebv || 0), 0);
console.log('EBV 합계:', ebvSum.toFixed(2));
// EBV 평균
const ebvAvg = ebvSum / traits.length;
console.log('EBV 평균:', ebvAvg.toFixed(2));
console.log('\n=== 선발지수 계산 방식 비교 ===\n');
// 방식 1: EBV 합계 (getSelectionIndex 방식)
console.log('방식1 - EBV 합계 (weightedSum):', ebvSum.toFixed(2));
// 방식 2: EBV 평균
console.log('방식2 - EBV 평균 (sum/count):', ebvAvg.toFixed(2));
// 농가/보은군 평균도 각 방식으로 계산
console.log('\n=== 농가 평균 계산 방식 비교 ===\n');
// 농가 내 모든 개체
const farmCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv, AVG(gtd.trait_ebv) as avg_ebv, COUNT(*) as cnt
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
`, [farmNo]);
const farmCows = farmCowsResult.rows;
// 방식 1: 개체별 합계의 평균
const farmSumAvg = farmCows.reduce((sum, c) => sum + Number(c.sum_ebv), 0) / farmCows.length;
console.log('방식1 - 개체별 합계의 평균:', farmSumAvg.toFixed(2));
// 방식 2: 개체별 평균의 평균
const farmAvgAvg = farmCows.reduce((sum, c) => sum + Number(c.avg_ebv), 0) / farmCows.length;
console.log('방식2 - 개체별 평균의 평균:', farmAvgAvg.toFixed(2));
console.log('\n=== 보은군 평균 계산 방식 비교 ===\n');
// 보은군 전체 개체
const allCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv, AVG(gtd.trait_ebv) as avg_ebv, COUNT(*) as cnt
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
`);
const allCows = allCowsResult.rows;
// 방식 1: 개체별 합계의 평균
const regionSumAvg = allCows.reduce((sum, c) => sum + Number(c.sum_ebv), 0) / allCows.length;
console.log('방식1 - 개체별 합계의 평균:', regionSumAvg.toFixed(2));
// 방식 2: 개체별 평균의 평균
const regionAvgAvg = allCows.reduce((sum, c) => sum + Number(c.avg_ebv), 0) / allCows.length;
console.log('방식2 - 개체별 평균의 평균:', regionAvgAvg.toFixed(2));
console.log('\n=======================================================');
console.log('결과 비교');
console.log('=======================================================\n');
console.log('만약 "합계" 방식 사용 시:');
console.log(' 내 개체:', ebvSum.toFixed(2));
console.log(' 농가 평균:', farmSumAvg.toFixed(2));
console.log(' 보은군 평균:', regionSumAvg.toFixed(2));
console.log(' → 내 개체 vs 농가: +', (ebvSum - farmSumAvg).toFixed(2));
console.log(' → 내 개체 vs 보은군: +', (ebvSum - regionSumAvg).toFixed(2));
console.log('\n만약 "평균" 방식 사용 시:');
console.log(' 내 개체:', ebvAvg.toFixed(2));
console.log(' 농가 평균:', farmAvgAvg.toFixed(2));
console.log(' 보은군 평균:', regionAvgAvg.toFixed(2));
console.log(' → 내 개체 vs 농가: +', (ebvAvg - farmAvgAvg).toFixed(2));
console.log(' → 내 개체 vs 보은군: +', (ebvAvg - regionAvgAvg).toFixed(2));
console.log('\n=======================================================');
console.log('리스트 선발지수 확인 (page.tsx의 GENOMIC_TRAITS)');
console.log('=======================================================\n');
// 리스트에서 보여주는 선발지수는 어떻게 계산되나?
// page.tsx:350-356 확인 필요
console.log('리스트의 overallScore 계산식 확인 필요:');
console.log(' - selectionIndex.score 사용 시: 합계 방식');
console.log(' - GENOMIC_TRAITS.reduce / length 사용 시: 평균 방식');
await conn.end();
}
main().catch(console.error);

47
backend/check-data5.js Normal file
View File

@@ -0,0 +1,47 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const farmNo = 1;
// 농가 내 모든 개체의 선발지수(가중 합계) 조회
const farmCowsResult = await conn.query(`
SELECT c.cow_id, SUM(gtd.trait_ebv) as sum_ebv
FROM tb_genome_request gr
JOIN tb_cow c ON gr.fk_cow_no = c.pk_cow_no
JOIN tb_genome_trait_detail gtd ON gtd.cow_id = c.cow_id AND gtd.del_dt IS NULL
WHERE gr.fk_farm_no = $1 AND gr.del_dt IS NULL AND c.del_dt IS NULL
AND gr.chip_sire_name = '일치'
GROUP BY c.cow_id
HAVING COUNT(*) = 35
ORDER BY sum_ebv DESC
`, [farmNo]);
const farmCows = farmCowsResult.rows;
console.log('=== 농가 1번 개체별 선발지수 (가중 합계) ===\n');
let total = 0;
farmCows.forEach((c, i) => {
const score = Number(c.sum_ebv);
total += score;
console.log(`${i+1}. ${c.cow_id}: ${score.toFixed(2)}`);
});
console.log('\n=== 계산 ===');
console.log('개체 수:', farmCows.length);
console.log('선발지수 총합:', total.toFixed(2));
console.log('농가 평균 = 총합 / 개체수 =', total.toFixed(2), '/', farmCows.length, '=', (total / farmCows.length).toFixed(2));
await conn.end();
}
main().catch(console.error);

66
backend/check-data6.js Normal file
View File

@@ -0,0 +1,66 @@
const { Client } = require('pg');
async function main() {
const conn = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'turbo123',
database: 'genome_db'
});
await conn.connect();
const cowId = 'KOR002191643715';
console.log('=======================================================');
console.log('리스트 vs 개체상세 선발지수 비교');
console.log('=======================================================\n');
// 해당 개체의 35개 형질 EBV 조회
const traitsResult = await conn.query(`
SELECT trait_name, trait_ebv
FROM tb_genome_trait_detail
WHERE cow_id = $1 AND del_dt IS NULL
ORDER BY trait_name
`, [cowId]);
const traits = traitsResult.rows;
console.log('형질 수:', traits.length);
// 1. 가중 합계 (weight = 1)
let weightedSum = 0;
let totalWeight = 0;
traits.forEach(t => {
const ebv = Number(t.trait_ebv);
const weight = 1;
weightedSum += ebv * weight;
totalWeight += weight;
});
console.log('\n=== 계산 비교 ===');
console.log('가중 합계 (weightedSum):', weightedSum.toFixed(2));
console.log('총 가중치 (totalWeight):', totalWeight);
console.log('');
console.log('리스트 (cow.service.ts) - 가중 합계:', weightedSum.toFixed(2));
console.log('개체상세 (genome.service.ts) - 가중 합계:', weightedSum.toFixed(2));
console.log('\n=== 프론트엔드 가중치 확인 ===');
console.log('프론트엔드에서 weight / 100 정규화 확인 필요');
console.log('예: weight 100 → 1, weight 50 → 0.5');
// 만약 프론트에서 weight/100을 적용한다면?
console.log('\n=== 만약 weight가 0.01로 적용된다면? ===');
let weightedSum2 = 0;
let totalWeight2 = 0;
traits.forEach(t => {
const ebv = Number(t.trait_ebv);
const weight = 0.01; // 1/100
weightedSum2 += ebv * weight;
totalWeight2 += weight;
});
console.log('가중 합계:', weightedSum2.toFixed(2));
await conn.end();
}
main().catch(console.error);

View File

@@ -223,8 +223,24 @@ export class CowService {
*/
private async applyGenomeRanking(
cows: CowModel[],
traitConditions: TraitRankingCondition[],
inputTraitConditions: TraitRankingCondition[],
): Promise<any> {
// 35개 전체 형질 (기본값)
const ALL_TRAITS = [
'12개월령체중',
'도체중', '등심단면적', '등지방두께', '근내지방도',
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
];
// traitConditions가 비어있으면 35개 전체 형질 사용 (개체상세, 대시보드와 동일)
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
? inputTraitConditions
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }));
// 각 개체별로 점수 계산
const cowsWithScore = await Promise.all(
cows.map(async (cow) => {
@@ -260,7 +276,7 @@ export class CowService {
return { entity: { ...cow, unavailableReason: '형질정보없음' }, sortValue: null, details: [] };
}
// Step 4: 가중 평균 계산
// Step 4: 가중 합계 계산
let weightedSum = 0; // 가중치 적용된 EBV 합계
let totalWeight = 0; // 총 가중치
let hasAllTraits = true; // 모든 선택 형질 존재 여부
@@ -290,10 +306,10 @@ export class CowService {
}
}
// Step 6: 최종 점수 계산 (가중 평균)
// Step 6: 최종 점수 계산 (가중 합계)
// 모든 선택 형질이 있어야만 점수 계산
const sortValue = (hasAllTraits && totalWeight > 0)
? weightedSum / totalWeight // 가중 평균 = 가중합 / 총가중치
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
: null;
// Step 7: 응답 데이터 구성

View File

@@ -31,6 +31,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { useAuthStore } from "@/store/auth-store"
import { AuthGuard } from "@/components/auth/auth-guard"
import {
IconSearch,
IconDownload,
@@ -460,9 +461,11 @@ function GenomeMappingContent() {
export default function GenomeMappingPage() {
return (
<SidebarProvider>
<AdminSidebar />
<GenomeMappingContent />
</SidebarProvider>
<AuthGuard>
<SidebarProvider>
<AdminSidebar />
<GenomeMappingContent />
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -13,6 +13,7 @@ import {
CardTitle,
} from "@/components/ui/card"
import { useAuthStore } from "@/store/auth-store"
import { AuthGuard } from "@/components/auth/auth-guard"
import {
IconFileUpload,
IconUsers,
@@ -165,9 +166,11 @@ function AdminDashboardContent() {
export default function AdminDashboardPage() {
return (
<SidebarProvider>
<AdminSidebar />
<AdminDashboardContent />
</SidebarProvider>
<AuthGuard>
<SidebarProvider>
<AdminSidebar />
<AdminDashboardContent />
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -14,6 +14,7 @@ import {
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useAuthStore } from "@/store/auth-store"
import { AuthGuard } from "@/components/auth/auth-guard"
import {
IconUpload,
IconCheck,
@@ -416,9 +417,11 @@ function AdminUploadContent() {
export default function AdminUploadPage() {
return (
<SidebarProvider>
<AdminSidebar />
<AdminUploadContent />
</SidebarProvider>
<AuthGuard>
<SidebarProvider>
<AdminSidebar />
<AdminUploadContent />
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -468,10 +468,9 @@ export function CategoryEvaluationCard({
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0]?.payload
const breedVal = item?.breedVal ?? 0
const regionVal = item?.regionVal ?? 0
const farmVal = item?.farmVal ?? 0
const percentile = item?.percentile ?? 50
const epd = item?.epd ?? 0
const regionEpd = (item?.regionVal ?? 0) * (item?.epd / (item?.breedVal || 1)) || 0
const farmEpd = (item?.farmVal ?? 0) * (item?.epd / (item?.breedVal || 1)) || 0
return (
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
@@ -482,21 +481,21 @@ export function CategoryEvaluationCard({
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
<span className="text-slate-300"> </span>
</span>
<span className="text-white font-semibold">{regionVal > 0 ? '+' : ''}{regionVal.toFixed(2)}σ</span>
<span className="text-white font-semibold">{regionEpd > 0 ? '+' : ''}{regionEpd.toFixed(2)}</span>
</div>
<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: '#1F3A8F' }}></span>
<span className="text-slate-300"> </span>
</span>
<span className="text-white font-semibold">{farmVal > 0 ? '+' : ''}{farmVal.toFixed(2)}σ</span>
<span className="text-white font-semibold">{farmEpd > 0 ? '+' : ''}{farmEpd.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded" style={{ backgroundColor: '#1482B0' }}></span>
<span className="text-white font-medium">{formatCowNoShort(cowNo)} </span>
</span>
<span className="text-white font-bold">{breedVal > 0 ? '+' : ''}{breedVal.toFixed(2)}σ</span>
<span className="text-white font-bold">{epd > 0 ? '+' : ''}{epd.toFixed(2)}</span>
</div>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useToast } from "@/hooks/use-toast"
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail } from "@/lib/api"
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail, GenomeRequestDto } from "@/lib/api"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait } from "@/types/genome.types"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
@@ -34,6 +34,7 @@ import { TraitDistributionCharts } from "./genome/_components/trait-distribution
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
import { CowNumberDisplay } from "@/components/common/cow-number-display"
import { isValidGenomeAnalysis, getInvalidReason, getInvalidMessage } from "@/lib/utils/genome-analysis-config"
import { AuthGuard } from "@/components/auth/auth-guard"
// 형질명 → 카테고리 매핑 (한우 35개 형질)
const TRAIT_CATEGORY_MAP: Record<string, string> = {
@@ -156,6 +157,9 @@ export default function CowOverviewPage() {
const [hasGeneData, setHasGeneData] = useState(false)
const [hasReproductionData, setHasReproductionData] = useState(false)
// 분석 의뢰 정보 (친자감별 결과 포함)
const [genomeRequest, setGenomeRequest] = useState<GenomeRequestDto | null>(null)
// 선발지수 상태
const [selectionIndex, setSelectionIndex] = useState<{
score: number | null;
@@ -259,6 +263,15 @@ export default function CowOverviewPage() {
const genomeExists = genomeDataResult.length > 0 && !!genomeDataResult[0].genomeCows && genomeDataResult[0].genomeCows.length > 0
setHasGenomeData(genomeExists)
// 분석 의뢰 정보 가져오기 (친자감별 결과 포함)
try {
const requestData = await genomeApi.getRequest(cowNo)
setGenomeRequest(requestData)
} catch (reqErr) {
console.error('분석 의뢰 정보 조회 실패:', reqErr)
setGenomeRequest(null)
}
// 유전자(SNP) 데이터 가져오기
try {
const geneDataResult = await geneApi.findByCowId(cowNo)
@@ -305,11 +318,11 @@ export default function CowOverviewPage() {
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
]
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 (대시보드와 동일 로직)
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 (리스트와 동일 로직)
const finalConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0
? filters.selectedTraits.map(traitNm => ({
traitNm,
weight: (filters.traitWeights as Record<string, number>)[traitNm] || 1
weight: ((filters.traitWeights as Record<string, number>)[traitNm] || 100) / 100 // 0-100 → 0-1로 정규화
}))
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }))
@@ -463,6 +476,7 @@ export default function CowOverviewPage() {
}
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -625,7 +639,7 @@ export default function CowOverviewPage() {
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -658,8 +672,8 @@ export default function CowOverviewPage() {
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
@@ -700,7 +714,7 @@ export default function CowOverviewPage() {
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN번호</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -731,8 +745,8 @@ export default function CowOverviewPage() {
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
@@ -842,7 +856,7 @@ export default function CowOverviewPage() {
<Card className="bg-white border border-border rounded-xl overflow-hidden">
<CardContent className="p-0">
<div className="grid grid-cols-2 sm:grid-cols-4 divide-x divide-y sm:divide-y-0 divide-border">
<div className="grid grid-cols-3 divide-x divide-border">
<div className="p-4">
<div className="text-xs font-medium text-muted-foreground mb-1"></div>
<div className="text-sm font-semibold text-foreground truncate">
@@ -865,12 +879,6 @@ export default function CowOverviewPage() {
{genomeData[0]?.request?.chipType || '-'}
</div>
</div>
<div className="p-4">
<div className="text-xs font-medium text-muted-foreground mb-1"> </div>
<div className="text-sm font-semibold text-foreground truncate">
{genomeData[0]?.request?.chipNo || '-'}
</div>
</div>
</div>
</CardContent>
</Card>
@@ -910,15 +918,253 @@ export default function CowOverviewPage() {
)}
</>
) : (
<Card className="bg-slate-50 border border-border rounded-2xl">
<CardContent className="p-8 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground">
.
</p>
</CardContent>
</Card>
<>
{/* 개체 정보 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */}
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">-</span>
</div>
</div>
</div>
{/* 모바일: 좌우 배치 리스트 */}
<div className="lg:hidden divide-y divide-border">
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<div className="flex-1 px-4 py-3.5">
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
</div>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">-</span>
</div>
</div>
</CardContent>
</Card>
{/* 친자확인 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* 데스크탑: 가로 그리드 */}
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> KPN번호</span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
return (
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
</div>
</div>
</div>
{/* 모바일: 세로 리스트 */}
<div className="lg:hidden divide-y divide-border">
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
return (
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
</div>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 분석불가 메시지 */}
<Card className="bg-slate-100 border border-slate-300 rounded-2xl">
<CardContent className="p-8 text-center">
<BarChart3 className="h-12 w-12 text-slate-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-slate-700 mb-2">
{genomeRequest ? '유전체 분석 불가' : '유전체 미분석'}
</h3>
<p className="text-sm text-slate-500">
{genomeRequest
? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId)
: '이 개체는 아직 유전체 분석이 진행되지 않았습니다.'
}
</p>
</CardContent>
</Card>
</>
)}
</TabsContent>
@@ -1023,7 +1269,7 @@ export default function CowOverviewPage() {
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -1056,8 +1302,8 @@ export default function CowOverviewPage() {
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
@@ -1097,7 +1343,7 @@ export default function CowOverviewPage() {
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN번호</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -1128,8 +1374,8 @@ export default function CowOverviewPage() {
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
@@ -1220,43 +1466,6 @@ export default function CowOverviewPage() {
</div>
</div>
{/* 유전자형 필터 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600 shrink-0">:</span>
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
<button
onClick={() => setGenotypeFilter('all')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'all'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGenotypeFilter('homozygous')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'homozygous'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGenotypeFilter('heterozygous')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'heterozygous'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
</div>
{/* 정렬 드롭다운 */}
<div className="flex items-center gap-2 sm:ml-auto">
<Select
@@ -1545,5 +1754,6 @@ export default function CowOverviewPage() {
</DialogContent>
</Dialog>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -15,6 +15,7 @@ import { Activity, AlertCircle, CheckCircle } from "lucide-react"
import { CowNavigation } from "../_components/navigation"
import { useToast } from "@/hooks/use-toast"
import { MPT_REFERENCE_RANGES, isWithinRange } from "@/constants/mpt-reference"
import { AuthGuard } from "@/components/auth/auth-guard"
export default function ReproductionPage() {
const params = useParams()
@@ -150,6 +151,7 @@ export default function ReproductionPage() {
const healthScore = mptItems.length > 0 ? Math.round((normalItems.length / mptItems.length) * 100) : 0
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -256,5 +258,6 @@ export default function ReproductionPage() {
</main>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -23,6 +23,7 @@ import { cowApi } from "@/lib/api"
import { useAuthStore } from "@/store/auth-store"
import { useGlobalFilter, GlobalFilterProvider } from "@/contexts/GlobalFilterContext"
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext"
import { AuthGuard } from "@/components/auth/auth-guard"
/**
* 개체 리스트 페이지
@@ -975,10 +976,10 @@ function MyCowContent() {
{cow.cowSex === "암" ? "암소" : "수소"}
</td>
<td className="cow-table-cell">
{cow.damCowId || '-'}
{cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'}
</td>
<td className="cow-table-cell">
{cow.sireKpn || '-'}
{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</td>
<td className="cow-table-cell">
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', {
@@ -1176,7 +1177,7 @@ function MyCowContent() {
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{cow.damCowId ? (() => {
{cow.damCowId && cow.damCowId !== '0' ? (() => {
const digits = cow.damCowId.replace(/\D/g, '')
if (digits.length === 12) {
return `${digits.slice(0, 3)} ${digits.slice(3, 7)} ${digits.slice(7, 11)} ${digits.slice(11)}`
@@ -1187,7 +1188,7 @@ function MyCowContent() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{cow.sireKpn || '-'}</span>
<span className="font-medium">{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
@@ -1379,10 +1380,12 @@ function MyCowContent() {
export default function MyCowPage() {
return (
<GlobalFilterProvider>
<AnalysisYearProvider>
<MyCowContent />
</AnalysisYearProvider>
</GlobalFilterProvider>
<AuthGuard>
<GlobalFilterProvider>
<AnalysisYearProvider>
<MyCowContent />
</AnalysisYearProvider>
</GlobalFilterProvider>
</AuthGuard>
)
}

View File

@@ -30,6 +30,8 @@ import {
XCircle
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { AuthGuard } from "@/components/auth/auth-guard"
import { useRouter } from "next/navigation"
import {
Area,
Bar,
@@ -57,6 +59,7 @@ const TRAIT_CATEGORIES: Record<string, string[]> = {
}
export default function DashboardPage() {
const router = useRouter()
const { user } = useAuthStore()
const { filters } = useGlobalFilter()
const [farmNo, setFarmNo] = useState<number | null>(null)
@@ -73,44 +76,52 @@ export default function DashboardPage() {
return () => window.removeEventListener('resize', checkMobile)
}, [])
// 필터에서 고정된 첫 번째 형질 (없으면 '도체중')
const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중'
// 필터에서 대표 형질: 고정된 형질 중 selectedTraits 순서상 맨 위 > '도체중'
// 고정되지 않은 형질은 순서가 맨 위여도 반영 안 됨
const primaryTrait = (() => {
const pinnedTraits = filters.pinnedTraits || []
const selectedTraits = filters.selectedTraits || []
// selectedTraits 순서대로 순회하면서 고정된 형질 찾기
for (const trait of selectedTraits) {
if (pinnedTraits.includes(trait)) {
return trait
}
}
return '도체중'
})()
// 연도별 육종가 추이 관련 state
const [selectedTrait, setSelectedTrait] = useState<string>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('dashboard_trait') || firstPinnedTrait
return localStorage.getItem('dashboard_trait') || primaryTrait
}
return firstPinnedTrait
return primaryTrait
})
const [traitTrendData, setTraitTrendData] = useState<YearlyTraitTrendDto | null>(null)
const [traitTrendLoading, setTraitTrendLoading] = useState(false)
// 보은군 내 농가 위치 차트 분포기준 (선발지수 or 개별 형질)
// 필터 활성 시 'overall', 비활성 시 고정된 첫 번째 형질
// 필터 활성 시 'overall', 비활성 시 대표 형질
const [distributionBasis, setDistributionBasis] = useState<string>(() => {
return filters.isActive ? 'overall' : firstPinnedTrait
return filters.isActive ? 'overall' : primaryTrait
})
// 필터 변경 시 기본값 업데이트
useEffect(() => {
if (!filters.isActive && distributionBasis === 'overall') {
setDistributionBasis(firstPinnedTrait)
setDistributionBasis(primaryTrait)
}
}, [filters.isActive, distributionBasis, firstPinnedTrait])
}, [filters.isActive, distributionBasis, primaryTrait])
// 필터에서 고정된 형질이 변경되면 selectedTrait도 업데이트
// 대표 형질(고정 또는 첫 번째)이 변경되면 selectedTrait도 업데이트
useEffect(() => {
if (filters.pinnedTraits && filters.pinnedTraits.length > 0) {
const newFirstPinned = filters.pinnedTraits[0]
// 첫 번째 고정 형질로 변경
setSelectedTrait(newFirstPinned)
// distributionBasis가 overall이 아니면 첫 번째 고정 형질로 변경
if (distributionBasis !== 'overall') {
setDistributionBasis(newFirstPinned)
}
// 대표 형질로 변경
setSelectedTrait(primaryTrait)
// distributionBasis가 overall이 아니면 대표 형질로 변경
if (distributionBasis !== 'overall') {
setDistributionBasis(primaryTrait)
}
}, [filters.pinnedTraits])
}, [primaryTrait])
// 모든 형질 목록 (평탄화)
const allTraits = Object.entries(TRAIT_CATEGORIES).flatMap(([cat, traits]) =>
@@ -356,6 +367,7 @@ export default function DashboardPage() {
}, [farmRanking, stats, distributionBasis])
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -381,7 +393,10 @@ export default function DashboardPage() {
{/* ========== 1. 핵심 KPI 카드 (2개) ========== */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-sm:gap-3">
{/* 총 분석 두수 + 암/수 */}
<div className="bg-gradient-to-br from-primary/5 to-primary/10 rounded-xl border border-primary/20 p-5 pb-5 max-sm:p-4 max-sm:pb-4 shadow-sm hover:shadow-lg hover:border-primary/30 transition-all">
<div
className="bg-gradient-to-br from-primary/5 to-primary/10 rounded-xl border border-primary/20 p-5 pb-5 max-sm:p-4 max-sm:pb-4 shadow-sm hover:shadow-lg hover:border-primary/30 transition-all cursor-pointer"
onClick={() => router.push('/cow')}
>
<div className="flex items-center justify-between mb-2">
<p className="text-base max-sm:text-sm font-semibold text-primary/70"> </p>
<span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-primary/10 text-primary font-medium">
@@ -1074,6 +1089,30 @@ export default function DashboardPage() {
// 호버된 포인트 인덱스를 위한 로컬 컴포넌트
const RadarChart = () => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const [clickedIndex, setClickedIndex] = useState<number | null>(null)
// 실제 표시할 인덱스 (클릭된 것 우선, 없으면 호버된 것)
const activeIndex = clickedIndex !== null ? clickedIndex : hoveredIndex
// 클릭/터치 핸들러: 토글 방식
const handleClick = (e: React.MouseEvent | React.TouchEvent, index: number) => {
e.preventDefault()
e.stopPropagation()
setClickedIndex(prev => prev === index ? null : index)
}
// 호버 핸들러: 클릭된 상태가 아닐 때만 동작
const handleMouseEnter = (index: number) => {
if (clickedIndex === null) {
setHoveredIndex(index)
}
}
const handleMouseLeave = () => {
if (clickedIndex === null) {
setHoveredIndex(null)
}
}
return (
<svg width="260" height="290" className="overflow-visible scale-100 sm:scale-100">
@@ -1110,15 +1149,17 @@ export default function DashboardPage() {
{/* 농가 데이터 포인트 */}
{farmPoints.map((p, i) => (
<g key={i} className="cursor-pointer"
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onMouseEnter={() => handleMouseEnter(i)}
onMouseLeave={handleMouseLeave}
onClick={(e) => handleClick(e, i)}
onTouchEnd={(e) => handleClick(e, i)}
>
<circle cx={p.x} cy={p.y} r={4}
fill={categoryData[i].avgEpd >= 0 ? '#1F3A8F' : '#ef4444'}
stroke="white" strokeWidth={1.5}
/>
{/* 호버 영역 확대 */}
<circle cx={p.x} cy={p.y} r={15} fill="transparent" />
{/* 호버/터치 영역 확대 */}
<circle cx={p.x} cy={p.y} r={20} fill="transparent" />
</g>
))}
{/* 카테고리 라벨 (클릭/호버 가능) */}
@@ -1130,23 +1171,24 @@ export default function DashboardPage() {
return (
<g key={cat}
className="cursor-pointer"
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => setHoveredIndex(hoveredIndex === i ? null : i)}
onMouseEnter={() => handleMouseEnter(i)}
onMouseLeave={handleMouseLeave}
onClick={(e) => handleClick(e, i)}
onTouchEnd={(e) => handleClick(e, i)}
>
{/* 호버 영역 확대 */}
{/* 호버/터치 영역 확대 */}
<rect
x={labelX - 25}
y={labelY - 12}
width={50}
height={24}
x={labelX - 30}
y={labelY - 16}
width={60}
height={32}
fill="transparent"
/>
<text
x={labelX}
y={labelY}
textAnchor="middle" dominantBaseline="middle"
className={`text-sm font-bold transition-colors ${hoveredIndex === i ? 'fill-[#1F3A8F]' : 'fill-slate-600'}`}
className={`text-sm font-bold transition-colors ${activeIndex === i ? 'fill-[#1F3A8F]' : 'fill-slate-600'}`}
>
{cat}
</text>
@@ -1154,48 +1196,85 @@ export default function DashboardPage() {
)
})}
{/* 툴팁 - 맨 마지막에 렌더링하여 항상 위에 표시 */}
{hoveredIndex !== null && (() => {
const p = farmPoints[hoveredIndex]
const data = categoryData[hoveredIndex]
const tooltipY = Math.max(140, p.y - 10)
{activeIndex !== null && (() => {
const data = categoryData[activeIndex]
// 라벨 위치 계산 (카테고리 라벨 근처에 툴팁 표시)
const angle = startAngle + activeIndex * angleStep
const labelRadius = maxRadius + 22
const labelX = centerX + labelRadius * Math.cos(angle)
const labelY = centerY + labelRadius * Math.sin(angle)
// 툴팁 위치 조정 (라벨 기준으로 배치)
const tooltipWidth = 160
const tooltipHeight = 130
// 카테고리별 툴팁 위치 최적화
let tooltipX = labelX
let tooltipY = labelY
// 성장 (위쪽) - 아래로
if (activeIndex === 0) {
tooltipY = labelY + 20
}
// 생산 (오른쪽 위) - 왼쪽 아래로
else if (activeIndex === 1) {
tooltipX = labelX - 30
tooltipY = labelY + 10
}
// 체형 (오른쪽 아래) - 왼쪽 위로
else if (activeIndex === 2) {
tooltipX = labelX - 40
tooltipY = labelY - tooltipHeight + 20
}
// 무게 (왼쪽 아래) - 오른쪽 위로
else if (activeIndex === 3) {
tooltipX = labelX + 40
tooltipY = labelY - tooltipHeight + 20
}
// 비율 (왼쪽 위) - 오른쪽 아래로
else if (activeIndex === 4) {
tooltipX = labelX + 30
tooltipY = labelY + 10
}
return (
<g className="pointer-events-none">
{/* 배경 */}
<rect
x={p.x - 90}
y={tooltipY - 135}
width={180}
height={145}
x={tooltipX - tooltipWidth / 2}
y={tooltipY}
width={tooltipWidth}
height={tooltipHeight}
rx={10}
fill="#1e293b"
filter="drop-shadow(0 4px 12px rgba(0,0,0,0.25))"
/>
{/* 카테고리명 + 형질 개수 */}
<text x={p.x} y={tooltipY - 110} textAnchor="middle" fontSize={16} fontWeight={700} fill="#ffffff">
<text x={tooltipX} y={tooltipY + 25} textAnchor="middle" fontSize={16} fontWeight={700} fill="#ffffff">
{data.category}
</text>
<text x={p.x} y={tooltipY - 92} textAnchor="middle" fontSize={11} fill="#94a3b8">
<text x={tooltipX} y={tooltipY + 43} textAnchor="middle" fontSize={11} fill="#94a3b8">
{data.traitCount}
</text>
{/* 구분선 */}
<line x1={p.x - 75} y1={tooltipY - 80} x2={p.x + 75} y2={tooltipY - 80} stroke="#475569" strokeWidth={1} />
<line x1={tooltipX - 65} y1={tooltipY + 55} x2={tooltipX + 65} y2={tooltipY + 55} stroke="#475569" strokeWidth={1} />
{/* 보은군 대비 차이 */}
<text x={p.x} y={tooltipY - 58} textAnchor="middle" fontSize={22} fontWeight={800} fill={data.avgEpd >= 0 ? '#60a5fa' : '#f87171'}>
<text x={tooltipX} y={tooltipY + 77} textAnchor="middle" fontSize={22} fontWeight={800} fill={data.avgEpd >= 0 ? '#60a5fa' : '#f87171'}>
{data.avgEpd >= 0 ? '+' : ''}{data.avgEpd.toFixed(2)}
</text>
<text x={p.x} y={tooltipY - 40} textAnchor="middle" fontSize={11} fill="#94a3b8">
<text x={tooltipX} y={tooltipY + 95} textAnchor="middle" fontSize={11} fill="#94a3b8">
</text>
{/* 순위 표시 */}
<rect
x={p.x - 40}
y={tooltipY - 30}
x={tooltipX - 40}
y={tooltipY + 103}
width={80}
height={24}
rx={12}
height={22}
rx={11}
fill="#16a34a"
/>
<text x={p.x} y={tooltipY - 13} textAnchor="middle" fontSize={13} fontWeight={700} fill="#ffffff">
<text x={tooltipX} y={tooltipY + 118} textAnchor="middle" fontSize={12} fontWeight={700} fill="#ffffff">
{data.avgPercentile}%
</text>
</g>
@@ -1295,5 +1374,6 @@ export default function DashboardPage() {
</div>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Trophy, ChevronLeft, TrendingUp, Award, Star } from "lucide-react"
import { useRouter } from "next/navigation"
import { AuthGuard } from "@/components/auth/auth-guard"
export default function TopCowsPage() {
const router = useRouter()
@@ -217,6 +218,7 @@ export default function TopCowsPage() {
]
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -448,5 +450,6 @@ export default function TopCowsPage() {
</div>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/auth-store'
interface AuthGuardProps {
children: React.ReactNode
}
/**
* 인증 가드 컴포넌트
* 로그인하지 않은 사용자가 보호된 페이지에 접근하면 로그인 페이지로 리다이렉트
*/
export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter()
const { isAuthenticated, user } = useAuthStore()
const [isChecking, setIsChecking] = useState(true)
useEffect(() => {
// Zustand hydration 후 체크
const timer = setTimeout(() => {
if (!isAuthenticated || !user) {
router.replace('/login')
} else {
setIsChecking(false)
}
}, 50)
return () => clearTimeout(timer)
}, [isAuthenticated, user, router])
if (isChecking) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
</div>
)
}
return <>{children}</>
}

View File

@@ -38,9 +38,7 @@ export function SiteHeader() {
// 활성화된 필터 개수 계산
const geneCount = filters.selectedGenes.length;
const traitCount = filters.traitWeights
? Object.values(filters.traitWeights).filter(weight => weight > 0).length
: 0;
const traitCount = filters.selectedTraits?.length || 0;
return (
<>

View File

@@ -43,6 +43,24 @@ export const useFilterStore = create<FilterState>()(
partialize: (state) => ({
filters: state.filters,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as { filters?: GlobalFilterSettings }
// localStorage에 저장된 값이 없거나, selectedTraits가 비어있으면 기본값 적용
if (!persisted?.filters ||
!persisted.filters.selectedTraits ||
persisted.filters.selectedTraits.length === 0) {
return {
...currentState,
filters: DEFAULT_FILTER_SETTINGS,
}
}
return {
...currentState,
filters: persisted.filters,
}
},
}
)
)

View File

@@ -127,7 +127,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
analysisIndex: "GENE",
selectedGenes: [],
pinnedGenes: [],
selectedTraits: [],
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "체장", "체고", "흉위"],
pinnedTraits: [],
traitWeights: {
// 성장형질 (점수: 0 ~ 10)
@@ -176,7 +176,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
갈비rate: 0,
},
inbreedingThreshold: 0, // 근친도 기본값 0
isActive: false,
isActive: true, // 기본 7개 형질이 선택되어 있으므로 활성화
updtDt: new Date(),
};