페이지 수정사항 반영
This commit is contained in:
95
backend/check-data.js
Normal file
95
backend/check-data.js
Normal 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
97
backend/check-data2.js
Normal 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
114
backend/check-data3.js
Normal 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
126
backend/check-data4.js
Normal 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
47
backend/check-data5.js
Normal 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
66
backend/check-data6.js
Normal 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);
|
||||
@@ -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: 응답 데이터 구성
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
41
frontend/src/components/auth/auth-guard.tsx
Normal file
41
frontend/src/components/auth/auth-guard.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user